Compare commits

...
Author SHA1 Message Date
michele 7fd03607ea Soft-bump 2.87.3: HubAudio AF verify + USB 90 Ω regression.
HubAudio's last exam was still on 2.87.0 while live carried 2.87.2 AF
fixes. Cue a re-run and lock USB_D+ skipping PE-AF-002 in favour of the
English protocol 90 Ω cert line.
2026-09-23 08:29:10 +02:00
michele 78a7da2dbf Fix HubAudio PE-AF-002 noise: MDIO≠MDI, RD≡RX, USB/RMII cert (2.87.2).
Exclude ETH_MDIO/MDC from IEEE MDI 100 Ω. Deduplicate RD+/RX and TD+/TX
pair aliases to one cert per channel; inherit LAN8720A 10/100 when the
magnetics-side nets lack the PHY. USB D+/D− skip AF tr/f (90 Ω cert owns
them); RMII stays on the English PE-AF-003 AN-133 line.
2026-09-23 08:05:24 +02:00
michele 5c87d706c2 Remove coffee/email notice from progress UI (2.87.1).
Replace the running-pipeline coffee banner and email promise with a
neutral status line so the notice never shows again.
2026-09-23 07:46:13 +02:00
michele 299ffaf396 Document GitHub as primary source of truth for Periscope.
Gitea remains a secondary mirror. No version bump — README-only.
2026-09-23 07:40:30 +02:00
michele 09a7b85770 Note README rewrite in 2.87.0 changelog. 2026-09-23 07:33:43 +02:00
michele 065fb508a4 Rewrite README for 2.87.0 as Periscope product copy.
Describe PCB analysis, protocol cert (USB/Ethernet/MAC–PHY), findings
tree, top-bar UI, and Markdown export. Gitea is the source of truth.
No predecessor-product mentions.
2026-09-23 07:33:31 +02:00
michele 7ad5a692a7 Merge MAC–PHY pack into integrate; soft-bump live to 2.87.0.
Resolves changelog conflict: one coherent release above 2.86.0 covering
iPad top-bar / Markdown export, MAC–PHY catalog pack, USB/RMII cert lines,
and not_reviewed fix. Cert branch content already present via identical
patch on mac-phy (627db2e5359729).
2026-09-23 07:26:15 +02:00
michele 085273e356 Merge iPad top-bar / Save as MD (2.85.0 UI). 2026-09-23 07:25:27 +02:00
michele 35cbc7ffd5 Pack MAC–PHY bus specs (AN-133, OPEN RGMII, SGMII) and fix fake RMII cert (2.86.0).
Recognition alone is not certified; PE-AF-003 requires a cited calculable check.
RJ45/MDI 100 Ω stays separate from RMII/MII/RGMII/SGMII.
2026-09-23 07:04:57 +02:00
michele 5b2d3cd613 iPad top-bar shell, light theme default, Markdown report export (2.85.0).
Replace the left sidebar with a thin top toolbar and tab/sheet navigation.
Light is default; dark mode lives in Settings and persists. Report export
is Save as Markdown (.md) with the findings-tree hierarchy — not Excel.
2026-09-23 07:03:16 +02:00
michele 627db2eba6 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.
2026-09-23 06:51:47 +02:00
michele 5ab69a835c English findings-tree labels and PCB stage timings (2.84.0).
Certifications, Protocols, domains, empty states, and the protocol empty message are English. Error / Warning / Info stay. Sidebar version is stamped 2.84.0. A PCB exam records extract, pcb_checks, protocol, af, and total seconds.
2026-09-22 22:15:23 +02:00
michele f79c722e68 Match exposed pads by land, cite ESD, follow CC to Rd (2.84.0).
PE-BOM-011 treats EP/EPAD/THERMAL PAD and the footprint exposed land as one pad when both sides have one. PE-ESD-001 quotes a datasheet only with part and section, and does not warn on a 3V3 jack rail, uncitable VBUS, or optical S/PDIF. CC Rd is the packed 5.1 kΩ on the Type-C CC pin net. Ethernet MDI pair Z is PASS, FAIL, or ERROR against the cited 10/100 or gigabit clause.
2026-09-22 21:56:09 +02:00
michele 7e872cfbc3 Drop .ts suffixes from frontend imports for Next typecheck.
Production next build rejects allowImportingTsExtensions-style paths.
2026-09-22 18:54:25 +02:00
michele 3046c4aeee Fix findings-tree TS: do not pass findings twice.
Spread FindingsTreeProps first, then override protocol findings for CertificationsBranch so Next production typecheck can build 2.83.0.
2026-09-22 18:53:13 +02:00
michele b32d581d27 Merge protocol-cert-m0 (vSafe UNKNOWN 2.83) into ui-apple-shell.
Keep Apple shell + findings tree; include USB-C vSafe UNKNOWN from catalog (no invented volts). Changelog 2.83.0 covers both.
2026-09-22 18:49:52 +02:00
michele d5fff7c6dd Apple-like UI shell: findings tree and HDMI 1.4 UNKNOWN.
Keep Error/Warning/Info folders with + disclosure, then domains/rules,
then Certificazioni/Protocolli. FAIL stays first. HDMI 1.4 TMDS Zdiff
renders UNKNOWN — catalog JSON unchanged.
2026-09-22 18:41:20 +02:00
64 changed files with 6530 additions and 1675 deletions
+34 -23
View File
@@ -2,35 +2,39 @@
Periscope is full **PCB analysis** for a board you intend to send to fab with confidence. Schematic review is a stage, not the deliverable.
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints once into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
Live instance: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
Live: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
Version: **2.87.2** (see `periscope/src/frontend/content/changelog.md`)
Operator: Michele Bigi
This tree is the Periscope product (operator: Michele Bigi). It is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope); Faradworks does not operate this instance.
## What you get
## What it checks — and what it will not invent
- **PCB + schematic review** — same finding object for `MODE=run` (schematic) and `MODE=pcb` (layout). Findings keep FACT, REQUIREMENT, and INFERENCE apart.
- **Protocol-aware certification** — gated on parts that are present. USB 2.0 D+/D (pair Z vs packed 90 Ω), Ethernet MDI / RJ45, and MACPHY buses (RMII / MII / RGMII / SGMII) with catalog cites from packed specs. Missing evidence is silence or `not certified — because …`, not invented ohms or timing.
- **Findings tree** — Error / Warning / Info first, then domains, rules, Certifications, and Protocols. Report export is **Save as Markdown** (`.md`).
- **Apple / iPad-style shell** — thin top bar, tab navigation, light theme by default, dark mode in Settings.
USB pair impedance, Ethernet class, and trace current versus width are **examples** of gated checks. They run when that bus, connector, or datasheet number exists on the board. They are not a catalog of every interface in electronics.
## What it will not invent
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net, sitting inside a courtyard, or having nearby coordinates does not change the object type.
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net or sitting inside a courtyard does not change the object type.
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips the check). It does not invent current, impedance, copper weight, geometry, IEC creepage, or “typical USB 500 mA”.
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips). It does not invent current, impedance, copper weight, geometry, or “typical” bus amps.
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the datasheet reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip — not a guessed ampacity table, not via-IPC folklore.
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the part reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip.
**Interface class certifiers are gated on parts that are present.** USB-C, RJ45/Ethernet, PoE, and DDR-style certifiers fire only if that connector or device is on the graph. No USB-C receptacle → no USB-C findings (silence, not N/A). A bare RJ45 is Ethernet, not PoE. No DDR device → no DDR findings.
**Certifiers are gated on parts that are present.** No USB-C receptacle → no USB-C findings. A bare RJ45 is Ethernet, not PoE. RMII PHYMAC nets are not treated as RJ45/MDI 100 Ω pairs. Recognition alone is not certification.
Periscope does **not** claim FEM, thermal spreading, OpenEMS, or a field solver. Closed-form Z0 comes from vendored ImpedenceFinder (`vendor/impedancefinder/`; upstream license UNKNOWN).
Findings keep FACT, REQUIREMENT, and INFERENCE apart. Recommended datasheet notes are not errors. The same finding object is used for schematic (`MODE=run`) and PCB (`MODE=pcb`).
## Repository layout
| Path | Role |
| --- | --- |
| `periscope/src/` | Native Periscope (finding engine, PCB/placement, DeepSeek, Dockerfiles, KiCad plugin) |
| `periscope/dependency/` | Inherited PinScope (in-tree AGPL dependency — do not delete) |
| `periscope/src/` | Product code finding engine, PCB/placement, protocol catalog, DeepSeek pipelines, Next.js UI, Dockerfiles, KiCad plugin |
| `periscope/dependency/` | Shared AGPL overlays (parsers, extraction shell, UI primitives) merged at build/runtime — do not delete |
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
| `standards/protocol-specs/` | On-disk protocol PDF archive (PDFs gitignored; `INDEX.md` committed) |
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
| `LICENSE` | GNU AGPL v3 |
@@ -50,7 +54,7 @@ cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
python3 -m uvicorn backend.main:app --reload --host 127.0.0.1 --port 18741
```
Frontend — overlay native UI onto the inherited shell, then start Next.js (this is what CI runs):
Frontend — materialize the UI tree, then start Next.js:
```bash
./scripts/materialize-frontend.sh
@@ -60,7 +64,7 @@ NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostnam
Open the frontend URL, create a project, and upload a netlist plus BOM. A `.kicad_pcb` is required for the PCB job; without it the project can complete schematic review only. `periscope/dependency/simple_project/` is a small bundled schematic fixture (MSPM0G3507 + CH340E + SPX3819).
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`. Do not run an Anthropic Console uploader.
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`.
Tests from the repo root:
@@ -81,7 +85,7 @@ Backend listens on port 8080, frontend on port 3000 (`docker-compose.yml`).
### Live host
The public site is rebuilt from **`/root/periscope`** with the script that actually ships:
The public site is rebuilt from **`/root/periscope`**:
```bash
cd /root/periscope
@@ -90,17 +94,24 @@ cd /root/periscope
`--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL. The script refuses to run unless the checkout is `/root/periscope` (override with `CANONICAL_ROOT` only if the host layout differs). It does not touch `data/`.
## Source
## Source of truth
| Remote | URL |
| --- | --- |
| GitHub | [https://github.com/manvalan/pinscope](https://github.com/manvalan/pinscope) (`git@github.com:manvalan/pinscope.git`) |
| Gitea | [http://192.168.1.71:3000/michele/periscope.git](http://192.168.1.71:3000/michele/periscope.git) |
**GitHub** is the primary remote for this product. Push `main` there first.
Push both: `git push github && git push gitea` (no force).
| Remote | URL | Role |
| --- | --- | --- |
| GitHub | [https://github.com/manvalan/periscope](https://github.com/manvalan/periscope) (`git@github.com:manvalan/periscope.git`) | **Primary** — source of truth |
| Gitea | [http://192.168.1.71:3000/michele/periscope](http://192.168.1.71:3000/michele/periscope) (`git@gitea:michele/periscope.git`, SSH port **2223**) | Secondary / mirror — do not delete |
```bash
git push github main
git push gitea main # optional mirror
```
Do not force-push `main`.
## License
[AGPL-3.0](LICENSE) — GNU Affero General Public License v3.0.
This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). For a commercial license of **upstream Pinscope**, contact Faradworks as they publish it. For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
+5 -5
View File
@@ -1,14 +1,14 @@
# Periscope tree layout
Physical split (not a rewrite). AGPL `LICENSE` stays at the git root. The GitHub fork is not detached.
Physical split under the git root. AGPL `LICENSE` stays at the repository root.
| Tree | Path | Role |
| --- | --- | --- |
| Native Periscope | `periscope/src/` | Finding engine clamp, PCB/placement/antenna, DeepSeek, local auth, KiCad plugin, deploy Dockerfiles |
| Inherited PinScope | `periscope/dependency/` | Graph/parsers/`validate.py`, pipeline/extraction, OSS Next.js shell, skills, taxonomy, `simple_project` |
| Product | `periscope/src/` | Finding engine, PCB/placement, protocol catalog + certifiers, DeepSeek pipelines, local auth, Next.js UI, KiCad plugin, deploy Dockerfiles |
| Overlays | `periscope/dependency/` | Shared AGPL packages (graph/parsers, extraction pipeline, UI primitives, skills, taxonomy, `simple_project`) merged at build/runtime |
| Third party | `vendor/impedancefinder/` | ImpedenceFinder (license UNKNOWN) |
| Glue | `backend/__init__.py` | Merges the two `backend` packages for local imports |
**Do not empty-delete `periscope/dependency/`.** Later phases replace PinScope modules incrementally in `periscope/src` (Fase C). `validate.py` stays until a native reviewer exists.
**Do not empty-delete `periscope/dependency/`.** Native modules land in `periscope/src/` over time; overlays stay until each piece has a product replacement.
Docker overlays `dependency` then `src` into `/app`. Local frontend: `cd periscope/dependency/frontend && npm run dev` (native files are symlinked from `periscope/src/frontend`).
Docker overlays `dependency` then `src` into `/app`. Local frontend materialization: `./scripts/materialize-frontend.sh` (see root `README.md`).
@@ -26,7 +26,6 @@ import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
@@ -248,23 +247,23 @@ export default function ProgressPage({
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">
Feel free to grab a coffee
Pipeline running
</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
Stages update below as the run proceeds.
</p>
</div>
</div>
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import math
import re
from collections import defaultdict
from backend.periscopex.af_rlgc import cascade_sparam
@@ -24,8 +25,9 @@ from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM
from backend.periscopex.impedance import GeometryError
from backend.periscopex.impedance_traces import analyze_specified_nets
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import partner_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.si_check import bus_class, partner_net, phy_mac_kind, single_ended_eth_mac
log = logging.getLogger(__name__)
SOURCE = "af_trace_check"
@@ -35,6 +37,288 @@ _WIDTH_STEP_MM = 0.05
_PARALLEL_MIN_MM = 5.0
_MDI_POL = re.compile(
r"^(?P<stem>.*(?:TX|RX|TD|RD|TRD\d|TP))(?P<pol>[PN])$",
re.I,
)
# RD≡RX, TD≡TX, +≡P, −≡N — same physical MDI pair under HubAudio-style dual names.
_MDI_CHANNEL_RE = re.compile(
r"(?P<ch>RX|RD|TX|TD|TRD\d|TP)(?P<pol>[+\-PN])$",
re.I,
)
_GBE_RE = re.compile(r"1000\s*BASE|1000BASE|\bGBE\b|GIGABIT", re.I)
_FAST_RE = re.compile(r"LAN8720|10\s*/\s*100|100BASE|10BASE|BASE[\s\-]?TX", re.I)
def _mdi_mate(name: str) -> str | None:
"""TXP/TXN and RXP/RXN, plus the existing _P/_N and +/- partners."""
leaf = (name or "").rsplit("/", 1)[-1]
m = _MDI_POL.match(leaf)
if m:
pol = m.group("pol").upper()
stem = m.group("stem")
mate_leaf = stem + ("N" if pol == "P" else "P")
if "/" in (name or ""):
return name.rsplit("/", 1)[0] + "/" + mate_leaf
return mate_leaf
return partner_net(name)
def _mdi_channel_key(net: str) -> tuple[str, str] | None:
"""Canonical (hierarchy, rx|tx|…) so RD+/RXP and TD/TXN share one cert."""
if not net:
return None
prefix, _, leaf = net.rpartition("/")
m = _MDI_CHANNEL_RE.search(leaf)
if not m:
return None
raw = m.group("ch").upper()
if raw.startswith("TRD"):
ch = f"trd{raw[3:]}"
elif raw in ("RX", "RD"):
ch = "rx"
elif raw in ("TX", "TD"):
ch = "tx"
else:
ch = raw.lower()
return (prefix, ch)
def _prefer_mdi_unit(a: AfUnit, b: AfUnit) -> AfUnit:
"""Prefer PHY-side RXP/TXP names over magnetics RD+/TD+ when both exist."""
def score(u: AfUnit) -> tuple[int, int]:
blob = " ".join(u.nets).upper()
phy = 1 if re.search(r"(?:RX|TX)[PN]\b", blob) else 0
return (phy, len(u.nets))
return a if score(a) >= score(b) else b
def _is_eth_mdi_net(net: str) -> bool:
if single_ended_eth_mac(net):
return False
return bus_class(net) == "eth_mdi"
def _coalesce_eth_pairs(units: list[AfUnit]) -> list[AfUnit]:
"""Treat each RJ45/PHY MDI pair as one unit, including TXP/TXN and RD≡RX aliases."""
by_net = {n: u for u in units for n in u.nets}
out: list[AfUnit] = []
seen: set[str] = set()
for unit in units:
if unit.nets[0] in seen:
continue
if len(unit.nets) == 1 and _is_eth_mdi_net(unit.nets[0]):
mate = _mdi_mate(unit.nets[0])
other = by_net.get(mate or "")
if mate and other is not None and mate not in seen:
nets = tuple(sorted((unit.nets[0], mate)))
seen.update(nets)
length = max(unit.length_mm, other.length_mm)
out.append(AfUnit(nets=nets, length_mm=length, bus="eth_mdi"))
continue
if all(_is_eth_mdi_net(n) for n in unit.nets):
seen.update(unit.nets)
out.append(AfUnit(
nets=tuple(sorted(unit.nets)),
length_mm=unit.length_mm,
bus="eth_mdi",
))
continue
seen.update(unit.nets)
out.append(unit)
return _dedupe_mdi_aliases(out)
def _dedupe_mdi_aliases(units: list[AfUnit]) -> list[AfUnit]:
"""One unit per (sheet, rx|tx): RD+/RD- and RXP/RXN are the same physical pair."""
best: dict[tuple[str, str], AfUnit] = {}
passthrough: list[AfUnit] = []
for unit in units:
if unit.bus != "eth_mdi" and not all(_is_eth_mdi_net(n) for n in unit.nets):
passthrough.append(unit)
continue
keys = {_mdi_channel_key(n) for n in unit.nets}
keys.discard(None)
if len(keys) != 1:
# Mixed or unknown — keep as-is (still eth_mdi tagged).
tagged = unit if unit.bus == "eth_mdi" else AfUnit(
nets=unit.nets, length_mm=unit.length_mm, bus="eth_mdi",
)
passthrough.append(tagged)
continue
key = next(iter(keys))
assert key is not None
prev = best.get(key)
if prev is None:
best[key] = AfUnit(
nets=tuple(sorted(unit.nets)),
length_mm=unit.length_mm,
bus="eth_mdi",
)
else:
best[key] = _prefer_mdi_unit(prev, AfUnit(
nets=tuple(sorted(unit.nets)),
length_mm=max(prev.length_mm, unit.length_mm),
bus="eth_mdi",
))
return passthrough + list(best.values())
def _component_speed_bits(graph: DesignGraph, refs: list[str] | None = None) -> str:
bits: list[str] = []
comps = (
{r: graph.components[r] for r in refs if r in graph.components}
if refs is not None
else graph.components
)
for ref, comp in comps.items():
if comp is None:
continue
bits.extend([
comp.mpn or "", comp.value or "", comp.footprint or "",
comp.component_subtype or "",
])
row = (graph.bom_fields or {}).get(ref) or {}
for key in ("description", "Description", "value", "Value"):
if row.get(key):
bits.append(str(row.get(key)))
return " ".join(bits)
def _eth_speed(graph: DesignGraph, nets: tuple[str, ...]) -> str | None:
"""10/100 or gbe from the PHY/jack on these nets, else any board PHY (LAN8720A)."""
local_refs = [ref for net in nets for ref in refs_on_matched_net(graph, net)]
text = _component_speed_bits(graph, local_refs)
if _GBE_RE.search(text):
return "gbe"
if _FAST_RE.search(text):
return "10/100"
# Magnetics-side RD+/TD+ often lack the PHY MPN — inherit board speed.
board = _component_speed_bits(graph, None)
if _GBE_RE.search(board):
return "gbe"
if _FAST_RE.search(board):
return "10/100"
return None
def _eth_cite(speed: str) -> tuple[float, str] | None:
"""Packed cable_channel ohm for the identified speed. Nothing invented."""
want = "1000base-t-mdi" if speed == "gbe" else "100base-tx-mdi"
cat = load_catalog()
for iface in cat.physical_interfaces:
if iface.id != want:
continue
for c in iface.constraints:
if c.parameter != "cable_channel" or c.unit != "ohm" or c.value is None:
continue
src = c.source
doc = (src.document if src and src.document else "IEEE Std 802.3")
section = (src.section if src and src.section else "")
if speed == "gbe":
label = f"{doc} Clause 40 (§{section})" if section else f"{doc} Clause 40"
else:
label = (
f"{doc} 100BASE-TX 25.4.9 (§{section})"
if section else f"{doc} 100BASE-TX 25.4.9"
)
return float(c.value), label
return None
def _pair_z_ohm(layout: LayoutGraph, unit: AfUnit) -> float | None:
"""One Z for the pair. None when stackup or geometry cannot produce it."""
if layout.stackup is None or len(unit.nets) < 2:
return None
samples = _samples(layout, unit)
vals = _z_vals(samples, True)
if not vals:
return None
return sum(vals) / len(vals)
def _ethernet_mdi_finding(
graph: DesignGraph,
layout: LayoutGraph,
unit: AfUnit,
) -> Finding:
"""Calculated pair Z vs the cited clause for the speed on the board.
Inside → PASS. Outside → FAIL. Cannot calculate → ERROR.
"""
speed = _eth_speed(graph, unit.nets)
cite = _eth_cite(speed) if speed else None
measured = _pair_z_ohm(layout, unit)
label = _label(unit)
if cite is None:
text = (
f"Ethernet not certified — because the speed on {label} is not "
"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."
rec = "Identify the PHY/jack speed. No ohm value is assumed."
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
facts=f"nets={label}; speed=unidentified; measured=none.",
requirement=req, rec=rec, status="ERROR", cls="RULE",
evidence="SUFFICIENT", provenance="MANDATORY",
)
limit, cite_label = cite
req = f"Cited limit {limit:g} Ω, {cite_label}."
if measured is None:
missing: list[str] = []
if len(unit.nets) < 2:
missing.append("pair")
if layout.stackup is None:
missing.append("stackup")
else:
missing.append("geometry")
miss = "/".join(missing)
text = (
"Ethernet not certified — because Z cannot be calculated from the "
f"PCB stackup/geometry ({miss} missing). Cited {limit:g} Ω ({cite_label})."
)
rec = (
f"Pair Z was not calculated ({miss} missing). "
f"Cited {limit:g} Ω ({cite_label})."
)
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
facts=f"nets={label}; speed={speed}; missing={miss}; measured=none.",
requirement=req, rec=rec, status="ERROR", cls="RULE",
evidence="SUFFICIENT", provenance="MANDATORY",
calculation=f"no computed Z; cited {limit:g} Ω.",
)
inside = abs(measured - limit) <= 1e-6 * max(1.0, abs(limit))
if inside:
text = (
f"Ethernet certified — calculated Z {measured:g} Ω vs cited "
f"{limit:g} Ω ({cite_label})."
)
rec = f"Calculated pair Z matches cited {limit:g} Ω ({cite_label})."
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
requirement=req, rec=rec, status="INFO", cls="INFO",
evidence="SUFFICIENT", provenance="MANDATORY",
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
)
text = (
f"Ethernet not certified — because Z {measured:g} Ω is outside cited "
f"{limit:g} Ω ({cite_label})."
)
rec = (
f"Calculated pair Z {measured:g} Ω is outside cited {limit:g} Ω ({cite_label})."
)
return _finding(
rule_id="PE-AF-002", unit=unit, finding=text,
facts=f"nets={label}; speed={speed}; Z={measured:g} Ω; limit={limit:g} Ω.",
requirement=req, rec=rec, status="ERROR", cls="RULE",
evidence="SUFFICIENT", provenance="MANDATORY",
calculation=f"pair Z {measured:g} Ω compared to {limit:g} Ω.",
)
def check_af_traces(
graph: DesignGraph,
constraints_map: dict,
@@ -50,7 +334,27 @@ def check_af_traces(
if f.rule_id == "PE-SI-002" and f.net
}
zrows = _z_rows(impedance_nets)
for unit in iter_af_units(layout, graph):
mac_phy_nets: list[str] = []
sgmii_units: list[AfUnit] = []
for unit in _coalesce_eth_pairs(list(iter_af_units(layout, graph))):
# USB 2.0 D+/D: protocol_l2 owns the 90 Ω cert line. Never PE-AF-002 tr/f.
if unit.bus == "usb2" or any(bus_class(n) == "usb2" for n in unit.nets):
continue
if any(single_ended_eth_mac(n) or phy_mac_kind(n) in {"rmii", "mii", "rgmii"} for n in unit.nets):
if not any(_is_eth_mdi_net(n) for n in unit.nets):
mac_phy_nets.extend(unit.nets)
continue
if unit.bus == "sgmii" or any(
(bus_class(n) == "sgmii") or ("SGMII" in (n or "").upper()) for n in unit.nets
):
sgmii_units.append(unit)
continue
if unit.bus == "eth_mdi":
if any(single_ended_eth_mac(n) for n in unit.nets):
mac_phy_nets.extend(unit.nets)
continue
out.append(_ethernet_mdi_finding(graph, layout, unit))
continue
trig = evaluate_trigger(graph, constraints_map, layout, unit)
if trig.missing and not trig.af:
out.append(_skip(unit, trig.missing))
@@ -58,10 +362,266 @@ def check_af_traces(
if not trig.af:
continue
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
if mac_phy_nets:
out.append(_mac_phy_cert_finding(graph, mac_phy_nets, layout))
for unit in sgmii_units:
out.append(_sgmii_cert_finding(graph, layout, unit))
complete_findings(out)
return out
def _mac_phy_iface_label(nets: list[str]) -> str:
kinds = {phy_mac_kind(n) or "rmii" for n in nets}
if kinds == {"mii"}:
return "MII"
if kinds == {"rgmii"} or ("rgmii" in kinds and "rmii" not in kinds and "mii" not in kinds):
return "RGMII"
return "RMII"
def _mac_phy_catalog_id(iface: str) -> str:
return {
"MII": "mii-pcb",
"RMII": "rmii-pcb",
"RGMII": "rgmii-pcb",
"SGMII": "sgmii-pcb",
}[iface]
def _cite_label(c) -> str:
src = c.source
if src is None:
return c.id
bits = [src.document or "", src.revision or ""]
if src.table:
bits.append(src.table)
elif src.section:
bits.append(f"§{src.section}")
if src.page:
bits.append(f"p.{src.page}")
return " ".join(b for b in bits if b)
def _mac_phy_cert_finding(
graph: DesignGraph,
nets: list[str],
layout: LayoutGraph | None,
) -> Finding:
"""Certified only when a cited calculable check passes — never on recognition alone."""
uniq = sorted({n for n in nets if n})
label = " / ".join(uniq[:6]) + (" / …" if len(uniq) > 6 else "")
iface = _mac_phy_iface_label(uniq)
catalog_id = _mac_phy_catalog_id(iface)
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"
identified = (
f"Identified as single-ended {iface} PHYMAC (not an RJ45/MDI 100 Ω "
"differential pair)."
)
cat = load_catalog()
phys = next((p for p in cat.physical_interfaces if p.id == catalog_id), None)
z_row = None
if phys is not None:
for c in phys.constraints:
if c.parameter == "impedance":
z_row = c
break
# Calculable Z check — same rule as Ethernet: need cited NUMERIC + stackup/geometry.
if z_row is not None and z_row.value_kind == "NUMERIC" and z_row.value is not None:
cite = f"{z_row.value:g} Ω ({_cite_label(z_row)})"
measured = _se_z_ohm(layout, uniq) if layout is not None else None
if measured is None:
miss = "stackup/geometry" if layout is None or layout.stackup is None else "geometry"
text = (
f"{iface} not certified — because Z cannot be calculated from the "
f"PCB {miss}. Cited {cite}. {identified}"
)
status, cls, prov = "ERROR", "RULE", "MANDATORY"
elif abs(measured - float(z_row.value)) <= 1e-6 * max(1.0, abs(float(z_row.value))):
text = (
f"{iface} certified — calculated Z {measured:g} Ω vs cited {cite}. "
f"{identified}"
)
status, cls, prov = "INFO", "INFO", "MANDATORY"
else:
text = (
f"{iface} not certified — because Z {measured:g} Ω is outside cited "
f"{cite}. {identified}"
)
status, cls, prov = "ERROR", "RULE", "MANDATORY"
rec = (
f"Provide stackup/geometry and match cited {cite}."
if status != "INFO"
else f"Calculated single-ended Z matches cited {cite}."
)
else:
needed = (z_row.needed_document if z_row else None) or (
f"{catalog_id} impedance not packed as NUMERIC"
)
# Point at the primary pack source without inventing a Z0.
src_note = ""
if phys is not None:
for c in phys.constraints:
if c.value_kind == "NUMERIC" and c.source and c.source.document:
src_note = f" Packed cite on file: {_cite_label(c)}."
break
text = (
f"{iface} not certified — because PCB impedance is "
f"{(z_row.value_kind if z_row else 'MISSING_SOURCE')} "
f"({needed}).{src_note} {identified}"
)
status, cls, prov = "ERROR", "RULE", "MANDATORY"
rec = (
f"Do not treat recognition as certification. Pack a cited PCB Z0 "
f"or provide the needed document. Keep {iface} off the RJ45/MDI 100 Ω clause."
)
return Finding(
designator=host,
mpn="",
aspect="si",
finding=text,
facts=(
f"nets={label}; interface={iface}; catalog={catalog_id}; "
f"phy={','.join(phy_refs) or ''}; "
f"impedance_kind={(z_row.value_kind if z_row else 'missing')}."
),
requirement=(
f"{iface} is certified only against a cited calculable check "
f"(e.g. PCB Z). Recognition alone is not certification. "
"Not an RJ45/MDI 100 Ω pair."
),
inference=(
"Interface class from net names / PHY. Certification requires a "
"packed NUMERIC cite and a measurable PCB quantity."
),
why=text,
status=status,
recommendation=rec,
action=rec,
source=SOURCE,
rule_id="PE-AF-003",
finding_class=cls,
provenance=prov,
evidence_status="SUFFICIENT",
net=uniq[0] if uniq else None,
pins=[],
)
def _se_z_ohm(layout: LayoutGraph, nets: list[str]) -> float | None:
"""Single-ended microstrip Z0 from layout samples. None if stackup/geometry missing."""
if layout.stackup is None or not nets:
return None
unit = AfUnit(nets=tuple(nets[:1]), length_mm=0.0, bus="rmii")
samples = _samples(layout, unit)
vals = _z_vals(samples, False)
if not vals:
return None
return sum(vals) / len(vals)
def _sgmii_cert_finding(
graph: DesignGraph,
layout: LayoutGraph,
unit: AfUnit,
) -> Finding:
"""SGMII pair Z vs Cisco ENG-46158 Rload 100 Ω / Rin 80120 Ω. Recognition ≠ cert."""
label = _label(unit)
cat = load_catalog()
phys = next((p for p in cat.physical_interfaces if p.id == "sgmii-pcb"), None)
z_row = None
rin_lo = rin_hi = None
if phys is not None:
for c in phys.constraints:
if c.parameter == "differential_impedance" and c.value_kind == "NUMERIC":
z_row = c
if c.parameter == "receiver_differential_impedance_min" and c.value is not None:
rin_lo = float(c.value)
if c.parameter == "receiver_differential_impedance_max" and c.value is not None:
rin_hi = float(c.value)
identified = "Identified as SGMII MACPHY (not RJ45/MDI cabling)."
if z_row is None or z_row.value is None:
text = (
f"SGMII not certified — because no packed differential_impedance cite "
f"is available. {identified}"
)
return _finding(
rule_id="PE-AF-003", unit=unit, finding=text,
facts=f"nets={label}; catalog=sgmii-pcb; measured=none.",
requirement="SGMII certification needs a cited Z and a calculated pair Z.",
rec="Pack Cisco SGMII Rload/Rin cites before certifying.",
status="ERROR", cls="RULE",
evidence="SUFFICIENT", provenance="MANDATORY",
)
cite = f"{z_row.value:g} Ω ({_cite_label(z_row)})"
measured = _pair_z_ohm(layout, unit)
if measured is None:
missing: list[str] = []
if len(unit.nets) < 2:
missing.append("pair")
if layout.stackup is None:
missing.append("stackup")
else:
missing.append("geometry")
miss = "/".join(missing)
text = (
"SGMII not certified — because Z cannot be calculated from the "
f"PCB stackup/geometry ({miss} missing). Cited {cite}. {identified}"
)
return _finding(
rule_id="PE-AF-003", unit=unit, finding=text,
facts=f"nets={label}; missing={miss}; measured=none; cite={cite}.",
requirement=f"Cited limit {cite}.",
rec=f"Pair Z was not calculated ({miss} missing). Cited {cite}.",
status="ERROR", cls="RULE",
evidence="SUFFICIENT", provenance="MANDATORY",
calculation=f"no computed Z; cited {z_row.value:g} Ω.",
)
rload = float(z_row.value)
match_rload = abs(measured - rload) <= 1e-6 * max(1.0, abs(rload))
match_rin = (
rin_lo is not None and rin_hi is not None and rin_lo <= measured <= rin_hi
)
if match_rload or match_rin:
text = (
f"SGMII certified — calculated Z {measured:g} Ω vs cited {cite}. "
f"{identified}"
)
return _finding(
rule_id="PE-AF-003", unit=unit, finding=text,
facts=f"nets={label}; Z={measured:g} Ω; cite={cite}.",
requirement=f"Cited limit {cite}.",
rec=f"Calculated pair Z matches cited {cite}.",
status="INFO", cls="INFO",
evidence="SUFFICIENT", provenance="MANDATORY",
calculation=f"pair Z {measured:g} Ω compared to {rload:g} Ω.",
)
text = (
f"SGMII not certified — because Z {measured:g} Ω is outside cited "
f"{cite}. {identified}"
)
return _finding(
rule_id="PE-AF-003", unit=unit, finding=text,
facts=f"nets={label}; Z={measured:g} Ω; cite={cite}.",
requirement=f"Cited limit {cite}.",
rec=f"Calculated pair Z {measured:g} Ω is outside cited {cite}.",
status="ERROR", cls="RULE",
evidence="SUFFICIENT", provenance="MANDATORY",
calculation=f"pair Z {measured:g} Ω compared to {rload:g} Ω.",
)
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
raw = impedance_nets
if isinstance(impedance_nets, dict):
@@ -75,12 +635,15 @@ def _label(unit: AfUnit) -> str:
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
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:
text = f"{text} {extra}".strip()
rec = (
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
"Non si assume 50 Ω o 90 Ω."
f"Provide {miss} from the datasheet or KiCad stackup. "
"50 Ω or 90 Ω is not assumed."
)
return Finding(
designator="layout",
@@ -116,7 +679,9 @@ def _finding(
cls: str = "RISK",
evidence: str = "SUFFICIENT",
calculation: str = "",
provenance: str = "",
) -> Finding:
prov = provenance or ("RECOMMENDED" if cls != "REVIEW" else "TYPICAL")
return Finding(
designator="layout",
mpn="",
@@ -132,7 +697,7 @@ def _finding(
source=SOURCE,
rule_id=rule_id,
finding_class=cls, # type: ignore[arg-type]
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
provenance=prov,
evidence_status=evidence, # type: ignore[arg-type]
net=unit.nets[0],
pins=[],
@@ -52,7 +52,8 @@ _MODULE_FP = re.compile(
re.I,
)
_EP_PAD = re.compile(
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|"
r"THERMAL[\s_]?PAD|EXPOSED[\s_]?PAD)(?:[_-]?\d+)?$",
re.I,
)
_EP_SPLIT = re.compile(r"^\d+_\d+$")
@@ -112,6 +113,45 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None
return False
def _is_exposed_alias(name: str) -> bool:
"""EP, EPAD, THERMAL PAD, exposed pad. Not a signal pin number."""
s = str(name or "").strip()
if not s:
return False
if _EP_PAD.match(s):
return True
compact = re.sub(r"[\s_\-]+", "", s).lower()
return compact in {"thermalpad", "exposedpad", "epad", "thermal"}
def _ids_have_exposed(
ids: list[tuple[str, str]] | None,
pin_count: int | None,
) -> bool:
if not ids:
return False
return any(_is_ep_land(n, pf, pin_count) for n, pf in ids)
def _drop_exposed_name_mismatch(
miss: list[str],
extra: list[str],
*,
left_has: bool,
right_has: bool,
) -> tuple[list[str], list[str]]:
"""Same exposed pad under two names is not a missing/extra pin.
Datasheet EP with no exposed land on the other side stays a miss.
"""
if not (left_has and right_has):
return miss, extra
return (
[m for m in miss if not _is_exposed_alias(m)],
[e for e in extra if not _is_exposed_alias(e)],
)
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
s = str(number).strip()
pf = str(pinfunction or "").strip()
@@ -273,6 +313,16 @@ def resolve_kicad_mod(
return None
def _lib_pad_ids(
footprint: str,
search_dirs: list[Path] | None,
) -> list[tuple[str, str]] | None:
path = resolve_kicad_mod(footprint, search_dirs)
if path is None:
return None
return parse_kicad_mod_pads(path)
def _lib_join_keys(
footprint: str,
*,
@@ -489,6 +539,10 @@ def _pinout_findings(
declared = _declared_io_pins(pkg, cad)
ep_count = declared or pin_count
pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds)
pcb_has_ep = _ids_have_exposed(pcb_ids, ep_count)
ds_has_ep = any(_is_exposed_alias(k) for k in ds)
lib_raw = _lib_pad_ids(fp_name, footprint_dirs)
lib_has_ep = _ids_have_exposed(lib_raw, ep_count)
lib, lib_path = _lib_join_keys(
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
)
@@ -522,8 +576,10 @@ def _pinout_findings(
)
if lib is not None:
miss_lib = sorted(ds - lib)
extra_lib = sorted(lib - ds)
miss_lib, extra_lib = _drop_exposed_name_mismatch(
sorted(ds - lib), sorted(lib - ds),
left_has=ds_has_ep, right_has=lib_has_ep,
)
if miss_lib or extra_lib:
rec = (
f"Change {ref}'s KiCad footprint library so pad names match "
@@ -547,8 +603,11 @@ def _pinout_findings(
)
if fnd:
out.append(fnd)
miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
extra_emb = sorted(pcb - lib) if pcb else []
miss_emb, extra_emb = _drop_exposed_name_mismatch(
sorted(lib - pcb) if pcb else sorted(lib),
sorted(pcb - lib) if pcb else [],
left_has=lib_has_ep, right_has=pcb_has_ep,
)
if pcb and (miss_emb or extra_emb):
rec = (
f"Replace {ref}'s embedded PCB footprint so it matches the "
@@ -571,8 +630,10 @@ def _pinout_findings(
out.append(fnd)
return out
if pcb:
miss = sorted(ds - pcb)
extra = sorted(pcb - ds)
miss, extra = _drop_exposed_name_mismatch(
sorted(ds - pcb), sorted(pcb - ds),
left_has=ds_has_ep, right_has=pcb_has_ep,
)
if miss or extra:
rec = (
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
@@ -27,7 +27,14 @@ _NC_NET_RE = re.compile(
re.I,
)
_ONBOARD_POWER_RE = re.compile(
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$",
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|VCC\d*|VDD\d*|3V3|\+3V3|3\.3V)"
r"(?:[_./-].*)?$",
re.I,
)
_VBUS_LEAF_RE = re.compile(r"(?:^|[_./-])VBUS(?:$|[_./-])", re.I)
_SPDIF_RE = re.compile(r"SP(?:DIF|IF)", re.I)
_OPTICAL_RE = re.compile(
r"TOSLINK|OPTOCOUPL|OPTICAL|OPTODEVICE|\bAFBR\b|PHOTO[-_ ]?COUPL",
re.I,
)
@@ -57,6 +64,50 @@ def _skip_esd_net(net: str) -> bool:
return False
def _optical_evidence(graph: DesignGraph, comp) -> bool:
"""Schematic/BOM/footprint shows TOSLINK, optocoupler, or optical."""
parts = [
comp.value or "",
comp.footprint or "",
comp.mpn or "",
comp.component_subtype or "",
]
row = (graph.bom_fields or {}).get(comp.reference) or {}
for key in ("description", "Description", "value", "Value", "footprint", "Footprint"):
if row.get(key):
parts.append(str(row.get(key)))
return bool(_OPTICAL_RE.search(" ".join(parts)))
def _esd_cite(cons, *, need_vbus: bool) -> str | None:
"""Part + section/page quote. None when the datasheet is not cited."""
if cons is None:
return None
for rule in cons.layout_rules or []:
if not isinstance(rule, dict):
continue
blob = " ".join(
str(rule.get(k) or "")
for k in ("kind", "parameter", "note", "net", "pin", "requirement")
)
if "esd" not in blob.lower():
continue
if need_vbus and not re.search(r"vbus", blob, re.I):
continue
doc = str(
rule.get("document") or rule.get("part") or rule.get("source") or ""
).strip()
section = str(rule.get("section") or "").strip()
page = str(rule.get("page") or "").strip()
if not doc or not (section or page):
continue
loc = section
if page:
loc = f"{loc} p.{page}".strip()
return f"{doc} {loc}".strip()
return None
def _is_j_connector(comp) -> bool:
if comp.component_type != ComponentType.CONNECTOR:
return False
@@ -68,7 +119,8 @@ def check_esd(
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
"""One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam."""
_ = constraints_map
from backend.periscopex.constraints_lookup import match_constraints
out: list[Finding] = []
seen: set[tuple[str, str, str]] = set()
@@ -106,28 +158,64 @@ def check_esd(
(str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)),
"",
)
leaf = _net_leaf(net)
is_vbus = bool(_VBUS_LEAF_RE.search(leaf))
is_rail = bool(_ONBOARD_POWER_RE.match(leaf))
is_spdif = bool(_SPDIF_RE.search(leaf))
optical = _optical_evidence(graph, j_comp)
cons = match_constraints(ic.mpn or ic.value, constraints_map or {})
quote = _esd_cite(cons, need_vbus=is_vbus)
if is_spdif and optical:
continue
if is_vbus and not quote:
continue
if is_rail and not quote:
continue
if is_spdif and not optical:
finding = (
f"{net} ({j_ref}{ic_ref}) was not verified as optical "
"S/PDIF."
)
rec = (
f"Add a datasheet-specified ESD device on {net} between "
f"{j_ref} and {ic_ref}."
f"Confirm whether {net} is photo-coupled "
"(TOSLINK/optocoupler). The path was not verified as optical."
)
requirement = (
"Optical S/PDIF is not an external copper signal. "
"Without TOSLINK/optocoupler evidence the path stays visible."
)
else:
finding = (
f"{net} is a {j_ref}{ic_ref} path with no ESD/"
"protection part on the net."
)
if quote:
rec = (
f"Add a datasheet-specified ESD device ({quote}) on "
f"{net} between {j_ref} and {ic_ref}."
)
requirement = f"Cited ESD requirement: {quote}."
else:
rec = (
f"Review board ESD on {net} between {j_ref} and {ic_ref}."
)
requirement = (
"ConnectorIC nets are reviewed for a board ESD part. "
"No datasheet section is cited for this net."
)
out.append(Finding(
designator=ic_ref,
mpn=ic.mpn or "",
aspect="esd",
finding=(
f"{net} is a {j_ref}{ic_ref} path with no ESD/"
"protection part on the net."
),
finding=finding,
facts=(
f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; "
"ESD parts on net: 0."
+ (f" cite={quote}." if quote else " cite=none.")
),
requirement=(
"External ESD is recommended on connectorIC nets unless "
"the extraction lists a mandatory clamp with a measured FACT."
),
requirement=requirement,
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.",
why="Only J* ∩ IC nets; NC, unconnected, onboard rails excluded.",
status="WARNING",
recommendation=rec,
action=rec,
+1 -1
View File
@@ -385,7 +385,7 @@ class ValidationReport(BaseModel):
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
# M0 protocol cert: empty instances / "nessun protocollo riconosciuto". No Z.
# M0 protocol cert: empty instances / "No protocol recognized.". No Z.
protocol_certification: dict[str, Any] | None = None
+55 -2
View File
@@ -149,6 +149,52 @@ def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
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(
schema: dict | None, pcb: dict | None,
) -> dict | None:
@@ -189,10 +235,17 @@ def merge_schema_pcb_reports(
if st in summary:
summary[st] = summary.get(st, 0) + 1
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 [])
pcb_nr = list((pcb or {}).get("not_reviewed") or [])
if schema_nr or pcb_nr:
out["not_reviewed"] = schema_nr + pcb_nr
out["not_reviewed"] = _merge_not_reviewed(schema_nr, pcb_nr, covered, merged_err)
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
@@ -25,7 +25,7 @@ CATALOG_DIR = Path(__file__).resolve().parent / "protocol_data"
CATALOG_PATH = CATALOG_DIR / "catalog.json"
SCHEMA_PATH = CATALOG_DIR / f"schema-{SCHEMA_VERSION}.json"
EMPTY_PROTOCOL_MESSAGE = "nessun protocollo riconosciuto"
EMPTY_PROTOCOL_MESSAGE = "No protocol recognized."
BusType = Literal[
"MEMORY",
@@ -306,6 +306,7 @@ def _cite(
section: str | None = None,
table: str | None = None,
page: str | None = None,
url: str | None = None,
) -> dict[str, str | None]:
return {
"document": document,
@@ -314,6 +315,7 @@ def _cite(
"section": section,
"table": table,
"page": page,
"url": url,
}
@@ -372,6 +374,7 @@ def _num(
section: str | None = None,
table: str | None = None,
page: str | None = None,
url: str | None = None,
mandatory: MandatoryClass = "MANDATORY",
source_type: SourceType = "STANDARD",
source_class: SourceClass = "NORMATIVE",
@@ -397,6 +400,7 @@ def _num(
section=section,
table=table,
page=page,
url=url,
),
)
@@ -642,6 +646,30 @@ DOC_SLLA633 = (
"TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204"
)
REV_SLLA633 = "SLLA633 MAY 2024"
DOC_MICREL_AN133 = "Application Note 133 Interfacing Fast Ethernet Transceivers to MAC Processors"
REV_MICREL_AN133 = "Rev. 1.0, January 2006"
ORG_MICREL = "Micrel"
PATH_MICREL_AN133 = "standards/protocol-specs/micrel_ap_note_133_interfacing_to_mac.pdf"
DOC_OPEN_RGMII = (
"OPEN Alliance RGMII EPL (Electrical-Physical Layer) Recommendations"
)
REV_OPEN_RGMII = "V2.2, October 25, 2016"
ORG_OPEN = "OPEN Alliance"
PATH_OPEN_RGMII = "standards/protocol-specs/OPEN_RGMII_EPL_V_2_public.pdf"
DOC_SGMII_CISCO = "Serial-GMII Specification"
REV_SGMII_CISCO = "Revision 1.7, July 19, 2001"
ORG_CISCO = "Cisco Systems"
PATH_SGMII = "standards/protocol-specs/SGMII.pdf"
DOC_SGMII_NEEDED = (
"Cisco Serial-GMII Specification ENG-46158 Rev 1.7 on file — field not stated as a "
"PCB routing number in that document"
)
DOC_OPEN_RGMII_NEEDED = (
"OPEN Alliance RGMII EPL V2.2 on file — field not stated (no PCB Z0 / mm length in EPL)"
)
DOC_MICREL_AN133_NEEDED = (
"Micrel AN-133 Rev 1.0 on file — field not stated (no PCB Z0 / setup-hold / skew mm)"
)
DDR_CHECKS = [
"byte_lane_mapping",
"dq_to_dqs_skew",
@@ -710,10 +738,65 @@ CAN_CHECKS = [
RGMII_CHECKS = [
"timing",
"clock_data_relationship",
"clock_duty_high_min",
"clock_duty_low_min",
"data_clock_skew_dod_source_max",
"data_clock_skew_dod_dest_max",
"data_clock_skew_dos_source_min",
"data_clock_skew_dos_dest",
"clock_data_delay_mismatch",
"receiver_load_cap_max",
"rise_time_max",
"fall_time_max",
"impedance",
"length",
"delay",
"topology",
]
RMII_CHECKS = [
"refclk_frequency",
"mdc_max_frequency",
"source_termination_min",
"source_termination_max",
"impedance",
"length",
"skew",
"setup_time",
"hold_time",
"topology",
]
MII_CHECKS = [
"tx_rx_clock_frequency",
"mdc_max_frequency",
"source_termination_min",
"source_termination_max",
"impedance",
"length",
"skew",
"setup_time",
"hold_time",
"topology",
]
SGMII_CHECKS = [
"data_rate",
"clock_frequency",
"differential_impedance",
"impedance_tolerance",
"receiver_differential_impedance_min",
"receiver_differential_impedance_max",
"intra_pair_skew",
"clock_to_data_min",
"clock_to_data_max",
"setup_time",
"hold_time",
"rise_time_min",
"rise_time_max",
"fall_time_min",
"fall_time_max",
"maximum_channel_length",
"topology",
"termination",
]
HYPERBUS_CHECKS = [
"dq_to_rwds_skew",
"ck_to_dq_relationship",
@@ -755,7 +838,14 @@ def build_m0_catalog() -> dict[str, Any]:
L("2.5gbase-t", "2.5GBASE-T", "SERIAL", "ETHERNET")
L("5gbase-t", "5GBASE-T", "SERIAL", "ETHERNET")
L("10gbase-t", "10GBASE-T", "SERIAL", "ETHERNET")
L("sgmii", "SGMII", "SERIAL", "ETHERNET")
L("sgmii", "SGMII", "SERIAL", "ETHERNET",
"Serial GMII MACPHY (Cisco ENG-46158). Not IEEE 802.3 MDI / RJ45.")
L("rgmii", "RGMII MAC-PHY", "PERIPHERAL", "ETHERNET",
"Reduced Gigabit MII MACPHY. OPEN Alliance EPL V2.2 on file.")
L("rmii", "RMII MAC-PHY", "PERIPHERAL", "ETHERNET",
"Reduced MII MACPHY (10/100). Micrel AN-133 on file (VENDOR).")
L("mii", "MII MAC-PHY", "PERIPHERAL", "ETHERNET",
"Media Independent Interface MACPHY. Micrel AN-133 on file (VENDOR); IEEE 802.3 Clause 22 referenced there.")
L("1000base-x", "1000BASE-X", "SERIAL", "ETHERNET")
L("100base-fx", "100BASE-FX", "SERIAL", "ETHERNET")
L("10gbase-r", "10GBASE-R", "SERIAL", "ETHERNET")
@@ -822,7 +912,6 @@ def build_m0_catalog() -> dict[str, Any]:
L("ucie", "UCIe (logical)", "CHIP_TO_CHIP", "UCIE")
L("i2c", "I2C / SMBus", "PERIPHERAL", "I2C")
L("can", "CAN / CAN FD", "PERIPHERAL", "CAN")
L("rgmii", "RGMII MAC-PHY", "PERIPHERAL", "ETHERNET")
def _usb2_cons(pid: str, *, speed: str) -> list[dict[str, Any]]:
"""speed: ls | fs | hs | lsfs. PCB Zdiff stays UNKNOWN (cable 90 Ω is CABLE; PCB is should)."""
@@ -1508,8 +1597,374 @@ def build_m0_catalog() -> dict[str, Any]:
source_type="CABLE",
conditions=["maximum propagation delay of a link segment, 2100 MHz"],
))
return out
def _sgmii_cons(pid: str) -> list[dict[str, Any]]:
"""Cisco Serial-GMII ENG-46158 Rev 1.7 — VENDOR, not IEEE MDI."""
out: list[dict[str, Any]] = [
_num(
pid, "data_rate", 1.25, "Gbaud",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Overview", page="3",
url=PATH_SGMII, source_type="VENDOR", source_class="VENDOR",
conditions=["1.25 Gbaud data; 625 MHz DDR clock"],
),
_num(
pid, "clock_frequency", 625.0, "MHz",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Overview", page="3",
url=PATH_SGMII, source_type="VENDOR", source_class="VENDOR",
conditions=["DDR interface"],
),
_num(
pid, "differential_impedance", 100.0, "ohm",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver DC specification",
table="table 2", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
mandatory="RECOMMENDED",
conditions=[
"All parameters measured at Rload = 100 ohms +-1% load",
"measurement load — not IEEE 802.3 MDI cabling 100 Ω",
],
),
_num(
pid, "impedance_tolerance", 1.0, "%",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver DC specification",
table="table 2", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
mandatory="RECOMMENDED",
conditions=["Rload = 100 ohms +-1%"],
),
_num(
pid, "receiver_differential_impedance_min", 80.0, "ohm",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Receiver DC specification",
table="table 3", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["Rin receiver differential input impedance"],
),
_num(
pid, "receiver_differential_impedance_max", 120.0, "ohm",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Receiver DC specification",
table="table 3", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["Rin receiver differential input impedance"],
),
_num(
pid, "intra_pair_skew", 20.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["tskew1 max; skew measured at 50% of the transition"],
),
_num(
pid, "clock_to_data_min", 250.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["tclock2q min; clock to data at 0 V differential"],
),
_num(
pid, "clock_to_data_max", 550.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["tclock2q max"],
),
_num(
pid, "setup_time", 100.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Receiver AC specification",
table="table 5", page="10", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["tsetup min; measured at 50% of the transition"],
),
_num(
pid, "hold_time", 100.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Receiver AC specification",
table="table 5", page="10", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["thold min"],
),
_num(
pid, "rise_time_min", 100.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["trise Vod 20%-80%"],
),
_num(
pid, "rise_time_max", 200.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["trise Vod 20%-80%"],
),
_num(
pid, "fall_time_min", 100.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["tfall Vod 20%-80%"],
),
_num(
pid, "fall_time_max", 200.0, "ps",
document=DOC_SGMII_CISCO, organization=ORG_CISCO,
revision=REV_SGMII_CISCO, section="Driver AC specification",
table="table 4", page="9", url=PATH_SGMII,
source_type="VENDOR", source_class="VENDOR",
conditions=["tfall Vod 20%-80%"],
),
_c(pid, "maximum_channel_length", "UNKNOWN",
needed_document=DOC_SGMII_NEEDED),
_c(pid, "topology", "UNKNOWN",
needed_document=DOC_SGMII_NEEDED),
_c(pid, "termination", "UNKNOWN",
needed_document=(
DOC_SGMII_NEEDED + "; LVDS basis IEEE1596.3-1996 referenced, "
"no separate PCB termination ohm stated beyond Rin/Rload"
)),
]
return out
def _rgmii_cons(pid: str) -> list[dict[str, Any]]:
"""OPEN Alliance RGMII EPL V2.2 — industry STANDARD cite."""
out: list[dict[str, Any]] = [
_num(
pid, "clock_duty_high_min", 3.6, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.2", table="Table 2",
page="11", url=PATH_OPEN_RGMII,
conditions=["TdutyHigh; DoD source (also Tables 35)"],
),
_num(
pid, "clock_duty_low_min", 3.6, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.2", table="Table 2",
page="11", url=PATH_OPEN_RGMII,
conditions=["TdutyLow"],
),
_num(
pid, "data_clock_skew_dod_source_max", 0.5, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.2", table="Table 2",
page="11", url=PATH_OPEN_RGMII,
conditions=["TDCskewDoDTr / TCDskewDoDTr / Tf max at signal source"],
),
_num(
pid, "data_clock_skew_dod_dest_max", 0.65, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.2", table="Table 3",
page="12", url=PATH_OPEN_RGMII,
conditions=["TDCskewDoDRr / TCDskewDoDRr max at signal destination"],
),
_num(
pid, "data_clock_skew_dos_source_min", 1.2, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.3", table="Table 4",
page="13", url=PATH_OPEN_RGMII,
conditions=["TDCskewDoSTr / TCDskewDoSTr min at signal source"],
),
_num(
pid, "data_clock_skew_dos_dest", 1.05, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.3", table="Table 5",
page="14", url=PATH_OPEN_RGMII,
conditions=["TDCskewDoSRr / TCDskewDoSRr at signal destination"],
),
_num(
pid, "clock_data_delay_mismatch", 150.0, "ps",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="7.1", page="10",
url=PATH_OPEN_RGMII, mandatory="INFORMATIONAL",
conditions=[
"Note: parameters assume a delay mismatch between clock and data of 150psec",
],
),
_num(
pid, "receiver_load_cap_max", 5.0, "pF",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="8", table="Table 6",
page="15", url=PATH_OPEN_RGMII,
conditions=["CloadR_max"],
),
_num(
pid, "rise_time_max", 1.0, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="8", table="Table 6",
page="15", url=PATH_OPEN_RGMII,
conditions=["tR_max 20% to 80%; test load 5 pF at signal source"],
),
_num(
pid, "fall_time_max", 1.0, "ns",
document=DOC_OPEN_RGMII, organization=ORG_OPEN,
revision=REV_OPEN_RGMII, section="8", table="Table 6",
page="15", url=PATH_OPEN_RGMII,
conditions=["tF_max 80% to 20%; test load 5 pF"],
),
_c(pid, "impedance", "UNKNOWN",
needed_document=DOC_OPEN_RGMII_NEEDED),
_c(pid, "length", "UNKNOWN",
needed_document=DOC_OPEN_RGMII_NEEDED),
_c(pid, "delay", "UNKNOWN",
needed_document=(
DOC_OPEN_RGMII_NEEDED + "; skews are time (ns), not PCB mm delay"
)),
_c(pid, "topology", "UNKNOWN",
needed_document=(
"OPEN Alliance RGMII EPL V2.2 §7.1 defines DoD and DoS delay modes "
"but no PCB topology mm rule"
)),
_c(pid, "timing", "UNKNOWN",
needed_document=(
"OPEN Alliance RGMII EPL V2.2 Tables 25 pack duty/skew as separate "
"NUMERIC rows; no single timing scalar"
)),
_c(pid, "clock_data_relationship", "UNKNOWN",
needed_document=(
"OPEN Alliance RGMII EPL V2.2 §7.1 / Tables 25 — see skew NUMERIC rows "
"and clock_data_delay_mismatch 150 ps note"
)),
]
return out
def _rmii_cons(pid: str) -> list[dict[str, Any]]:
"""Micrel AN-133 — VENDOR app note (not STANDARD)."""
return [
_num(
pid, "refclk_frequency", 50.0, "MHz",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="RMII", page="2",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
conditions=["Connect a 50 MHz clock source to REFCLK; also used by MAC"],
),
_num(
pid, "mdc_max_frequency", 5.0, "MHz",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="MIIM", page="4",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
conditions=["MDC clock input maximum 5 MHz (KS8721/KS8001)"],
),
_num(
pid, "source_termination_min", 22.0, "ohm",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="RMII Example", page="7",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
mandatory="OPTIONAL",
conditions=[
"Source termination (22-50 Ohms) maybe required on the RMII clock and data lines",
],
),
_num(
pid, "source_termination_max", 50.0, "ohm",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="RMII Example", page="7",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
mandatory="OPTIONAL",
conditions=[
"Source termination (22-50 Ohms) maybe required on the RMII clock and data lines",
],
),
_c(pid, "impedance", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=(
DOC_MICREL_AN133_NEEDED + ". Optional PHY: LAN8720A DS00002165C has "
"RMII setup/hold (Tables 5-10/5-11) but no PCB Z0"
)),
_c(pid, "length", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "skew", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "setup_time", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=(
"PHY datasheet (optional HubAudio cite: LAN8720A/LAN8720Ai "
"DS00002165C Table 5-11 REF_CLK In Mode tsu min 4.0 ns — not packed "
"into generic rmii-pcb)"
)),
_c(pid, "hold_time", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=(
"PHY datasheet (optional HubAudio cite: LAN8720A DS00002165C "
"Table 5-11 tihold min 1.5 ns — not packed into generic rmii-pcb)"
)),
_c(pid, "topology", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
]
def _mii_cons(pid: str) -> list[dict[str, Any]]:
"""Micrel AN-133 MII — VENDOR. IEEE Clause 22 referenced but no IEEE PCB ohms."""
return [
_num(
pid, "tx_rx_clock_frequency", 25.0, "MHz",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="RMII", page="2",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
conditions=[
"RMII 50 MHz reference clock replaces the 25 MHz transmit and receive clocks in MII",
],
),
_num(
pid, "mdc_max_frequency", 5.0, "MHz",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="MIIM", page="4",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
conditions=["MDC clock input maximum 5 MHz"],
),
_num(
pid, "source_termination_min", 22.0, "ohm",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="MII Example", page="6",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
mandatory="OPTIONAL",
conditions=[
"Source termination (22-50 Ohms) maybe required on the MII clock and data lines",
],
),
_num(
pid, "source_termination_max", 50.0, "ohm",
document=DOC_MICREL_AN133, organization=ORG_MICREL,
revision=REV_MICREL_AN133, section="MII Example", page="6",
url=PATH_MICREL_AN133, source_type="VENDOR", source_class="VENDOR",
mandatory="OPTIONAL",
conditions=[
"Source termination (22-50 Ohms) maybe required on the MII clock and data lines",
],
),
_c(pid, "impedance", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "length", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "skew", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "setup_time", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "hold_time", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
_c(pid, "topology", "UNKNOWN",
source_type="VENDOR", source_class="VENDOR",
needed_document=DOC_MICREL_AN133_NEEDED),
]
def _hdmi_cons(pid: str, *, hdmi20_plus: bool = False, frl: bool = False) -> list[dict[str, Any]]:
# HDMI 2.0+ Michele exception: TI layout as source_type VENDOR.
# TMDS1204-specific limits (role/chip) stay PHY_DEPENDENT with no number.
@@ -2049,12 +2504,11 @@ def build_m0_catalog() -> dict[str, Any]:
("2.5gbase-t", "2.5gbase-t-mdi", ["2.5GBASE", "NBASE"]),
("5gbase-t", "5gbase-t-mdi", ["5GBASE"]),
("10gbase-t", "10gbase-t-mdi", ["10GBASE-T"]),
("sgmii", "sgmii-pcb", ["SGMII"]),
("1000base-x", "1000base-x-pcb", ["1000BASE-X", "SFP"]),
("100base-fx", "100base-fx-pcb", ["100BASE-FX", "FX"]),
("10gbase-r", "10gbase-r-pcb", ["10GBASE-R", "SFI"]),
):
conn = "none" if lid in {"sgmii", "1000base-x", "100base-fx", "10gbase-r"} else "RJ45"
conn = "none" if lid in {"1000base-x", "100base-fx", "10gbase-r"} else "RJ45"
physical.append(_iface(
pid, lid, "SERIAL", "SERIAL_DIFFERENTIAL", "YES",
ETH_CHECKS, _eth_cons(pid, lid),
@@ -2064,7 +2518,21 @@ def build_m0_catalog() -> dict[str, Any]:
"IEEE 802.3-2012 Section Two (Clauses 2133), and "
"IEEE 802.3-2012 Section Three (Clause 40 1000BASE-T; Clause 36 1000BASE-X) on file. "
"100 Ω in Clause 14/25/40 is cabling/link-segment, not PCB Z. "
"Not auto from RJ45. IEEE vs PHY vs PCB vs magnetics are distinct."
"Not auto from RJ45. IEEE vs PHY vs PCB vs magnetics are distinct. "
"SGMII is packed separately from Cisco ENG-46158 (not an IEEE MDI clause)."
),
incomplete=True,
))
physical.append(_iface(
"sgmii-pcb", "sgmii", "SERIAL", "SERIAL_DIFFERENTIAL", "YES",
SGMII_CHECKS,
_sgmii_cons("sgmii-pcb"),
connector="none",
hints=["SGMII", "SGMII_TX", "SGMII_RX"],
notes=(
"SGMII MACPHY from Cisco Serial-GMII Specification ENG-46158 Rev 1.7. "
"source_type=VENDOR (Cisco IP released for industry adoption) — not IEEE 802.3 MDI. "
"Not RJ45 100 Ω cabling. Measurement Rload 100 Ω ±1%; Rin 80120 Ω."
),
incomplete=True,
))
@@ -2421,20 +2889,39 @@ def build_m0_catalog() -> dict[str, Any]:
physical.append(_iface(
"rgmii-pcb", "rgmii", "PERIPHERAL", "PARALLEL_SINGLE_ENDED", "YES",
RGMII_CHECKS,
[
_c("rgmii-pcb", "timing", "PHY_DEPENDENT",
source_type="PHY", source_class="VENDOR"),
_c("rgmii-pcb", "clock_data_relationship", "PHY_DEPENDENT",
source_type="PHY", source_class="VENDOR"),
_c("rgmii-pcb", "length", "UNKNOWN"),
_c("rgmii-pcb", "delay", "UNKNOWN"),
_c("rgmii-pcb", "topology", "UNKNOWN"),
],
_rgmii_cons("rgmii-pcb"),
hints=["RGMII", "TX_CTL", "RX_CTL", "GTX_CLK", "RX_CLK"],
notes=(
"RGMII MACPHY. Timing/delay UNKNOWN without PHY cite. "
"Length is not delay. Not 100BASE-TX magnetics."
"RGMII MACPHY from OPEN Alliance RGMII EPL V2.2 (STANDARD/industry). "
"Timing skews NUMERIC; PCB Z0 and mm length UNKNOWN (not stated). "
"Not RJ45/MDI 100 Ω. Not 100BASE-TX magnetics."
),
incomplete=True,
))
physical.append(_iface(
"rmii-pcb", "rmii", "PERIPHERAL", "PARALLEL_SINGLE_ENDED", "YES",
RMII_CHECKS,
_rmii_cons("rmii-pcb"),
hints=["RMII", "ETH_RXD", "ETH_TXD", "ETH_TXEN", "CRS_DV", "REF_CLK"],
notes=(
"RMII MACPHY from Micrel AN-133 Rev 1.0 (VENDOR app note — not STANDARD). "
"REFCLK 50 MHz and optional 2250 Ω source termination packed. "
"PCB Z0 / setup-hold / skew UNKNOWN in AN-133. Not RJ45/MDI 100 Ω. "
"Optional PHY timing: LAN8720A datasheet (HubAudio) — not packed into this generic row."
),
incomplete=True,
))
physical.append(_iface(
"mii-pcb", "mii", "PERIPHERAL", "PARALLEL_SINGLE_ENDED", "YES",
MII_CHECKS,
_mii_cons("mii-pcb"),
hints=["MII", "TX_CLK", "RX_CLK", "TX_EN", "RX_DV", "CRS", "COL"],
notes=(
"MII MACPHY from Micrel AN-133 Rev 1.0 (VENDOR). "
"25 MHz TX/RX clocks cited via RMII contrast; optional 2250 Ω source termination. "
"PCB Z0 UNKNOWN. IEEE 802.3 Clause 22 referenced in AN-133 but no IEEE PCB ohms packed."
),
incomplete=True,
))
return {
File diff suppressed because it is too large Load Diff
+165 -8
View File
@@ -13,8 +13,18 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
ResistorSpecs,
)
from backend.periscopex.pcb_net_match import (
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
from backend.periscopex.protocol_catalog import (
SCHEMA_VERSION,
PhysicalInterface,
@@ -47,7 +57,8 @@ L0_CHECKS = frozenset({
"superspeed_pairs",
})
_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I)
_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I)
_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I)
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
@@ -160,6 +171,156 @@ def certify_instance_l0(
return out
def _parse_ohms(raw: str | None) -> float | None:
t = (raw or "").strip().upper().replace("OHMS", "").replace("OHM", "")
t = t.replace("Ω", "").replace(" ", "")
if not t:
return None
m = _OHM_EMBEDDED.match(t)
if m:
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult
m = _OHM_SUFFIX.match(t)
if m:
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
return float(m.group(1)) * mult
try:
return float(t)
except ValueError:
return None
def _resistor_ohms(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
return float(specs.value_ohms)
return _parse_ohms(comp.value)
def _rd_cite() -> tuple[float | None, str]:
"""CabCon Rd already packed. No other USB number is introduced here."""
cat = load_catalog()
chosen = None
fallback = None
for iface in cat.physical_interfaces:
if iface.id not in {"usb-c-usb2-receptacle", "usb-c-receptacle"}:
continue
for c in iface.constraints:
if c.parameter == "rd" and c.value is not None:
if iface.id == "usb-c-usb2-receptacle":
chosen = c
elif fallback is None:
fallback = c
chosen = chosen or fallback
if chosen is None or chosen.value is None:
return None, "CabCon Rd cite is not in the protocol pack."
src = chosen.source
bits = []
if src and src.document:
bits.append(src.document)
if src and src.revision:
bits.append(src.revision)
if src and src.section:
bits.append(f"§{src.section}")
if src and src.table:
bits.append(src.table)
if src and src.page:
bits.append(f"p.{src.page}")
nominal = "nominal 5.1 kΩ" if any(
"5.1" in (cond or "") for cond in (chosen.conditions or [])
) else f"{chosen.value:g} {chosen.unit or 'ohm'}"
cite = ", ".join(bits) if bits else chosen.id
return float(chosen.value), f"{cite}; {nominal}"
def _cc_pin_pairs(comp: Component) -> list[tuple[str, str | None]]:
"""Receptacle CC pins. A5/B5 are the Type-C CC contacts, not net names."""
pins = {str(k).upper(): v for k, v in comp.pins.items()}
if "A5" in pins or "B5" in pins:
return [("A5", pins.get("A5")), ("B5", pins.get("B5"))]
if "CC1" in pins or "CC2" in pins:
return [("CC1", pins.get("CC1")), ("CC2", pins.get("CC2"))]
return []
def _typec_receptacle(graph: DesignGraph, inst: PhysicalBusInstance) -> Component | None:
found: list[Component] = []
for comp in graph.components.values():
if comp.component_type != ComponentType.CONNECTOR:
continue
if _cc_pin_pairs(comp):
found.append(comp)
if not found:
return None
for comp in found:
if comp.reference == inst.host_ref:
return comp
inst_nets = set(inst.nets)
for g in inst.groups:
inst_nets.update(g.nets)
for comp in found:
if inst_nets.intersection(v for v in comp.pins.values() if v):
return comp
return found[0]
def _rd_refs_on_net(graph: DesignGraph, net: str, nominal: float) -> list[str]:
"""Resistors on this net whose value is the packed Rd (float slack 1 Ω)."""
hits: list[str] = []
for ref in refs_on_matched_net(graph, net):
comp = graph.components.get(ref)
if comp is None or comp.component_type != ComponentType.RESISTOR:
continue
ohms = _resistor_ohms(comp)
if ohms is not None and abs(ohms - nominal) <= 1.0:
hits.append(ref)
return hits
def _cc_rd_on_pins(
graph: DesignGraph,
inst: PhysicalBusInstance,
mand: MandatoryClass,
) -> L0CheckResult:
"""Pin → net → Rd. The net does not have to be named CC1/CC2."""
nominal, cite = _rd_cite()
jack = _typec_receptacle(graph, inst)
if jack is None or nominal is None:
return _pack(
"cc_rd_rp", "FAIL", mand,
notes=(
"Type-C CC pins were not followed to an Rd. "
f"{cite}"
),
)
pairs = _cc_pin_pairs(jack)
nets: list[str] = []
missing: list[str] = []
present: list[str] = []
for pin, net in pairs:
if not net:
missing.append(f"{jack.reference}.{pin} has no net")
continue
nets.append(net)
refs = _rd_refs_on_net(graph, net, nominal)
if refs:
present.append(f"{jack.reference}.{pin} net {net} Rd {', '.join(refs)}")
else:
missing.append(
f"{jack.reference}.{pin} net {net} has no {nominal:g} Ω Rd"
)
if missing or len(present) < 2:
detail = "; ".join(missing) if missing else "both CC pins need an Rd"
return _pack(
"cc_rd_rp", "FAIL", mand, nets=nets,
notes=f"{detail}. {cite}. Net name is not required.",
)
return _pack(
"cc_rd_rp", "PASS", mand, nets=nets,
notes=f"{'; '.join(present)}. {cite}.",
)
def _run_l0_check(
graph: DesignGraph,
inst: PhysicalBusInstance,
@@ -212,11 +373,7 @@ def _run_l0_check(
ok, msg = _structural_nets_ok(graph, clocks)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg)
if check == "cc_rd_rp":
ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))]
if len(ccs) < 1:
return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.")
ok, msg = _structural_nets_ok(graph, ccs)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg)
return _cc_rd_on_pins(graph, inst, mand)
if check == "vbus_gnd":
vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
@@ -4,7 +4,9 @@ Skew is compared only when the constraint is NUMERIC (length unit) or an
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
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
@@ -497,11 +499,14 @@ def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> list
finding = f"L1 geometric {r.check}: {r.notes}"
elif skip_family:
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
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"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per {r.check} ({r.result}). Not electrical."
f"{inst.physical_interface_id} L1 {r.check} is {r.result}. "
"Geometric only, not electrical."
)
else:
rule_id = "PE-PRT-L0-002"
+308 -35
View File
@@ -1,21 +1,23 @@
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
Z only from datasheet / stackup / fabricator / cited pack — never invented
90 Ω USB. USB-IF/IEEE numbers only if the pack has a cite. Silicon cross is
max PCB × PHY subset when datasheet windows exist. Length is not delay.
Not L3 / OpenEMS. L0/L1 are not electrical certification.
RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent.
Visible skip (PE-AF-002 style) when Z cannot be obtained.
Z only from datasheet / stackup / fabricator / cited pack. USB 2.0 HS
D+/D pair Z is compared to the packed ``pcb_trace_nominal_zdiff``
(RECOMMENDED). That number is not invented here and is not a MANDATORY
FAIL. Silicon cross is max PCB × PHY subset when datasheet windows exist.
A missing field (termination cite, rise/fall, return path, voltage) does
not emit a warning. Length is not delay. Not L3 / OpenEMS.
"""
from __future__ import annotations
import math
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.constraints_lookup import match_constraints
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.impedance import GeometryError, TraceGeometry, coupled_diff_z
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
@@ -23,7 +25,6 @@ from backend.periscopex.models import (
Finding,
LayoutGraph,
)
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.protocol_catalog import (
PhysicalInterface,
ProtocolCatalog,
@@ -31,8 +32,10 @@ from backend.periscopex.protocol_catalog import (
load_catalog,
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_recognize import PhysicalBusInstance
from backend.periscopex.si_check import single_ended_eth_mac
PACK_MACROPHASE = "M4"
SOURCE = "protocol_l2"
@@ -81,6 +84,8 @@ class L2CheckResult(BaseModel):
notes: str = ""
finding_class: str = ""
status: str = ""
# "" legacy, "silent" no finding, "warn" one WARNING, "error" one ERROR.
emit: str = ""
def _pack(
@@ -95,6 +100,7 @@ def _pack(
limit_ohm_max: float | None = None,
value_kind: str = "",
notes: str = "",
emit: str = "",
) -> L2CheckResult:
if result == "FAIL" and mandatory != "MANDATORY":
result = "WARNING"
@@ -120,6 +126,7 @@ def _pack(
notes=notes,
finding_class=cls,
status=status,
emit=emit,
)
@@ -286,6 +293,24 @@ def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | N
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:
return _pack(
check, "UNKNOWN" if kind not in {
@@ -294,10 +319,10 @@ def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: s
} else kind,
mand,
value_kind=kind,
emit="silent",
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di Z (datasheet/stackup/fab/cited pack). "
f"Not USB/IEC folklore. L0/L1 are not electrical certification."
f"{inst.physical_interface_id} has no cited Z window "
"(datasheet, stackup, fabricator, or pack). Not a warning."
),
)
@@ -349,8 +374,8 @@ def _run_l2_check(
"differential_impedance", "impedance", "impedance_if_required",
}:
return _z_check(
check, mand, cons, kind, inst, graph, nets,
constraints_map, impedance_nets,
check, mand, cons, kind, inst, iface, graph, nets,
constraints_map, impedance_nets, layout,
)
if check in {"termination", "cc_termination"}:
@@ -362,10 +387,10 @@ def _run_l2_check(
if check in {"rise_time", "fall_time", "timing"}:
return _pack(
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
nets=nets, value_kind=kind,
nets=nets, value_kind=kind, emit="silent",
notes=(
"Length is not delay. Rise/fall/timing UNKNOWN without a cited "
"time source (no tpd invented from mm). Not L3."
"Length is not delay. Rise/fall/timing stays UNKNOWN without a cited "
"time source (no tpd invented from mm). Not L3. Not a warning."
),
)
@@ -374,27 +399,234 @@ def _run_l2_check(
if verdict == "NUMERIC":
verdict = "UNKNOWN"
return _pack(
check, verdict, mand, nets=nets, value_kind=kind,
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
notes=(
f"{check} not certified without a cited electrical source. "
f"Not invented. L0/L1 are not electrical certification."
f"{check} has no cited electrical source. "
"Not invented. Not a warning."
),
)
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(
check: str,
mand: MandatoryClass,
cons: ProtocolConstraint | None,
kind: str,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
graph: DesignGraph,
nets: list[str],
constraints_map: dict[str, ComponentConstraints] | None,
impedance_nets: list[dict] | dict | None,
layout: LayoutGraph | None,
) -> L2CheckResult:
pair = _usb_dp_dm(inst)
cite = _pcb_zdiff_cite(iface)
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)
windows: list[tuple[float, float]] = []
if pack_w:
@@ -403,7 +635,6 @@ def _z_check(
window = intersect_windows(windows) if windows else None
measured = measured_z_ohm(nets, impedance_nets)
if window is None:
# no cited pack number and no datasheet PHY window → never invent 90 Ω
if measured is None:
return _skip_z(check, mand, inst, kind)
return _pack(
@@ -413,15 +644,15 @@ def _z_check(
"VENDOR_DEPENDENT", "UNKNOWN",
} else "UNKNOWN",
mand,
nets=nets, measured_ohm=measured, value_kind=kind,
nets=nets, measured_ohm=measured, value_kind=kind, emit="silent",
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di Z target (cited pack or PHY datasheet). "
f"Measured stackup/fab Z is not compared to invented USB/IEC ohms. "
f"L0/L1 are not electrical certification."
f"{inst.physical_interface_id} has measured Z and no cited target. "
"Not compared to an invented ohm. Not a warning."
),
)
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)
verdict = electrical_verdict(cons, measured, window)
source_note = (
@@ -474,10 +705,10 @@ def _termination_check(
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
} else "UNKNOWN"
return _pack(
check, verdict, mand, nets=nets, value_kind=kind,
check, verdict, mand, nets=nets, value_kind=kind, emit="silent",
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di termination cite. No invented Rd/ohm."
f"{inst.physical_interface_id} has no termination cite. "
"No invented resistor. Not a warning."
),
)
measured = None
@@ -517,8 +748,8 @@ def _levels_check(
unit = (cons.unit or "").strip().lower()
if unit in _VOLT_UNITS:
return _pack(
check, "UNKNOWN", mand, value_kind="NUMERIC",
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V.",
check, "UNKNOWN", mand, value_kind="NUMERIC", emit="silent",
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V. Not a warning.",
)
if constraints_map:
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)
if ds and ds.absolute_maximum_ratings:
return _pack(
check, "UNKNOWN", mand, value_kind=kind,
check, "UNKNOWN", mand, value_kind=kind, emit="silent",
notes=(
"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 {
@@ -539,15 +770,58 @@ def _levels_check(
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
} else "UNKNOWN"
return _pack(
check, verdict, mand, value_kind=kind,
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented.",
check, verdict, mand, value_kind=kind, emit="silent",
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]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
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":
continue
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"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per {r.check} ({r.result})."
f"{inst.physical_interface_id} L2 {r.check} is {r.result}."
)
else:
rule_id = "PE-PRT-L0-002"
@@ -509,9 +509,28 @@ def certify_instance_l3(
if c in L3_CHECKS and c not in checks:
checks.append(c)
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]
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]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
@@ -533,12 +552,8 @@ def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list
status = "ERROR"
finding = f"L3 channel {r.check}: {r.notes}"
elif skip_family:
rule_id = "PE-PRT-L3-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
f"per {r.check} ({r.result})."
)
# No channel FACT, or a field with nothing to certify. Omit the warning.
continue
else:
rule_id = "PE-PRT-L0-002"
status = "INFO"
@@ -114,6 +114,12 @@ def check_to_report_row(
result = str(dump.get("result") or "")
cons = _constraint_for(iface, check) if iface is not None else None
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 [])
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
group = _group_for_net(inst, net or None)
@@ -130,6 +136,7 @@ def check_to_report_row(
"source": doc,
"method": method,
"notes": dump.get("notes") or "",
"emit": dump.get("emit") or "",
"skip_visible": skip,
"rank": result_rank(result),
"chain": {
@@ -181,6 +188,21 @@ def format_chain(chain: dict[str, Any]) -> str:
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(
inst: PhysicalBusInstance,
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 (l3 or [])]
)
rows = [check_to_report_row(d, inst, iface) for d in dumps]
if not any(r["level"] == "L3" for r in rows):
rows.append(l3_not_run_row(inst))
rows = [r for r in (check_to_report_row(d, inst, iface) for d in dumps) if not _noise_row(r)]
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
+56 -5
View File
@@ -35,6 +35,9 @@ _XTAL_RE = re.compile(
re.I,
)
# MDIO/MDC management (often single-ended). Not RJ45/MDI 100 Ω differential.
_MDIO_MGMT_RE = re.compile(r"(?:^|[_/.])(?:MDIO|MDC)$", re.I)
_HS_CLASS_RE = (
("usb3", re.compile(
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
@@ -42,9 +45,15 @@ _HS_CLASS_RE = (
("hdmi", re.compile(r"HDMI", re.I)),
("pcie", re.compile(r"PCIE|PEX_", 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(
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
# MDI(?!O) — RJ45 Media Dependent Interface, never MDIO management.
r"(?:^|[_/])(MDI(?!O)|TRD[0-3]|TCT|RCT)|"
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
re.I,
)),
@@ -92,8 +101,10 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
"magnetics": frozenset({"eth_mdi"}),
"rgmii": frozenset({"rgmii"}),
"gmii": frozenset({"rgmii"}),
"mac_phy": frozenset({"rgmii", "sgmii"}),
"mac": frozenset({"rgmii", "sgmii"}),
"rmii": frozenset({"rmii"}),
"mii": frozenset({"rmii"}),
"mac_phy": frozenset({"rmii", "rgmii", "sgmii"}),
"mac": frozenset({"rmii", "rgmii", "sgmii"}),
"sgmii": frozenset({"sgmii"}),
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
@@ -143,6 +154,31 @@ def partner_net(name: str) -> str | 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 PHYMAC 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:
n = normalize_kicad_hierarchy_net(net)
return n.split("/")[-1] if n else ""
@@ -162,19 +198,29 @@ def skip_si_net(net: str) -> bool:
return bool(_SKIP_RE.search(leaf))
def is_mdio_mgmt(net: str) -> bool:
"""ETH_MDIO / ETH_MDC / MDIO — management, not IEEE MDI cabling."""
leaf = _leaf(net)
return bool(_MDIO_MGMT_RE.search(leaf) or _MDIO_MGMT_RE.search(net or ""))
def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if _XTAL_RE.search(leaf):
return None
if is_mdio_mgmt(net):
return None
u = leaf.upper()
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
return "usb3"
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
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"
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):
return "eth_mdi"
for cls, cre in _HS_CLASS_RE:
@@ -228,6 +274,7 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
scans: tuple[tuple[str, str], ...] = (
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
(r"\brmii\b|\bmii\b|txen|rxdv|crs_dv", "rmii"),
(r"sgmii", "sgmii"),
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
(r"ddr3|\bddr\b", "ddr3"),
@@ -358,6 +405,8 @@ def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]
if targets:
if not _bus_in_targets(bc, targets):
continue
if single_ended_eth_mac(net) and "eth_mdi" in targets:
continue
else:
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
if not pin:
@@ -530,6 +579,8 @@ def check_si(
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
paired: set[tuple[str, str]] = set()
for net in nets:
if want_diff and single_ended_eth_mac(net):
continue
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow) if want_diff else None
key_net = _pair_key(net, partner) if partner else (net, "")
+57 -12
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -153,16 +154,15 @@ def si_extract_needed_skips(
}
if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver):
continue
refs = ",".join(sorted(refs_by_mpn.get(mpn, []))) or mpn
out.append({
"designator": refs,
"reason": (
refs = sorted(refs_by_mpn.get(mpn, [])) or [mpn]
reason = (
f"SI layout_rules empty at model_version="
f"{cons.model_version}; re-run schematic review "
f"to re-extract pintable {scan_ver} "
"(PCB does not re-read the PDF)."
),
})
)
for ref in refs:
out.append({"designator": ref, "reason": reason})
return out
@@ -220,6 +220,14 @@ async def run_pcb_pipeline(
pcb_state=None,
)
t_total = time.perf_counter()
stage_seconds: dict[str, float] = {}
def _lap(name: str, started: float) -> float:
seconds = round(time.perf_counter() - started, 3)
stage_seconds[name] = seconds
return seconds
try:
async with PipelineWorkspace(storage, user_id, project_id) as ws:
if _cancelled(storage, user_id, project_id):
@@ -247,7 +255,10 @@ async def run_pcb_pipeline(
return
_step(project_id, "classify", "running", "domains and groups")
t_extract = time.perf_counter()
cmap = _load_constraints_map(ws.local_path("extracted"), storage)
extract_s = _lap("extract", t_extract)
_step(project_id, "extract", "complete", f"loaded stored extractions; {extract_s}s")
plan = build_placement_plan(graph, cmap)
fg_path = ws.local_path("functional_groups.json")
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
@@ -306,7 +317,9 @@ async def run_pcb_pipeline(
return
_step(project_id, "checks", "running")
t_checks = time.perf_counter()
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
checks_s = _lap("pcb_checks", t_checks)
scan_ver = app_settings.get_default_model_version()
si_skip = si_extract_needed_skips(graph, cmap, scan_ver)
sipath = ws.local_path("si_extract_needed.json")
@@ -315,7 +328,8 @@ async def run_pcb_pipeline(
_step(
project_id, "checks", "complete",
f"{len(findings)} deterministic findings"
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
+ (f"; {len(si_skip)} SI re-extract" if si_skip else "")
+ f"; {checks_s}s",
)
if _cancelled(storage, user_id, project_id):
@@ -323,6 +337,7 @@ async def run_pcb_pipeline(
return
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
t_af_trace = time.perf_counter()
try:
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
findings.extend(extra_af)
@@ -330,9 +345,10 @@ async def run_pcb_pipeline(
except Exception:
logger.exception("AF trace analysis failed — keeping PCB checks")
n_trace = 0
af_trace_s = _lap("af_trace", t_af_trace)
_step(
project_id, "af_trace", "complete",
f"{n_trace} AF trace findings (SI/HF checks kept)",
f"{n_trace} AF trace findings (SI/HF checks kept); {af_trace_s}s",
)
if _cancelled(storage, user_id, project_id):
@@ -340,10 +356,13 @@ async def run_pcb_pipeline(
return
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
t_af_ai = time.perf_counter()
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
af_ai_s = _lap("af_ai", t_af_ai)
stage_seconds["af"] = round(stage_seconds.get("af_trace", 0) + af_ai_s, 3)
_step(
project_id, "af_ai", "complete",
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
f"{n_af} extra AF+AI findings (SI/HF checks kept); {af_ai_s}s; af={stage_seconds['af']}s",
)
if _cancelled(storage, user_id, project_id):
@@ -353,6 +372,7 @@ async def run_pcb_pipeline(
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
review_errors: dict[str, str] = {}
try:
from backend.services.api_logs import ApiLogger
from backend.services.pcb_validation import review_pcb_ics
@@ -367,7 +387,7 @@ async def run_pcb_pipeline(
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,
pdf_dir, storage=storage, api_logger=logger_api,
on_progress=_prog,
@@ -376,6 +396,7 @@ async def run_pcb_pipeline(
logger_api.flush(storage, user_id, project_id)
detail = (
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
+ (f", {len(review_errors)} review errors" if review_errors else "")
)
except Exception:
logger.exception("PCB AI review failed — keeping deterministic findings")
@@ -387,11 +408,14 @@ async def run_pcb_pipeline(
return
_step(project_id, "write_report", "running")
t_protocol = time.perf_counter()
proto_section, proto_findings = protocol_exam(
graph, layout=layout,
constraints_map=cmap, impedance_nets=zrep,
channel_data=_load_channel_data(ws),
)
protocol_s = _lap("protocol", t_protocol)
_step(project_id, "protocol", "complete", f"{protocol_s}s")
findings.extend(proto_findings)
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
@@ -404,13 +428,20 @@ async def run_pcb_pipeline(
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
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(
project=project_id,
timestamp=datetime.now(timezone.utc).isoformat(),
findings=findings,
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
review_errors=review_errors,
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
report_path = ws.local_path("pcb_report.json")
@@ -422,10 +453,22 @@ async def run_pcb_pipeline(
ws._upload_file("periscope-findings.json")
_step(project_id, "write_report", "complete", f"{len(findings)} findings")
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
_step(
project_id, "timing", "complete",
"extract={extract}s pcb_checks={pcb_checks}s protocol={protocol}s af={af}s total={total}s".format(
extract=stage_seconds.get("extract"),
pcb_checks=stage_seconds.get("pcb_checks"),
protocol=stage_seconds.get("protocol"),
af=stage_seconds.get("af"),
total=stage_seconds.get("total"),
),
)
_publish(project_id, "pcb_complete", {
"findings": len(findings),
"domains": len(plan.domains),
"groups": len(plan.groups),
"stage_seconds": stage_seconds,
})
proj_svc.update_project(
storage, user_id, project_id,
@@ -435,15 +478,17 @@ async def run_pcb_pipeline(
"domains": len(plan.domains),
"groups": len(plan.groups),
"skipped": len(skipped),
"stage_seconds": stage_seconds,
},
pcb_cancel_requested=False,
)
except Exception as e:
logger.exception("pcb pipeline failed for %s", project_id)
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
proj_svc.update_project(
storage, user_id, project_id,
pcb_status="error",
pcb_state={"error": str(e)},
pcb_state={"error": str(e), "stage_seconds": stage_seconds},
)
_publish(project_id, "pcb_error", {"error": str(e)})
@@ -54,15 +54,20 @@ async def review_pcb_ics(
storage=None,
api_logger: ApiLogger | None = 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/).
Does not attach a datasheet PDF. ICs without a library pintable are skipped
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] = []
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
review_errors: dict[str, str] = {}
cache: dict = {}
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
@@ -102,15 +107,21 @@ async def review_pcb_ics(
system_prompt=PCB_SYSTEM_PROMPT,
log_stage="pcb_review",
)
except Exception:
log.exception("PCB AI review failed for %s — skipping", ref)
skipped.append({"designator": ref, "reason": "pcb_review error"})
except Exception as exc:
msg = f"{type(exc).__name__}: {exc}"
log.exception("PCB AI review failed for %s — recording review_errors", ref)
review_errors[ref] = msg
continue
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
_ensure_recs(result.findings)
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
findings.extend(result.findings)
if result.checked_areas:
coverage[ref] = list(result.checked_areas)
return findings, coverage, skipped
# Empty checked_areas still means the IC was reviewed (no issues).
coverage[ref] = list(result.checked_areas or ["layout"])
return findings, coverage, skipped, review_errors
+78 -2
View File
@@ -2,10 +2,86 @@
What's new in Periscope.
## 2.83.0 — 2026-09-22USB-C vSafe still UNKNOWN (no USB-IF PD on file)
## 2.87.3 — 2026-09-23HubAudio AF verify + re-run cue
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe. HDMI 1.4 TMDS stays UNKNOWN.
Live already carried the 2.87.2 PE-AF-002 cleanup, but HubAudio's last PCB exam was still stamped **2.87.0** — so Michele still saw duplicate RD/TD “speed not identified”, ETH_MDIO 100 Ω, Italian RMII/USB tr/f. Replay of the saved HubAudio design/layout graphs confirms the fix. Soft-bump so the report version moves; **re-run HubAudio PCB exam** on live.
- [Changed] Regression: USB_D+ skips PE-AF-002; protocol owns the English 90 Ω cert line.
- [Changed] Changelog cue: HubAudio must be re-examined after AF noise fixes.
## 2.87.2 — 2026-09-23 — HubAudio AF noise: MDIO≠MDI, RD≡RX, USB/RMII cert
HubAudio PE-AF-002 cleanup on live 2.87.x. Ethernet MDI aliases (`RD+/``RXP/RXN`, `TD+/``TXP/TXN`) are one cert per pair; LAN8720A supplies 10/100 when the magnetics-side nets lack the PHY MPN. `ETH_MDIO` / `MDC` are management — not IEEE 100BASE-TX 25.4.9. USB D+/D leave the AF tr/f skip (protocol 90 Ω cert owns them). RMII stays on the AN-133 PE-AF-003 English line.
- [Changed] `si_check.py`: `is_mdio_mgmt`; `MDI(?!O)` so MDIO is not eth_mdi.
- [Changed] `af_trace_check.py`: MDI channel dedupe; graph-wide PHY speed; USB skips PE-AF-002.
- [Changed] pytest `test_hubaudio_af_noise.py`; AF geometry tests use HDMI (USB is cert-owned).
## 2.87.1 — 2026-09-23 — Remove coffee/email progress notice
Drop the running-pipeline “grab a coffee / well email you” banner. Progress shows a neutral “Pipeline running…” status instead.
- [Removed] Coffee icon + email promise on project progress while a run is active.
## 2.87.0 — 2026-09-23 — Live integrate: iPad top-bar, MACPHY pack, USB/RMII cert
Combines the iPad top-bar shell (light default, Save as Markdown), the MACPHY bus catalog pack (Micrel AN-133, OPEN Alliance RGMII EPL V2.2, Cisco SGMII ENG-46158), USB 2.0 D+/D cert lines versus the packed 90 Ω cite, RMII not-certified-on-recognition, and the `not_reviewed` count fix. One coherent live version above 2.86.0.
- [Changed] App shell: `TopBar` instead of sidebar; theme default `light`; Markdown report export.
- [Changed] `protocol_catalog.py` / `catalog.json`: `rmii-pcb`, `mii-pcb`, `rgmii-pcb`, `sgmii-pcb`.
- [Changed] PE-AF-003 / USB / Ethernet cert lines; J2 silence on missing L1/L2/L3 fields.
- [Changed] `not_reviewed` one designator per row; AI failures → `review_errors`.
- [Changed] pytest mac-phy / USB / RMII / not_reviewed; frontend shell + export tests.
- [Changed] Root `README.md` (+ `periscope/README.md`): product copy for 2.87.0 — PCB analysis, protocol cert, findings tree, top-bar UI, Markdown export; Gitea as source of truth.
## 2.86.0 — 2026-09-23 — MACPHY bus pack (RMII/MII/RGMII/SGMII)
Packed Micrel AN-133, OPEN Alliance RGMII EPL V2.2, and Cisco SGMII ENG-46158 into the protocol catalog. Only explicitly stated NUMERIC fields with provenance. PCB Z0 / length stay UNKNOWN where the PDF does not state them.
PE-AF-003 no longer certifies on recognition alone. HubAudio-style RMII (LAN8720A ETH_RXD/TXD/TXEN) reports `RMII not certified — because …` with the AN-133 cite when PCB impedance is UNKNOWN. SGMII uses the Cisco 100 Ω Rload / Rin 80120 Ω cite when geometry allows a pair Z. RJ45/MDI 100 Ω stays separate.
- [Changed] `protocol_catalog.py` / `catalog.json`: `rmii-pcb`, `mii-pcb`, `rgmii-pcb`, `sgmii-pcb`.
- [Changed] `af_trace_check.py` PE-AF-003 cert lines.
- [Changed] `standards/protocol-specs/INDEX.md` (+ on-disk PDFs, gitignored).
- [Changed] pytest `test_mac_phy_pack.py`, `test_rmii_cert_line.py`.
## 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 PHYMAC), not RJ45/MDI 100 Ω. Recognition alone is not certification (fixed in 2.86.0). 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
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-ESD-001 says "datasheet-specified" only with a part and section/page. A 3V3 jack rail and VBUS are not warned without that cite. Optical S/PDIF (TOSLINK/optocoupler) is not an ESD warning; an unverified S/PDIF path stays a visible warning.
PE-PRT-L0-001 follows Type-C CC pins (A5/B5) to the net and looks for the packed CabCon Rd 5.1 kΩ. The net does not have to be named CC1/CC2.
PE-AF-002 on an Ethernet MDI pair compares one calculated pair Z to the packed cite for the speed on the board (LAN8720A 10/100 → 100 Ω, IEEE 100BASE-TX 25.4.9; Clause 40 only if gigabit). Inside is PASS, outside is FAIL, and no calculation is ERROR. No tr/f request.
- [Changed] `bom_pcb_check.py`, `esd_return_check.py`, `protocol_l0.py`, `af_trace_check.py`.
- [Changed] pytest `tests/pcb/test_bom_esd_ep_cc_eth.py`.
- [Changed] Findings tree labels are English: domains, Certifications, Protocols, actions, and empty states. Error / Warning / Info stay.
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe.
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
- [Changed] Findings tree grouping + protocol section inside the tree.
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.83.0",
"version": "2.87.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
@@ -10,7 +10,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts"
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts src/lib/report-export.test.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
LayoutGrid,
List,
Loader2,
X,
} from "lucide-react";
import { useCredits } from "@/components/billing/credits-context";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -23,13 +21,10 @@ import {
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/types";
type LayoutKind = "cards" | "table";
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
const LAYOUT_KEY = "periscopex:dashboard:view";
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
export default function DashboardPage() {
@@ -62,28 +57,9 @@ function DashboardBody() {
const [bannerVisible, setBannerVisible] = useState(false);
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [layout, setLayout] = useState<LayoutKind>("cards");
const ordered = useMemo(() => newestFirst(projects), [projects]);
useEffect(() => {
try {
const stored = localStorage.getItem(LAYOUT_KEY);
if (stored === "cards" || stored === "table") setLayout(stored);
} catch {
/* storage blocked */
}
}, []);
function persistLayout(next: LayoutKind) {
setLayout(next);
try {
localStorage.setItem(LAYOUT_KEY, next);
} catch {
/* ignore */
}
}
function stripCheckoutQuery() {
const url = new URL(window.location.href);
url.searchParams.delete("topup");
@@ -208,7 +184,7 @@ function DashboardBody() {
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
<OnboardingSurvey />
{bannerVisible && topupPhase !== "dismissed" && (
<TopupBanner
@@ -217,11 +193,10 @@ function DashboardBody() {
onDismiss={hideBanner}
/>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<CreditsLine credits={credits} />
</div>
<PageHeader
title="Progetti"
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
actions={
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
@@ -229,104 +204,33 @@ function DashboardBody() {
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
</div>
{!loading && projects.length > 0 && (
<div className="flex items-center justify-end gap-2 mb-4">
<div className="inline-flex rounded-lg border border-input p-0.5">
<button
type="button"
onClick={() => persistLayout("cards")}
aria-pressed={layout === "cards"}
title="Card view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => persistLayout("table")}
aria-pressed={layout === "table"}
title="Table view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
}
/>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-[11px]" />
))}
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No projects yet. Create one to get started.
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
<p className="mt-1 text-[13px] text-neutral-500">
Crea un progetto e carica netlist, BOM e PCB. Lesame parte da .
</p>
) : layout === "table" ? (
</div>
) : (
<ProjectsTable
projects={ordered}
onDeleted={dropProject}
onRerun={setRerunProject}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ordered.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDeleted={() => dropProject(p.id)}
onRerun={setRerunProject}
/>
))}
</div>
)}
</div>
);
}
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
if (!credits) {
return (
<p className="text-sm text-muted-foreground">
Schematic validation projects
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{credits.balance.toFixed(2)} credits
{" · "}
<Link
href="/billing"
className="underline underline-offset-2 hover:text-foreground"
>
buy more
</Link>
{" · "}
<Link
href="/credits"
className="underline underline-offset-2 hover:text-foreground"
>
ledger
</Link>
</p>
);
}
function TopupBanner({
phase,
detail,
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { CreditsProvider } from "@/components/billing/credits-context";
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
import { AuthGate } from "@/components/layout/auth-gate";
import { Sidebar } from "@/components/layout/sidebar";
import { TopBar } from "@/components/layout/top-bar";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function AppShellLayout({
@@ -12,9 +12,9 @@ export default function AppShellLayout({
<TooltipProvider>
<CreditsProvider>
<AuthGate>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
<div className="flex h-full flex-col">
<TopBar />
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
{children}
</main>
</div>
@@ -48,6 +48,7 @@ import { CollaboratorsSection } from "@/components/project/collaborators-section
import { SkippedComponentsSection } from "@/components/project/skipped-components-section";
import { ReportVersionSection } from "@/components/project/report-version-section";
import { PipelineErrorBanner } from "@/components/project/pipeline-error-banner";
import { AppearanceSettings } from "@/components/theme/appearance-settings";
import {
deratingOverridesKey,
deratingSettingsKey,
@@ -518,6 +519,9 @@ export default function ProjectDetailPage({
{tab === "settings" && (
<div className="space-y-6">
<div className="rounded-[11px] border border-black/10 bg-white px-5 py-4 dark:border-white/10 dark:bg-[#2c2c2e]">
<AppearanceSettings />
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">KiCad PCB</CardTitle>
@@ -33,7 +33,6 @@ import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
@@ -237,21 +236,21 @@ export default function ProgressPage({
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">Feel free to grab a coffee</p>
<p className="text-base font-medium text-foreground">Pipeline running</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
Stages update below as the run proceeds.
</p>
</div>
</div>
@@ -18,7 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { ProtocolSection } from "@/components/report/protocol-section";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
@@ -31,7 +31,7 @@ import {
signReport,
downloadEcoCsv,
} from "@/lib/api";
import { exportReportToExcel } from "@/lib/report-export";
import { exportReportToMarkdown } from "@/lib/report-export";
import { cn, getFindingKey } from "@/lib/utils";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
@@ -240,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
return (
<div
ref={rootRef}
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
className={cn("mx-auto w-full px-6 py-6", focus ? "max-w-[1920px]" : "max-w-[720px]")}
>
<div className={cn("space-y-6", focus && "hidden")}>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Validation Report</h1>
<p className="text-sm text-muted-foreground">
{report.findings.length} findings &middot;{" "}
{new Date(report.timestamp).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<div className={cn("space-y-5", focus && "hidden")}>
<PageHeader
title={projectName || "Esame"}
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -264,10 +260,13 @@ function ReportContent({ projectId }: { projectId: string }) {
<Button
variant="outline"
size="sm"
onClick={() => exportReportToExcel(report, graph, projectName)}
disabled={report.findings.length === 0}
onClick={() => exportReportToMarkdown(report, graph, projectName)}
disabled={
report.findings.length === 0 &&
!(report.protocol_certification?.recognized_instances?.length)
}
>
<Download /> Export Excel
<Download /> Save as Markdown
</Button>
<Button
variant="outline"
@@ -297,14 +296,14 @@ function ReportContent({ projectId }: { projectId: string }) {
Sign release
</Button>
</div>
</div>
}
/>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
creditsSpent={creditsSpent}
totalCostUsd={totalCostUsd}
/>
<ProtocolSection section={report.protocol_certification} />
{failedReviews > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
@@ -332,7 +331,10 @@ function ReportContent({ projectId }: { projectId: string }) {
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
</p>
<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>
<ul className="space-y-1 text-xs">
{report.not_reviewed.map((nr) => (
@@ -344,12 +346,12 @@ function ReportContent({ projectId }: { projectId: string }) {
</ul>
</div>
)}
{report.summary.total === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
<p className="text-sm font-medium">No findings</p>
<p className="text-xs text-muted-foreground">
No IC datasheets were available for review. Upload datasheets for each IC
on the project page and re-run the pipeline to get findings.
{report.summary.total === 0 &&
!(report.protocol_certification?.recognized_instances?.length) ? (
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">No findings</p>
<p className="mt-1 text-[13px] text-neutral-500">
Upload the IC datasheets on the project page and run the exam again.
</p>
</div>
) : (
@@ -370,6 +372,7 @@ function ReportContent({ projectId }: { projectId: string }) {
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
protocolSection={report.protocol_certification}
/>
)}
</div>
+26 -24
View File
@@ -6,10 +6,11 @@
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
@theme inline {
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
system-ui, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
--font-heading: var(--font-sans);
--font-headline: var(--font-dm-serif-display), Georgia, serif;
--font-headline: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -53,15 +54,15 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--radius: 0.6875rem;
--background: #f5f5f7;
--foreground: #1d1d1f;
--card: #ffffff;
--card-foreground: #1d1d1f;
--popover: #ffffff;
--popover-foreground: #1d1d1f;
--primary: #007aff;
--primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -69,22 +70,22 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: rgba(0, 0, 0, 0.1);
--input: rgba(0, 0, 0, 0.1);
--ring: #007aff;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar: #f5f5f7;
--sidebar-foreground: #1d1d1f;
--sidebar-primary: #007aff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: rgba(0, 122, 255, 0.12);
--sidebar-accent-foreground: #007aff;
--sidebar-border: rgba(0, 0, 0, 0.1);
--sidebar-ring: #007aff;
}
.dark {
@@ -129,7 +130,8 @@
@apply font-sans;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-feature-settings: "tnum" 1;
}
}
+1 -18
View File
@@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next";
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
@@ -13,22 +12,6 @@ import {
TWITTER_HANDLE,
} from "@/lib/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const dmSerifDisplay = DM_Serif_Display({
variable: "--font-dm-serif-display",
weight: "400",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
@@ -109,7 +92,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
className="h-full antialiased"
>
<body className="h-full">
<ThemeProvider>
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function PageHeader({
title,
meta,
actions,
}: {
title: string;
meta?: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
<div className="min-w-0">
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
{title}
</h1>
{meta ? (
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
@@ -13,7 +13,7 @@ export function SidebarCredits() {
return null;
}
export function SidebarUserButton() {
export function SidebarUserButton({ compact = false }: { compact?: boolean }) {
const { user, isLoaded } = useOptionalUser();
const { signOut, isSignedIn } = useOptionalAuth();
@@ -26,6 +26,16 @@ export function SidebarUserButton() {
}
if (!isSignedIn || !user) {
if (compact) {
return (
<Link
href="/sign-in"
className={cn(buttonVariants({ size: "sm", variant: "outline" }), "h-7 px-2 text-[11px]")}
>
Sign in
</Link>
);
}
return (
<div className="flex flex-col gap-1 px-1">
<Link
@@ -44,6 +54,22 @@ export function SidebarUserButton() {
);
}
if (compact) {
return (
<button
type="button"
title={user.email || user.name || "Account"}
className="max-w-[120px] truncate rounded-[8px] px-2 py-1 text-[11px] text-neutral-600 hover:bg-black/[0.04] dark:text-neutral-300 dark:hover:bg-white/[0.06]"
onClick={() => {
signOut?.();
window.location.href = "/sign-in";
}}
>
{user.name || user.email}
</button>
);
}
return (
<div className="flex flex-col gap-1 px-2 py-1">
<div className="truncate text-xs font-medium">{user.name || user.email}</div>
@@ -1,294 +1,2 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ArrowLeft,
Boxes,
CircuitBoard,
ClipboardList,
LayoutDashboard,
Library,
Loader2,
MessageSquareWarning,
Ruler,
ScrollText,
Settings,
Shield,
TableProperties,
Zap,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { useAuthApi } from "@/hooks/use-auth-api";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { fetchProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import { APP_VERSION } from "@/lib/version";
function useProjectFromPath(pathname: string): {
projectId: string | null;
project: Project | null;
} {
const match = pathname.match(/^\/project\/([^/]+)/);
const projectId = match ? match[1] : null;
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (!projectId) {
setProject(null);
return;
}
fetchProject(projectId)
.then(setProject)
.catch(() => setProject(null));
}, [projectId]);
useEffect(() => {
if (!projectId || project?.status !== "running") return;
const interval = setInterval(() => {
fetchProject(projectId)
.then(setProject)
.catch(() => {
/* keep last known project */
});
}, 3000);
return () => clearInterval(interval);
}, [projectId, project?.status]);
return { projectId, project };
}
export function Sidebar() {
const pathname = usePathname();
const { user } = useOptionalUser();
useAuthApi();
const [feedbackOpen, setFeedbackOpen] = useState(false);
const { projectId, project } = useProjectFromPath(pathname);
const isAdmin = user?.isAdmin ?? false;
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
<div className="px-4 py-4 border-b border-border">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
{projectId ? (
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
<ProjectNav
pathname={pathname}
projectId={projectId}
project={project}
isAdmin={isAdmin}
/>
</Suspense>
) : (
<DefaultNav pathname={pathname} isAdmin={isAdmin} />
)}
</div>
<div className="border-t border-border">
<SidebarCredits />
<div className="px-2 py-1.5 border-b border-border/60">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<SidebarUserButton />
<Link
href="/changelog"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
v{APP_VERSION}
</Link>
<ThemeToggle className="ml-auto" />
</div>
</div>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</aside>
);
}
function navItemClass(active: boolean) {
return cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
);
}
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 px-2 py-3 space-y-0.5">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<LayoutDashboard className="h-4 w-4" />
Projects
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-4 w-4" />
Library
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-4 w-4" />
My Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-4 w-4" />
Admin
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
</Link>
)}
</nav>
);
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
const PROJECT_NAV_ITEMS: NavItem[] = [
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
function NavLink({
href,
active,
children,
forceDocument,
}: {
href: string;
active?: boolean;
children: ReactNode;
forceDocument?: boolean;
}) {
const className = navItemClass(Boolean(active));
if (forceDocument) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
function ProjectNav({
pathname,
projectId,
project,
isAdmin,
}: {
pathname: string;
projectId: string;
project: Project | null;
isAdmin: boolean;
}) {
const searchParams = useSearchParams();
const base = `/project/${projectId}`;
const currentTab = searchParams.get("tab");
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
function isActive(item: NavItem): boolean {
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
if (item.tab === "bom") {
return pathname === base && (currentTab === "bom" || currentTab === null);
}
return pathname === base && currentTab === item.tab;
}
function getHref(item: NavItem): string {
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
return (
<nav className="flex-1 px-2 py-3 space-y-1">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<ArrowLeft className="h-4 w-4" />
Dashboard
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-4 w-4" />
Library
</NavLink>
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-foreground truncate">
{project?.name ?? "Loading..."}
</p>
</div>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
</NavLink>
)}
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</NavLink>
);
})}
</div>
</nav>
);
}
/** @deprecated Left sidebar removed — iPad top bar is the shell. Kept for import stability. */
export { TopBar as Sidebar } from "./top-bar";
@@ -0,0 +1,395 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
BookOpen,
ClipboardList,
Folder,
Library,
Loader2,
Menu,
MessageSquareWarning,
ScanLine,
Settings,
Shield,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
import { AppearanceSettings } from "@/components/theme/appearance-settings";
import { useAuthApi } from "@/hooks/use-auth-api";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { fetchProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import { APP_VERSION } from "@/lib/version";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
function useProjectFromPath(pathname: string): {
projectId: string | null;
project: Project | null;
} {
const match = pathname.match(/^\/project\/([^/]+)/);
const projectId = match ? match[1] : null;
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (!projectId) {
setProject(null);
return;
}
fetchProject(projectId)
.then(setProject)
.catch(() => setProject(null));
}, [projectId]);
useEffect(() => {
if (!projectId || project?.status !== "running") return;
const interval = setInterval(() => {
fetchProject(projectId)
.then(setProject)
.catch(() => {
/* keep last known project */
});
}, 3000);
return () => clearInterval(interval);
}, [projectId, project?.status]);
return { projectId, project };
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
const PRIMARY_NAV: NavItem[] = [
{ type: "tab", tab: "bom", label: "Exam", icon: ScanLine },
{ type: "route", path: "/report", label: "Protocols", icon: BookOpen },
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
];
const SECONDARY_NAV: NavItem[] = [
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
function tabClass(active: boolean) {
return cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] transition-colors duration-150 ease-out",
active
? "bg-[#007aff]/12 text-[#007aff]"
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
);
}
function NavLink({
href,
active,
children,
forceDocument,
className,
}: {
href: string;
active?: boolean;
children: ReactNode;
forceDocument?: boolean;
className?: string;
}) {
const cls = className ?? tabClass(Boolean(active));
if (forceDocument) {
return (
<a href={href} className={cls}>
{children}
</a>
);
}
return (
<Link href={href} className={cls}>
{children}
</Link>
);
}
/** Thin iPad-style top toolbar. Replaces the left sidebar column. */
export function TopBar() {
const pathname = usePathname();
const { user } = useOptionalUser();
useAuthApi();
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { projectId, project } = useProjectFromPath(pathname);
const isAdmin = user?.isAdmin ?? false;
return (
<>
<header className="sticky top-0 z-40 flex h-11 shrink-0 items-center gap-2 border-b border-black/10 bg-[#f5f5f7]/90 px-2 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90 sm:px-3">
<Link
href="/dashboard"
className="flex shrink-0 items-center gap-1.5 pr-1"
aria-label="Periscope home"
>
<PeriscopeMark className="h-5 w-5" />
<span className="hidden text-[13px] font-semibold tracking-tight sm:inline">
Periscope
</span>
</Link>
<div className="min-w-0 flex-1 overflow-x-auto">
{projectId ? (
<Suspense fallback={<div className="h-7" aria-hidden />}>
<ProjectTabs
pathname={pathname}
projectId={projectId}
project={project}
isAdmin={isAdmin}
/>
</Suspense>
) : (
<DefaultTabs pathname={pathname} isAdmin={isAdmin} />
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
<SidebarCredits />
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="hidden items-center gap-1 rounded-[8px] px-2 py-1 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900 sm:inline-flex dark:hover:bg-white/[0.06]"
aria-label="Feedback"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
<span className="hidden md:inline">Feedback</span>
</button>
<Link
href="/changelog"
className="hidden text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900 sm:inline dark:hover:text-white"
>
v{APP_VERSION}
</Link>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className={tabClass(false)}
aria-label="Open settings"
>
<Settings className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Settings</span>
</button>
<button
type="button"
onClick={() => setMenuOpen(true)}
className={cn(tabClass(false), "sm:hidden")}
aria-label="Open menu"
>
<Menu className="h-3.5 w-3.5" />
</button>
<div className="hidden sm:block">
<SidebarUserButton compact />
</div>
</div>
</header>
<Sheet open={settingsOpen} onOpenChange={setSettingsOpen}>
<SheetContent side="right" className="w-full sm:max-w-sm">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Appearance and account.</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-4 pb-6">
<AppearanceSettings />
<div className="sm:hidden">
<SidebarUserButton />
</div>
<div className="flex items-center justify-between text-[12px] text-neutral-500">
<Link href="/changelog" className="hover:text-neutral-900 dark:hover:text-white">
Changelog
</Link>
<span className="tabular-nums">v{APP_VERSION}</span>
</div>
</div>
</SheetContent>
</Sheet>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="top" className="max-h-[70vh]">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
<SheetDescription>Navigate Periscope.</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-1 px-4 pb-6" onClick={() => setMenuOpen(false)}>
<NavLink href="/dashboard" className={tabClass(pathname === "/dashboard")}>
<Folder className="h-3.5 w-3.5" />
Projects
</NavLink>
<NavLink href="/library" className={tabClass(pathname === "/library")}>
<Library className="h-3.5 w-3.5" />
Library
</NavLink>
<NavLink href="/feedback" className={tabClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</NavLink>
{isAdmin && (
<NavLink
href="/admin"
className={tabClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-3.5 w-3.5" />
Admin
</NavLink>
)}
{projectId ? (
<>
<p className="truncate px-2.5 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
{project?.name ?? "…"}
</p>
<NavLink href={`/project/${projectId}?tab=bom`}>Exam</NavLink>
<NavLink href={`/project/${projectId}/report`}>Report</NavLink>
<NavLink href={`/project/${projectId}?tab=settings`}>Project settings</NavLink>
</>
) : null}
</div>
</SheetContent>
</Sheet>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</>
);
}
function DefaultTabs({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex items-center gap-0.5" aria-label="Main">
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
<Folder className="h-3.5 w-3.5" />
Projects
</NavLink>
<NavLink href="/library" active={pathname === "/library"}>
<Library className="h-3.5 w-3.5" />
Library
</NavLink>
<NavLink
href="/feedback"
active={pathname === "/feedback"}
className={cn(tabClass(pathname === "/feedback"), "hidden md:inline-flex")}
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</NavLink>
{isAdmin && (
<NavLink
href="/admin"
active={pathname === "/admin" || pathname.startsWith("/admin/")}
className={cn(
tabClass(pathname === "/admin" || pathname.startsWith("/admin/")),
"hidden lg:inline-flex",
)}
>
<Shield className="h-3.5 w-3.5" />
Admin
</NavLink>
)}
</nav>
);
}
function ProjectTabs({
pathname,
projectId,
project,
isAdmin,
}: {
pathname: string;
projectId: string;
project: Project | null;
isAdmin: boolean;
}) {
const searchParams = useSearchParams();
const base = `/project/${projectId}`;
const currentTab = searchParams.get("tab");
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
const onReport = pathname === `${base}/report`;
function isActive(item: NavItem): boolean {
if (item.label === "Protocols") return onReport;
if (item.label === "Report") return onReport;
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
if (item.tab === "bom") {
return pathname === base && (currentTab === "bom" || currentTab === null);
}
return pathname === base && currentTab === item.tab;
}
function getHref(item: NavItem): string {
if (item.label === "Protocols") return `${base}/report`;
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
function renderItem(item: NavItem) {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] text-neutral-400"
>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</NavLink>
);
}
return (
<nav className="flex items-center gap-0.5" aria-label="Project">
<NavLink href="/dashboard" forceDocument={onNestedRoute} className={cn(tabClass(false), "hidden sm:inline-flex")}>
<Folder className="h-3.5 w-3.5" />
Projects
</NavLink>
<span className="hidden max-w-[140px] truncate px-1 text-[11px] font-medium text-neutral-500 md:inline">
{project?.name ?? "…"}
</span>
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Running
</NavLink>
)}
{PRIMARY_NAV.map(renderItem)}
<span className="mx-0.5 hidden h-4 w-px bg-black/10 sm:inline dark:bg-white/10" aria-hidden />
{SECONDARY_NAV.map(renderItem)}
{isAdmin ? (
<NavLink
href={`${base}?tab=logs`}
forceDocument={onNestedRoute}
className={cn(tabClass(pathname === base && currentTab === "logs"), "hidden xl:inline-flex")}
>
<Shield className="h-3.5 w-3.5" />
Log
</NavLink>
) : null}
</nav>
);
}
@@ -12,6 +12,7 @@ import type {
FindingStatus,
DesignGraph,
Collaborator,
ProtocolCertificationSection,
} from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
@@ -33,6 +34,7 @@ interface FindingsListProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
}
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
@@ -54,6 +56,7 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
}: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -161,8 +164,11 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
};
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
return (
<div className="space-y-4">
<ReportFilters
@@ -180,7 +186,7 @@ export function FindingsList({
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators}
/>
{filtered.length > 0 ? (
{showTree ? (
<FindingsTree findings={filtered} {...treeProps} />
) : reviewedFindings.length === 0 && findings.length > 0 ? (
<FindingsTreeEmpty />
@@ -1,10 +1,8 @@
"use client";
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import { ProtocolInstanceBody } from "./protocol-section";
import type {
Finding,
FindingComment,
@@ -12,10 +10,22 @@ import type {
Collaborator,
Component,
DesignGraph,
FindingStatus,
ProtocolCertificationSection,
} from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
import {
STATUS_TINT,
forestHasCertifications,
groupByWorstStatus,
isProtocolFinding,
protocolEmptyMessage,
protocolInstancesFromSection,
protocolInstancesForStatus,
worstStatus,
type DomainGroup,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
interface FindingsTreeProps {
findings: Finding[];
@@ -35,6 +45,9 @@ interface FindingsTreeProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
selectedKey?: string | null;
onSelect?: (key: string) => void;
}
function pinNetLabel(f: Finding): string {
@@ -46,73 +59,126 @@ function pinNetLabel(f: Finding): string {
return f.rule_id || "Finding";
}
function DisclosureMark({ open }: { open: boolean }) {
return (
<span
aria-hidden
className={cn(
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
"border border-black/20 text-[9px] leading-none text-neutral-600",
"dark:border-white/25 dark:text-neutral-300",
)}
>
{open ? "" : "+"}
</span>
);
}
function TreeBranch({
label,
extra,
findings,
children,
tint,
defaultOpen = false,
}: {
label: string;
extra?: string;
findings: Finding[];
findings: { status: FindingStatus }[];
children: ReactNode;
tint?: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const worst = worstStatus(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
>
<ChevronRight
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
"flex w-full items-center gap-2 px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]",
tint,
)}
/>
<span className="font-mono text-sm truncate">{label}</span>
>
<DisclosureMark open={open} />
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
{extra ? (
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
) : null}
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<StatusBadge status={worst} />
<span className="text-xs text-muted-foreground">{findings.length}</span>
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
{extra}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
) : null}
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
{findings.length}
</span>
<span className="sr-only">{worst}</span>
</button>
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
</div>
);
}
export function FindingsTree(props: FindingsTreeProps) {
const sorted = sortFindings(props.findings);
const af = sorted.filter(isAfAiFinding);
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
const schematic = sorted.filter(
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
);
const roots: { id: string; label: string; items: Finding[] }[] = [];
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
const protocolFindings = sorted.filter(isProtocolFinding);
const rest = sorted.filter((f) => !isProtocolFinding(f));
const folders = groupByWorstStatus(rest.length ? rest : sorted.filter((f) => !isProtocolFinding(f)));
const instances = protocolInstancesFromSection(props.protocolSection);
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
const showRoots = roots.length > 1;
const merged = order
.map((status) => {
const folder = folders.find((g) => g.status === status);
const cert = forestHasCertifications(instances, protocolFindings, status);
if (!folder && !cert) return null;
return { status, folder, cert };
})
.filter(Boolean) as {
status: FindingStatus;
folder: ReturnType<typeof groupByWorstStatus>[number] | undefined;
cert: boolean;
}[];
if (merged.length === 0 && instances.length === 0) {
return <FindingsTreeEmpty />;
}
return (
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
{roots.map((root) => {
const body = (
<DesignatorForest
<nav
aria-label="Findings tree"
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
>
{merged.map(({ status, folder, cert }, index) => {
const label =
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
const findings = [
...(folder?.findings ?? []),
...protocolFindings.filter((f) => f.status === status),
];
const protocolAtStatus = protocolFindings.filter((f) => f.status === status);
const inst = protocolInstancesForStatus(instances, status);
const count = Math.max(findings.length, inst.length);
return (
<TreeBranch
key={status}
label={label}
findings={count ? findings : [{ status }]}
tint={STATUS_TINT[status]}
defaultOpen={index === 0}
>
{(folder?.domains ?? []).map((domain) => (
<DomainBranch key={domain.id} domain={domain} {...props} />
))}
{cert ? (
<CertificationsBranch
{...props}
findings={root.items}
status={status}
instances={inst}
findings={protocolAtStatus}
/>
);
if (!showRoots) return <div key={root.id}>{body}</div>;
return (
<TreeBranch key={root.id} label={root.label} findings={root.items}>
{body}
) : null}
</TreeBranch>
);
})}
@@ -120,25 +186,18 @@ export function FindingsTree(props: FindingsTreeProps) {
);
}
function DesignatorForest({
findings,
graph,
...cardProps
}: FindingsTreeProps) {
const folders = groupByWorstStatus(findings);
function DomainBranch({
domain,
...props
}: { domain: DomainGroup } & FindingsTreeProps) {
return (
<div>
{folders.map((folder) => (
<TreeBranch
key={folder.status}
label={folder.label}
findings={folder.findings}
>
{folder.designators.map((row) => {
<TreeBranch label={domain.label} findings={domain.findings}>
{domain.rules.map((rule) => (
<TreeBranch key={rule.ruleId} label={rule.ruleId} findings={rule.findings}>
{rule.designators.map((row) => {
const d = row.designator;
const group = row.findings;
const component: Component | undefined = graph.components[d];
const component: Component | undefined = props.graph.components[d];
const extra = [
component?.mpn,
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
@@ -157,31 +216,31 @@ function DesignatorForest({
<TreeBranch key={d} label={d} extra={extra} findings={group}>
{leaves.map(([label, items]) => (
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
<div className="space-y-3 py-2">
<div className="space-y-2 py-1.5">
{sortFindings(items).map((item) => {
const key = cardProps.findingKeys.get(item);
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={cardProps.onViewReference}
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && cardProps.onToggleReviewed
? () => cardProps.onToggleReviewed!(key)
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
projectId={cardProps.projectId}
collaborators={cardProps.collaborators}
currentUserId={cardProps.currentUserId}
currentUserName={cardProps.currentUserName}
onCommentAdded={cardProps.onCommentAdded}
onCommentDeleted={cardProps.onCommentDeleted}
onReportFinding={cardProps.onReportFinding}
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
onReviewSaved={cardProps.onReviewSaved}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
@@ -193,14 +252,88 @@ function DesignatorForest({
})}
</TreeBranch>
))}
</TreeBranch>
);
}
function CertificationsBranch({
status,
instances,
findings,
...props
}: {
status: FindingStatus;
instances: ProtocolTreeInstance[];
findings: Finding[];
} & FindingsTreeProps) {
const all = [...findings, ...instances.map(() => ({ status }))];
return (
<TreeBranch label="Certifications" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch
label="Protocols"
findings={all.length ? all : [{ status }]}
defaultOpen
>
{instances.map((inst) => (
<TreeBranch
key={inst.instanceId}
label={`${inst.logicalId || "protocol"}${inst.physicalId || "—"}`}
extra={inst.recognition}
findings={[{ status: inst.status }]}
>
<ProtocolInstanceBody instance={inst} />
</TreeBranch>
))}
{findings.length > 0 ? (
<TreeBranch label="Findings" findings={findings}>
<div className="space-y-2 py-1.5">
{sortFindings(findings).map((item) => {
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</TreeBranch>
) : null}
{instances.length === 0 && findings.length === 0 ? (
<p className="px-2 py-2 text-[13px] text-neutral-500">
{protocolEmptyMessage(props.protocolSection?.message)}
</p>
) : null}
</TreeBranch>
</TreeBranch>
);
}
export function FindingsTreeEmpty() {
return (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
<p className="text-[15px] font-medium tracking-tight">No visible findings</p>
<p className="mt-1 text-[13px] text-neutral-500">
Filters are hiding the list. Clear a filter, or open the reviewed findings below.
</p>
</div>
);
}
@@ -1,176 +1,143 @@
"use client";
import type { ProtocolCertificationSection } from "@/lib/types";
import { StatusBadge } from "@/components/report/status-badge";
import {
formatLimit,
formatMargin,
formatMeasured,
formatProtocolChain,
protocolResultToFindingStatus,
sortProtocolChecks,
type ProtocolReportCheck,
} from "@/lib/protocol-report";
import {
protocolEmptyMessage,
protocolInstancesFromSection,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
import { cn } from "@/lib/utils";
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
const raw = row.report_checks;
if (Array.isArray(raw) && raw.length > 0) {
return sortProtocolChecks(raw as ProtocolReportCheck[]);
}
const fallback: ProtocolReportCheck[] = [];
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
const list = row[key];
if (!Array.isArray(list)) continue;
for (const c of list) {
const rec = c as Record<string, unknown>;
fallback.push({
check: String(rec.check ?? ""),
level: String(rec.level ?? ""),
result: String(rec.result ?? ""),
notes: String(rec.notes ?? ""),
skip_visible:
String(rec.result ?? "") !== "PASS" &&
String(rec.result ?? "") !== "FAIL",
});
}
}
return sortProtocolChecks(fallback);
}
export function ProtocolSection({
section,
}: {
section?: ProtocolCertificationSection | null;
}) {
const instances = section?.recognized_instances ?? [];
const message = section?.message || "nessun protocollo riconosciuto";
const maxLevel = section?.max_level_reached || "—";
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
const checks = sortProtocolChecks(instance.checks);
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
const logical = instance.logicalId;
const physical = instance.physicalId;
const certLine = checks.find((c) =>
/^(USB|Ethernet|RMII|MII|RGMII) (certified|not certified) —/.test(c.notes || ""),
);
return (
<section className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-semibold">Protocolli</h2>
<p className="text-xs text-muted-foreground">
Livello massimo: <span className="font-mono text-foreground">{maxLevel}</span>
{" "}
(L0L3; L3 skip unless channel FACT exists no OpenEMS)
<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>
</div>
<p className="text-xs text-muted-foreground">
Logical protocol vs physical interface. L0 structural, L1 geometric,
L2 electrical (Z only from datasheet/stackup/fab or a cited pack).
L3 channel (timing budget / IL / RL / crosstalk) only from existing FACT.
L0/L1 are not electrical. Length is not delay. FAIL is listed first
it is not filed under Warning.
</p>
{instances.length === 0 ? (
<p className="text-sm">{message}</p>
) : (
<ul className="space-y-4">
{instances.map((row, i) => {
const rec = row as Record<string, unknown>;
const phys = String(rec.physical_interface_id ?? "");
const log = String(rec.logical_protocol_id ?? "");
const status = String(rec.recognition_status ?? "");
const worst = String(rec.worst_result ?? "");
const checks = asChecks(rec);
const fail = Number(rec.fail_count ?? 0) > 0 || worst === "FAIL";
return (
<li
key={String(rec.instance_id ?? i)}
className={cn(
"rounded-md border p-3 space-y-2",
fail
? "border-rose-500/50 bg-rose-500/5"
: "border-border",
)}
>
<div className="flex flex-wrap items-center gap-2">
<p className="font-mono text-xs">
{log} {phys}
{status ? ` · ${status}` : ""}
</p>
{worst ? (
<StatusBadge status={protocolResultToFindingStatus(worst)} />
) : null}
<p className="text-[11px] tabular-nums text-neutral-500">
{logical} {physical}
{instance.recognition ? ` · ${instance.recognition}` : ""}
{instance.worst ? ` · ${instance.worst}` : ""}
</p>
{fail ? (
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
FAIL visible not under Warning
</span>
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL is listed first not under Warning.</p>
) : null}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="w-full text-[11px] tabular-nums">
<thead>
<tr className="text-left text-muted-foreground">
<th className="pr-2 py-1 font-medium">Lv</th>
<th className="pr-2 py-1 font-medium">Check</th>
<th className="pr-2 py-1 font-medium">Result</th>
<th className="pr-2 py-1 font-medium">Measured</th>
<th className="pr-2 py-1 font-medium">Limit</th>
<th className="pr-2 py-1 font-medium">Margin</th>
<th className="pr-2 py-1 font-medium">Source</th>
<tr className="text-left text-neutral-500">
<th className="py-1 pr-2 font-medium">Lv</th>
<th className="py-1 pr-2 font-medium">Check</th>
<th className="py-1 pr-2 font-medium">Result</th>
<th className="py-1 pr-2 font-medium">Measured</th>
<th className="py-1 pr-2 font-medium">Limit</th>
<th className="py-1 pr-2 font-medium">Margin</th>
<th className="py-1 pr-2 font-medium">Source</th>
<th className="py-1 font-medium">Method</th>
</tr>
</thead>
<tbody>
{checks.map((c, j) => {
const isFail = c.result === "FAIL";
const skip = Boolean(c.skip_visible);
return (
<tr
{checks.map((c, j) => (
<ProtocolCheckRow
key={`${c.level}-${c.check}-${j}`}
className={cn(
isFail && "text-rose-700 dark:text-rose-400 font-medium",
skip && !isFail && "text-amber-800 dark:text-amber-300",
)}
>
<td className="pr-2 py-1 font-mono">{c.level}</td>
<td className="pr-2 py-1">{c.check}</td>
<td className="pr-2 py-1">
{c.result}
{skip ? " · skip" : ""}
</td>
<td className="pr-2 py-1 font-mono">{formatMeasured(c)}</td>
<td className="pr-2 py-1 font-mono">{formatLimit(c)}</td>
<td className="pr-2 py-1 font-mono">{formatMargin(c)}</td>
<td className="pr-2 py-1">{c.source || "—"}</td>
<td className="py-1">{c.method || "—"}</td>
</tr>
);
})}
check={c}
logicalId={logical}
physicalId={physical}
/>
))}
</tbody>
</table>
</div>
{String(rec.chain_example || "") ? (
<p className="text-[11px] text-muted-foreground break-all">
Catena: {String(rec.chain_example)}
</p>
{instance.chainExample ? (
<p className="break-all text-[11px] text-neutral-500">Chain: {instance.chainExample}</p>
) : null}
{checks
.filter((c) => c.result === "FAIL" || c.skip_visible)
.map((c, j) =>
c.chain ? (
<p
key={`chain-${j}`}
className="text-[11px] text-muted-foreground break-all"
>
<p key={`chain-${j}`} className="break-all text-[11px] text-neutral-500">
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
</p>
) : null,
)}
{checks.some((c) => c.skip_visible && c.notes) ? (
<ul className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
{checks
.filter((c) => c.skip_visible && c.notes)
.map((c, j) => (
<li key={`skip-${j}`}>{c.notes}</li>
))}
</ul>
) : null}
</li>
</div>
);
})}
}
function ProtocolCheckRow({
check: c,
logicalId,
physicalId,
}: {
check: ProtocolReportCheck;
logicalId: string;
physicalId: string;
}) {
const isFail = c.result === "FAIL";
const unknown = c.result === "UNKNOWN";
const measured = formatMeasured(c, logicalId, physicalId);
const limit = formatLimit(c, logicalId, physicalId);
const margin = formatMargin(c, logicalId, physicalId);
return (
<tr
className={cn(
"border-t border-black/[0.06] dark:border-white/10",
isFail && "font-medium text-[rgb(180,40,35)]",
unknown && !isFail && "text-neutral-600",
)}
>
<td className="py-1 pr-2 font-mono">{c.level}</td>
<td className="py-1 pr-2">{c.check}</td>
<td className="py-1 pr-2">{c.result}{c.skip_visible && c.result !== "FAIL" ? " · skip" : ""}</td>
<td className="py-1 pr-2 font-mono">{measured}</td>
<td className="py-1 pr-2 font-mono">{limit}</td>
<td className="py-1 pr-2 font-mono">{margin}</td>
<td className="py-1 pr-2">{c.source || "—"}</td>
<td className="py-1">{c.method || "—"}</td>
</tr>
);
}
/** Standalone block — used only if the tree is empty of findings. */
export function ProtocolSection({
section,
}: {
section?: ProtocolCertificationSection | null;
}) {
const instances = protocolInstancesFromSection(section);
const message = protocolEmptyMessage(section?.message);
const maxLevel = section?.max_level_reached || "—";
return (
<section className="space-y-2">
<p className="text-[11px] text-neutral-500">
Max level {maxLevel} (L0L3). HDMI 1.4 TMDS stays UNKNOWN no invented Zdiff.
</p>
{instances.length === 0 ? (
<p className="text-[13px] text-neutral-600">{message}</p>
) : (
<ul className="space-y-3">
{instances.map((inst) => (
<li key={inst.instanceId}>
<ProtocolInstanceBody instance={inst} />
</li>
))}
</ul>
)}
</section>
@@ -1,6 +1,5 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ReportSummaryProps {
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
totalCostUsd?: number | null;
}
const CELLS = [
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
];
export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
const grid =
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total || errors + warnings + infos;
const outcome =
errors > 0 ? "Error" : warnings > 0 ? "Warning" : total > 0 ? "Complete" : "No findings";
const bits = [
`${errors} Error`,
`${warnings} Warning`,
`${infos} Info`,
`${reviewedCount} reviewed`,
];
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
bits.push(`$${totalCostUsd.toFixed(2)}`);
}
if (typeof creditsSpent === "number" && creditsSpent > 0) {
bits.push(`${creditsSpent.toFixed(2)} credits`);
}
return (
<div className="space-y-4">
<div className={cn("grid gap-4", grid)}>
{CELLS.map(({ key, label, color }) => (
<Card key={key}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
{summary[key] ?? 0}
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
{" · "}
{bits.join(" · ")}
</p>
</CardContent>
</Card>
))}
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Checked</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{reviewedCount}
</p>
</CardContent>
</Card>
{showCost && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">API cost</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
${totalCostUsd!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
{showCredits && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Credits</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
{creditsSpent!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
</div>
{total > 0 && (
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
({ key, barColor }) => (
<div
key={key}
className={cn("h-full", barColor)}
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
/>
),
)}
</div>
)}
</div>
);
}
@@ -1,17 +1,22 @@
import { Badge } from "@/components/ui/badge";
import type { FindingStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
/** Footnote-weight status. Light fill only (~7%), never a saturated badge. */
const TONE: Record<FindingStatus, string> = {
ERROR: "bg-rose-500/10 text-rose-600 border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-400",
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
ERROR: "bg-[rgba(255,59,48,0.07)] text-[rgb(160,30,28)]",
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)]",
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)]",
};
export function StatusBadge({ status }: { status: FindingStatus }) {
return (
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
<span
className={cn(
"inline-flex items-center rounded-[6px] px-1.5 py-px text-[11px] font-medium tracking-tight",
TONE[status],
)}
>
{status}
</Badge>
</span>
);
}
@@ -0,0 +1,59 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/** Light/dark preference. Persisted by next-themes (localStorage). */
export function AppearanceSettings({ className }: { className?: string }) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const current = mounted ? (theme ?? resolvedTheme ?? "light") : "light";
return (
<section className={cn("space-y-3", className)}>
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Appearance</h2>
<p className="mt-0.5 text-[13px] text-neutral-500">
Light is the default. Dark mode is optional and remembered on this device.
</p>
</div>
<div
role="radiogroup"
aria-label="Color theme"
className="inline-flex rounded-[10px] border border-black/10 bg-black/[0.03] p-0.5 dark:border-white/10 dark:bg-white/[0.06]"
>
{(
[
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
] as const
).map((opt) => {
const active = current === opt.id;
return (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setTheme(opt.id)}
className={cn(
"rounded-[8px] px-3.5 py-1.5 text-[13px] transition-colors duration-150 ease-out",
active
? "bg-white text-neutral-900 shadow-sm dark:bg-[#2c2c2e] dark:text-neutral-50"
: "text-neutral-500 hover:text-neutral-900 dark:hover:text-white",
)}
>
{opt.label}
</button>
);
})}
</div>
</section>
);
}
@@ -3,14 +3,15 @@
import type { ReactNode } from "react";
import { ThemeProvider as NextThemes } from "next-themes";
/** Class-based color scheme. Default is dark; OS preference is ignored. */
/** Class-based color scheme. Light by default; preference persisted in localStorage. */
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemes
attribute="class"
defaultTheme="dark"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
storageKey="periscope-theme"
>
{children}
</NextThemes>
@@ -1,8 +1,15 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
import { isPcbExamFinding } from "./layout-finding.ts";
import type { Finding } from "./types.ts";
import {
groupByWorstStatus,
groupDomains,
isProtocolFinding,
protocolEmptyMessage,
ruleDomain,
worstStatus,
} from "./findings-forest";
import { isPcbExamFinding } from "./layout-finding";
import type { Finding } from "./types";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
@@ -50,6 +57,38 @@ test("Error folder lists J2 connectors, not only U*", () => {
assert.equal(groups[1].label, "Warning");
});
test("Error folder lists domains and rules after status, protocol stays out of those domains", () => {
const groups = groupByWorstStatus([
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
f({ designator: "U18", status: "WARNING", rule_id: "PE-PLC-001" }),
f({
designator: "J1",
status: "ERROR",
rule_id: "PE-PRT-L0-001",
source: "protocol_l0",
}),
]);
assert.equal(groups[0].label, "Error");
assert.deepEqual(
groups[0].domains.map((d) => d.id),
["PLC"],
);
assert.equal(groups[0].domains[0].rules.map((r) => r.ruleId).sort().join(","), "PE-PLC-001,PE-PLC-002");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
assert.ok(isProtocolFinding({ rule_id: "PE-PRT-L0-001", source: "protocol_l0" }));
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
});
test("protocol folder label is Protocols, including a stored Italian empty message", () => {
const domain = ruleDomain(
f({ designator: "J2", status: "ERROR", rule_id: "PE-PRT-L0-001", source: "protocol_l0" }),
);
assert.equal(domain.label, "Protocols");
assert.equal(protocolEmptyMessage("nessun protocollo riconosciuto"), "No protocol recognized.");
assert.equal(protocolEmptyMessage(""), "No protocol recognized.");
assert.equal(protocolEmptyMessage("USB2 recognized"), "USB2 recognized");
});
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
assert.equal(
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
@@ -1,8 +1,11 @@
/** Group report findings for the tree: status folders, then every ref (J2 included). */
/** Group report findings for the tree: status folders, then domains/rules, then Certifications. */
import type { Finding, FindingStatus } from "./types";
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
import {
instanceWorstResult,
protocolResultToFindingStatus,
type ProtocolReportCheck,
} from "./protocol-report";
export const STATUS_FOLDER: Record<FindingStatus, string> = {
ERROR: "Error",
@@ -10,6 +13,46 @@ export const STATUS_FOLDER: Record<FindingStatus, string> = {
INFO: "Info",
};
export const STATUS_TINT: Record<FindingStatus, string> = {
ERROR: "bg-[rgba(255,59,48,0.07)]",
WARNING: "bg-[rgba(255,214,10,0.08)]",
INFO: "bg-[rgba(142,142,147,0.08)]",
};
const DOMAIN_LABEL: Record<string, string> = {
PLC: "Placement",
LAY: "Layout",
SI: "Signal integrity",
BOM: "BOM",
PWR: "Power",
THM: "Thermal",
VIA: "Vias",
KEL: "Kelvin",
STCH: "Stitching",
DDR: "DDR",
CPU: "CPU",
FPGA: "FPGA",
DRT: "Derating",
HIER: "Hierarchy",
TIM: "Timing",
PI: "Power integrity",
ESD: "ESD",
RET: "Return path",
SPOF: "SPOF",
EMI: "EMI",
STK: "Stackup",
ANT: "Antenna",
PDN: "PDN",
PD: "USB-PD",
AF: "AF Board + AI",
PRT: "Protocols",
MUX: "Pin mux",
NC: "NC pins",
DEC: "Decoupling",
SCH: "Schematic",
PCB: "PCB exam",
};
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
@@ -21,11 +64,38 @@ export type DesignatorGroup = {
findings: Finding[];
};
export type RuleGroup = {
ruleId: string;
findings: Finding[];
designators: DesignatorGroup[];
};
export type DomainGroup = {
id: string;
label: string;
findings: Finding[];
rules: RuleGroup[];
};
export type StatusGroup = {
status: FindingStatus;
label: string;
findings: Finding[];
designators: DesignatorGroup[];
domains: DomainGroup[];
};
export type ProtocolTreeInstance = {
instanceId: string;
logicalId: string;
physicalId: string;
recognition: string;
worst: string;
status: FindingStatus;
checks: ProtocolReportCheck[];
notes: string;
chainExample: string;
raw: Record<string, unknown>;
};
/** Sidebar rows: ICs, connectors (J*), and any other ref. Never U-only. */
@@ -42,9 +112,79 @@ export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
.map(([designator, items]) => ({ designator, findings: items }));
}
export const PROTOCOL_EMPTY_MESSAGE = "No protocol recognized.";
/** Tree copy. Stored 2.83 reports still carry the Italian empty phrase. */
export function protocolEmptyMessage(message?: string | null): string {
const text = (message || "").trim();
if (!text || text.toLowerCase() === "nessun protocollo riconosciuto") {
return PROTOCOL_EMPTY_MESSAGE;
}
return text;
}
export function isProtocolFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const src = f.source || "";
const rid = f.rule_id || "";
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
}
export function ruleDomain(f: Finding): { id: string; label: string } {
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocols" };
const rid = f.rule_id || "";
const m = rid.match(/^PE-([A-Z]+)/);
if (m) {
const id = m[1];
return { id, label: DOMAIN_LABEL[id] || id };
}
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
return { id: "PCB", label: DOMAIN_LABEL.PCB };
}
return { id: "SCH", label: DOMAIN_LABEL.SCH };
}
export function groupRules(findings: Finding[]): RuleGroup[] {
const byRule = new Map<string, Finding[]>();
for (const f of findings) {
const id = (f.rule_id || "").trim() || "(no rule)";
const list = byRule.get(id) ?? [];
list.push(f);
byRule.set(id, list);
}
return [...byRule.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ruleId, items]) => ({
ruleId,
findings: items,
designators: groupByDesignator(items),
}));
}
export function groupDomains(findings: Finding[]): DomainGroup[] {
const byId = new Map<string, Finding[]>();
for (const f of findings) {
const { id } = ruleDomain(f);
const list = byId.get(id) ?? [];
list.push(f);
byId.set(id, list);
}
return [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, items]) => ({
id,
label: DOMAIN_LABEL[id] || id,
findings: items,
rules: groupRules(items),
}));
}
/**
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
* U18 with PE-PLC-002 ERROR + five WARNING Error, badge ERROR, count 6.
* Protocol findings (PE-PRT) are excluded from domain folders they sit under Certifications.
*/
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
@@ -60,11 +200,80 @@ export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
.filter((status) => buckets[status].length > 0)
.map((status) => {
const designators = buckets[status];
const folderFindings = designators.flatMap((d) => d.findings);
const nonProtocol = folderFindings.filter((f) => !isProtocolFinding(f));
return {
status,
label: STATUS_FOLDER[status],
findings: designators.flatMap((d) => d.findings),
findings: folderFindings,
designators,
domains: groupDomains(nonProtocol),
};
});
}
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
const raw = row.report_checks;
if (Array.isArray(raw) && raw.length > 0) {
return raw as ProtocolReportCheck[];
}
const fallback: ProtocolReportCheck[] = [];
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
const list = row[key];
if (!Array.isArray(list)) continue;
for (const c of list) {
const rec = c as Record<string, unknown>;
fallback.push({
check: String(rec.check ?? ""),
level: String(rec.level ?? ""),
result: String(rec.result ?? ""),
notes: String(rec.notes ?? ""),
skip_visible:
String(rec.result ?? "") !== "PASS" &&
String(rec.result ?? "") !== "FAIL",
});
}
}
return fallback;
}
export function protocolInstancesFromSection(
section?: ProtocolCertificationSection | null,
): ProtocolTreeInstance[] {
const instances = section?.recognized_instances ?? [];
return instances.map((row, i) => {
const rec = row as Record<string, unknown>;
const worst = String(rec.worst_result ?? "");
const checks = asChecks(rec);
const results = checks.map((c) => c.result).filter(Boolean);
const worstResolved = worst || instanceWorstResult(results);
return {
instanceId: String(rec.instance_id ?? i),
logicalId: String(rec.logical_protocol_id ?? ""),
physicalId: String(rec.physical_interface_id ?? ""),
recognition: String(rec.recognition_status ?? ""),
worst: worstResolved,
status: protocolResultToFindingStatus(worstResolved),
checks,
notes: String(rec.notes ?? rec.message ?? ""),
chainExample: String(rec.chain_example ?? ""),
raw: rec,
};
});
}
export function protocolInstancesForStatus(
instances: ProtocolTreeInstance[],
status: FindingStatus,
): ProtocolTreeInstance[] {
return instances.filter((row) => row.status === status);
}
export function forestHasCertifications(
instances: ProtocolTreeInstance[],
protocolFindings: Finding[],
status: FindingStatus,
): boolean {
if (protocolInstancesForStatus(instances, status).length > 0) return true;
return protocolFindings.some((f) => f.status === status);
}
@@ -1,14 +1,17 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
formatLimit,
formatMeasured,
formatProtocolChain,
isHdmi14TmdsElectrical,
instanceWorstResult,
protocolResultRank,
protocolResultToFindingStatus,
sortProtocolChecks,
} from "./protocol-report.ts";
import { groupByWorstStatus } from "./findings-forest.ts";
import type { Finding } from "./types.ts";
} from "./protocol-report";
import { groupByWorstStatus } from "./findings-forest";
import type { Finding } from "./types";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
@@ -68,10 +71,44 @@ test("protocol FAIL finding stays in Error folder with WARNING skips on same ref
status: "WARNING",
rule_id: "PE-PRT-L2-001",
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].status, "ERROR");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
});
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
const row = {
check: "differential_impedance",
level: "L2",
result: "UNKNOWN",
measured: 100,
limit: 100,
margin: 0,
unit: "ohm",
chain: {
logical_protocol_id: "hdmi-1.4",
physical_interface_id: "hdmi-1.4-tmds-type-a",
},
};
assert.equal(isHdmi14TmdsElectrical(row), true);
assert.equal(formatLimit(row), "UNKNOWN");
assert.equal(formatMeasured(row), "UNKNOWN");
assert.ok(!formatLimit(row).includes("100"));
assert.ok(!formatMeasured(row).includes("Ω"));
});
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
const row = {
check: "cec",
level: "L2",
result: "PASS",
limit: 27000,
unit: "ohm",
chain: { logical_protocol_id: "hdmi-1.4" },
};
assert.equal(isHdmi14TmdsElectrical(row), false);
assert.equal(formatLimit(row), "27000 ohm");
});
@@ -94,19 +94,80 @@ export function formatProtocolChain(chain?: ProtocolChain | null): string {
return keys.map((k) => chain[k] || "—").join(" → ");
}
export function formatMeasured(row: ProtocolReportCheck): string {
const HDMI14_TMDS_ELECTRICAL = new Set([
"differential_impedance",
"impedance_tolerance",
"intra_pair_skew",
"inter_lane_skew",
"clock_data_relationship",
"data_rate",
"transition_rate",
"channel_loss",
"insertion_loss",
"return_loss",
"crosstalk",
]);
export function isHdmi14Protocol(id?: string | null): boolean {
const s = (id || "").toLowerCase();
return s === "hdmi-1.4" || s.startsWith("hdmi-1.4-");
}
/** HDMI 1.4 TMDS electrical is UNKNOWN until the Adopter spec is on file. CEC from HDMI 1.3 stays. */
export function isHdmi14TmdsElectrical(
row: Pick<ProtocolReportCheck, "check" | "chain">,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
const logical = logicalId || row.chain?.logical_protocol_id || "";
const physical = physicalId || row.chain?.physical_interface_id || "";
if (!isHdmi14Protocol(logical) && !isHdmi14Protocol(physical)) return false;
const check = (row.check || "").toLowerCase();
if (check === "cec" || check.startsWith("cec_")) return false;
if (HDMI14_TMDS_ELECTRICAL.has(check)) return true;
return check.includes("impedance") || check.includes("zdiff");
}
export function formatUnknownNotGuessed(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return true;
return row.result === "UNKNOWN";
}
export function formatMeasured(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.measured == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.measured}${unit}`;
}
export function formatLimit(row: ProtocolReportCheck): string {
export function formatLimit(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.limit == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.limit}${unit}`;
}
export function formatMargin(row: ProtocolReportCheck): string {
export function formatMargin(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.margin == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.margin}${unit}`;
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildReportMarkdown } from "./report-export";
import type { Component, DesignGraph, Finding, ValidationReport } from "./types";
function comp(reference: string, mpn: string): Component {
return {
reference,
value: "",
footprint: "",
component_type: "ic",
component_subtype: null,
mpn,
pins: {},
specs: null,
};
}
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status" | "finding">): Finding {
return {
finding_id: partial.finding_id ?? null,
mpn: partial.mpn ?? "",
aspect: null,
why: partial.why ?? "Because",
source_page: null,
reference: "",
rule_id: partial.rule_id ?? "PE-PLC-002",
...partial,
};
}
test("Markdown export has title, summary counts, and Error→Warning tree", () => {
const findings: Finding[] = [
f({
designator: "U1",
status: "ERROR",
finding: "Missing decoupling",
finding_id: "PCB-U1-001",
rule_id: "PE-DEC-001",
}),
f({
designator: "R2",
status: "WARNING",
finding: "Tolerance wide",
finding_id: "PCB-R2-001",
rule_id: "PE-BOM-011",
}),
];
const report: ValidationReport = {
project: "demo",
timestamp: "2026-09-23T00:00:00Z",
findings,
summary: { ERROR: 1, WARNING: 1, INFO: 0, total: 2 },
coverage: {},
};
const graph: DesignGraph = {
components: {
U1: comp("U1", "MSPM0"),
R2: comp("R2", "10k"),
},
nets: {},
};
const md = buildReportMarkdown(report, graph, "Demo Board");
assert.match(md, /^# Demo Board/m);
assert.match(md, /## Summary/);
assert.match(md, /- Error: 1/);
assert.match(md, /- Warning: 1/);
assert.match(md, /## Error/);
assert.match(md, /## Warning/);
assert.match(md, /##### U1/);
assert.match(md, /\*\*Missing decoupling\*\*/);
assert.ok(md.indexOf("## Error") < md.indexOf("## Warning"));
assert.doesNotMatch(md, /xlsx|\.xls\b/i);
});
+186 -47
View File
@@ -1,60 +1,199 @@
/** Client-side findings workbook (.xlsx) for a finished review. */
/** Client-side findings report as readable Markdown (.md). Not a spreadsheet. */
import * as XLSX from "xlsx";
import type { DesignGraph, Finding, ValidationReport } from "./types";
import { sortFindings } from "./utils";
const COLUMNS: string[] = [
"Designator",
"MPN",
"ID",
"Severity",
"Title",
"Description",
"Recommendation",
"Source",
"Net",
"Pins",
"Rule",
];
const WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24, 16, 16, 14];
function sourceCell(f: Finding): string {
if (f.source && f.source !== "review") return "Automated check";
const who = f.source_designator || f.designator;
return f.source_page ? `${who} datasheet p.${f.source_page}` : `${who} datasheet`;
}
import type {
DesignGraph,
Finding,
FindingStatus,
ProtocolCertificationSection,
ValidationReport,
} from "./types";
import {
STATUS_FOLDER,
groupByWorstStatus,
isProtocolFinding,
protocolEmptyMessage,
protocolInstancesFromSection,
protocolInstancesForStatus,
} from "./findings-forest";
function fileStem(name: string): string {
const slug = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return slug || "report";
}
export function exportReportToExcel(
function escapeMd(text: string): string {
return text.replace(/\r\n/g, "\n").trim();
}
function findingBullet(f: Finding, mpn: string): string {
const bits: string[] = [];
const title = escapeMd(f.finding || f.finding_id || "Finding");
bits.push(`- **${title}**`);
if (f.status) bits.push(` - Severity: ${f.status}`);
if (f.finding_id) bits.push(` - ID: \`${f.finding_id}\``);
if (f.rule_id) bits.push(` - Rule: \`${f.rule_id}\``);
if (mpn) bits.push(` - MPN: ${mpn}`);
if (f.net) bits.push(` - Net: \`${f.net}\``);
if (f.pins?.length) bits.push(` - Pins: ${(f.pins ?? []).join(", ")}`);
if (f.why) bits.push(` - ${escapeMd(f.why)}`);
if (f.recommendation) bits.push(` - Recommendation: ${escapeMd(f.recommendation)}`);
return bits.join("\n");
}
function renderStatusTree(
findings: Finding[],
graph: DesignGraph,
protocolSection?: ProtocolCertificationSection | null,
): string[] {
const lines: string[] = [];
const protocolFindings = findings.filter(isProtocolFinding);
const nonProtocol = findings.filter((f) => !isProtocolFinding(f));
const instances = protocolInstancesFromSection(protocolSection);
const groups = groupByWorstStatus(nonProtocol.length ? nonProtocol : findings);
// Always walk Error → Warning → Info even if empty folders omitted by groupByWorstStatus
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
const byStatus = new Map(groups.map((g) => [g.status, g]));
for (const status of order) {
const group = byStatus.get(status);
const protoHere = protocolInstancesForStatus(instances, status);
const protoFindingsHere = protocolFindings.filter((f) => f.status === status);
if (!group && protoHere.length === 0 && protoFindingsHere.length === 0) continue;
lines.push(`## ${STATUS_FOLDER[status]}`);
lines.push("");
if (group) {
for (const domain of group.domains) {
lines.push(`### ${domain.label}`);
lines.push("");
for (const rule of domain.rules) {
lines.push(`#### ${rule.ruleId}`);
lines.push("");
for (const des of rule.designators) {
lines.push(`##### ${des.designator}`);
lines.push("");
for (const f of des.findings) {
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
lines.push(findingBullet(f, mpn));
lines.push("");
}
}
}
}
}
if (protoHere.length > 0 || protoFindingsHere.length > 0) {
lines.push("### Certifications");
lines.push("");
lines.push("#### Protocols");
lines.push("");
if (protoHere.length === 0 && protoFindingsHere.length === 0) {
lines.push(`- ${protocolEmptyMessage(protocolSection?.message)}`);
lines.push("");
}
for (const inst of protoHere) {
lines.push(
`- **${inst.logicalId || inst.physicalId || inst.instanceId}** — ${inst.worst || inst.recognition}`,
);
if (inst.notes) lines.push(` - ${escapeMd(inst.notes)}`);
for (const c of inst.checks) {
lines.push(
` - [${c.level || "?"}] ${c.check || "check"}: ${c.result}${c.notes ? `${escapeMd(c.notes)}` : ""}`,
);
}
lines.push("");
}
for (const f of protoFindingsHere) {
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
lines.push(findingBullet(f, mpn));
lines.push("");
}
}
}
if (
instances.length === 0 &&
protocolFindings.length === 0 &&
protocolSection
) {
lines.push("## Certifications");
lines.push("");
lines.push("### Protocols");
lines.push("");
lines.push(`- ${protocolEmptyMessage(protocolSection.message)}`);
lines.push("");
}
return lines;
}
/** Build Markdown for the report tree (title, summary counts, hierarchy). */
export function buildReportMarkdown(
report: ValidationReport,
graph: DesignGraph,
projectName?: string,
): string {
const title = projectName || report.project || "Periscope report";
const summary = report.summary ?? {};
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total ?? report.findings.length;
const lines: string[] = [
`# ${title}`,
"",
`Generated ${report.timestamp || new Date().toISOString()}`,
"",
"## Summary",
"",
`- Total: ${total}`,
`- Error: ${errors}`,
`- Warning: ${warnings}`,
`- Info: ${infos}`,
"",
];
lines.push(
...renderStatusTree(report.findings, graph, report.protocol_certification),
);
if (report.not_reviewed?.length) {
lines.push("## Not reviewed");
lines.push("");
for (const nr of report.not_reviewed) {
lines.push(`- **${nr.designator}** — ${escapeMd(nr.reason)}`);
}
lines.push("");
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
}
function downloadTextFile(filename: string, contents: string): void {
const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/** Save the findings tree as a `.md` file (readable Markdown lists). */
export function exportReportToMarkdown(
report: ValidationReport,
graph: DesignGraph,
projectName?: string,
): void {
const body = sortFindings(report.findings).map((f) => [
f.designator,
f.mpn || graph.components[f.designator]?.mpn || "",
f.finding_id ?? "",
f.status,
f.finding,
f.why ?? "",
f.recommendation ?? "",
sourceCell(f),
f.net ?? "",
(f.pins ?? []).join(", "),
f.rule_id ?? "",
]);
const sheet = XLSX.utils.aoa_to_sheet([COLUMNS, ...body]);
sheet["!cols"] = WIDTHS.map((wch) => ({ wch }));
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, "Findings");
XLSX.writeFile(
book,
`${fileStem(projectName || report.project || "report")}-findings.xlsx`,
const md = buildReportMarkdown(report, graph, projectName);
downloadTextFile(
`${fileStem(projectName || report.project || "report")}-findings.md`,
md,
);
}
+2 -2
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.66.0";
export const APP_VERSION_DATE = "2026-09-22";
export const APP_VERSION = "2.87.0";
export const APP_VERSION_DATE = "2026-09-23";
+5
View File
@@ -23,5 +23,10 @@ TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync
| `HDMI-FRL-Software-Datasheet-61W6169600.pdf` | HDMI 2.1 FRL Compliance Test Solution Datasheet (Tektronix) | (on file; 4 pages) | **not packed** — test-equipment datasheet, not HDMI Forum Adopter | archived only |
| `snla027b.pdf` | AN-807 Reflections: Computations and Waveforms | SNLA027B May 2004 / revised May 2004 | **not packed** for USB-C vSafe — TI SI, no vSafe5V/vSafe0V | archived only |
| `sdaa499.pdf` | Simple Methods to Reduce EMI from a PCB Trace | SDAA499 September 2026 | **not packed** for USB-C vSafe — TI EMI, no vSafe | archived only |
| `micrel_ap_note_133_interfacing_to_mac.pdf` | Application Note 133 Interfacing Fast Ethernet Transceivers to MAC Processors | Rev. 1.0, January 2006 | `rmii-pcb`, `mii-pcb` (REFCLK 50 MHz, MII 25 MHz, MDC ≤5 MHz, optional 2250 Ω source termination). **VENDOR** app note — not STANDARD. PCB Z0 / setup-hold UNKNOWN | **VENDOR** |
| `OPEN_RGMII_EPL_V_2_public.pdf` | OPEN Alliance RGMII EPL (Electrical-Physical Layer) Recommendations | V2.2, October 25, 2016 | `rgmii-pcb` duty/skew Tables 25, CloadR 5 pF, tR/tF 1 ns. PCB Z0 / mm length **UNKNOWN** (not stated). Industry STANDARD cite (OPEN Alliance) | **STANDARD** |
| `SGMII.pdf` | Serial-GMII Specification (Cisco ENG-46158) | Revision 1.7, July 19, 2001 | `sgmii-pcb` 1.25 Gbaud, Rload 100 Ω ±1%, Rin 80120 Ω, tskew1 20 ps, tsetup/thold 100 ps. **VENDOR** (Cisco IP) — not IEEE 802.3 MDI. Channel length UNKNOWN | **VENDOR** |
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
MACPHY note: RJ45/MDI 100 Ω (`100base-tx-mdi` / `1000base-t-mdi`) stays separate from RMII/MII/RGMII/SGMII bus packs.
+75 -35
View File
@@ -47,6 +47,28 @@ def _net(name: str, *pairs: tuple[str, str]) -> Net:
)
# Generic AF geometry tests use HDMI (not USB): USB is owned by the 90 Ω cert path.
_AF_P = "HDMI_D0+"
_AF_N = "HDMI_D0-"
def _af_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": _AF_P, "2": _AF_N}),
"J1": Component(
reference="J1", value="HDMI", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": _AF_P, "2": _AF_N},
),
},
nets={
_AF_P: _net(_AF_P, ("U1", "1"), ("J1", "1")),
_AF_N: _net(_AF_N, ("U1", "2"), ("J1", "2")),
},
)
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
@@ -72,26 +94,26 @@ def _stack() -> LayoutStackup:
)
def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
def _pair_layout(*, length: float = 10.0, elbow: bool = False, p: str = _AF_P, n: str = _AF_N) -> LayoutGraph:
segs = [
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net=p),
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net=n),
]
if elbow:
segs.append(LayoutSegment(
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net="USB_D+",
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net=p,
))
return LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", footprint="P", x=0, y=0,
pads=[LayoutPad(number="1", x=0, y=0, net="USB_D+"),
LayoutPad(number="2", x=0, y=0.4, net="USB_D-")],
pads=[LayoutPad(number="1", x=0, y=0, net=p),
LayoutPad(number="2", x=0, y=0.4, net=n)],
),
"J2": LayoutFootprint(
reference="J2", footprint="P", x=length, y=0,
pads=[LayoutPad(number="A6", x=length, y=0, net="USB_D+"),
LayoutPad(number="A7", x=length, y=0.4, net="USB_D-")],
"J1": LayoutFootprint(
reference="J1", footprint="P", x=length, y=0,
pads=[LayoutPad(number="1", x=length, y=0, net=p),
LayoutPad(number="2", x=length, y=0.4, net=n)],
),
},
segments=segs,
@@ -99,6 +121,10 @@ def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
)
def _usb_pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
return _pair_layout(length=length, elbow=elbow, p="USB_D+", n="USB_D-")
def _rules(*extra: dict) -> dict:
rules = list(extra)
return {
@@ -118,9 +144,9 @@ def test_catalog_pe_af_ids():
def test_pair_is_one_unit():
units = iter_af_units(_pair_layout(), _usb_graph())
units = iter_af_units(_pair_layout(), _af_graph())
assert len(units) == 1
assert set(units[0].nets) == {"USB_D+", "USB_D-"}
assert set(units[0].nets) == {_AF_P, _AF_N}
def test_gpio_is_not_a_candidate():
@@ -137,31 +163,42 @@ def test_gpio_is_not_a_candidate():
def test_missing_tr_f_is_visible_skip_no_ohm():
layout = _pair_layout()
out = check_af_traces(_usb_graph(), {}, layout)
out = check_af_traces(_af_graph(), {}, layout)
assert out
assert all(f.rule_id == "PE-AF-002" for f in out)
assert len(out) == 1
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 "mancanza" not in text
assert "Pista ad alta" not in text
assert out[0].evidence_status == "INSUFFICIENT"
assert out[0].finding_class == "REVIEW"
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_usb_pair_skips_af_tr_f_pe_af_002():
"""USB D+/D must not get PE-AF-002 tr/f; protocol_l2 owns the 90 Ω cert line."""
out = check_af_traces(_usb_graph(), {}, _usb_pair_layout(length=40.0))
assert not any(f.rule_id == "PE-AF-002" for f in out)
assert not any("mancanza" in (f.finding or "") for f in out)
assert not any("Pista ad alta" in (f.finding or "") for f in out)
def test_short_pair_with_tr_is_not_af():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 5.0})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 5.0})
layout = _pair_layout(length=8.0)
trig = evaluate_trigger(_usb_graph(), cons, layout, iter_af_units(layout, _usb_graph())[0])
trig = evaluate_trigger(_af_graph(), cons, layout, iter_af_units(layout, _af_graph())[0])
assert trig.af is False
assert check_af_traces(_usb_graph(), cons, layout) == []
assert check_af_traces(_af_graph(), cons, layout) == []
def test_triggered_without_z_target_is_visible_skip():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
layout = _pair_layout(length=40.0)
out = check_af_traces(_usb_graph(), cons, layout)
out = check_af_traces(_af_graph(), cons, layout)
skips = [f for f in out if f.rule_id == "PE-AF-002"]
assert skips
assert "Z target datasheet" in skips[0].finding
@@ -170,45 +207,45 @@ def test_triggered_without_z_target_is_visible_skip():
def test_triggered_zdiff_window_uses_datasheet_not_folklore():
cons = _rules(
{"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05},
{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90, "tolerance_pct": 10},
{"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05},
{"kind": "impedance", "net_class": "hdmi", "zdiff_ohm": 100, "tolerance_pct": 10},
)
layout = _pair_layout(length=40.0)
out = check_af_traces(_usb_graph(), cons, layout)
out = check_af_traces(_af_graph(), cons, layout)
zhits = [f for f in out if f.rule_id == "PE-AF-050"]
if zhits:
assert zhits[0].net in {"USB_D+", "USB_D-"}
assert "90" in zhits[0].finding or "90" in zhits[0].facts
assert zhits[0].net in {_AF_P, _AF_N}
assert "100" in zhits[0].finding or "100" in zhits[0].facts
assert zhits[0].calculation == "" or "Z_ref" in zhits[0].calculation
assert sum(1 for f in out if f.rule_id == "PE-AF-050") <= 1
def test_right_angle_on_triggered_pair():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
layout = _pair_layout(length=40.0, elbow=True)
out = check_af_traces(_usb_graph(), cons, layout)
out = check_af_traces(_af_graph(), cons, layout)
assert any(f.rule_id == "PE-AF-020" for f in out)
def test_via_without_span_is_visible_skip():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
layout = _pair_layout(length=40.0)
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3)]
out = check_af_traces(_usb_graph(), cons, layout)
layout.vias = [LayoutVia(x=5, y=0, net=_AF_P, drill=0.3)]
out = check_af_traces(_af_graph(), cons, layout)
assert any("span via" in f.finding for f in out if f.rule_id == "PE-AF-002")
def test_via_with_span_is_not_a_pad():
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
cons = _rules({"kind": "rise_time", "net_class": "hdmi", "tr_ns": 0.05})
layout = _pair_layout(length=40.0)
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3, layers=("F.Cu", "B.Cu"))]
out = check_af_traces(_usb_graph(), cons, layout)
layout.vias = [LayoutVia(x=5, y=0, net=_AF_P, drill=0.3, layers=("F.Cu", "B.Cu"))]
out = check_af_traces(_af_graph(), cons, layout)
assert any(f.rule_id == "PE-AF-060" for f in out)
assert all(f.rule_id != "PE-VIA-001" for f in out)
def test_si_stub_still_fires_without_af_module():
layout = _pair_layout(length=10.0, elbow=True)
layout = _usb_pair_layout(length=10.0, elbow=True)
cons = _rules({"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0, "note": "USB stub"})
pcb = run_pcb_checks(_usb_graph(), cons, layout)
assert any(f.rule_id == "PE-SI-007" for f in pcb)
@@ -231,10 +268,13 @@ def test_hubaudio_usb_skip_has_no_invented_ohm():
"USB_D-": _net("USB_D-", ("U1", "2")),
},
)
# Real net names may be hierarchical; still must not invent ohms on AF skips.
out = check_af_traces(graph, {}, layout)
# USB must not produce PE-AF-002 tr/f or invented ohms.
for f in out:
if f.net and "USB" in (f.net or "").upper():
assert f.rule_id != "PE-AF-002"
if f.rule_id == "PE-AF-002":
low = f.finding.lower()
assert "90 Ω" not in f.finding and "50 Ω" not in f.finding
assert "90 ohm" not in low
assert "mancanza" not in low
+373
View File
@@ -0,0 +1,373 @@
"""HubAudio false findings: exposed-pad names, ESD cites, CC-via-net, Ethernet Z."""
from __future__ import annotations
import pytest
from backend.periscopex.af_trace_check import check_af_traces
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.esd_return_check import check_esd
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
Net,
NetType,
PackageInfo,
Pin,
PinConnection,
)
from backend.periscopex.protocol_l0 import _run_l0_check, l0_findings
from backend.periscopex.protocol_recognize import PhysicalBusInstance
def _ic(ref: str, mpn: str, pins: dict[str, str], *, footprint: str = "") -> Component:
return Component(
reference=ref, value=mpn, footprint=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 _cons(mpn: str, numbers: list[str], pin_count: int) -> ComponentConstraints:
return ComponentConstraints(
mpn=mpn,
package_info=PackageInfo(base_family="QFN", package=f"QFN-{pin_count}", pin_count=pin_count),
pintable=[Pin(number=n, name=n) for n in numbers],
absolute_maximum_ratings=[],
rules=[],
)
def _layout(ref: str, pads: list[tuple[str, str]], *, footprint: str) -> LayoutGraph:
return LayoutGraph(
footprints={
ref: LayoutFootprint(
reference=ref, footprint=footprint, x=0, y=0,
pads=[
LayoutPad(number=n, x=0, y=0, net="GND", pinfunction=pf)
for n, pf in pads
],
),
},
)
def _bom(ref: str, mpn: str, numbers: list[str], pads: list[tuple[str, str]], pin_count: int):
graph = DesignGraph(components={
ref: _ic(ref, mpn, {n: "N" for n, _pf in pads if n}, footprint="Lib:Part"),
})
# Schematic pins follow the pintable numbers that are real contacts.
graph.components[ref].pins = {n: "N" for n in numbers if n not in {"EP", "THERMAL PAD"}}
layout = _layout(ref, pads, footprint="Lib:Part")
findings = check_bom_pcb_datasheet(graph, {mpn: _cons(mpn, numbers, pin_count)}, layout)
return [f for f in findings if f.rule_id == "PE-BOM-011"]
def test_u16_ep_matches_pad_41():
"""AXP2101: pintable EP, footprint pad 41 / EP_41."""
hits = _bom(
"U16", "AXP2101", ["1", "EP"],
[("1", "CHGLED_1"), ("41", "EP_41")],
40,
)
assert hits == []
def test_u18_ep_matches_pad_89():
"""ADAU1467: pintable EP, footprint pad 89 / EP_89."""
hits = _bom(
"U18", "ADAU1467", ["1", "EP"],
[("1", "DGND_1_1"), ("89", "EP_89")],
88,
)
assert hits == []
def test_u19_ep_matches_pad_25_vss():
"""LAN8720A: pintable EP, VQFN-24 land is pad 25 / VSS_25."""
hits = _bom(
"U19", "LAN8720A", ["1", "EP"],
[("1", "VDD2A_1"), ("25", "VSS_25")],
24,
)
assert hits == []
def test_u5_thermal_pad_matches_epad_29():
"""TAC5212: pintable THERMAL PAD, footprint pad 29 / EPAD_29."""
hits = _bom(
"U5", "XTAC5212", ["1", "THERMAL PAD"],
[("1", "DREG_1"), ("29", "EPAD_29")],
24,
)
assert hits == []
def test_exposed_pad_absent_from_footprint_stays_error():
hits = _bom(
"U9", "BARE", ["1", "EP"],
[("1", "PIN_1")],
24,
)
assert hits
assert any("EP" in (f.finding or "") for f in hits)
def _esd_graph(net: str, *, j_fp: str = "", j_value: str = "JACK") -> DesignGraph:
return DesignGraph(
components={
"J1": Component(
reference="J1", value=j_value, footprint=j_fp,
component_type=ComponentType.CONNECTOR, mpn="",
pins={"13": net},
),
"U19": _ic("U19", "LAN8720A", {"1": net}),
},
nets={net: _net(net, ("J1", "13"), ("U19", "1"))},
)
def test_esd_does_not_say_datasheet_specified_without_a_cite():
findings = check_esd(_esd_graph("USB_DP"))
assert findings and findings[0].rule_id == "PE-ESD-001"
blob = f"{findings[0].action} {findings[0].recommendation}"
assert "datasheet-specified" not in blob
def test_esd_quotes_datasheet_when_part_and_section_exist():
cons = ComponentConstraints(
mpn="LAN8720A",
pintable=[],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "esd",
"document": "LAN8720A datasheet",
"section": "3.2",
"page": "12",
"note": "ESD on USB_DP",
}],
)
findings = check_esd(_esd_graph("USB_DP"), {"LAN8720A": cons})
assert findings
blob = f"{findings[0].action} {findings[0].finding}"
assert "datasheet-specified" in blob
assert "LAN8720A datasheet" in blob
assert "3.2" in blob
def test_3v3_ethernet_rail_is_not_an_esd_warning():
assert check_esd(_esd_graph("3V3_ETHERNET")) == []
def test_vbus_without_cite_is_not_an_esd_warning():
graph = _esd_graph("VBUS")
graph.components["U16"] = _ic("U16", "AXP2101", {"37": "VBUS"})
graph.components["J2"] = Component(
reference="J2", value="USB_C", footprint="Connector_USB:USB_C_Receptacle",
component_type=ComponentType.CONNECTOR,
pins={"A4": "VBUS"},
)
graph.nets = {"VBUS": _net("VBUS", ("J2", "A4"), ("U16", "37"))}
findings = check_esd(graph)
assert findings == []
blob = " ".join(f.action or "" for f in findings)
assert "datasheet-specified" not in blob
def test_optical_spdif_is_not_an_esd_warning():
findings = check_esd(_esd_graph(
"SPIF_IN",
j_fp="OptoDevice:Broadcom_AFBR-16xxZ_Horizontal",
j_value="TOSLINK",
))
assert findings == []
def test_unverified_spdif_warns_it_was_not_seen_as_optical():
findings = check_esd(_esd_graph("SPIF_OUT", j_fp="Connector:PinHeader"))
assert findings
assert findings[0].status == "WARNING"
assert "not verified as optical" in (findings[0].finding or "")
assert "datasheet-specified" not in (findings[0].action or "")
def _cc_inst() -> PhysicalBusInstance:
return PhysicalBusInstance(
instance_id="usb-c-usb2-receptacle:J2",
logical_protocol_id="usb2-hs",
physical_interface_id="usb-c-usb2-receptacle",
pcb_relevant="YES",
confidence=1.0,
evidence_kind="pinout",
recognition_status="RECOGNIZED",
host_ref="J2",
nets=["Net-(J2-A5)", "Net-(J2-B5)"],
)
def _cc_graph(*, both: bool) -> DesignGraph:
comps = {
"J2": Component(
reference="J2",
value="USB_C_Receptacle_USB2.0_16P",
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
component_type=ComponentType.CONNECTOR,
pins={"A5": "Net-(J2-A5)", "B5": "Net-(J2-B5)", "A4": "VBUS"},
),
"R1": Component(
reference="R1", value="5.1k", footprint="R_0603",
component_type=ComponentType.RESISTOR,
pins={"1": "Net-(J2-A5)", "2": "GND"},
),
}
nets = {
"Net-(J2-A5)": _net("Net-(J2-A5)", ("J2", "A5"), ("R1", "1")),
"Net-(J2-B5)": _net("Net-(J2-B5)", ("J2", "B5")),
"GND": _net("GND", ("R1", "2")),
}
if both:
comps["R2"] = Component(
reference="R2", value="5.1k", footprint="R_0603",
component_type=ComponentType.RESISTOR,
pins={"1": "Net-(J2-B5)", "2": "GND"},
)
nets["Net-(J2-B5)"] = _net("Net-(J2-B5)", ("J2", "B5"), ("R2", "1"))
nets["GND"] = _net("GND", ("R1", "2"), ("R2", "2"))
return DesignGraph(components=comps, nets=nets)
def test_cc_rd_present_on_unnamed_nets_does_not_fail():
row = _run_l0_check(_cc_graph(both=True), _cc_inst(), "cc_rd_rp", "MANDATORY")
assert row.result == "PASS"
assert "5.1" in row.notes
assert "CC1/CC2 net not present" not in row.notes
findings = l0_findings(_cc_inst(), [row])
assert not any(f.rule_id == "PE-PRT-L0-001" for f in findings)
def test_cc_rd_missing_on_pin_net_fails():
row = _run_l0_check(_cc_graph(both=False), _cc_inst(), "cc_rd_rp", "MANDATORY")
assert row.result == "FAIL"
assert "B5" in row.notes
assert "CC1/CC2 net not present" not in row.notes
assert "5.1" in row.notes
findings = l0_findings(_cc_inst(), [row])
assert any(f.rule_id == "PE-PRT-L0-001" and f.status == "ERROR" for f in findings)
def _eth_graph(mpn: str, *, value: str = "") -> DesignGraph:
return DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45",
footprint="Connector_RJ:RJ45_Abracon_ARJP11A-MA_Horizontal",
component_type=ComponentType.CONNECTOR,
pins={"9": "RJ45_TXP", "10": "RJ45_TXN"},
),
"U19": _ic("U19", mpn, {"21": "RJ45_TXP", "20": "RJ45_TXN"}, footprint="VQFN-24"),
},
nets={
"RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")),
"RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")),
},
) if not value else DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45",
footprint="Connector_RJ:RJ45",
component_type=ComponentType.CONNECTOR,
pins={"9": "RJ45_TXP", "10": "RJ45_TXN"},
),
"U19": Component(
reference="U19", value=value, footprint="",
component_type=ComponentType.IC, mpn=mpn,
pins={"21": "RJ45_TXP", "20": "RJ45_TXN"},
),
},
nets={
"RJ45_TXP": _net("RJ45_TXP", ("J1", "9"), ("U19", "21")),
"RJ45_TXN": _net("RJ45_TXN", ("J1", "10"), ("U19", "20")),
},
)
def _eth_layout() -> LayoutGraph:
return LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_TXP"),
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_TXN"),
],
)
def _eth_findings(graph: DesignGraph, monkeypatch, z: float | None):
if z is not None:
monkeypatch.setattr(
"backend.periscopex.af_trace_check._pair_z_ohm",
lambda layout, unit: z,
)
return [f for f in check_af_traces(graph, {}, _eth_layout()) if f.rule_id == "PE-AF-002"]
def test_lan8720_pair_without_z_is_error_with_100base_cite(monkeypatch):
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, None)
assert len(findings) == 1
f = findings[0]
assert f.status == "ERROR"
blob = f"{f.finding} {f.action} {f.requirement}"
assert f.finding.startswith("Ethernet not certified — because Z cannot be calculated")
assert "100" in blob and "25.4.9" in blob
assert "stackup" in blob
assert "Fornire" not in blob
assert "mancanza di tr" not in blob.lower()
assert "mancanza di f" not in blob.lower()
assert "90" not in blob
assert "Clause 40" not in blob
assert "1000BASE" not in blob
assert "PASS" not in f.finding
def test_lan8720_pair_z_inside_cite_is_pass(monkeypatch):
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 100.0)
assert len(findings) == 1
assert findings[0].finding.startswith("Ethernet certified —")
assert findings[0].status == "INFO"
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
def test_lan8720_pair_z_outside_cite_is_fail(monkeypatch):
findings = _eth_findings(_eth_graph("LAN8720A"), monkeypatch, 90.0)
assert len(findings) == 1
assert findings[0].finding.startswith("Ethernet not certified — because Z")
assert findings[0].status == "ERROR"
assert "PASS" not in findings[0].finding
assert "25.4.9" in findings[0].finding
def test_gigabit_phy_uses_clause_40_not_100base(monkeypatch):
findings = _eth_findings(
_eth_graph("RTL8211F", value="1000BASE-T"),
monkeypatch,
None,
)
assert len(findings) == 1
blob = findings[0].finding
assert findings[0].status == "ERROR"
assert "Clause 40" in blob
assert "25.4.9" not in blob
assert "PASS" not in blob
+258
View File
@@ -0,0 +1,258 @@
"""HubAudio AF noise: MDIO≠MDI, RD≡RX pair dedup, RMII/USB not Italian tr/f."""
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, is_mdio_mgmt, 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 _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_eth_mdio_is_not_mdi():
assert is_mdio_mgmt("ETH_MDIO")
assert is_mdio_mgmt("ETH_MDC")
assert is_mdio_mgmt("MDIO")
assert bus_class("ETH_MDIO") is None
assert bus_class("ETH_MDC") is None
assert bus_class("/Ethernet/RJ45_RXP") == "eth_mdi"
def test_mdio_does_not_get_100ohm_ieee_mdi(monkeypatch):
graph = DesignGraph(
components={
"U19": _ic("U19", "LAN8720A", {"12": "ETH_MDIO", "11": "ETH_MDC"}),
"U3": _ic("U3", "MCU", {"20": "ETH_MDIO", "21": "ETH_MDC"}),
},
nets={
"ETH_MDIO": _net("ETH_MDIO", ("U19", "12"), ("U3", "20")),
"ETH_MDC": _net("ETH_MDC", ("U19", "11"), ("U3", "21")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(30, 0), width=0.15, layer="F.Cu", net="ETH_MDIO"),
LayoutSegment(start=(0, 0.5), end=(30, 0.5), width=0.15, layer="F.Cu", net="ETH_MDC"),
],
stackup=_stack(),
)
findings = check_af_traces(graph, {}, layout)
blob = " ".join(f.finding for f in findings)
assert "25.4.9" not in blob
assert "100BASE-TX" not in blob
assert not any(
f.finding.startswith("Ethernet certified") or f.finding.startswith("Ethernet not certified")
for f in findings
)
def test_rd_plus_and_rxn_are_one_mdi_pair(monkeypatch):
"""HubAudio-style dual names: RD+/RD- ≡ RXP/RXN — one cert, keep real Z FAIL."""
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45", footprint="RJ45",
component_type=ComponentType.CONNECTOR,
pins={
"1": "/Ethernet/RJ45_RD+", "2": "/Ethernet/RJ45_RD-",
"3": "/Ethernet/RJ45_TD+", "4": "/Ethernet/RJ45_TD-",
},
),
"U19": _ic("U19", "LAN8720A", {
"23": "/Ethernet/RJ45_RXP", "22": "/Ethernet/RJ45_RXN",
"21": "/Ethernet/RJ45_TXP", "20": "/Ethernet/RJ45_TXN",
}),
},
nets={
"/Ethernet/RJ45_RD+": _net("/Ethernet/RJ45_RD+", ("J1", "1")),
"/Ethernet/RJ45_RD-": _net("/Ethernet/RJ45_RD-", ("J1", "2")),
"/Ethernet/RJ45_TD+": _net("/Ethernet/RJ45_TD+", ("J1", "3")),
"/Ethernet/RJ45_TD-": _net("/Ethernet/RJ45_TD-", ("J1", "4")),
"/Ethernet/RJ45_RXP": _net("/Ethernet/RJ45_RXP", ("U19", "23")),
"/Ethernet/RJ45_RXN": _net("/Ethernet/RJ45_RXN", ("U19", "22")),
"/Ethernet/RJ45_TXP": _net("/Ethernet/RJ45_TXP", ("U19", "21")),
"/Ethernet/RJ45_TXN": _net("/Ethernet/RJ45_TXN", ("U19", "20")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RD+"),
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RD-"),
LayoutSegment(start=(0, 1), end=(20, 1), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TD+"),
LayoutSegment(start=(0, 1.2), end=(20, 1.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TD-"),
LayoutSegment(start=(30, 0), end=(50, 0), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RXP"),
LayoutSegment(start=(30, 0.2), end=(50, 0.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_RXN"),
LayoutSegment(start=(30, 1), end=(50, 1), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TXP"),
LayoutSegment(start=(30, 1.2), end=(50, 1.2), width=0.2, layer="F.Cu", net="/Ethernet/RJ45_TXN"),
],
stackup=_stack(),
)
monkeypatch.setattr(
"backend.periscopex.af_trace_check._pair_z_ohm",
lambda layout, unit: 46.0,
)
eth = [f for f in check_af_traces(graph, {}, layout) if f.rule_id == "PE-AF-002"]
assert len(eth) == 2 # one RX, one TX — not four
for f in eth:
assert f.status == "ERROR"
assert f.finding.startswith("Ethernet not certified — because Z")
assert "46" in f.finding
assert "100" in f.finding
assert "25.4.9" in f.finding
assert "speed" not in f.finding.lower() or "not identified" not in f.finding.lower()
assert "mancanza" not in f.finding
# Prefer PHY-side RXP/TXP naming in the kept unit.
facts = " ".join((f.facts or "") + f.finding for f in eth)
assert "RXP" in facts or "RXN" in facts
assert "TXP" in facts or "TXN" in facts
def test_rd_pair_alone_inherits_lan8720_speed(monkeypatch):
"""Magnetics RD+/RD- with LAN8720 elsewhere: no 'speed not identified' ERROR."""
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45", footprint="RJ45",
component_type=ComponentType.CONNECTOR,
pins={"1": "RJ45_RD+", "2": "RJ45_RD-"},
),
"U19": _ic("U19", "LAN8720A", {"1": "ETH_RXD0"}),
},
nets={
"RJ45_RD+": _net("RJ45_RD+", ("J1", "1")),
"RJ45_RD-": _net("RJ45_RD-", ("J1", "2")),
"ETH_RXD0": _net("ETH_RXD0", ("U19", "1")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="RJ45_RD+"),
LayoutSegment(start=(0, 0.2), end=(20, 0.2), width=0.2, layer="F.Cu", net="RJ45_RD-"),
],
)
monkeypatch.setattr(
"backend.periscopex.af_trace_check._pair_z_ohm",
lambda layout, unit: None,
)
eth = [f for f in check_af_traces(graph, {}, layout) if f.rule_id == "PE-AF-002"]
assert len(eth) == 1
assert "not identified" not in eth[0].finding
assert "25.4.9" in eth[0].finding
assert eth[0].finding.startswith("Ethernet not certified — because Z cannot be calculated")
def test_rmii_no_italian_tr_f_pe_af_002():
graph = 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")),
},
)
segs = []
y = 0.0
for net in ("ETH_RXD0", "ETH_RXD1", "ETH_TXD0", "ETH_TXD1", "ETH_TXEN"):
assert single_ended_eth_mac(net)
segs.append(LayoutSegment(start=(0, y), end=(40, y), width=0.15, layer="F.Cu", net=net))
y += 0.5
layout = LayoutGraph(segments=segs, stackup=_stack())
findings = check_af_traces(graph, {}, layout)
assert not any(f.rule_id == "PE-AF-002" for f in findings)
rmii = [
f for f in findings
if (f.finding or "").startswith("RMII certified —")
or (f.finding or "").startswith("RMII not certified —")
]
assert len(rmii) == 1
assert rmii[0].rule_id == "PE-AF-003"
assert "AN-133" in rmii[0].finding or "impedance" in rmii[0].finding.lower()
assert "mancanza" not in rmii[0].finding
assert "Pista ad alta" not in rmii[0].finding
assert "tr" not in rmii[0].finding.lower() or "because" in rmii[0].finding
def test_usb_d_plus_skips_pe_af_002_italian_tr_f():
"""USB_D+ must not get PE-AF-002 tr/f; protocol L2 owns the 90 Ω cert line."""
graph = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C", footprint="USB_C",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-"},
),
"U11": _ic("U11", "USB_PHY", {"1": "USB_D+", "2": "USB_D-"}),
},
nets={
"USB_D+": _net("USB_D+", ("J2", "A6"), ("U11", "1")),
"USB_D-": _net("USB_D-", ("J2", "A7"), ("U11", "2")),
},
)
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(40, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.2), end=(40, 0.2), width=0.2, layer="F.Cu", net="USB_D-"),
],
stackup=_stack(),
)
findings = check_af_traces(graph, {}, layout)
assert not any(f.rule_id == "PE-AF-002" for f in findings)
assert not any("mancanza" in (f.finding or "") for f in findings)
assert not any("Pista ad alta" in (f.finding or "") for f in findings)
from backend.periscopex.protocol_l2 import certify_l2
from backend.periscopex.protocol_recognize import recognize_physical_buses
_l2, cert = certify_l2(
graph, recognize_physical_buses(graph), layout=layout, impedance_nets=None,
)
usb = [
f for f in cert
if (f.finding or "").startswith("USB certified —")
or (f.finding or "").startswith("USB not certified —")
]
assert len(usb) == 1
assert "90" in usb[0].finding
assert "mancanza" not in usb[0].finding
+134
View File
@@ -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
+100
View File
@@ -0,0 +1,100 @@
"""MACPHY pack from Micrel AN-133, OPEN Alliance RGMII EPL, Cisco SGMII."""
from __future__ import annotations
from backend.periscopex.protocol_catalog import load_catalog
def _by(pid: str) -> dict:
cat = load_catalog()
iface = next(p for p in cat.physical_interfaces if p.id == pid)
return {c.parameter: c for c in iface.constraints}
def _cite_ok(c) -> None:
assert c.value_kind == "NUMERIC"
assert c.value is not None
src = c.source
assert src is not None
assert src.document
assert src.organization
assert src.revision
assert src.page
assert src.section or src.table
assert src.url # archive path under standards/protocol-specs/
def test_rmii_an133_vendor_numeric_and_z_unknown():
by = _by("rmii-pcb")
_cite_ok(by["refclk_frequency"])
assert by["refclk_frequency"].value == 50.0
assert by["refclk_frequency"].unit == "MHz"
assert by["refclk_frequency"].source_type == "VENDOR"
assert by["refclk_frequency"].source_class == "VENDOR"
assert by["refclk_frequency"].source.organization == "Micrel"
assert "AN-133" in by["refclk_frequency"].source.document or "133" in by["refclk_frequency"].source.document
assert by["mdc_max_frequency"].value == 5.0
assert by["source_termination_min"].value == 22.0
assert by["source_termination_max"].value == 50.0
assert by["source_termination_min"].mandatory == "OPTIONAL"
assert by["impedance"].value_kind == "UNKNOWN"
assert by["setup_time"].value_kind == "UNKNOWN"
assert by["hold_time"].value_kind == "UNKNOWN"
assert by["length"].value_kind == "UNKNOWN"
# Must not invent a PCB Z0 from blogs or "typical".
assert by["impedance"].value is None
def test_mii_an133_vendor_25mhz():
by = _by("mii-pcb")
_cite_ok(by["tx_rx_clock_frequency"])
assert by["tx_rx_clock_frequency"].value == 25.0
assert by["tx_rx_clock_frequency"].source_type == "VENDOR"
assert by["impedance"].value_kind == "UNKNOWN"
def test_rgmii_open_alliance_standard_skews_z_unknown():
by = _by("rgmii-pcb")
_cite_ok(by["clock_duty_high_min"])
assert by["clock_duty_high_min"].value == 3.6
assert by["clock_duty_high_min"].source_type == "STANDARD"
assert by["clock_duty_high_min"].source_class == "NORMATIVE"
assert by["clock_duty_high_min"].source.organization == "OPEN Alliance"
assert by["data_clock_skew_dod_source_max"].value == 0.5
assert by["data_clock_skew_dod_dest_max"].value == 0.65
assert by["data_clock_skew_dos_source_min"].value == 1.2
assert by["data_clock_skew_dos_dest"].value == 1.05
assert by["clock_data_delay_mismatch"].value == 150.0
assert by["clock_data_delay_mismatch"].mandatory == "INFORMATIONAL"
assert by["receiver_load_cap_max"].value == 5.0
assert by["rise_time_max"].value == 1.0
assert by["impedance"].value_kind == "UNKNOWN"
assert by["length"].value_kind == "UNKNOWN"
assert by["impedance"].value is None
def test_sgmii_cisco_vendor_100ohm_rin_not_ieee_mdi():
by = _by("sgmii-pcb")
_cite_ok(by["differential_impedance"])
assert by["differential_impedance"].value == 100.0
assert by["differential_impedance"].source_type == "VENDOR"
assert by["differential_impedance"].source_class == "VENDOR"
assert by["differential_impedance"].source.organization == "Cisco Systems"
assert by["differential_impedance"].mandatory == "RECOMMENDED"
assert by["impedance_tolerance"].value == 1.0
assert by["receiver_differential_impedance_min"].value == 80.0
assert by["receiver_differential_impedance_max"].value == 120.0
assert by["intra_pair_skew"].value == 20.0
assert by["setup_time"].value == 100.0
assert by["hold_time"].value == 100.0
assert by["data_rate"].value == 1.25
assert by["maximum_channel_length"].value_kind == "UNKNOWN"
# Honest: not IEEE STANDARD / not RJ45 MDI clause.
assert by["differential_impedance"].source_class != "NORMATIVE"
assert by["differential_impedance"].source_type != "STANDARD"
def test_logical_mii_rmii_present():
cat = load_catalog()
lids = {p.id for p in cat.logical_protocols}
assert {"mii", "rmii", "rgmii", "sgmii"} <= lids
+131
View File
@@ -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 == {}
+2 -1
View File
@@ -619,8 +619,9 @@ def test_pcb_ai_skips_without_library_extraction():
graph, {}, None, None, None, Path("/tmp"),
)
_f, _c, skipped = asyncio.run(_run())
_f, _c, skipped, errors = asyncio.run(_run())
assert skipped
assert errors == {}
assert "library extraction" in skipped[0]["reason"]
+2 -2
View File
@@ -223,7 +223,7 @@ def test_reject_same_logical_and_physical_id():
def test_empty_report_section_has_no_z():
section = empty_protocol_section()
dumped = section.model_dump()
assert section.message == "nessun protocollo riconosciuto"
assert section.message == "No protocol recognized."
assert section.recognized_instances == []
assert dumped.get("max_level_reached") is None
blob = json.dumps(dumped)
@@ -238,7 +238,7 @@ def test_empty_report_section_has_no_z():
summary={"ERROR": 0, "WARNING": 0, "INFO": 0},
protocol_certification=dumped,
)
assert report.protocol_certification["message"] == "nessun protocollo riconosciuto"
assert report.protocol_certification["message"] == "No protocol recognized."
def test_pcie_cxl_physical_packs_are_empty_numbers():
+14 -3
View File
@@ -62,7 +62,7 @@ def test_m10_high_speed_processor_ids_present():
assert all(p.id != pid for p in ifaces)
def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
def test_m10_i2c_can_skeletons_named_checks_no_numbers():
cat = load_catalog()
i2c = next(p for p in cat.physical_interfaces if p.id == "i2c-pcb")
can = next(p for p in cat.physical_interfaces if p.id == "can-pcb")
@@ -76,7 +76,7 @@ def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
assert set(i2c.required_checks) == set(I2C_CHECKS)
assert set(can.required_checks) == set(CAN_CHECKS)
assert set(rgmii.required_checks) == set(RGMII_CHECKS)
for iface in (i2c, can, rgmii):
for iface in (i2c, can):
for c in iface.constraints:
assert c.value is None
assert c.value_kind in {
@@ -84,6 +84,16 @@ def test_m10_i2c_can_rgmii_skeletons_named_checks_no_numbers():
"CONTROLLER_DEPENDENT", "VENDOR_DEPENDENT", "NOT_APPLICABLE",
}
assert c.value_kind != "NUMERIC"
# RGMII is packed from OPEN Alliance EPL V2.2 — NUMERIC skews with cites;
# PCB Z0 / mm length stay UNKNOWN (not invented).
z = next(c for c in rgmii.constraints if c.parameter == "impedance")
assert z.value_kind == "UNKNOWN"
assert z.value is None
skew = next(c for c in rgmii.constraints if c.parameter == "data_clock_skew_dod_source_max")
assert skew.value_kind == "NUMERIC"
assert skew.value == 0.5
assert skew.source is not None
assert skew.source.organization == "OPEN Alliance"
def test_m10_no_invented_ohms_and_no_usbif_fill():
@@ -96,9 +106,10 @@ def test_m10_no_invented_ohms_and_no_usbif_fill():
else:
assert c.value is None
assert c.unit is None
# i2c/can/HS processor skeletons must not invent ohms; RGMII NUMERIC is cited OPEN Alliance.
blob = json.dumps([
c.model_dump() for p in loaded.physical_interfaces for c in p.constraints
if p.logical_protocol_id in HS_LOGICAL | {"i2c", "can", "rgmii"}
if p.logical_protocol_id in HS_LOGICAL | {"i2c", "can"}
])
for token in NO_INVENTED:
assert token not in blob
+1 -2
View File
@@ -155,8 +155,7 @@ def test_usb_no_layout_is_visible_skip_not_pass():
assert rows
assert all(r.result != "PASS" or r.check == "never" for r in rows)
assert any(r.result == "UNKNOWN" for r in rows)
assert any(f.rule_id == "PE-PRT-L1-001" for f in findings)
assert any("non certificata a L1" in (f.finding or "") for f in findings)
assert not any(f.rule_id == "PE-PRT-L1-001" for f in findings)
def test_axi_internal_l1_not_applicable():
+13 -9
View File
@@ -109,21 +109,25 @@ def test_usb_z_missing_source_never_invents_90():
rows, findings, _ = _l2_for(_usb_connected(), "usb2", impedance_nets=zrep)
z = [r for r in rows if r.check == "differential_impedance"]
assert z
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0].result != "PASS"
blob = json.dumps(z[0].model_dump())
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
assert any("non certificata a L2" in (f.finding or "") for f in findings)
assert z[0].result == "FAIL"
assert z[0].limit_ohm == 90.0
assert z[0].measured_ohm is None
line = z[0].notes
assert line.startswith("USB not certified — because Z cannot be calculated")
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():
rows, findings, _ = _l2_for(_usb_connected(), "usb2")
z = [r for r in rows if r.check == "differential_impedance"]
assert z
assert z[0].result in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0].result == "FAIL"
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():
@@ -220,7 +224,7 @@ def test_ddr_impedance_phy_dependent_not_invented_ohm():
blob = json.dumps(z[0].model_dump())
assert "40" not in blob
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L2-001" for f in findings)
assert not any(f.rule_id == "PE-PRT-L2-001" for f in findings)
def test_length_is_not_delay_timing_unknown():
+14 -29
View File
@@ -173,6 +173,7 @@ def test_remaining_margin_is_total_minus_used():
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")):
sec, findings = protocol_exam(graph)
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()
)
l3 = row["l3_checks"]
assert l3
names = {c["check"] for c in l3}
assert {"timing_budget", "insertion_loss", "return_loss", "crosstalk", "channel"} <= names
assert all(c["result"] != "PASS" for c in l3)
notes = " ".join(c.get("notes") or "" for c in l3)
assert "non certificata a L3 per mancanza" in notes
assert "OpenEMS" in notes
blob = json.dumps(l3)
assert "90" not in blob
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert "s21" not in blob.lower() or "invent" in notes.lower()
assert l3 == []
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert not any("mancanza" in (f.finding or "") for f in findings if f.source == "protocol_l3")
def test_axi_l3_not_applicable_not_fail():
@@ -269,13 +262,8 @@ def test_raw_touchstone_without_fact_is_skip_not_invented():
inst = _inst()
data = {inst.instance_id: {"sparam_file": "lane.s4p", "touchstone": "lane.s4p"}}
rows = certify_instance_l3(inst, iface, channel_data=data)
assert all(r.result != "PASS" for r in rows)
notes = " ".join(r.notes for r in rows)
assert "non certificata a L3" in notes
assert "invent" in notes.lower() or "non inventati" in notes.lower() or "not invented" in notes.lower()
blob = json.dumps([r.model_dump() for r in rows])
# no synthesized S21 numbers
assert '"measured_db": null' in blob or all(r.measured_db is None for r in rows)
# Raw S-param file is not reduced channel FACT — omit L3 (no warning wall).
assert rows == []
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)
notes = " ".join(r.notes for r in rows)
assert "non certificata a L3" in notes
# OpenEMS/FEM is out of product — omit L3 rather than invent S-parameters.
assert 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())
by, findings = certify_l3(insts, channel_data=None)
rows = [r for lst in by.values() for r in lst]
blob = json.dumps([r.model_dump() for r in rows])
assert "s21" not in blob.lower()
assert all(r.measured_db is None for r in rows)
assert any("mancanza" in r.notes for r in rows)
assert any(f.rule_id == "PE-PRT-L3-001" for f in findings)
assert rows == []
assert not any(f.rule_id == "PE-PRT-L3-001" for f in findings)
def test_protocol_exam_attaches_l3_and_does_not_call_l0_electrical():
sec, _ = protocol_exam(_usb_connected())
row = sec.recognized_instances[0]
assert row.get("l3_checks")
assert all(c.get("level") == "L3" for c in row["l3_checks"])
notes = " ".join(c.get("notes") or "" for c in row["l3_checks"])
assert "L0/L1 are not electrical" in notes
# L3 omitted without channel FACT; L0/L1/L2 still attached.
assert row.get("l3_checks") == []
assert row.get("l0_checks")
assert row.get("l2_checks")
l0notes = json.dumps(row.get("l0_checks") or [])
assert "ohm" not in l0notes.lower() or "not" in l0notes.lower()
+2 -2
View File
@@ -138,9 +138,9 @@ def test_axi4_internal_does_not_create_pcb_nets():
assert "AXI_WDATA" not in one.nets
def test_empty_graph_keeps_nessun_protocollo():
def test_empty_graph_keeps_no_protocol_message():
sec = protocol_section_for_graph(DesignGraph())
assert sec.message == "nessun protocollo riconosciuto"
assert sec.message == "No protocol recognized."
assert sec.recognized_instances == []
blob = json.dumps(sec.model_dump())
assert "ohm" not in blob.lower()
+9 -10
View File
@@ -93,7 +93,7 @@ def test_result_rank_fail_before_warning():
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())
assert sec.max_level_reached == "L3"
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"
z = [c for c in checks if c["check"] == "differential_impedance"]
assert z
assert z[0]["result"] in {"MISSING_SOURCE", "UNKNOWN"}
assert z[0]["skip_visible"] is True
assert z[0]["measured"] is None
assert z[0]["limit"] is None
blob = json.dumps(z[0])
assert "90" not in blob
assert z[0]["result"] == "FAIL"
assert z[0]["limit"] == 90.0 or (z[0].get("notes") or "").find("90") >= 0
notes = z[0].get("notes") or ""
assert notes.startswith("USB not certified —")
assert "90" in notes
assert "not invented" not in notes.lower()
chain = z[0]["chain"]
assert chain["physical_interface_id"]
assert 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"]
assert l3 and l3[0]["skip_visible"]
assert l3[0]["result"] != "PASS"
assert "OpenEMS" in l3[0]["notes"]
assert l3 == []
def test_fail_listed_before_warning_in_report_checks():
+115
View File
@@ -0,0 +1,115 @@
"""RMII PHYMAC is not RJ45 100 Ω; recognition alone is not certified."""
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_recognition_is_not_certified():
findings = check_af_traces(_rmii_graph(), {}, _layout())
rmii = [
f for f in findings
if (f.finding or "").startswith("RMII certified —")
or (f.finding or "").startswith("RMII not certified —")
]
assert len(rmii) == 1
blob = rmii[0].finding
assert blob.startswith("RMII not certified — because")
assert "Identified as single-ended RMII" in blob
assert "not an RJ45/MDI" in blob or "not an RJ45" in blob
# Old fake line certified on recognition alone — must never reappear.
assert not blob.startswith("RMII certified —")
assert "identified as single-ended RMII PHYMAC" not in blob.split("", 1)[0]
assert "AN-133" in blob or "UNKNOWN" in blob or "impedance" in blob.lower()
assert rmii[0].status == "ERROR"
assert rmii[0].rule_id == "PE-AF-003"
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
)
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)
)
@@ -25,6 +25,7 @@ def test_rewritten_pages_are_src():
"app/(marketing)/file-guide/page.tsx",
"app/layout.tsx",
"components/layout/sidebar.tsx",
"components/layout/top-bar.tsx",
"hooks/use-pipeline-progress.ts",
"hooks/use-report.ts",
]
@@ -35,6 +36,20 @@ def test_rewritten_pages_are_src():
assert "Native Periscope overlay" not in head
def test_app_shell_uses_top_bar():
text = (SRC / "app/(app)/layout.tsx").read_text(encoding="utf-8")
assert "TopBar" in text
assert "flex-col" in text
assert "<Sidebar" not in text
def test_report_save_as_markdown():
text = (SRC / "app/(app)/project/[id]/report/page.tsx").read_text(encoding="utf-8")
assert "exportReportToMarkdown" in text
assert "Save as Markdown" in text
assert "Export Excel" not in text
def test_landing_keeps_pricing_seam_import():
text = (SRC / "app/(marketing)/page.tsx").read_text(encoding="utf-8")
assert "PricingSection" in text
@@ -37,8 +37,12 @@ def test_site_exports():
def test_report_export_entry():
text = (SRC / "src/lib/report-export.ts").read_text(encoding="utf-8")
assert "export function exportReportToExcel" in text
assert "Findings" in text
assert "export function exportReportToMarkdown" in text
assert "export function buildReportMarkdown" in text
assert "Save as Markdown" not in text # UI copy lives on the report page
assert ".md" in text
assert "xlsx" not in text.lower()
assert "spreadsheet" in text.lower() or "Markdown" in text
def test_version_constants():
+10 -1
View File
@@ -21,13 +21,22 @@ def test_theme_provider_contract():
text = (SRC / "components/theme/theme-provider.tsx").read_text(encoding="utf-8")
assert "export function ThemeProvider" in text
assert 'attribute="class"' in text
assert 'defaultTheme="dark"' in text
assert 'defaultTheme="light"' in text
assert "enableSystem={false}" in text
assert "disableTransitionOnChange" in text
assert 'storageKey="periscope-theme"' in text
clerk = SRC / "components/theme/clerk-theme-provider.tsx"
assert not clerk.exists()
def test_appearance_settings_contract():
text = (SRC / "components/theme/appearance-settings.tsx").read_text(encoding="utf-8")
assert "export function AppearanceSettings" in text
assert "setTheme" in text
assert "Light" in text
assert "Dark" in text
def test_theme_toggle_contract():
text = (SRC / "components/theme/theme-toggle.tsx").read_text(encoding="utf-8")
assert "export function ThemeToggle" in text