Compare commits

...
34 Commits
Author SHA1 Message Date
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
michele 29e1a3033d Leave USB-C vSafe UNKNOWN; AF zip has no USB-IF PD (2.83.0).
Alta Frequenza / af-allegati only hold TI SI (SNLA027B, SDAA499), which do not state vSafe5V/vSafe0V. CabCon R2.5 §1.6 defers volts to USB PD. HDMI 1.4 TMDS stays UNKNOWN.
2026-09-22 18:38:33 +02:00
michele bb83f63114 HDMI 2.0+ TI layout uses source_type VENDOR (2.82.0).
Add VENDOR to SourceType. HDMI 2.0/FRL fill TMDS1204 PCB numbers as VENDOR RECOMMENDED; TMDS1204-specific skew/vias/data_rate/SIGDET stay PHY_DEPENDENT. HDMI 1.4 TMDS remains UNKNOWN. Not HDMI Forum STANDARD.
2026-09-22 18:25:26 +02:00
michele 3c0b3e14dc Archive protocol PDFs on disk; HDMI 2.0/FRL TI layout as VENDOR (2.81.0).
INDEX.md lists cites. PDFs gitignored. HDMI 1.4 TMDS Zdiff stays UNKNOWN. pcbsync not packed.
2026-09-22 18:18:03 +02:00
michele 1455e974ac Add TI TMDS1204 HDMI layout as VENDOR/PHY only (2.80.0).
Sink/source ZPCB, skew, length, vias from SLLSF57A Tables 8-6/8-2. Generic hdmi-* TMDS Zdiff stays UNKNOWN. SLLA633 is SIGDET only.
2026-09-22 18:12:23 +02:00
michele 25aa608052 Fill HDMI CEC electrical from HDMI Specification 1.3 (2.79.0).
27 kΩ pull-up, VOL/VOH, 200 pF device / 700 pF cable, 5 Ω interconnect. TMDS/FRL Zdiff stays UNKNOWN.
2026-09-22 18:07:52 +02:00
michele d32af5627a Fill 1000BASE-T from IEEE 802.3-2012 Section Three Clause 40 (2.78.0).
Cable 100 Ω / 100 m / 570 ns / 50 ns skew stay CABLE. MDI 16 dB and 0.67 V are PHY. L2 PCB Zdiff UNKNOWN. 1000BASE-X 1000 Mb/s from Clause 36.
2026-09-22 18:03:44 +02:00
michele 8a93ffc1c5 Fill 10BASE-T numbers from IEEE 802.3-2015 Section One Clause 14 (2.77.0).
Cable 100 Ω / 100 m / 11.5 dB stay CABLE. L2 PCB Zdiff UNKNOWN. 1000BASE-T not in this volume.
2026-09-22 18:00:10 +02:00
michele 421f659054 Fill Type-C VBUS and CC holes from CabCon R2.5 and R2.0 (2.76.0).
R2.5 keeps Rp/Rd/Ra, charger 4.75/4.0 V, zOPEN, tCCDebounce, tVBUSON.
R2.0 August 2019 Table 4-32 fills Source CC shall-detect only. vSafe5V stays UNKNOWN.
2026-09-22 17:55:47 +02:00
michele c4cd046ea9 Fill USB 2.0 Rev 2.0 and Type-C CabCon R2.5 numbers (2.75.0).
LS/FS rates, pull-up/down, and VBUS come from the base spec. Cable Z0 90 ohm is CABLE; PCB should 90 ohm is RECOMMENDED. L2 differential_impedance stays UNKNOWN. Rp/Rd/Ra from CabCon Tables 4-27/28/29. IEEE Section One/Three and HDMI still missing. PDFs not in git.
2026-09-22 16:53:49 +02:00
michele f3be485b75 Fill protocol packs from recovered USB CTS, Type-C FTS, DVI 1.0, and IEEE 802.3-2012 Section Two (2.74.0).
NUMERIC rows cite document title, revision, section/table, and page. USB Zdiff, Type-C Rp/Rd, 10BASE-T, and 1000BASE-T stay UNKNOWN. PDFs are not in git.
2026-09-22 14:43:26 +02:00
michele 949f5f1541 Add protocol-certification M6/M7 USB-C Ethernet HDMI packs (2.73.0).
USB 2.0 is protocol, Type-C is connector, USB 2.0 over Type-C is the
implementation. Ethernet/HDMI skeletons stay UNKNOWN with needed_document.
No invented ohms; Mini HDMI is connector-only; RJ45 is not 1000BASE-T.
2026-09-22 13:40:37 +02:00
michele 7baffadd6b Add protocol-certification M10 catalog skeletons (2.72.0).
PCIe/CXL keep logical vs physical ids. I2C, CAN, RGMII and missing
high-speed processor interfaces get required_checks; numbers stay
UNKNOWN/MISSING_SOURCE until an official cite. No invented ohms.
2026-09-22 13:03:48 +02:00
michele 866c2e9242 Add protocol-certification M9 L3 channel certifier (2.71.0).
L3 CHANNEL structure for timing budget, insertion loss, return loss,
crosstalk, and complete channel. Numeric PASS/FAIL only from existing
channel FACT; otherwise a visible skip. No OpenEMS, no invented S-parameters,
no M6 packs.
2026-09-22 12:58:18 +02:00
michele 14272d7446 Add protocol-certification M8 DDR instance grouping (2.70.0).
Controller-aware per-memory instances, DQ/DQS vs addr/cmd/ctrl/ck, HBM not classic DDR PCB DQ. No universal mm/ps. No M6 packs, no L3 solver.
2026-09-22 12:46:01 +02:00
michele 24c4beecc9 Add protocol-certification M5 Protocolli report UI (2.69.0).
Instances, L0–L3 max level, measured/limit/margin/source/method, traceability chain. FAIL listed first, not under Warning. Visible skips. No pack numbers, no L3 solver.
2026-09-22 12:37:31 +02:00
michele 0848eb8416 Add protocol-certification M4 L2 electrical certifier (2.68.0).
Z/termination/levels from datasheet, stackup, fab, or cited pack only. Never invented USB ohms. No L3/OpenEMS. L0/L1 are not electrical.
2026-09-22 12:30:13 +02:00
michele 10d21cdc80 Add protocol-certification M3 L1 geometric certifier (2.67.0).
Length, topology, vias, stubs, layer, grouping. Skew only vs NUMERIC mm or DESIGN LIMIT. No invented ohms/mm/ps. Not electrical. No L2/L3.
2026-09-22 12:22:18 +02:00
michele 3cc2e5469b Add protocol-certification M2 L0 structural certifier (2.66.0).
Presence and connection only. FAIL solely on missing MANDATORY structure.
AXI4 internal is NOT_APPLICABLE, not FAIL. RECOMMENDED never FAIL. No L1–L3
and no invented ohms/mm/ps.
2026-09-22 12:12:25 +02:00
michele fc6524a2a9 Add protocol-certification M1 instance recognition (2.65.0).
physical_bus_instance from declaration, silicon/part, pin/net names, and
pairs. USB and DDR fixtures emit groups; AXI4 internal attaches no PCB
nets. Ambiguity is REVIEW, not FAIL. No L0–L3 certifier and no invented Z.
2026-09-22 12:04:37 +02:00
michele 36ad979612 Add protocol-certification M0 catalog, loader, and empty report (2.64.0).
Versioned JSON skeletons without invented ohms/mm/ps. RECOMMENDED is not
FAIL-mandatory; typical-as-standard is rejected. Report Protocolli is empty
until recognition (M1+). Native src overlays inherited periscopex on the
package path.
2026-09-22 11:57:26 +02:00
michele c87adf11a9 Add AF trace analysis after PCB checks (2.63.5).
Trigger λ/10 and tr/(6 tpd) from FACT; pairs analyzed together; Z only from
datasheet; visible INSUFFICIENT skips; lossless RLGC cascade (no OpenEMS).
2026-09-22 08:39:37 +02:00
michele 8b4f7710cf Show Error groups with max child severity (2.63.4).
PCB tree folders are Error / Warning / Info. U18 with PE-PLC-002
ERROR is ERROR, not WARNING. J2 and other connectors list with ICs.
PE-PLC stays in the PCB exam bucket. No deploy.
2026-09-22 01:04:08 +02:00
michele 8f434ffd31 Skip module-die and truncated QFN pintable false PE-BOM hits (2.63.3).
WROOM lands are not the ESP32 die table (U11). QFN-48 pads 25–48 are
not extras when extraction stopped at 1–24 (U12). VQFN is the QFN
family (U19 24-QFN 4×4). Real extra signal pads still ERROR.
2026-09-22 00:57:57 +02:00
michele cd7a7ca3a6 Join PE-BOM-011 pins by name, not pin_count (2.63.2).
USB-C A5/B5 are contacts, not EP. Datasheet pintable, KiCad
.kicad_mod, and embedded PCB pads join by pin identity. Missing
pintable skips; scalar pin_count is never an ERROR authority.
2026-09-22 00:47:58 +02:00
michele 6b751522b7 Keep rust-criteri and conformita-coding in the local docs tree.
Absolute paths in the headings so the files open from
~/Development/periscope/docs. PCB 2.62.1 check list unchanged.
2026-09-21 22:40:12 +02:00
michele 6d9d21a023 Register library components from a datasheet without an exam (2.63.1).
POST /api/library/datasheets now writes an IC inbox card or a
passive/discrete model. Empty pintables never enter library/extracted.
AF Board+AI runs after unchanged run_pcb_checks (PE-SI / HF line stay).
2026-09-21 22:33:30 +02:00
michele 4df04df5d4 Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
2026-09-21 22:12:00 +02:00
michele 20733f0ec6 Add remaining PCB analysis checks to run_pcb_checks (2.62.1).
Phase B: stackup vs fab spec, via current with datasheet I (no IPC via
chart), ESD pad distance, USB-PD, PoE isolation voltage, antenna, and
PDN Z(f). Skip without evidence. Not DRC or FEM.
2026-09-21 20:49:21 +02:00
michele 69d5dd9313 Add HF line and bus analysis to run_pcb_checks (2.62.0).
Phase A: stub, return-split, and BOM termination on buses present on the
graph; DDR/CPU/FPGA class certifiers only when that device exists. Skip
without evidence. Via, pad, track, and zone stay distinct. Not DRC or FEM.
2026-09-21 20:42:55 +02:00
michele 309644e654 Rewrite README as Periscope PCB-analysis product copy.
Replace the schematic-only DeepSeek scaffold with what this tree actually is: full board analysis for fab, gated interface certifiers, evidence rules, and the run/Docker/update commands that exist on disk.
2026-09-21 17:51:41 +02:00
179 changed files with 34301 additions and 1072 deletions
+3
View File
@@ -65,3 +65,6 @@ edif-files/
# Cloud agent scratch
agent-tools/
# Licensed protocol PDFs — keep on disk as the local archive; do not commit binaries
standards/protocol-specs/*.pdf
+62 -55
View File
@@ -1,55 +1,45 @@
# Periscope (DeepSeek)
# Periscope
Periscope reviews schematics the way a good senior engineer does: with the datasheets open.
Periscope is full **PCB analysis** for a board you intend to send to fab with confidence. Schematic review is a stage, not the deliverable.
This tree is adapted from [manvalan/pinscope](https://github.com/manvalan/pinscope) so the pipeline talks to the **DeepSeek API** (`deepseek-flash`, with legacy aliases still accepted). Do not use Anthropic.
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, and your datasheet PDFs. It builds a graph of your design, reads each IC's datasheet, and checks the circuit around every part against what the manufacturer actually specifies — reference application, pin functions, absolute maximums, recommended operating conditions. Every finding points at the datasheet page that backs it up.
Live instance: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
## What changed for DeepSeek
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.
DeepSeek's Chat Completions API is OpenAI-compatible but **does not accept native PDF documents**. Periscope therefore:
## What it checks — and what it will not invent
1. **Extracts datasheet text** with `pypdf` (page-marked) and sends it as chat content.
2. **Renders pages to JPEG** with PyMuPDF when the stage uses a vision model, so pin diagrams and tables survive.
3. **Runs extraction skills locally.** `skills/*/SKILL.md` is inlined as the system prompt; `validate.py` runs in-process. You do not need Anthropic Console Skills.
4. **Round-trips `reasoning_content`** when DeepSeek thinking mode is on, so multi-turn review and tool calls do not 400.
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.
Default routing:
**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.
| Stage | Model |
**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”.
**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.
**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.
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 |
| --- | --- |
| Pintable / pattern / specs extraction | `deepseek-flash` (native vision) |
| Per-IC datasheet review | `deepseek-flash` |
| Auto-resolve / normalize | `deepseek-flash` |
| `periscope/src/` | Native Periscope (finding engine, PCB/placement, DeepSeek, Dockerfiles, KiCad plugin) |
| `periscope/dependency/` | Inherited PinScope (in-tree AGPL dependency — do not delete) |
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
| `LICENSE` | GNU AGPL v3 |
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `.env`. See `periscope/src/backend/.env.example`.
See `periscope/README.md` for the physical split.
## Layout
## Run locally
- `periscope/src/` — native Periscope
- `periscope/dependency/` — inherited PinScope (AGPL in-tree dependency; do not delete)
- `LICENSE` — GNU AGPL v3 (visible at repo root)
- `vendor/impedancefinder/` — third party (license UNKNOWN)
See `periscope/README.md` and `periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md`.
## How it works
<p align="center">
<img src="periscope/dependency/docs/how-it-works.svg" width="920" alt="Pipeline: the netlist and BOM are parsed into a design graph; datasheet PDFs are extracted into pin tables and specs; a per-IC review reads both and files findings cited to datasheet pages; the derating table and BOM roll-up are computed straight from the graph, no model involved.">
</p>
1. **Parse** the BOM (CSV/XLSX) and netlist (PADS-PCB `.asc` or EDIF 2.0.0 `.edn`) into a queryable bipartite graph of components and nets.
2. **Extract** pin tables and specs from the PDFs. Large datasheets are trimmed to the relevant pages first, and every extraction is cached in a shared library.
3. **Review** each IC in isolation. The model gets the datasheet plus that IC's circuit neighborhood, can query the graph, and files findings with severity, reasoning, and page citations. Extraction now also stores absolute-maximum ratings so the reviewer does not have to rediscover supply limits from a 300-page PDF.
4. **Compute** the deterministic parts deterministically — BOM roll-up and a capacitor voltage-derating table come straight from the graph.
## Try it on the bundled design
`periscope/dependency/simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO.
You need Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/):
Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/) for live extraction and review. Offline graph/smoke does not need the key.
```bash
python3 -m venv .venv
@@ -58,13 +48,27 @@ pip install -r periscope/src/backend/requirements.txt
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
```
# in another terminal
cd periscope/src/frontend && npm install
Frontend — overlay native UI onto the inherited shell, then start Next.js (this is what CI runs):
```bash
./scripts/materialize-frontend.sh
cd .merge/frontend && npm install
NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostname 127.0.0.1
```
Open the frontend URL, create a project, and feed it the netlist and BOM from `periscope/dependency/simple_project/`. Datasheets are fetched automatically (LCSC, TI, optional DigiKey); you can still drop in PDFs by hand. Fetched PDFs and extracted pin tables land in the **Library** (sidebar) and are reused on later projects. Everything runs locally against your own DeepSeek key; projects and the extraction library live in `data/`. Skills are local `periscope/src/skills/*/SKILL.md` — do not run `scripts/upload_skills.py`.
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.
Tests from the repo root:
```bash
pip install pytest pytest-asyncio
pytest tests/ -q
python3 scripts/smoke_simple_project.py # offline
```
## Docker
@@ -73,27 +77,30 @@ cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
docker compose up --build
```
Backend on port 8080, frontend on port 3000.
Backend listens on port 8080, frontend on port 3000 (`docker-compose.yml`).
### Update a live instance (e.g. periscope.michelebigi.it)
### Live host
One checkout on the VPS: **`/root/periscope`**. The GitHub clone URL may still be `manvalan/pinscope`; clone into that path so the folder is not `pinscope`:
```bash
git clone git@github.com:manvalan/pinscope.git /root/periscope
```
Do not keep a second live tree under `/root/pinscope` (or `/opt/pinscope`). Stop compose there, then deploy only from the canonical root:
The public site is rebuilt from **`/root/periscope`** with the script that actually ships:
```bash
cd /root/periscope
./scripts/update-periscope.sh
```
The script cds via `dirname "$0"/..` (no `find`). It refuses to run if the resolved root is not `/root/periscope`. Compose project name is `periscope`. After `up`, the script connects `periscope-frontend` / `periscope-backend` to Docker network **`pinscope_pinscope`** (where `railway-caddy` reverse_proxies `periscope-frontend:3000`). It pulls the current branch, writes `NEXT_PUBLIC_API_URL` / `CORS_ORIGINS` for `https://periscope.michelebigi.it`, rebuilds both Docker images, and leaves `data/` alone. First run: put `DEEPSEEK_API_KEY` in `.env` at the repo root (compose reads that file). `--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL.
`--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/`.
Do not set `ENVIRONMENT=production` unless Clerk auth is configured — that flag refuses to boot with auth disabled.
## Source
| 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) |
Push both: `git push github && git push gitea` (no force).
## License
AGPL-3.0 (see `LICENSE`). This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). Faradworks does not operate this instance. For a commercial license of **upstream Pinscope**, write to Faradworks as they publish it (`dev@faradworks.com` historically). For this Periscope instance, contact the operator (Michele Bigi, mikbigi@gmail.com).
[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).
+23 -3
View File
@@ -10,9 +10,29 @@ from pathlib import Path
from pkgutil import extend_path
_REPO = Path(__file__).resolve().parents[1]
for _p in (_REPO / "periscope" / "src", _REPO / "periscope" / "dependency"):
_SRC = _REPO / "periscope" / "src"
_DEP = _REPO / "periscope" / "dependency"
# Last insert is searched first: native src overlays inherited dependency.
for _p in (_DEP, _SRC):
_s = str(_p)
if _p.is_dir() and _s not in sys.path:
if not _p.is_dir():
continue
if _s in sys.path:
sys.path.remove(_s)
sys.path.insert(0, _s)
__path__ = list(extend_path(__path__, __name__))
def _prefer_native_backend(paths: list[str]) -> list[str]:
src, other, dep = [], [], []
for p in paths:
norm = p.replace("\\", "/")
if "/periscope/src/" in norm:
src.append(p)
elif "/periscope/dependency/" in norm:
dep.append(p)
else:
other.append(p)
return src + other + dep
__path__ = _prefer_native_backend(list(extend_path(__path__, __name__)))
+669
View File
@@ -0,0 +1,669 @@
# Albero delle analisi Periscope
Versione codice **2.63.1** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa lanalisi, non implementa.
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
Due pipeline più una porta libreria (non è un esame):
0. **Libreria**`POST /api/library/datasheets` registra un componente da PDF senza `MODE=run`/`MODE=pcb`.
1. **Schematico / review AI**`MODE=run`, `services/pipeline.py``validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
2. **Esame PCB**`MODE=pcb`, `services/pcb_pipeline.py``run_pcb_checks` (`pcb_checks.py`, lista 2.62.1 **invariata**) + sezione additiva AF Board+AI (`af_ai_hf.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/AI). Test pytest citati sotto i nodi: nomi reali da `tests/`.
---
```
Periscope 2.63.1
├── 0. Libreria (porta, non esame)
│ └── PDF + MPN → scheda (inbox IC / passivo / discreto) senza MODE=run/pcb
├── 1. Schematico / review AI (MODE=run)
│ ├── ingest (non è analisi: parser → grafo)
│ ├── estrazione datasheet
│ ├── check deterministici schema
│ ├── certifier di classe (condivisi con PCB)
│ └── review AI per-IC + post-pass
└── 2. Esame PCB (MODE=pcb)
├── ingest layout (non è DRC)
├── run_pcb_checks ← 2.62.1 invariato (SI + HF line inclusi)
│ ├── net / footprint
│ ├── placement vs layout_rules
│ ├── SI + linee HF (2.62.0 Fase A)
│ ├── DDR / CPU / FPGA (2.62.0)
│ ├── potenza / via / termico
│ ├── derating / timing / PI
│ ├── ESD / return / EMI / SPOF
│ ├── BOM ↔ PCB ↔ datasheet
│ └── resto Fase B (2.62.1)
├── AF Board + AI (additivo: flag → investigate_hf_hypotheses; non sostituisce SI/HF)
├── review AI PCB
└── tab antenna RF (non in run_pcb_checks)
```
---
## 1. Schematico / review AI (`MODE=run`)
Orchestrazione: `pipeline.py` stadi `bom_parse``ic_extraction``simple_extraction``passive_extraction``graph_build``validation`. Check deterministici: `_run_deterministic_checks` in `validation.py`. Finding ID schema `PS-{ref}-{nnn}` (non `PCB-`).
### 1.1 Ingest (prerequisito, non analisi)
Parser e grafo non emettono PE-*. Skip MPN: `ic_mpn_skip_reason` (silenzio onesto, niente MPN inventato).
- Test parser/grafo: `tests/test_bom_parser.py` (`test_parse_bom_uses_value_for_ic_when_mpn_column_empty`, `test_parse_bom_keeps_pnm_and_datasheet_url`); `tests/test_hubaudio_bom.py` (tutti e 4); `tests/test_netlist_parser.py` (tutti e 3); `tests/test_edif_parser.py` (14); `tests/test_kicad_parser.py` (17); `tests/test_kicad_project.py` (19); `tests/test_netlist_bundle.py` (9); `tests/test_functional_groups.py` (8).
- Test overlay parser: `tests/test_periscope_parsers_rewrite.py`, `tests/test_periscope_graph_rewrite.py`, `tests/test_periscope_models_rewrite.py`.
### 1.2 Estrazione datasheet (processo)
Skip se manca PDF o lo skill non ha numero. Libreria `library/extracted` condivisa schema+PCB. PCB review non rilegge il PDF (`pdf_path=None`).
#### `extract-pintable` — skill `skills/extract-pintable/`
Modulo: `services/datasheet_extract.py` + `llm/local_skill.py`. Analizza: pin table, package, taxonomy IC.
- Skip se: niente PDF; IC già in libreria alla `model_version` minima.
- Test: `tests/test_native_datasheet_extract.py` (`test_coerce_abs_max_matches_inherited`, `test_native_extract_has_no_anthropic_import`); `tests/test_pdf_ingest.py` (6); `tests/test_deepseek_provider.py` (`test_local_skills_load`, `test_extract_pdf_text_includes_page_markers`, `test_wroom_rejects_bare_soc_pin1_ant`); `tests/test_periscope_taxonomy_skills_src.py` (`test_pintable_skill_mentions_save_tool_and_wroom`, `test_skills_dir_is_src_and_local`).
#### `extract-specs` — skill `skills/extract-specs/`
Analizza: specs discreti/connettori/cristalli; parametri fuori taxonomy → scartati.
- Skip se: niente PDF o tipo non in `SIMPLE_TYPES`.
- Test: `tests/test_pdf_ingest.py` (`test_coerce_abs_max_keeps_valid_drops_junk`); `tests/test_layout_rules.py` (5 — `layout_rules` numerici, niente mm inventati).
#### `extract-pattern` — skill `skills/extract-pattern/`
Analizza: pattern MPN passivi (R/C/L/FB).
- Skip se: MPN opaco; DigiKey/value fallback copre prima.
- Test: `tests/test_inductor_specs.py` (13, Z ferrite solo se il PDF dice N Ω @ freq); `tests/test_pcb_software_bugs.py` (`test_lqw_decoder_and_unknown_skip`, `test_fb1_blm_is_bead_not_dcr_resistor`).
#### Resolve DigiKey / catalogo (esatto MPN)
Modulo: `services/digikey.py`, `passive_from_distributor.py`, `passive_from_mpn.py`. Analizza: parametri catalogo per auto-resolve. Hit fuzzy rifiutati.
- Skip se: MPN non esatto; API assente (fail-soft).
- Test: `tests/test_digikey_match.py` (3); `tests/test_passive_from_distributor.py` (7); `tests/test_passive_from_mpn.py` (7); `tests/test_datasheet_finder.py` (17); `tests/test_library.py` (5).
#### Resolve da stringa Value (solo progetto)
Modulo: `services/passive_from_value.py`. Specs value-derived **non** in libreria condivisa.
- Skip se: stringa ambigua / placeholder.
- Test: `tests/test_passive_from_value.py` (8).
### 1.3 Check deterministici schema
Fail-soft per modulo. Senza evidenza: skip (lista vuota), non folklore.
#### `PE-MUX-001` — `pin_mux_check.py`
Analizza: funzione pin vs net (UART/SPI/I2C mux).
Skip se: `functions` vuote; link inter-device stesso peripheral; net opaco.
- Test: `tests/test_pin_mux_check.py``test_real_defect_uart5_swapped_is_error`, `test_correct_assignment_no_finding`, `test_inter_device_same_peripheral_link_is_skipped`, `test_transceiver_peer_without_peripheral_still_fires`, `test_empty_functions_skipped`, `test_pin_exposes_peripheral_but_not_signal_no_complement`, `test_opaque_net_not_flagged`, `test_token_parser_and_normalizer`, `test_spi_legacy_net_names_match_modern_pin_functions`, `test_spi_controller_peripheral_names_are_synonyms`, `test_simple_project_uart0_nets_are_feasible_on_mspm0_pins`, `test_simple_project_uart0_swapped_on_mspm0_is_error`, `test_spi_genuine_infeasibility_still_fires_with_modern_names`, `test_finding_prints_full_raw_capability_list_and_intent_caveat`, `test_legacy_report_without_source_validates`.
#### `PE-NC-001` — `nc_pin_check.py`
Analizza: pin NC collegato a net attivo.
Skip se: niente pintable.
- Test: `tests/test_nc_pin_check.py``test_nc_pin_on_active_net_warns`, `test_nc_pin_on_nc_net_silent`, `test_no_pintable_silent`.
#### `PE-DEC-001` / `PE-DEC-002` — `passive_rail_check.py` `check_supply_decoupling`
Analizza: condensatore sul net di alimentazione IC (001 presenza; 002 valore vs datasheet).
Skip se: net NC; pin enable strap; manca valore per 002.
- Test: `tests/test_passive_rail_check.py``test_missing_decoupling_is_warning`, `test_cap_to_gnd_clears_decoupling`, `test_enable_strapped_to_rail_is_not_decoupling`, `test_nc_supply_net_is_skipped`, `test_ldo_vout_needs_cout`, `test_ldo_vout_100n_only_is_value_warning`, `test_vdd_100n_is_not_a_value_warning`, `test_ki_cad_voltage_prefix_is_power`, `test_fb_and_rn_prefixes`.
#### `PE-I2C-001` / `PE-I2C-002` — `passive_rail_check.py` `check_i2c_pullups`
Analizza: pull-up I2C (001 presenza; 002 valore vs banda).
Skip se: pin SDA è alias SPI; pull-up senza valore (002 non sizato); bus assente.
- Test: `tests/test_passive_rail_check.py``test_i2c_missing_pullup`, `test_i2c_pullup_present`, `test_i2c_pullup_to_3v3_digital_typed_as_signal`, `test_spi_pin_alias_sda_is_not_i2c`, `test_i2c_from_slash_alias_in_pin_name`, `test_i2c_4k7_pullup_is_in_nxp_wide_band`, `test_i2c_100ohm_pullup_is_too_stiff`, `test_i2c_100k_pullup_is_too_weak`, `test_i2c_pullup_without_value_is_not_sized`.
#### `PE-RST-001` / `PE-RST-002` — `passive_rail_check.py` `check_reset_pullups`
Analizza: reset/enable pull (001 presenza; 002 verso vs datasheet).
Skip se: GPIO guida il reset.
- Test: `tests/test_passive_rail_check.py``test_reset_no_finding_when_gpio_drives`, `test_reset_floating_is_warning`, `test_nrst_pulldown_is_warning`, `test_nrst_pullup_is_not_pulldown`.
#### `PE-BOM-001` / `PE-BOM-002` — `bom_match_check.py`
Analizza: MPN schema vs BOM (001 mismatch; 002 riga BOM orfana).
Skip se: mappa schematica vuota (PADS/EDIF senza properties).
- Test: `tests/test_bom_match_check.py``test_matching_mpns_produce_no_findings`, `test_mpn_mismatch_is_error_ps_bom_001`, `test_orphan_bom_ref_is_warning`, `test_mpn_case_and_whitespace_are_not_a_mismatch`, `test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch`, `test_empty_schematic_map_skips_check`, `test_legacy_design_graph_without_source_fields_still_validates`, `test_build_graph_kicad_mpn_mismatch_surfaces`.
#### `PE-DNP-001` — `dnp_check.py`
Analizza: DNP vs enable floating.
Skip se: colonna DNP assente.
- Test: `tests/test_dnp_check.py``test_dnp_pull_leaves_enable_floating`, `test_fitted_pull_is_silent`, `test_no_dnp_column_skips_floating_enable`, `test_parse_bom_reads_dnp_and_skips_when_column_absent`.
#### LED corrente — `led_current_check.py` (nessun `PE-*`)
Analizza: I_f Ohm vs rating canale. Source `led_current_check`.
Skip se: niente `forward_current_*`; tensione rail sconosciuta (non si indovina).
- Test: `tests/test_led_current_check.py``test_over_current_is_error`, `test_proper_resistor_no_finding`, `test_unknown_rail_no_error`, `test_no_rating_skipped`, `test_parse_resistance`.
#### `PE-TH-001` / `PE-TH-002` / `PE-TH-003` — `thermal_check.py`
Analizza: P = I_load × drop; Tj = Ta + P·θJA (001 INFO senza θJA o Tj ok; 002 WARNING Tj alto — **non in catalogo `finding_engine`**; 003 I²R shunt).
Skip se: manca I_load (Iout_max non è carico).
- Test: `tests/test_thermal_check.py``test_ldo_without_theta_ja_is_info`, `test_iout_max_is_not_used_as_load`, `test_ldo_hot_tj_is_warning`, `test_shunt_over_rating_is_warning`, `test_led_resistor_within_rating_is_silent`.
#### `PE-PWR-001` (schema) — `power_margin_check.py`
Analizza: Σ IQ + I_load vs Iout regolatore; caduta su R serie. Source `power_margin_check`.
Skip se: manca I_load; IQ assente non è fail.
- Test: `tests/test_power_margin_check.py``test_load_over_iout_max_is_ps_pwr_001`, `test_missing_iq_is_not_guessed_into_margin_fail`, `test_series_r_ir_drop_uses_i_load_not_trace`, `test_no_series_r_does_not_invent_trace_drop`.
#### `PE-SEQ-001` — `sequencing_check.py`
Analizza: PG → EN da note datasheet.
Skip se: niente spec `power_sequence`.
- Test: `tests/test_sequencing_check.py``test_pg_not_tied_to_en_is_warning`, `test_pg_tied_to_en_is_silent`, `test_no_power_sequence_spec_skips_even_if_pg_open`.
#### `PE-FLT-001` / `PE-FLT-002` / `PE-FLT-003` — `filter_check.py`
Analizza: topologia filtro (001), pezzo (002), fc vs ADC / DCR ferrite (003).
Skip se: manca C; pull-up+decoupling non è filtro; DCR ferrite senza limite datasheet.
- Test: `tests/test_filter_check.py``test_rc_reports_fc_info_without_adc_rate`, `test_rc_vs_adc_rate_is_warning`, `test_pullup_plus_decoupling_is_not_a_filter`, `test_missing_c_value_does_not_invent_fc_warning`, `test_pi_and_t_need_l_and_c`, `test_ferrite_dcr_warns_only_with_datasheet_limit`.
#### `PE-XTAL-001` / `PE-XTAL-002` / `PE-XTAL-003` — `crystal_cl_check.py`
Analizza: CL vs C1/C2 (001 coppia; 002 serie/stray; 003 drive). Cstray solo se in specs.
Skip se: niente CL in specs.
- Test: `tests/test_crystal_cl_check.py``test_no_cl_in_specs_is_silent`, `test_series_above_cl_without_stray_warns`, `test_with_stray_mismatch_warns`, `test_simple_project_without_cl_silent`.
#### `PE-LF-001` / `PE-LF-002` / `PE-LF-003` — `lifecycle.py`
Analizza: Obsolete / NRND / RoHS non-compliant.
Skip se: catalogo assente.
- Test: `tests/test_lifecycle_check.py``test_obsolete_is_warning_and_uses_distributor_replacement`, `test_nrnd_is_info`, `test_explicit_rohs_non_compliant_is_warning`, `test_active_is_silent`, `test_rohs_not_applicable_is_not_a_fail`, `test_missing_catalog_and_missing_substitute_are_not_guessed`.
#### `PE-INT-001` — `internal_features_check.py`
Analizza: ESD/pull interni vs net.
Skip se: `internal_features` vuoto (non si indovina open-drain).
- Test: `tests/test_internal_features_check.py``test_listed_open_drain_without_pull_is_warning`, `test_listed_pin_with_pullup_is_silent`, `test_empty_features_does_not_guess_open_drain`.
#### `PE-ESR-001` — `hf_coverage_check.py`
Analizza: copertura HF/ESR (bulk vs 100 n).
Skip se: valore C sconosciuto.
- Test: `tests/test_hf_coverage_check.py``test_bulk_only_is_info_ps_esr_001`, `test_bulk_plus_100n_is_silent`, `test_unknown_cap_value_is_not_guessed`.
#### `PE-ERRATA-001` — `errata_check.py`
Analizza: errata pubblicata vs pull sul pin.
Skip se: MPN sconosciuto o entry senza URL.
- Test: `tests/test_errata_check.py``test_missing_errata_pullup_is_ps_errata_001`, `test_pullup_present_is_silent`, `test_unknown_mpn_and_url_less_entry_are_silent`.
Suite deterministica end-to-end: `tests/test_validation_deterministic_seed.py` `test_deterministic_findings_seeded_and_not_reviewed`; `tests/test_eval_report.py` `test_simple_project_eval_matches_committed_golden`.
### 1.4 Certifier di classe (grafo — schema **e** PCB)
Moduli: `interface_class_check.py` (schema **e** PCB), `hf_bus_class.py` (2.62.0, solo `run_pcb_checks`), `usb_pd_check.py` (2.62.1, solo PCB). Niente geometria. Niente connettore/dispositivo → **silenzio**.
#### USB-C — solo se receptacle USB-C sul grafo
- **`PE-USBC-001`** classe USB2 vs USB3 da BOM/footprint/MPN/net.
- **`PE-USBC-002`** CC1 e CC2.
- **`PE-USBC-003`** Rd 5.1 kΩ ±10% o Rp 56/22/10 kΩ. Skip/INSUFFICIENT se R ignota o CC verso controller senza R.
- **`PE-USBC-004`** VBUS e GND.
- **`PE-USBC-005`** SuperSpeed solo se classe USB3; USB2 = N/A (non ERROR).
Test: `tests/test_interface_class_check.py``test_no_connector_emits_nothing`, `test_usbc_rd_vbus_gnd_certified`, `test_usbc_missing_cc2_is_error`, `test_usbc_wrong_rd_is_error`, `test_usbc_cc_to_controller_without_r_is_insufficient_not_error`, `test_usbc_unknown_r_value_is_insufficient`, `test_usb3_class_without_ss_pairs_is_error`, `test_usb3_with_ss_pairs_is_certified`, `test_missing_vbus_is_error`, `test_simple_project_usbc_not_false_error`, `test_hubaudio_like_usbc_usb2_and_poe_magjack`, `test_usbc_vbus_does_not_invent_usb_500ma`, `test_does_not_use_layout_geometry`, `test_rule_catalog_shared_not_si`.
#### Ethernet — solo se RJ45/MagJack
- **`PE-ETH-001`** classe 10/100 vs GbE. Skip/INSUFFICIENT se velocità assente (non si inventa GbE).
- **`PE-ETH-002`** TX/RX (10/100) o quattro MDI (GbE).
- **`PE-ETH-003`** magnetics se GbE.
- **`PE-ETH-004`** Bob Smith 75 Ω: REVIEW (spesso in MagJack).
Test: `tests/test_interface_class_check.py``test_ethernet_10_100_magjack_no_gbe_magnetics_error`, `test_gbe_without_magnetics_is_error`, `test_gbe_with_magnetics_is_certified`, `test_rj45_without_speed_is_insufficient_not_invented_gbe`.
#### PoE — solo con evidenza PoE (RJ45 nudo = skip)
- **`PE-POE-001`** evidenza PoE.
- **`PE-POE-002`** classe/tipo (802.3af/at/bt, Class n) — mai inventata.
- **`PE-POE-003`** magnetics/isolamento (MagJack o LAN transformer).
- **`PE-POE-004`** (2.62.1) tensione di isolamento solo da numero sourced. Skip senza V.
Test: `tests/test_interface_class_check.py``test_bare_rj45_does_not_invent_poe`, `test_poe_with_evidence_requires_magnetics_isolation`, `test_poe_evidence_on_bare_jack_is_isolation_error`, `test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr`, `test_run_pcb_checks_skips_absent_interfaces`; `tests/test_pcb_phase_b.py``test_poe_isolation_skips_without_voltage_fact`.
#### DDR — solo se DRAM sul grafo (2.62.0)
- **`PE-DDR-001`** classe DRAM.
- **`PE-DDR-002`** CK, DQS, ≥8 DQ.
- **`PE-DDR-003`** VTT/VREF solo se quei net esistono (altrimenti skip, non VTT inventato).
Test: `tests/test_hf_line_check.py``test_no_ddr_cpu_fpga_on_usb_only_graph`, `test_ddr_present_class_and_nets_skip_invented_vtt`, `test_rule_catalog_hf_ids`.
#### CPU bus parallelo — solo se D/A/controllo esistono (2.62.0)
- **`PE-CPU-001`** presenza bus parallelo.
- **`PE-CPU-002`** ≥8 D/DQ/AD.
- **`PE-CPU-003`** address o AD mux.
- **`PE-CPU-004`** CS/WE/OE (o FMC).
Test: `tests/test_hf_line_check.py``test_cpu_parallel_bus_only_when_data_addr_control_exist`.
#### FPGA — solo se FPGA sul grafo (2.62.0)
- **`PE-FPGA-001`** classe FPGA.
- **`PE-FPGA-002`** flash di config se c’è un flash IC (altrimenti skip).
- **`PE-FPGA-003`** VCCINT/VCCIO solo se net nominati.
Test: `tests/test_hf_line_check.py``test_fpga_only_when_present_config_flash_optional`.
#### USB-PD contratto — `usb_pd_check.py` (2.62.1)
- **`PE-PD-001`** controller PD sul grafo (FUSB302/TPS257/…). Non è USB2 Rd. PDO non inventati.
Test: `tests/test_pcb_phase_b.py``test_usb_pd_skips_without_pd_controller`.
### 1.5 Review AI per-IC + post-pass
Moduli: `review_session.py`, `pcb_review.py` non entra qui. DeepSeek, PDF una volta per IC, fail-soft. Prompt tools grafo (`find_connected_components`, `get_net_for_pin`, `get_pintable`, `get_datasheet_excerpt`). Finding LLM = REVIEW, mai RULE ERROR (`finding_engine`).
Skip se: niente MPN; niente PDF (`not_reviewed`); fingerprint invariato (`review_fingerprint.py`).
- Review loop: `tests/test_native_review_loop.py` (5); `tests/test_validation_no_tool_recovery.py` `test_no_tool_calls_triggers_forced_submit_next_turn`; `tests/test_validation_concurrency.py` (3); `tests/test_validation_trace_log.py` (3); `tests/test_review_parse.py` (2); `tests/test_quote_verify.py` (5); `tests/test_finding_engine.py` (`test_llm_review_is_never_rule`, `test_review_and_risk_cannot_stay_error`, `test_insufficient_evidence_does_not_stay_error`); `tests/test_fase_b_checks.py` `test_llm_error_review_clamped_in_complete_finding`; `tests/test_review_fingerprint.py` (2); `tests/test_reprocess.py` (4); `tests/test_deepseek_provider.py` (`test_thinking_only_echoes_reasoning_content_on_follow_up` e roundtrip tool).
- Excerpt cross-IC: `tests/test_datasheet_excerpt_tool.py` (12); `tests/test_cache_breakpoint_limit.py` (3).
- **Normalize** (downgrade-only): `tests/test_normalize_findings.py` (14); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_normalize_cannot_promote_warning`.
- **Dedupe** cross-IC: `tests/test_dedupe_findings.py` (11); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_dedupe_collapses_both_endpoints`.
---
## 2. Esame PCB (`MODE=pcb`)
Stadi: `ensure_graph``parse_pcb``classify``inventory`**`run_pcb_checks`** → `ai_review``pcb_report.json`. ID finding `PCB-{ref}-{001}`. Mutex vs analisi schema e vs placement. Inventario net (`pcb_inventory.py`) è numeri, non finding.
KiCad DRC **non** è un nodo Periscope. `tests/test_pcb_software_bugs.py` `test_drc_unconnected_items_count` e `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude` servono solo a **non** copiare il DRC.
Suite orchestrazione: `tests/test_pcb_review.py` `test_run_pcb_checks_assigns_pcb_ids_and_recommendations`, `test_pcb_pipeline_imports_build_placement_plan`; `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`, `test_phase_b_rule_catalog`; `tests/test_interface_class_check.py` `test_merge_drops_duplicate_pcb_interface_findings`; `tests/test_pcb_sse_terminal.py` (4).
### 2.1 Layout ingest (non DRC)
`parsers_kicad_pcb.py``LayoutGraph`. Nessun default 1 oz.
- Test: `tests/test_kicad_pcb.py` (14); `tests/test_pcb_via_not_pad.py` (geometria via≠pad); `tests/test_pcb_software_bugs.py` `test_kicad10_name_only_nets_fill_index`.
### 2.2 Net / footprint — `pcb_net_match.py` + `pcb_checks._incomplete_layout_finding`
#### `PE-LAY-001`
Pad PCB su net diverso dallo schema (stesso pin).
Skip se: layout assente.
- Test: `tests/test_pcb_review.py``test_pad_net_mismatch_is_pe_lay_001`, `test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001`.
#### `PE-LAY-002`
Ref in schema, nessun footprint (tranne #PWR). Distingue “manca sulla PCB”.
- Test: `tests/test_pcb_review.py` `test_missing_footprint_is_pe_lay_002`; `tests/test_incomplete_pcb.py` `test_unplaced_ref_is_lay_002_not_invented_xy`.
#### `PE-LAY-003`
Stesso net, solo prefisso `/sheet/` KiCad (INFO, una volta).
- Test: `tests/test_pcb_review.py` `test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad`.
#### `PE-LAY-004` — `pcb_checks.py` (**non in catalogo `finding_engine`**)
Pad net senza rame (track **o** via **o** zone). Skip NC `unconnected-(…)`. INFO INSUFFICIENT, non FAIL inventato.
- Test: `tests/test_incomplete_pcb.py` `test_run_pcb_checks_on_partial_layout_does_not_invent_tracks`; `tests/test_pcb_software_bugs.py``test_lay004_zone_counts_as_copper`, `test_lay004_skips_unconnected_nc_and_matches_sheet_prefix`, `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude`.
### 2.3 Placement vs `layout_rules` — `placement_check.py`
Senza mm in `layout_rules`: skip. Niente 3 mm default. Distanza euclidea pad-pad **non** usata.
#### `PE-PLC-001`
Path rame (segmenti) vs `max_distance_mm`.
- Test: `tests/test_placement_check.py``test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001`, `test_crystal_load_cap_within_max_distance_mm_is_silent`, `test_track_path_longer_than_max_distance_mm_is_ps_plc_001`; `tests/test_pcb_review.py` `test_close_decoupling_has_no_plc_001`; `tests/test_placement_check.py` `test_simple_project_without_pcb_has_no_ps_plc`.
#### `PE-PLC-002`
Via in courtyard vs `min_via_count`. Skip senza vertici courtyard e senza min.
- Test: `tests/test_placement_check.py` `test_via_count_is_calculated_from_courtyard_and_min_parameter` (geometria `_in_poly`; non emette ancora il finding end-to-end).
#### `PE-PLC-003`
`same_layer` decoupling.
- Test: `tests/test_placement_check.py``test_same_layer_param_opposite_layers_is_ps_plc_003`, `test_same_layer_param_same_copper_is_silent`, `test_opposite_layers_without_same_layer_param_is_silent`, `test_opposite_layers_with_via_in_courtyard_is_silent`.
#### `PE-PLC-004`
Keepout: net estraneo nel courtyard. Non è DRC rame-pad.
- Test: `tests/test_placement_check.py``test_keepout_foreign_track_in_courtyard_is_ps_plc_004`, `test_ic_courtyard_pad_nets_are_not_keepout_violations`, `test_keepout_own_net_in_courtyard_is_silent`, `test_keepout_without_courtyard_is_silent`.
#### `PE-PLC-005` (**non in catalogo `finding_engine`**)
Proximity non misurabile perché manca la **traccia**.
- Test: `tests/test_placement_check.py` `test_unrouted_crystal_cap_is_insufficient_not_euclidean`.
Schema runner omette i check layout: `tests/test_placement_check.py` `test_schema_deterministic_runner_omits_layout_checks`.
### 2.4 SI — `si_check.py` + ImpedenceFinder
Gate: `layout_rules` kind + `net_class`/quote sul **bus citato**. Skip I2C/GPIO/EN/CC/strap RC. Bus: USB2, USB3, ETH MDI, RGMII, SGMII, DDR3 CLK/DQS/DQ/ADDR, HDMI, PCIe, LVDS; 2.62.0 aggiunge MIPI e clock HF (`bus_class`); XTAL/OSCIN fuori.
Z0: `impedance.py` / `impedance_traces.py`. CPWG → errore, non numero inventato. Senza stackup: skip misura.
Suite gate: `tests/test_si_check.py` `test_skip_i2c_gpio_cc_regn`, `test_i2c_not_checked_as_50_ohm`, `test_simple_project_without_pcb_has_no_ps_si_001`; `tests/test_hf_line_check.py` `test_bus_class_mipi_hf_clk_not_xtal`. Calcolatrice: `tests/test_impedance.py` (20); misura net: `tests/test_impedance_traces.py` (10).
#### `PE-SI-001` length_match / skew coppia
Skip senza mm datasheet.
- Test: `tests/test_si_check.py` `test_length_match_2_5mm_vs_1mm_is_fail`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
#### `PE-SI-002` Zavg/min/max vs finestra datasheet
Mai folklore 90 Ω.
- Test: `tests/test_si_check.py` `test_emmaforo_usb_avg_in_window_min_out_is_margin`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
#### `PE-SI-003` lunghezza max mm
- Test: `tests/test_si_check.py` `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
#### `PE-SI-004` spacing intra-coppia mm
Skip senza mm. Copertura indiretta nella stessa suite SI gated.
#### `PE-SI-005` piano di riferimento / layer
Skip senza FACT datasheet.
#### `PE-SI-006` conteggio via HS vs min/max datasheet
Skip senza min/max.
#### `PE-SI-008` return via accanto alla coppia (`return_path`)
Skip senza kind.
#### `PE-SI-009` serie R ohm sul net HS (non EN-RC)
- Test: `tests/test_si_check.py` `test_en_rc_series_resistor_does_not_paint_usb`.
#### `PE-SI-010` bus HS misurato, libreria senza FACT Z → INFO, non FAIL 90 Ω
- Test: `tests/test_si_check.py` `test_usb_without_library_z_is_insufficient_not_90ohm_fail`, `test_empty_net_class_impedance_does_not_paint_usb`; `tests/test_hf_line_check.py` `test_existing_si_usb_not_polluted_by_hf_geometry`.
Refresh extract SI vecchio: `tests/test_pcb_plan_closeout.py` `test_si_refresh_when_old_extract_has_only_decoupling`, `test_si_extract_needed_skips_board_ics_only_not_shared_library`.
### 2.5 Linee HF extra — `hf_line_check.py` (2.62.0 Fase A)
Track ≠ via ≠ pad ≠ zona. Stub = traccia dangling.
#### `PE-SI-007` stub vs mm datasheet
INFO se misura c’è e mm mancano (INSUFFICIENT). Skip: pad-pad, via, zone.
- Test: `tests/test_hf_line_check.py``test_no_layout_skips_hf_geometry`, `test_pad_to_pad_usb_has_no_stub`, `test_via_is_not_a_stub`, `test_zone_is_not_a_track_stub`, `test_dangling_track_stub_vs_datasheet_mm_is_fail`, `test_measured_stub_without_datasheet_mm_is_insufficient`.
#### `PE-SI-011` gap pour GND sotto coppia HF (REVIEW)
Skip senza stackup/zone.
- Test: `tests/test_hf_line_check.py` `test_split_under_pair_needs_stackup_and_zone`.
#### `PE-SI-012` terminazione BOM serie/parallelo FACT
Skip se manca il terminatore (non si inventa 50 Ω).
- Test: `tests/test_hf_line_check.py` `test_bom_termination_is_fact_missing_is_skip`.
Catalogo HF: `tests/test_hf_line_check.py` `test_rule_catalog_hf_ids`. DDR/CPU/FPGA: §1.4 (stesso `check_memory_fpga_classes` in `run_pcb_checks`).
### 2.6 Potenza / via / termico sul board — `pcb_power_thermal.py`
#### `PE-PWR-001` (PCB) — `check_pcb_power_traces`
Larghezza **traccia** × spessore rame vs I_load/Imax/I_abs. IPC-2221 solo con thickness **e** ΔT da Tjmax. Iout_max non è carico. Niente 500 mA USB.
Skip se: manca I datasheet (**skip vero**, non INFO INSUFFICIENT — 2.61.1).
- Test: `tests/test_pcb_review.py``test_power_trace_ipc2221_errors_when_load_exceeds_ampacity`, `test_power_trace_uses_parsed_width_and_thickness`, `test_power_trace_skips_without_i_load`, `test_power_trace_skips_iout_max_as_load`, `test_power_trace_uses_imax_when_i_load_absent`, `test_power_trace_uses_i_abs_when_i_load_absent`; `tests/test_interface_class_check.py` `test_usbc_vbus_does_not_invent_usb_500ma`.
#### `PE-VIA-001` — `check_pcb_via_current`
Se I c’è e sul net **zero via**: INFO conteggio. Se le via ci sono: silenzio — **nessuna tabella ampacity**. Via ≠ traccia.
Skip se: manca I.
- Test: `tests/test_pcb_software_bugs.py` `test_via_current_skips_without_i_load`.
#### `PE-VIA-002` (2.62.1)
I + conteggio/drill FACT. Tabella IPC via **non** applicata.
Skip se: manca I.
- Test: `tests/test_pcb_phase_b.py` `test_via_current_skips_without_i_does_not_invent_ipc`.
#### `PE-THM-001` — `check_pcb_thermal_copper`
P = I_load × drop; courtyard senza pour/via.
Skip se: P sconosciuta.
- Test: `tests/test_pcb_review.py` `test_thermal_copper_warns_without_pour_or_vias`.
#### `PE-THM-002` — `check_pcb_junction_temp`
Tj = Ta + P·θJA solo con P, θJA, area rame, conteggio via. Non è FEM.
Skip/INSUFFICIENT se manca un fattore.
- Test: `tests/test_pcb_plan_closeout.py``test_tj_when_theta_copper_vias_exist`, `test_tj_insufficient_without_theta`.
#### `PE-KEL-001`
Pin Kelvin/sense sullo stesso net di carico. Ignora CRS/IN±.
- Test: `tests/test_pcb_review.py` `test_kelvin_sense_pin_on_shared_net`; `tests/test_pcb_software_bugs.py` `test_kelvin_ignores_crs_and_in_plus`.
#### `PE-STCH-001`
Segnale sopra pour GND senza via GND nel bbox. Skip GPIO minuscoli.
- Test: `tests/test_pcb_review.py``test_gnd_stitch_skips_tiny_gpio`, `test_gnd_stitch_info_when_signal_has_pour_but_no_via`.
### 2.7 Derating / gerarchia / timing / PI
#### `PE-DRT-001` / `PE-DRT-002` / `PE-DRT-003` — `pcb_checks.check_pcb_derating` + `derating.py`
Vop vs Vrated: ERROR / MARGIN / PASS INFO. Niente % dielettrico inventato. Skip se manca Vop o Vr.
- Test: `tests/test_fase_b_checks.py` `test_derating_pass_margin_risk`; `tests/test_derating.py` (7, C_eff stima — non è un PE-* a parte).
#### `PE-HIER-001` — `hierarchy.py`
Pin IC senza net nominato.
- Test: `tests/test_fase_b_checks.py` `test_hierarchy_component_pin_net_block`.
#### `PE-TIM-001` / `PE-TIM-002` — `timing_check.py`
001 RC reset vs t_reset; 002 strap vs Vih/Vil.
Skip senza R/C e t_reset o Vih/Vil.
- Test: `tests/test_fase_b_checks.py``test_timing_skips_without_numbers`, `test_timing_reset_rc_vs_t_reset`. **`PE-TIM-002` non ha un test che fa scattare il finding** (solo skip condiviso).
#### `PE-PI-001` / `PE-PI-002` — `pi_check.py`
001 cap locale sul rail IC; 002 locale vs bulk se i valori C esistono. Distanza mm solo da `layout_rules` (altrimenti è PE-PLC-001). Skip net NC.
- Test: `tests/test_fase_b_checks.py``test_pi_missing_local_is_risk_not_error`, `test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local`, `test_pi_bulk_only_does_not_claim_no_local`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`. **`PE-PI-002` non ha un assert dedicato sul `rule_id`.**
### 2.8 ESD / return / EMI / SPOF
#### `PE-ESD-001` — `esd_return_check.check_esd`
Net J* ∩ IC senza parte ESD: REVIEW, non ERROR. Niente GPIO/NC/rail spam.
- Test: `tests/test_fase_b_checks.py``test_esd_without_part_is_review_not_error`, `test_esd_skips_gpio_nc_unconnected_and_onboard_power`, `test_esd_one_finding_per_connector_ic_path`, `test_esd_skips_when_protection_part_on_net`.
#### `PE-ESD-002` (2.62.1) — `check_esd_distance`
Distanza **pad** TVSconnettore (via ≠ pad). INFO se no mm.
- Test: `tests/test_pcb_phase_b.py` `test_esd_distance_uses_pads_not_vias`.
#### `PE-RET-001` — `check_return_path`
Net HS sopra pour GND senza via GND nel bbox: REVIEW.
- Test: `tests/test_fase_b_checks.py` `test_return_path_hs_is_review`.
#### `PE-EMI-001` — `emi_check.py`
Solo se `layout_rules` cita choke/ferrite/shield. Non IEC 61000. Skip NC.
- Test: `tests/test_pcb_plan_closeout.py` `test_emi_only_with_datasheet_fact`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`.
#### `PE-SPOF-001` — `spof_check.py`
Un regolatore o XTAL condiviso: REVIEW, mai ERROR.
- Test: `tests/test_pcb_plan_closeout.py` `test_spof_review_single_ldo`.
### 2.9 BOM ↔ PCB ↔ datasheet — `bom_pcb_check.py`
Skip se manca un lato (BOM, footprint, o numero datasheet).
#### `PE-BOM-010` famiglia package
VQFN/WQFN/HVQFN = QFN. LQFP ≠ QFN.
- Test: `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_bom_011_named_join.py` `test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010`.
#### `PE-BOM-011` pintable vs pad, join per nome
Join datasheet pintable × `.kicad_mod` lib × pad embedded. Non `pin_count` scalare. A5/B5 USB-C sono pin, non EP. EP/SH/mount classificati; via ≠ pad. Senza pintable: skip.
- Test: `tests/pcb/test_bom_011_named_join.py`; `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/pcb/test_pcb_via_not_pad.py`.
#### `PE-BOM-012` Vop vs rating/abs-max legato al **pin**
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`; `tests/test_pcb_software_bugs.py` `test_vddcr_absmax_does_not_bind_3v3_io`.
#### `PE-BOM-013` Top vs abs-max T
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`.
#### `PE-BOM-014` I_load vs rating
Skip se manca I.
- Test: `tests/test_pcb_plan_closeout.py``test_abs_max_voltage_and_temp_and_current`, `test_inductor_current_rating`.
### 2.10 Resto Fase B (2.62.1)
#### `PE-STK-001` — `stackup_check.py`
Spessore rame board vs stackup fab sourced. Mai 1 oz default.
Skip se: niente stackup layout o niente FACT fab.
- Test: `tests/test_pcb_phase_b.py``test_stackup_skips_without_fab_spec`, `test_stackup_vs_fab_spec_is_review_not_invented_oz`.
#### `PE-ANT-001` / `PE-ANT-002` — `antenna_layout_check.py`
Keepout zona e matching L/C sul feed. Solo se antenna/net RF sul grafo. Niente 50 Ω inventati.
Skip se: antenna assente; matching manca e non c’è FACT (002 non emette).
- Test: `tests/test_pcb_phase_b.py``test_antenna_skips_when_absent`, `test_antenna_keepout_and_match_are_facts`.
#### `PE-PDN-001` — `pdn_check.py`
Z(f) solo con FACT in frequenza. Z0 SI **non** è PDN.
- Test: `tests/test_pcb_phase_b.py` `test_pdn_skips_without_zf`.
Silenzio Fase B se assente: `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`. Catalogo: `test_phase_b_rule_catalog`.
### 2.11 Review AI PCB
Modulo: `pcb_review.py` + `services/pcb_validation.py`. Stesso oggetto finding. Spiega FACT deterministici; **non** rilegge il PDF; non inventa interfacce / PoE / SuperSpeed / mm / Z / I.
Skip se: niente JSON libreria per lIC.
- Test: `tests/test_pcb_review.py``test_pcb_ai_finding_gets_action`, `test_parse_review_action_field_without_recommendation`, `test_layout_context_includes_via_counts`, `test_layout_context_includes_domain_and_group`, `test_pcb_ai_skips_without_library_extraction`, `test_library_constraints_fill_from_storage`, `test_inventory_lists_net_length_and_pair`; `tests/test_finding_engine.py` `test_pcb_review_ai_finding_action_without_recommendation`; `tests/test_pcb_software_bugs.py` `test_thinking_reasoning_content_echo_still_present`.
### 2.12 Tab antenna RF (non in `run_pcb_checks`)
Moduli: `antenna_rf.py`, `antenna_geometry.py`. Topologia matching + ricetta routing. No EM/VSWR/CPWG. Distinto da `PE-ANT-001/002`.
- Test: `tests/test_antenna_rf.py``test_verify_finds_matching_path_to_ant_footprint`, `test_verify_missing_matching_is_warning`, `test_design_recipe_needs_marker_without_ant`, `test_design_recipe_ready_with_ant_and_stackup`, `test_geometry_templates_produce_export`, `test_geometry_overflow_tiny_zone`, `test_design_recipe_meander_template`.
Placement/auto-place **non** è esame PCB: `tests/test_placement_pack.py`, `tests/test_placement_pipeline.py` (fuori da questo albero di analisi).
---
## Motore finding (condiviso)
`finding_engine.py`: FACT / REQUIREMENT / INFERENCE; ERROR solo RULE+MANDATORY; `decisions.json` non ri-naga.
- Test: `tests/test_finding_engine.py` (11); `tests/test_finding_schema.py` (7); `tests/test_fase_b_checks.py` `test_sort_findings_error_then_class`; `tests/test_review_workflow.py` (10 — stati finding UI, non check PE). CAD bridge: `tests/test_cad_bridge.py` (5).
---
## Test che esistono ma non sono analisi
`tests/` ha **114 file / ~730 test**. Oltre ai nodi sopra restano overlay nativi, upload, auth, billing seam, UI rewrite. Non sono check PE-*.
**Upload / ingest cartella** (non analisi): `tests/test_periscope_frontend_upload_rewrite.py` `test_upload_zone_is_src`; `tests/test_kicad_project.py` (picker cartella, `.history`, Safari stem); `tests/test_netlist_bundle.py` (zip, zip-slip); `tests/test_kicad_pcb.py` `test_upload_pcb_sets_has_pcb`, `test_upload_sch_as_pcb_is_rejected`; `tests/test_purple_parts_resolver.py` (upload BOM/LCSC); `tests/test_reopen_replace.py` `test_reopen_then_replace_bom_and_netlist`.
**Auth**: i test auth esistono (`tests/test_local_auth.py`, `tests/test_periscope_auth_rewrite.py`, `tests/test_pinscope_compat.py`, più overlay che tengono il contratto login). Non si descrivono segreti, JWT, password, o flussi di autenticazione.
Altri non-analisi: rewrite frontend/Docker (`test_periscope_*_rewrite.py`, `test_periscope_leftover_package_requirements.py`, `test_periscope_boot_without_dependency_product.py`); delete progetto; cost estimator; event bridge; job runner; `test_deepseek_only.py` (routing modello, non finding).
---
## Conteggio nodi
| Gruppo | Nodi |
| --- | --- |
| Check solo schema (27 PE-* + LED senza ID) | 28 |
| USB-C / ETH / PoE (schema e PCB) | 13 |
| `PE-PWR-001` (due source: schema e PCB) | 1 |
| DDR / CPU / FPGA / PD (grafo, invocati da `run_pcb_checks`) | 11 |
| Check solo PCB (layout, SI/HF, potenza, Fase B, …) | 47 |
| Processo: 3 skill + DigiKey + value + IC AI + PCB AI + tab RF | 8 |
| **Totale nodi analisi** | **108** |
Check PE-* in codice: **99** (tutti elencati). Più LED senza `rule_id`. `PE-LAY-004`, `PE-PLC-005`, `PE-TH-002` sono in codice; `PE-LAY-004` / `PE-PLC-005` non sono in `_seed()` di `finding_engine.py`.
Buchi di test noti (il check c’è, il fire test no): `PE-TIM-002`, `PE-PI-002` (nessun assert `rule_id`), `PE-PLC-002` (solo geometria `_in_poly`), `PE-SI-004` / `PE-SI-005` / `PE-SI-006` / `PE-SI-008` (gate SI, niente caso dedicato).
+142
View File
@@ -0,0 +1,142 @@
# Conformità coding — Periscope intero
Percorso locale: `/Users/michelebigi/Development/periscope/docs/conformita-coding.md`.
Costituzione: `docs/development/CODING_CONSTITUTION.md` (*code that fits in your head*). **Per sezione**, non per funzione: conforme sì / no e perché. Taglio 2.62.1 + questa macro-fase (libreria come porta, test riorganizzati, AF+AI extra). Auth/JWT/users: **non toccati**; la sezione esiste e viene giudicata, non modificata.
Giudizio: **conforme** = un ingegnere legge la sezione senza ricostruire un sistema nascosto. **Non conforme** = troppe responsabilità, stato implicito, eccezioni ingoiate, o semantica a rischio. Un file lungo può essere conforme se il flusso è ovvio; uno corto no se fa tre mestieri.
Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non si “passa” la costituzione dichiarandole OK.
---
## 1. Albero nativo / dependency
**Non conforme.** `periscope/src` (nativo) e `periscope/dependency` (PinScope ereditato) si fondono con `sys.path` + `pkgutil.extend_path`. Chi vince dipende dallordine di insert (`backend/__init__.py` vs `tests/conftest.py`). Due `projects.py`, due `pipeline.py`, due `extraction.py`. Per capire un import serve la mappa dei shadow, non il modulo. È un adattatore temporaneo con path di rimozione (Fase C) ma **oggi** non sta in testa.
## 2. Glue di root (Docker, compose, `backend/__init__.py`, script)
**Conforme a metà.** `backend/__init__.py` è corto e dice il mestiere. `scripts/update-periscope.sh` è una procedura esplicita (`/root/periscope`, niente `data/`). `docker-compose.yml` è piccolo. Il debito è il merge dei due tree a boot, non gli script.
## 3. Motore finding (`periscopex/finding_engine.py`, schema)
**Conforme.** Un oggetto finding, FACT / REQUIREMENT / INFERENCE, clamp verso il basso, `INSUFFICIENT`. Flusso visibile. Test stretti su tipo, codice, severity. ~450 righe: lungo ma un mestiere.
## 4. Modello semantico (`models.py`)
**Conforme.** Pad, via, track, zone, pin, net sono tipi distinti. Pydantic, niente god-class UI. ~534 righe di dati, non di orchestrazione.
## 5. Parser schematico (PADS, EDIF, KiCad sch)
**Conforme a metà.** Funzioni pure, fixture `simple_project`. `parsers_kicad.py` (~760) e `parsers_edif.py` (~465) sono grandi; il mestiere resta “testo → grafo”. Non classificano via PCB come pad (coperto da test rewrite). Debito: taglia dei file, non la semantica.
## 6. Parser PCB KiCad (`parsers_kicad_pcb.py`)
**Conforme.** Via ≠ pad è esplicito e testato. Un input `.kicad_pcb``LayoutGraph`. Non è DRC. ~446 righe.
## 7. Grafo (`graph.py`, `netlist_bundle.py`)
**Conforme.** Costruzione da BOM+netlist, helper di attraversamento. Ferrite Z è un innesto a parte (`ferrite_z.py`), non un if U1.
## 8. Check deterministici PCB (`pcb_checks.py` + moduli `PE-*`)
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
AF Board+AI (`af_ai_hf.py`) è **additivo**: `pcb_pipeline` chiama `append_investigated` dopo `run_pcb_checks`. Non entra nella lista 2.62.1. Non è DRC.
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
**Conforme.** Un file ≈ un check, grafo in → finding out, niente mm inventati. Alcuni file >400 righe (`passive_rail_check`, `filter_check`) ma il flusso è lo stesso.
## 10. Review AI (validate, pcb_review, review_tools)
**Non conforme come sezione unica.** Lautorità deterministica è nei check; lLLM spiega — questo è il disegno giusto (costituzione §8). In pratica `review_tools.py` (~940) e `validation.py` (dependency + native) sono loop di tool + state. `pcb_review.py` (~327) è il prompt + vicinato: quello sta in testa. Il loop live dipende ancora da pezzi ereditati. AF+AI (ipotesi HF → indagine deterministica) è un modulo extra, non sostituisce `run_pcb_checks`.
## 11. Estrazione datasheet (skills, `datasheet_extract.py`, `extraction.py`)
**Non conforme.** `datasheet_extract.py` ~1322 righe e `extraction.py` ereditato ~1298: prompt, tool, coerce, taxonomy, auto-resolve nello stesso file. Il mestiere “PDF → JSON libreria” è uno, limplementazione no. Native vs inherited duplicato finché il pipeline non switcha. **Conforme nel ruolo:** LLM estrae, non è il verificatore.
## 12. Libreria componenti (store + porta HTTP/UI)
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e lempty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
**Dopo 2.63.1: conforme come porta.** `POST /api/library/datasheets` registra una scheda (inbox IC o modello passivo/discreto) senza esame. Pintable vuota non va in `library/extracted/` (`library_gate`). PUT promuove inbox → extracted. `projects.py` re-export resta debito. `list_library_catalog` logga e salta JSON rotti.
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
**Non conforme.** `pipeline.py` ~1522 righe: stage, storage, LLM, copy-in-library, graph. `pcb_pipeline.py` è più lineare (ensure_graph → parse → checks → **af_ai additivo** → AI → report) e si segue. Job/SSE sono un secondo asse di stato. Flusso reale: sì, ma non locale. `run_pcb_checks` 2.62.1 non è stato svuotato.
## 14. Storage (`storage.py`, GCS)
**Conforme.** Backend con chiavi stringa, locale vs GCS. Prefissi `users/…/projects/` e `library/` visibili.
## 15. HTTP — progetti, pipeline, report, impedance
**Non conforme per `routers/projects.py` (~1004)** e `pipeline.py` router (~783): troppi mestieri (CRUD, upload KiCad, DigiKey, LCSC). Report e impedance sono sezioni più piccole. La nuova `routers/library.py` è la porta libreria (un mestiere).
## 16. Auth / JWT / users
**Non toccata in questa macro-fase (vincolo).** La sezione **non è conforme** alla costituzione (Clerk + JWT locale + `admin.py` users nello stesso router, middleware che decide tre modi). Non si “sistema” qui. Non si aggiunge auth alla libreria.
## 17. Frontend — shell e pagina libreria
**Conforme a metà.** Overlay `src` su `dependency` (materialize) è un secondo albero da tenere in testa. Pagine progetto/report/dashboard sono lunghe ma a mestiere. `/library` era browse-only; ora è porta (import + modifica scheda) con form/editor spezzati. `admin/page.tsx` ~1780: **non conforme** (libreria + users + usage). Non toccato (users).
## 18. Frontend `api.ts` / types
**Non conforme.** `api.ts` nativo ~1179 righe, client unico per tutto. Types allineati ai modelli: sì. Un file non sta in testa. Nuove funzioni libreria restano lì per non inventare un secondo client.
## 19. Skills e taxonomy
**Conforme.** `skills/*/SKILL.md` + `validate.py` in-process; taxonomy JSON per tipo. `repo_paths` al posto di `Path(__file__)` magici (test rewrite). Non si chiama `upload_skills.py`.
## 20. Vendor ImpedenceFinder
**Conforme come confine, non come licenza.** Core Z0 chiuso, non FEM, non secondo set di formule (test). LICENSE UNKNOWN resta debito legale, non di forma del codice.
## 21. Test
**Prima: non conforme come mappa.** Un flat `tests/test_*.py` mescolava datasheet, libreria, schema, PCB, rewrite, auth. Copertura vera, ordine mentale no.
**Dopo: conforme come mappa, copertura tenuta.**
| Cartella | Mestiere |
| --- | --- |
| `tests/datasheet/` | preliminare PDF / pin / abs-max / I / Z / ferrite / DigiKey |
| `tests/library/` | porta libreria (anche senza esame) |
| `tests/schematic/` | net, BOM, review IC, check su grafo |
| `tests/pcb/` | `run_pcb_checks`, geometria, SI/HF già in 2.62.1 |
| `tests/impedancefinder/` | vendor Z0 (stesso nome package del vendor — non va sotto `pcb/`) |
| `tests/af_ai/` | extra: ipotesi HF → indagine deterministica (non DRC, no Z inventata) |
| `tests/` root | identity/rewrite/auth/job — non analisi |
`conftest.py` e `paths.py` restano in root. Test stretti (codice finding, via≠pad) restano la regola; i rewrite “il file sta in src” sono deboli ma sono recinti di albero, non di fisica.
## 22. Costituzioni Cursor (`.cursor/rules`, questo doc)
**Conforme.** Un master Markdown, `.mdc` operativi, niente Rust speculativo. Questo file è il giudizio, non una seconda costituzione.
## 23. Git / deploy
**Conforme al testo, fragile in pratica.** Commit+push a fine fase; deploy a fine macro-fase; no force. Il worktree locale era un pointer a `~/Development/pinscope` sparito: history recuperata da `github` `cursor/pcb-hf-analisi-675d` @ `20733f0` (2.62.1). Non si tocca `~/Development/pinscope` per edit di prodotto.
---
## Sintesi
| Sezione | Conforme |
| --- | --- |
| Finding engine + modelli + parser PCB | sì |
| Check PE-* come moduli | sì (dispatcher fail-soft no) |
| Skills/taxonomy/storage/Z0 vendor | sì |
| Dual tree src/dependency | no |
| Pipeline + extract ~1.3k + projects router | no |
| Auth/admin users | no (non toccare) |
| Libreria come porta | sì dopo questa fase (re-export debito) |
| Test a cartelle di mestiere | sì dopo questa fase |
Priorità debito (non questa fase): spezzare `pipeline.py` / `datasheet_extract.py`; togliere lo shadow dependency quando un modulo nativo è provato; non ingoiare Exception nei check PCB (distinguere tool failure).
**Niente Rust in questa macro-fase** — vedi `docs/rust-criteri.md`. FEM fuori. DRC = KiCad.
+35
View File
@@ -0,0 +1,35 @@
# Libreria componenti — porta prodotto
La libreria **non** vive dentro lesame. È una porta a sé: pagina `/library` + API `/api/library`. Lanalisi *usa* la libreria; non è lunico modo per riempirla.
## Flusso
```text
PDF + MPN + kind (ic | passive_part | simple)
POST /api/library/datasheets
blob MD5 + ref MPN
scheda componente
IC → library/inbox/{mpn}.json (niente pintable inventata)
passive_part → library/passives/{mpn}.json
simple → library/models/{mpn}.json
```
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
`library/extracted/` si scrive **solo** con pintable valida (`library_gate`: ≥2 pin, almeno un nome). PUT `/api/library/components/ic/{mpn}` promuove inbox → extracted.
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
## Cosa non è
- Non è admin/Clerk/JWT (non si tocca auth).
- Non è DRC, non è FEM.
- Non inventa pin dal PDF. LLLM prelim resta un mestiere a parte; la scheda c’è comunque.
- Non sostituisce `library_has_*` usati dalla pipeline (`library_has_extraction` resta falso finché non c’è pintable).
## Test
`tests/library/` — catalogo, alias MPN, import PDF senza progetto (scheda IC in inbox, passivo in passives), GET/PUT scheda, promozione pintable, rifiuto PDF non valido / kind ignoto.
+154
View File
@@ -0,0 +1,154 @@
# Piano di implementazione: analisi AF della traccia (v1)
DRC = KiCad. Periscope = analisi. **Stesura ora = Python.** Niente rustup, Auth/JWT, OpenEMS, FEM 3D, field solver 2.5D. Pad ≠ via ≠ track ≠ zone. Non si inventano Z, I, mm, εr, tr, f.
Michele 2026-09-22: piano ok con correzioni sotto. SI/HF 2.62 e `PE-AF-001` restano **additivi**. ImpedenceFinder vendored (`vendor/impedancefinder`) non si riscrive.
Repo: `~/Development/periscope`. Copia di questo file anche in `docs/piano-af-analisi.md`.
---
## Paletti obbligatori (Michele)
1. **Z target solo da datasheet** (`z0_ohm` / `zdiff_ohm` / finestra `z_min``z_max` in `layout_rules`). Manca → **skip del confronto Z**, finding visibile INSUFFICIENT, **niente 50/90 Ω**.
2. **Coppia differenziale: i due membri insieme** (Zdiff, accoppiamento intra-coppia, lunghezze/skew). Mai una traccia sola se esiste il partner `_P/_N` o `+/-` sulla board.
3. **Simulazione numerica sì, OpenEMS no.** Niente FEM 3D né solver di campo 2.5D della spec. Metodo contenuto: sezioni da ImpedenceFinder Z0(x) → RLGC lossless per sezione → cascade ABCD → S11/S21 **solo se** Z_ref (= target datasheet) è FACT.
4. **C++ vs Rust:** valutati in §15. Implementazione v1 in Python. Niente rustup.
5. **Skip AF non silenziosi.** In report: *«Pista ad alta frequenza non controllata per mancanza di …»* (tr, f, εr, stackup, Z target datasheet, span via). Finding **INSUFFICIENT / REVIEW**. Non INFO con Z/I inventati. **Se il trigger è falso** (calcolato con FACT e `l` sotto soglia) → **niente finding** AF su quella net/coppia.
---
## 1. Perimetro vs spec
La spec (`specifica-af.md`) descrive Gerber/ODB++, field solver 2.5D, S/TDR completi. Periscope esamina `.kicad_pcb` + grafo + libreria.
| Spec | v1 Periscope |
| :--- | :--- |
| Parser Gerber/ODB++ | No. KiCad `LayoutGraph`. |
| Trigger λ/10 e tr/(6 tpd) | Sì, FACT only. |
| Gomiti, taper, 3W, piano 3H | Sì, solo net/coppie **triggerate**. Non DRC. |
| Via stub / L_via | Sì se drill + **span layer** + h. Altrimenti PE-AF-002 span via. |
| Field solver 2.5D / OpenEMS | **Fuori.** |
| RLGC + S-param | Cascade sezioni lossless + Z0 ImpedenceFinder. S11/S21 se Z_ref datasheet. |
| TDR IFFT | Fuori. Profilo Z0(x) non si chiama TDR. |
| Wheeler allegato B | Non usato. Hammerstad/Cohn del vendor. |
`run_pcb_checks` (SI/HF) **invariato**.
---
## 2. Trigger
Candidati: net con rame **traccia**, non power/GND, non `skip_si_net` (I2C/GPIO/EN/CC/strap), non XTAL. Coppia = ununità: `l = max(l_p, l_n)`; se un membro è candidato, lo è la coppia.
Costanti: c = 2.99792458×10⁸ m/s. `tpd = √εr_eff / c`. `λ = c / (f √εr_eff)`.
- λ/10 se `f` FACT oppure `f = 0.35/tr` con `tr` FACT, e εr_eff da stackup+geometria (ImpedenceFinder).
- Rise time: `l ≥ tr / (6 tpd)` con `tr` FACT (non la forma tr·vp/2).
**Trigger vero** → analisi discontinuità / Z / cascade / via.
**Trigger falso** (entrambe le soglie calcolate e `l` sotto) → silenzio AF.
**Trigger non calcolabile** su un candidato (manca tr **e** f, o manca stackup/εr) → **PE-AF-002 visibile**, testo italiano con la lista di ciò che manca. Non è “trigger falso”.
---
## 3. Skip visibili (non silenziosi)
| Manca | Testo (es.) | Quando |
| :--- | :--- | :--- |
| stackup / εr | mancanza di stackup, εr | candidato, trigger non valutabile |
| tr, f | mancanza di tr, f | candidato, serve almeno uno |
| Z target datasheet | mancanza di Z target datasheet | trigger **vero**, niente confronto ±10% e niente S |
| span via | mancanza di span via | trigger vero **e** c’è un via sulla net/coppia senza layers |
Niente ohm, niente ampere, niente 1 ns USB. Status WARNING, class REVIEW, `evidence_status=INSUFFICIENT` (il clamp del motore finding non deve promuovere RULE/ERROR).
`PE-AF-001` (AI Z0 senza stackup) resta; è già INSUFFICIENT visibile.
---
## 4. Coppia differenziale
Partner: ImpedenceFinder + `partner_net`. Un finding per coppia, non due cloni. Zdiff da `diff_microstrip_z0` / `diff_stripline_z0`. Intra-coppia: lunghezze, non regola 3W (il mate non è aggressore). 3W solo vs **altre** net.
---
## 5. Simulazione numerica (non OpenEMS)
Per net/coppia triggerata con campioni Z0:
1. Sezioni di lunghezza campionata, Z0 ImpedenceFinder (flag `plane_broken` esclusi dal voto ohm).
2. Lossless: `L' = Z0 · tpd`, `C' = tpd / Z0` per metro.
3. ABCD di cascata; S11/S21 rispetto a **Z_ref = target datasheet**.
4. Confronto ΔZ0 ±10% vs stessa finestra datasheet. Duplicato `PE-SI-002`: non secondo FAIL.
Senza Z_ref: PE-AF-002, cascade non pubblica S.
---
## 6. Dati
- Stackup KiCad già parsato (`epsilon_r`, thickness; mai 1 oz default).
- `LayoutVia.layers` da `(layers "F.Cu" "B.Cu")`. Size anulare ≠ drill ≠ track.
- `tr`/`f` da `layout_rules` del driver sulla net (`rise_time`, `tr_ns`, `f_hz`, …) e `net_class` espanso come SI.
---
## 7. Finding PE-*
| ID | Ruolo |
| :--- | :--- |
| PE-AF-001 | AI Z0 senza evidenza (esistente) |
| PE-AF-002 | Skip visibile “non controllata per mancanza di …” |
| PE-AF-020 | Gomito ~90° |
| PE-AF-021 | Gradino W / taper |
| PE-AF-030 | `plane_broken` |
| PE-AF-031 | Return 3H |
| PE-AF-032 | `plane_split_nearby` |
| PE-AF-040 | 3W vs aggressore (non il mate) |
| PE-AF-050 | Z0/Zdiff vs finestra datasheet + cascade in `calculation` |
| PE-AF-051 | CPWG/unknown: no ohm |
| PE-AF-060 | L_via se drill+h+span |
| PE-AF-061 | Via stub vs λ/20 |
---
## 8. Moduli e pipeline
`af_trigger.py`, `af_rlgc.py`, `af_trace_check.py`. Dopo `run_pcb_checks`, prima `af_ai`. Non dentro la lista 2.62. Stage SSE `af_trace`.
---
## 9. Test HubAudio
Path opzionale `~/Development/HubAudio/.../HubAudio.kicad_pcb`. USB senza tr FACT → PE-AF-002 (tr/f), zero 90 Ω. GPIO → niente AF. Trigger sintetico corto con tr FACT → zero finding. Lunga + target datasheet → PE-AF-050. Coppia: un finding, non D+ solo. SI stub `PE-SI-007` resta.
---
## 10. Fasi v1 (questa implementazione)
Parser via layers; trigger; skip visibili; coppia; Z/cascade se target; gomiti/width; 3W; via se span; pipeline; changelog; pytest.
---
## 11. Fuori scope
OpenEMS, FEM, 2.5D, TDR IFFT, NEXT/FEXT in volt, 5W default, auto-miter sul PCB, Gerber/ODB++, Auth/JWT, rustup.
---
## 15. C++ vs Rust (valutazione; stesura Python)
Criteri (come `docs/rust-criteri.md`): geometria pad≠via≠track≠zone al confine; memoria/ownership su HubAudio; hot path profilato; determinismo; FFI grosso (struct in → struct out).
| Criterio | AF v1 (trigger + Shapely + cascade) | C++ | Rust |
| :--- | :--- | :--- | :--- |
| Geometria | Tipi già distinti in Python; Shapely nel vendor. Un port deve **non** fondere via/pad. | Possibile (CGAL/Clipper), confine pericoloso se si “semplifica” AABB. | Stesso rischio; ownership aiuta i buffer, non la semantica PCB. |
| Memoria | HubAudio è un `.kicad_pcb` grande; il costo oggi è parse + zone Shapely, non la cascade (O(sezioni)). Nessuna misura che Python non basti. | Arena/SoA utili dopo profilo. | Stesso, dopo misura. |
| Hot path | Non profilato. Candidati *futuri* restano parser PCB e point-in-poly, non PE-AF isolato. | Solo se il profilo lo dice. | Idem. |
| Determinismo | Cascade ABCD e Z0 chiusi sono deterministici. | ok | ok (niente HashMap nel testo finding). |
| FFI grosso | Un rewrite ora = due runtime + duplicare LayoutGraph. **Complicherebbe** (Michele). | pybind/nanobind, grosso se si passa tutta la board. | PyO3, stesso costo. ImpedenceFinder resterebbe Python/Shapely. |
**Decisione:** v1 Python. Nessun pezzo scelto per C++ o Rust. Michele può volere Rust un giorno; sequenza obbligatoria resta: Python corretto → misura HubAudio → profilo → scelta esplicita. ImpedenceFinder non si riscrive “per Rust”. OpenEMS non è candidato a nessun linguaggio in Periscope.
+85
View File
@@ -0,0 +1,85 @@
# Criteri Rust — Periscope
Percorso locale: `/Users/michelebigi/Development/periscope/docs/rust-criteri.md`.
Costituzione: Python è il default. Rust **non** è un default. Questo documento dice *quando* un pezzo può entrare in Rust. Non sceglie ancora un pezzo. **Niente `rustup`, crate, né FFI finché una riga sotto non è scelta *e* misurata.**
FEM / OpenEMS / field solver: fuori dal prodotto. Non sono candidati Rust.
DRC: KiCad. Non è un candidato Rust in Periscope.
## Non solo velocità
Un bottleneck di CPU non basta. Un pezzo va in Rust solo se **tutti** i punti seguenti sono veri.
1. **Correttezza geometrica** — pad ≠ via ≠ traccia ≠ zona deve sopravvivere al confine. Se Rust collassa oggetti per “è più facile in un AABB”, il pezzo è rifiutato anche se è più veloce.
2. **Memoria / ownership** — pressione reale (PCB grandi, segmenti, zone) o lifetime che Python non può possedere senza copie cieche. Non “magari un giorno”.
3. **Parser o hot path** — il costo sta in un ciclo stretto (parse `.kicad_pcb`, walk segmenti, predicate geometriche) dopo profiling. Non nellorchestrazione, non nellI/O HTTP, non nellLLM.
4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dallLLM.
5. **Confine Python grosso** — una chiamata: struct in → struct out. Vietato un round-trip per pad, per via, per “is_point_in_poly” dal loop Python.
Se manca anche uno solo di questi, il pezzo resta in Python.
## Sequenza obbligatoria (costituzione §§3336)
```text
implementazione corretta in Python
misura (wall + allocazioni, board reale: HubAudio / Emmaforo)
profiling (il hot path ha un nome di funzione)
scelta esplicita del pezzo in questo file
Rust con confine grosso
benchmark sulla stessa board
accetta / rifiuta sui numeri e sulla semantica (via resta via)
```
Finché la riga “Pezzo scelto” sotto è vuota: **nessun rustup**.
## Cosa non va in Rust
| Area | Perché |
| --- | --- |
| Pipeline, job, SSE, FastAPI | orchestrazione |
| Estrazione datasheet, skills, LLM | I/O + modello |
| Review AI, normalize, dedup | non deterministico in autorità |
| Project CRUD, storage, report JSON | I/O |
| Frontend | altro runtime |
| Auth / JWT / users | fuori perimetro; non toccare |
| FEM, OpenEMS, ampacity IPC via, DRC | non è Periscope |
| ImpedenceFinder venduto | già un core chiuso; non riscrivere “per Rust” |
| Un check `PE-*` isolato | il check è regola + evidenza; il costo è il parser/geometria a monte |
## Candidati *solo dopo misura* (nessuno scelto)
Questi sono i posti dove un profilo *potrebbe* mostrare un hot path. Non sono un piano di porting.
- Parser `.kicad_pcb` (`parsers_kicad_pcb.py`) se il wall su HubAudio è il parse, non i check.
- Predicate geometriche usate da stub / zone / courtyard (track graph, point-in-poly) se il walk segmenti domina.
- Walk grafo net→pin su BOM grandi, se misurato.
Non candidati: `run_pcb_checks` come god-object Rust; “tutto il core”; Z inventata più veloce.
## Pezzo scelto
Nessuno.
Nessuna misura registrata in questo file. Quindi: niente toolchain Rust in questa macro-fase.
## Come registrare una scelta (futuro)
Quando una misura esiste, aggiungere qui:
```text
Pezzo: <modulo / funzione>
Board: <HubAudio / Emmaforo / …>
Python wall: <s> alloc: <MB>
Hot path: <nome>
Ipotesi Rust: correttezza geometrica | memoria | parser | determinismo | confine
Benchmark dopo: <s> e test via≠pad ancora verdi
Decisione: accetta | rifiuta
```
+11 -1
View File
@@ -1,4 +1,14 @@
"""PinScope-inherited backend package (in-tree dependency)."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = str(_p).replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
@@ -1,3 +1,13 @@
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = str(_p).replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
@@ -380,6 +380,7 @@ class ValidationReport(BaseModel):
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
protocol_certification: dict[str, Any] | None = None
class FindingComment(BaseModel):
@@ -492,6 +493,7 @@ class LayoutVia(BaseModel):
y: float
net: str = ""
drill: float | None = None
layers: tuple[str, ...] = ()
class LayoutDielectric(BaseModel):
@@ -0,0 +1 @@
../../../../../../src/frontend/src/app/(app)/library/library-editor.tsx
@@ -0,0 +1 @@
../../../../../../src/frontend/src/app/(app)/library/library-import.tsx
@@ -498,6 +498,56 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
return URL.createObjectURL(blob);
}
export async function importLibraryDatasheet(mpn: string, file: File): Promise<{
mpn: string;
blob_key: string;
has_extraction: boolean;
has_model: boolean;
}> {
const body = new FormData();
body.append("mpn", mpn);
body.append("file", file);
const res = await authFetch(`${BASE}/api/library/datasheets`, { method: "POST", body });
if (!res.ok) {
const err = (await res.json().catch(() => ({ detail: "Import failed" }))) as {
detail?: string;
};
throw new Error(err.detail || "Failed to add datasheet to the library");
}
return res.json();
}
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
export async function fetchLibraryComponent(
componentType: LibraryComponentType,
name: string,
): Promise<Record<string, unknown>> {
const res = await authFetch(
`${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`,
{ cache: "no-store" },
);
if (!res.ok) throw new Error("Failed to load component");
return res.json();
}
export async function updateLibraryComponent(
componentType: LibraryComponentType,
name: string,
payload: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const res = await authFetch(
`${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) throw new Error("Failed to update component");
return res.json();
}
// --- Pipeline ---
export async function reprocessPipeline(
+11 -1
View File
@@ -1,4 +1,14 @@
"""Native Periscope backend modules."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = str(_p).replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
+2
View File
@@ -21,6 +21,7 @@ from backend.routers import (
contact,
feedback,
impedance,
library,
pipeline,
projects,
reports,
@@ -163,6 +164,7 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
app.include_router(projects.router, prefix="/api")
app.include_router(library.router, prefix="/api")
app.include_router(pipeline.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
app.include_router(impedance.router, prefix="/api")
+11 -1
View File
@@ -1,4 +1,14 @@
"""Native Periscope core modules (finding engine, PCB, checks)."""
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
__path__ = list(extend_path(__path__, __name__))
_src, _other, _dep = [], [], []
for _p in __path__:
_n = _p.replace("\\", "/")
if "/periscope/src/" in _n:
_src.append(_p)
elif "/periscope/dependency/" in _n:
_dep.append(_p)
else:
_other.append(_p)
__path__[:] = _src + _other + _dep
@@ -0,0 +1,156 @@
"""AI flags probable HF issues; deterministic checks investigate.
Extra path — does not replace ``run_pcb_checks`` / SI / HF. Not DRC.
Never invents Z, I, or millimetres. Missing net → silence (no N/A).
"""
from __future__ import annotations
from dataclasses import dataclass
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import check_si
SOURCE = "af_ai_hf"
RULE_Z_NO_EVIDENCE = "PE-AF-001"
ALLOWED_ISSUE_CLASSES = frozenset({
"stub",
"split_reference",
"unterminated",
"z0",
})
@dataclass(frozen=True)
class HfHypothesis:
"""Structured AI flag. ``why`` is INFERENCE, not FACT."""
net: str
issue_class: str
why: str
def _net_on_board(graph: DesignGraph, layout: LayoutGraph | None, net: str) -> bool:
if net in graph.nets:
return True
if layout is None:
return False
for seg in layout.segments:
if seg.net and kicad_nets_match(seg.net, net):
return True
return False
def _has_z0_facts(layout: LayoutGraph | None) -> bool:
if layout is None or not layout.segments or layout.stackup is None:
return False
stack = layout.stackup
layers = getattr(stack, "copper_layers", None) or []
diel = getattr(stack, "dielectrics", None) or []
return bool(layers) and bool(diel)
def _z0_insufficient(net: str, why: str) -> Finding:
rec = (
"Provide stackup (copper + dielectric) and track geometry before Z0. "
"Do not assume 50 Ω or 90 Ω."
)
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=f"Unverified: AI flagged Z0 on {net} — no stackup+track FACT.",
facts="stackup or HF track geometry missing.",
requirement="Closed-form Z0 needs board stackup and width on this net.",
inference=why,
why="AI hypothesis is not a measured ohm.",
status="INFO",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=RULE_Z_NO_EVIDENCE,
finding_class="INFO",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=net,
pins=[],
)
def investigate_hf_hypotheses(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
hypotheses: list[HfHypothesis],
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
"""Run existing HF/SI checks only on nets the AI flagged."""
out: list[Finding] = []
flagged: set[str] = set()
for hyp in hypotheses:
kind = (hyp.issue_class or "").strip().lower()
net = (hyp.net or "").strip()
if not net or kind not in ALLOWED_ISSUE_CLASSES:
continue
if not _net_on_board(graph, layout, net):
continue
if kind == "z0" and not _has_z0_facts(layout):
out.append(_z0_insufficient(net, hyp.why))
continue
flagged.add(net)
if not flagged or layout is None:
return out
probed = check_hf_lines(graph, constraints_map, layout)
probed.extend(check_si(graph, constraints_map, layout, impedance_nets))
for finding in probed:
net = finding.net or ""
if any(kicad_nets_match(net, flag) for flag in flagged if net):
out.append(finding)
return out
def hypotheses_from_rows(rows: list | None) -> list[HfHypothesis]:
"""Parse structured AI flags. Unknown classes and empty nets are dropped."""
out: list[HfHypothesis] = []
for row in rows or []:
if not isinstance(row, dict):
continue
net = str(row.get("net") or "").strip()
kind = str(row.get("issue_class") or "").strip().lower()
why = str(row.get("why") or "").strip()
if not net or kind not in ALLOWED_ISSUE_CLASSES:
continue
out.append(HfHypothesis(net=net, issue_class=kind, why=why))
return out
def append_investigated(
pcb_findings: list[Finding],
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
hypotheses: list[HfHypothesis],
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
"""Append AF+AI extras. Never drops ``run_pcb_checks`` rows.
Duplicate SI/HF clones (same rule_id+net already present) are skipped so
a stub FAIL is not counted twice. ``PE-AF-001`` is always additive.
"""
extra = investigate_hf_hypotheses(
graph, constraints_map, layout, hypotheses, impedance_nets,
)
seen = {(f.rule_id, f.net) for f in pcb_findings}
added: list[Finding] = []
for finding in extra:
key = (finding.rule_id, finding.net)
if finding.rule_id == RULE_Z_NO_EVIDENCE or key not in seen:
pcb_findings.append(finding)
added.append(finding)
seen.add(key)
return added
@@ -0,0 +1,76 @@
"""Lossless RLGC sections + ABCD cascade. Not a field solver. Not OpenEMS."""
from __future__ import annotations
import cmath
import math
from dataclasses import dataclass
from backend.periscopex.af_trigger import C_MPS
@dataclass(frozen=True)
class CascadeResult:
s11: complex
s21: complex
z_ref_ohm: float
n_sections: int
def _abcd_line(z0: float, tpd_s_per_m: float, length_m: float, omega: float) -> tuple[complex, complex, complex, complex]:
beta = omega * tpd_s_per_m
bl = beta * length_m
j = 1j
a = cmath.cos(bl)
b = j * z0 * cmath.sin(bl)
c = (j / z0) * cmath.sin(bl) if z0 else 0j
d = a
return a, b, c, d
def _mul(
p: tuple[complex, complex, complex, complex],
q: tuple[complex, complex, complex, complex],
) -> tuple[complex, complex, complex, complex]:
a1, b1, c1, d1 = p
a2, b2, c2, d2 = q
return (
a1 * a2 + b1 * c2,
a1 * b2 + b1 * d2,
c1 * a2 + d1 * c2,
c1 * b2 + d1 * d2,
)
def cascade_sparam(
z0_ohms: list[float],
length_m: list[float],
er_eff: float,
f_hz: float,
z_ref_ohm: float,
) -> CascadeResult | None:
"""Uniform-per-section lossless cascade. Z_ref must be a datasheet FACT."""
if (
not z0_ohms or len(z0_ohms) != len(length_m)
or er_eff <= 0 or f_hz <= 0 or z_ref_ohm <= 0
):
return None
tpd = math.sqrt(er_eff) / C_MPS
omega = 2.0 * math.pi * f_hz
abcd = (1 + 0j, 0j, 0j, 1 + 0j)
n = 0
for z0, ell in zip(z0_ohms, length_m, strict=True):
if z0 <= 0 or ell <= 0:
continue
abcd = _mul(abcd, _abcd_line(z0, tpd, ell, omega))
n += 1
if n == 0:
return None
a, b, c, d = abcd
zr = z_ref_ohm
denom = a + b / zr + c * zr + d
if denom == 0:
return None
s11 = (b - c * zr * zr) / denom
s21 = 2.0 / denom
return CascadeResult(s11=s11, s21=s21, z_ref_ohm=zr, n_sections=n)
@@ -0,0 +1,593 @@
"""Triggered AF trace analysis. Additive. Not DRC. Not OpenEMS.
Skip without FACT is a visible PE-AF-002, never invented 50/90 Ω.
Differential pairs are one unit. Trigger false → no finding.
"""
from __future__ import annotations
import logging
import math
from collections import defaultdict
from backend.periscopex.af_rlgc import cascade_sparam
from backend.periscopex.af_trigger import (
AfUnit,
TriggerResult,
dielectric_height_mm,
evaluate_trigger,
iter_af_units,
z_window_for_unit,
)
from backend.periscopex.finding_engine import complete_findings
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
log = logging.getLogger(__name__)
SOURCE = "af_trace_check"
_RIGHT_DOT = 0.087 # ~5° from 90°
_WIDTH_STEP_MM = 0.05
_PARALLEL_MIN_MM = 5.0
def check_af_traces(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
existing: list[Finding] | None = None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
out: list[Finding] = []
seen_si_z = {
f.net for f in (existing or [])
if f.rule_id == "PE-SI-002" and f.net
}
zrows = _z_rows(impedance_nets)
for unit in iter_af_units(layout, graph):
trig = evaluate_trigger(graph, constraints_map, layout, unit)
if trig.missing and not trig.af:
out.append(_skip(unit, trig.missing))
continue
if not trig.af:
continue
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
complete_findings(out)
return out
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
raw = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("nets") or [])
return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")]
def _label(unit: AfUnit) -> str:
return " / ".join(unit.nets)
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}."
if extra:
text = f"{text} {extra}".strip()
rec = (
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
"Non si assume 50 Ω o 90 Ω."
)
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=text,
facts=f"nets={_label(unit)}; missing={miss}; l={unit.length_mm:.3f} mm.",
requirement="AF checks need FACT tr or f, stackup/εr, and datasheet Z for Z0.",
inference="INSUFFICIENT — skip, not an invented ohm or ampere.",
why="An HF candidate without evidence is reported, not guessed.",
status="WARNING",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id="PE-AF-002",
finding_class="REVIEW",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=unit.nets[0],
pins=[],
)
def _finding(
*,
rule_id: str,
unit: AfUnit,
finding: str,
facts: str,
requirement: str,
rec: str,
status: str = "WARNING",
cls: str = "RISK",
evidence: str = "SUFFICIENT",
calculation: str = "",
) -> Finding:
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=finding,
facts=facts,
requirement=requirement,
inference="Triggered AF net: l ≥ λ/10 or l ≥ tr/(6 tpd).",
why=requirement,
status=status, # type: ignore[arg-type]
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=rule_id,
finding_class=cls, # type: ignore[arg-type]
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
evidence_status=evidence, # type: ignore[arg-type]
net=unit.nets[0],
pins=[],
calculation=calculation,
)
def _analyze_triggered(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
trig: TriggerResult,
zrows: list[dict],
seen_si_z: set[str],
) -> list[Finding]:
unit = trig.unit
out: list[Finding] = []
out.extend(_corner_findings(layout, unit))
out.extend(_width_findings(layout, unit))
out.extend(_coupling_findings(layout, unit))
samples = _samples(layout, unit)
out.extend(_plane_findings(layout, unit, samples, trig))
win = z_window_for_unit(graph, constraints_map, unit)
if win is None:
out.append(_skip(unit, ("Z target datasheet",)))
else:
already = any(n in seen_si_z or any(kicad_nets_match(n, s) for s in seen_si_z) for n in unit.nets)
if not already:
zf = _z_finding(unit, samples, win, trig)
if zf is not None:
out.append(zf)
out.extend(_via_findings(layout, unit, trig))
return out
def _samples(layout: LayoutGraph, unit: AfUnit) -> list[dict]:
if layout.stackup is None:
return []
try:
rows = analyze_specified_nets(layout, list(unit.nets), 1.0)
except GeometryError:
return []
except Exception:
log.exception("ImpedenceFinder walk failed for %s", unit.nets)
return []
return [r for r in rows if isinstance(r, dict)]
def _z_vals(samples: list[dict], diff: bool) -> list[float]:
keys = ("zdiff_avg_ohms", "zdiff_ohms", "z0_avg_ohms", "z0_min_ohms") if diff else (
"z0_avg_ohms", "z0_min_ohms", "z0_max_ohms",
)
out: list[float] = []
for row in samples:
flags = str(row.get("flags") or "")
if "plane_broken" in flags or "topology_not_supported" in flags:
continue
for k in keys:
v = row.get(k)
if isinstance(v, (int, float)) and v > 0:
out.append(float(v))
break
return out
def _z_finding(
unit: AfUnit,
samples: list[dict],
window: tuple[float, float],
trig: TriggerResult,
) -> Finding | None:
diff = len(unit.nets) == 2
if any("topology_not_supported" in str(r.get("flags") or "") for r in samples) and not _z_vals(samples, diff):
rec = "CPWG is not solved by ImpedenceFinder; no ohm invented."
return _finding(
rule_id="PE-AF-051", unit=unit,
finding=f"Unverified: topology not supported on {_label(unit)} — no Z0 number.",
facts=f"flags from ImpedenceFinder; nets={_label(unit)}.",
requirement="Closed-form Z0 only for microstrip/stripline.",
rec=rec, cls="REVIEW", evidence="INSUFFICIENT",
)
vals = _z_vals(samples, diff)
if not vals:
return None
lo, hi = window
zmin, zmax = min(vals), max(vals)
zavg = sum(vals) / len(vals)
calc = ""
if trig.f_hz and trig.er_eff:
z0s = [zavg]
ell = [unit.length_mm / 1000.0]
casc = cascade_sparam(z0s, ell, trig.er_eff, trig.f_hz, (lo + hi) / 2.0)
if casc is not None:
calc = (
f"cascade lossless n={casc.n_sections} "
f"S11={casc.s11.real:.4f}{casc.s11.imag:+.4f}j "
f"S21={casc.s21.real:.4f}{casc.s21.imag:+.4f}j "
f"Z_ref={casc.z_ref_ohm:g} Ω (datasheet)."
)
kind = "Zdiff" if diff else "Z0"
facts = (
f"{kind} avg={zavg:.3f} min={zmin:.3f} max={zmax:.3f} Ω "
f"(ImpedenceFinder); window=[{lo:g}, {hi:g}] Ω; nets={_label(unit)}."
)
rec = f"Adjust geometry so {kind} stays in the datasheet window [{lo:g}, {hi:g}] Ω."
if zmin < lo or zmax > hi:
return _finding(
rule_id="PE-AF-050", unit=unit,
finding=f"FAIL: {kind} {zavg:.2f} Ω outside datasheet [{lo:g}, {hi:g}] Ω on {_label(unit)}.",
facts=facts, requirement="ΔZ vs datasheet target ± window.", rec=rec,
calculation=calc,
)
return None
def _plane_findings(
layout: LayoutGraph, unit: AfUnit, samples: list[dict], trig: TriggerResult,
) -> list[Finding]:
out: list[Finding] = []
broken = any("plane_broken" in str(r.get("flags") or "") for r in samples)
split = any("plane_split_nearby" in str(r.get("flags") or "") for r in samples)
if broken:
rec = "Restore continuous reference copper under the pair/net."
out.append(_finding(
rule_id="PE-AF-030", unit=unit,
finding=f"Reference plane broken under {_label(unit)} (ImpedenceFinder).",
facts=f"plane_broken on sampled walk; nets={_label(unit)}.",
requirement="AF return path must exist under the track.", rec=rec,
))
if split:
rec = "Move the track away from the plane split or close the void."
out.append(_finding(
rule_id="PE-AF-032", unit=unit,
finding=f"Reference-plane split nearby on {_label(unit)}.",
facts=f"plane_split_nearby; nets={_label(unit)}.",
requirement="3W proximity to a plane edge is REVIEW.", rec=rec, cls="REVIEW",
))
stack = layout.stackup
if stack is not None:
for net in unit.nets:
layer, _w = _any_layer_width(layout, net)
if not layer:
continue
h = dielectric_height_mm(stack, layer)
if h is None:
continue
if not _copper_within_3h(layout, net, layer, h):
rec = "Provide a reference pour within 3H of the track."
out.append(_finding(
rule_id="PE-AF-031", unit=unit,
finding=f"No reference copper within 3H under {net}.",
facts=f"H={h:g} mm; 3H={3 * h:g} mm; net={net}.",
requirement="Return path within 3H (not KiCad clearance).", rec=rec,
))
break
return out
def _any_layer_width(layout: LayoutGraph, net: str) -> tuple[str, float]:
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net) and s.layer and s.width > 0:
return s.layer, s.width
return "", 0.0
def _copper_within_3h(layout: LayoutGraph, net: str, layer: str, h: float) -> bool:
from backend.periscopex.placement_check import _in_poly
stack = layout.stackup
if stack is None:
return True
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return True
ref = None
if idx + 1 < len(stack.copper_layers):
ref = stack.copper_layers[idx + 1]
elif idx > 0:
ref = stack.copper_layers[idx - 1]
if not ref:
return True
pts: list[tuple[float, float]] = []
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net):
pts.append(s.start)
pts.append(s.end)
if not pts:
return True
margin = 3.0 * h
for z in layout.zones:
if z.keepout or z.layer != ref:
continue
for ring in z.outlines:
if len(ring) < 3:
continue
for x, y in pts:
if _in_poly(x, y, ring):
return True
for px, py in ring:
if math.hypot(px - x, py - y) <= margin:
return True
return not any(z.layer == ref and not z.keepout for z in layout.zones)
def _qxy(x: float, y: float) -> tuple[float, float]:
q = COORD_QUANT_MM
return (round(x / q) * q, round(y / q) * q)
def _track_edges(layout: LayoutGraph, net: str) -> list[tuple[tuple[float, float], tuple[float, float], float, str]]:
edges = []
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net):
continue
a, b = _qxy(*s.start), _qxy(*s.end)
if a == b:
continue
edges.append((a, b, s.width, s.layer))
return edges
def _corner_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
out: list[Finding] = []
for net in unit.nets:
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
for a, b, w, ly in _track_edges(layout, net):
nbrs[a].append((b, w, ly))
nbrs[b].append((a, w, ly))
hit = False
for node, friends in nbrs.items():
if len(friends) != 2:
continue
(p1, w1, ly1), (p2, w2, ly2) = friends
if ly1 != ly2:
continue
v1 = (p1[0] - node[0], p1[1] - node[1])
v2 = (p2[0] - node[0], p2[1] - node[1])
n1 = math.hypot(*v1)
n2 = math.hypot(*v2)
if n1 < ENDPOINT_SNAP_MM or n2 < ENDPOINT_SNAP_MM:
continue
dot = (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)
if abs(dot) <= _RIGHT_DOT:
hit = True
break
if hit:
rec = "Replace the 90° corner with a 45° miter or an arc R ≥ 3W."
out.append(_finding(
rule_id="PE-AF-020", unit=unit,
finding=f"Right-angle track corner on {net}.",
facts=f"two tracks meet at ~90°; net={net}; pair={_label(unit)}.",
requirement="AF corners should be mitered or curved (not DRC clearance).",
rec=rec,
))
return out
def _width_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
out: list[Finding] = []
for net in unit.nets:
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
for a, b, w, ly in _track_edges(layout, net):
nbrs[a].append((b, w, ly))
nbrs[b].append((a, w, ly))
for node, friends in nbrs.items():
if len(friends) != 2:
continue
(_p1, w1, ly1), (_p2, w2, ly2) = friends
if ly1 != ly2 or abs(w1 - w2) < _WIDTH_STEP_MM:
continue
rec = "Insert a taper L ≥ 2|W1W2|; pads are not the track width."
out.append(_finding(
rule_id="PE-AF-021", unit=unit,
finding=f"Abrupt width step {w1:.3f}{w2:.3f} mm on {net}.",
facts=f"W1={w1:g} mm W2={w2:g} mm at {_fmt(node)}; net={net}.",
requirement="Width steps on AF tracks need a taper (track ≠ pad).",
rec=rec,
))
break
return out
def _fmt(pt: tuple[float, float]) -> str:
return f"({pt[0]:g},{pt[1]:g})"
def _coupling_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
mates = set(unit.nets)
out: list[Finding] = []
for net in unit.nets:
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
if not segs:
continue
w = segs[0].width or 0.0
if w <= 0:
continue
for other in {s.net for s in layout.segments if s.net}:
if other in mates or any(kicad_nets_match(other, m) for m in mates):
continue
if partner_net(net) and kicad_nets_match(other, partner_net(net) or ""):
continue
ov, gap = _parallel_overlap_gap(layout, net, other)
if ov < _PARALLEL_MIN_MM or gap is None:
continue
# 3W is centre-to-centre; gap here is edge-to-edge.
c2c = gap + w / 2.0 + _mean_width(layout, other) / 2.0
if c2c < 3.0 * w:
rec = "Increase spacing to S ≥ 3W versus the aggressor (not KiCad clearance)."
out.append(_finding(
rule_id="PE-AF-040", unit=unit,
finding=f"Spacing to {other} is {c2c:.3f} mm (< 3W={3 * w:.3f} mm) on {net}.",
facts=f"overlap={ov:.2f} mm; edge_gap={gap:.3f} mm; W={w:g} mm.",
requirement="3W coupling proxy on triggered AF nets; pair mate excluded.",
rec=rec, cls="REVIEW",
))
return out
return out
def _mean_width(layout: LayoutGraph, net: str) -> float:
ws = [s.width for s in layout.segments if s.net and kicad_nets_match(s.net, net) and s.width > 0]
return sum(ws) / len(ws) if ws else 0.0
def _parallel_overlap_gap(layout: LayoutGraph, a: str, b: str) -> tuple[float, float | None]:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)]
best_gap = None
overlap = 0.0
for x in sa:
for y in sb:
if x.layer != y.layer:
continue
ov = _axis_overlap(x.start, x.end, y.start, y.end)
overlap = max(overlap, ov)
g = _segment_gap(x.start, x.end, x.width, y.start, y.end, y.width)
if g is not None and (best_gap is None or g < best_gap):
best_gap = g
return overlap, best_gap
def _axis_overlap(
a0: tuple[float, float], a1: tuple[float, float],
b0: tuple[float, float], b1: tuple[float, float],
) -> float:
if abs(a0[1] - a1[1]) < COORD_QUANT_MM and abs(b0[1] - b1[1]) < COORD_QUANT_MM:
return _interval_overlap(a0[0], a1[0], b0[0], b1[0])
if abs(a0[0] - a1[0]) < COORD_QUANT_MM and abs(b0[0] - b1[0]) < COORD_QUANT_MM:
return _interval_overlap(a0[1], a1[1], b0[1], b1[1])
return 0.0
def _interval_overlap(a: float, b: float, c: float, d: float) -> float:
lo = max(min(a, b), min(c, d))
hi = min(max(a, b), max(c, d))
return max(0.0, hi - lo)
def _segment_gap(
a0: tuple[float, float], a1: tuple[float, float], wa: float,
b0: tuple[float, float], b1: tuple[float, float], wb: float,
) -> float | None:
pts_a = (a0, a1)
pts_b = (b0, b1)
best = None
for p in pts_a:
for q in pts_b:
d = math.hypot(p[0] - q[0], p[1] - q[1]) - wa / 2.0 - wb / 2.0
if best is None or d < best:
best = d
return best
def _via_span_h(layout: LayoutGraph, via: LayoutVia) -> float | None:
stack = layout.stackup
if stack is None or len(via.layers) < 2:
return None
names = list(stack.copper_layers)
try:
i0 = names.index(via.layers[0])
i1 = names.index(via.layers[-1])
except ValueError:
return None
lo, hi = min(i0, i1), max(i0, i1)
h = 0.0
for i in range(lo, hi):
if i < len(stack.dielectrics):
h += stack.dielectrics[i].height_mm
return h if h > 0 else None
def _via_findings(layout: LayoutGraph, unit: AfUnit, trig: TriggerResult) -> list[Finding]:
out: list[Finding] = []
vias = [
v for v in layout.vias
if v.net and any(kicad_nets_match(v.net, n) for n in unit.nets)
]
if not vias:
return out
if any(len(v.layers) < 2 for v in vias):
out.append(_skip(unit, ("span via",)))
return out
for v in vias:
if v.drill is None or v.drill <= 0:
continue
h = _via_span_h(layout, v)
if h is None:
continue
ratio = 4.0 * h / v.drill
if ratio <= 1.0:
continue
l_nh = 0.2 * h * (math.log(ratio) + 1.0)
rec = "Treat the via as an RF discontinuity; add a return via or shorten the barrel."
out.append(_finding(
rule_id="PE-AF-060", unit=unit,
finding=f"Via L≈{l_nh:.3f} nH on {_label(unit)} (drill={v.drill:g} mm, h={h:g} mm).",
facts=f"drill={v.drill:g} mm; h={h:g} mm; layers={v.layers}; L={l_nh:.4f} nH.",
requirement="Via is not a pad or a track; L_via from drill and span.",
rec=rec, cls="REVIEW",
))
tracks = {
s.layer for s in layout.segments
if s.net and any(kicad_nets_match(s.net, n) for n in unit.nets) and s.layer
}
stack = layout.stackup
if stack is None or len(v.layers) < 2 or not tracks:
continue
names = list(stack.copper_layers)
try:
used = [names.index(ly) for ly in tracks if ly in names]
span = [names.index(v.layers[0]), names.index(v.layers[-1])]
except ValueError:
continue
if not used:
continue
stub_idx = max(span) - max(used)
stub_lo = min(used) - min(span)
layers_stub = max(stub_idx, stub_lo, 0)
stub_h = 0.0
lo, hi = min(span), max(span)
unused = [i for i in range(lo, hi) if i < min(used) or i >= max(used)]
for i in unused:
if 0 <= i < len(stack.dielectrics):
stub_h += stack.dielectrics[i].height_mm
if stub_h <= 0 or trig.lambda_10_mm is None:
continue
lam = trig.lambda_10_mm * 10.0
if stub_h > lam / 20.0:
rec = "Back-drill or use a blind via; unused barrel is a λ/4 stub risk."
out.append(_finding(
rule_id="PE-AF-061", unit=unit,
finding=f"Via stub {stub_h:.3f} mm > λ/20 on {_label(unit)}.",
facts=f"L_stub={stub_h:g} mm; λ={lam:g} mm; unused_layers~{layers_stub}.",
requirement="Via stub > λ/20 on a triggered AF net.",
rec=rec,
))
return out
@@ -0,0 +1,329 @@
"""AF trigger: λ/10 and tr/(6 tpd) from FACT only. Pad/via/zone are not length."""
from __future__ import annotations
import math
from dataclasses import dataclass
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.models import ComponentType, DesignGraph, LayoutGraph, LayoutStackup, NetType
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import (
_bus_in_targets,
_expand_bus_token,
bus_class,
net_length_mm,
partner_net,
skip_si_net,
)
C_MPS = 2.99792458e8
# Geometry snap for width pick — not an SI millimetre limit.
_MIN_W_MM = 1e-6
@dataclass(frozen=True)
class AfUnit:
"""One net or a differential pair analyzed together."""
nets: tuple[str, ...]
length_mm: float
bus: str | None
@dataclass(frozen=True)
class TriggerResult:
unit: AfUnit
af: bool
missing: tuple[str, ...]
er_eff: float | None
tr_s: float | None
f_hz: float | None
lambda_10_mm: float | None
l_crit_mm: float | None
def pair_partner(layout: LayoutGraph, net: str) -> str | None:
p = partner_net(net)
if not p:
return None
names = {s.net for s in layout.segments if s.net}
for n in names:
if kicad_nets_match(p, n):
return n
return None
def is_candidate_net(net: str, graph: DesignGraph | None) -> bool:
if not net or skip_si_net(net):
return False
if graph is not None:
rec = graph.nets.get(net)
if rec is not None and rec.net_type in (NetType.POWER, NetType.GROUND):
return False
return bus_class(net) is not None
def iter_af_units(layout: LayoutGraph, graph: DesignGraph | None) -> list[AfUnit]:
routed = sorted({s.net for s in layout.segments if s.net and s.width > 0})
seen: set[str] = set()
out: list[AfUnit] = []
for net in routed:
if net in seen or not is_candidate_net(net, graph):
continue
partner = pair_partner(layout, net)
if partner and is_candidate_net(partner, graph):
nets = tuple(sorted((net, partner)))
for n in nets:
seen.add(n)
lp = net_length_mm(layout, nets[0])
ln = net_length_mm(layout, nets[1])
out.append(AfUnit(
nets=nets, length_mm=max(lp, ln),
bus=bus_class(nets[0]) or bus_class(nets[1]),
))
else:
seen.add(net)
out.append(AfUnit(
nets=(net,), length_mm=net_length_mm(layout, net),
bus=bus_class(net),
))
return out
def _num(v) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
return x if math.isfinite(x) else None
def _tr_from_rule(rule: dict) -> float | None:
s = _num(rule.get("tr_s"))
if s is not None and s > 0:
return s
ns = _num(rule.get("tr_ns")) or _num(rule.get("rise_time_ns")) or _num(rule.get("t_r_ns"))
if ns is not None and ns > 0:
return ns * 1e-9
ps = _num(rule.get("tr_ps"))
if ps is not None and ps > 0:
return ps * 1e-12
kind = str(rule.get("kind") or "").lower()
raw = _num(rule.get("tr")) or _num(rule.get("value"))
if kind in {"rise_time", "tr", "t_r"} and raw is not None and raw > 0:
if raw > 1e-3:
return raw * 1e-9
return raw
return None
def _f_from_rule(rule: dict) -> float | None:
hz = _num(rule.get("f_hz")) or _num(rule.get("f_max_hz")) or _num(rule.get("frequency_hz"))
if hz is not None and hz > 0:
return hz
mhz = _num(rule.get("f_mhz"))
if mhz is not None and mhz > 0:
return mhz * 1e6
ghz = _num(rule.get("f_ghz"))
if ghz is not None and ghz > 0:
return ghz * 1e9
kind = str(rule.get("kind") or "").lower()
raw = _num(rule.get("f")) or _num(rule.get("value"))
if kind in {"frequency", "f_max", "f_clk"} and raw is not None and raw > 0:
if raw < 1e4:
return raw * 1e6
return raw
return None
def tr_f_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float | None, float | None]:
tr_s: float | None = None
f_hz: float | None = None
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
on_ic = any(
any(kicad_nets_match(n, icn) for icn in ic_nets)
for n in unit.nets
)
if not on_ic:
continue
for rule in cons.layout_rules or []:
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
continue
t = _tr_from_rule(rule)
f = _f_from_rule(rule)
if t is not None:
tr_s = t
if f is not None:
f_hz = f
return tr_s, f_hz
def z_window_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float, float] | None:
from backend.periscopex.si_check import _z_window
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
on_ic = any(
any(kicad_nets_match(n, icn) for icn in ic_nets)
for n in unit.nets
)
if not on_ic:
continue
for rule in cons.layout_rules or []:
kind = str(rule.get("kind") or "")
if kind not in {"impedance", "zdiff", "z0", "si"}:
continue
if kind == "si" and str(rule.get("parameter") or "").lower() not in {"impedance", "zdiff", "z0"}:
continue
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
continue
win = _z_window(rule)
if win:
return win
return None
def _dominant_trace(layout: LayoutGraph, net: str) -> tuple[str, float] | None:
by_layer: dict[str, list[float]] = {}
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net) or s.width <= _MIN_W_MM:
continue
layer = s.layer or ""
by_layer.setdefault(layer, []).append(s.width)
if not by_layer:
return None
layer = max(by_layer, key=lambda ly: len(by_layer[ly]))
widths = sorted(by_layer[layer])
w = widths[len(widths) // 2]
return layer, w
def _er_eff(layout: LayoutGraph, unit: AfUnit) -> tuple[float | None, list[str]]:
missing: list[str] = []
stack = layout.stackup
if stack is None or not stack.copper_layers or not stack.dielectrics:
return None, ["stackup", "εr"]
t = stack.copper_thickness_mm
if t is None or t <= 0:
return None, ["stackup"]
picked = None
for net in unit.nets:
picked = _dominant_trace(layout, net)
if picked:
break
if picked is None:
return None, ["εr"]
layer, w = picked
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return None, ["εr"]
outer = idx == 0 or idx == len(stack.copper_layers) - 1
if outer:
diel_i = 0 if idx == 0 else len(stack.dielectrics) - 1
if diel_i < 0 or diel_i >= len(stack.dielectrics):
return None, ["εr"]
d = stack.dielectrics[diel_i]
if d.er <= 0 or d.height_mm <= 0:
return None, ["εr"]
z = zsolver.microstrip_z0(w, d.height_mm, d.er, t)
zair = zsolver.microstrip_z0(w, d.height_mm, 1.0, t)
if z <= 0 or zair <= 0:
return None, ["εr"]
return (zair / z) ** 2, missing
if idx <= 0 or idx >= len(stack.dielectrics):
return None, ["εr"]
d = stack.dielectrics[idx]
if d.er <= 0:
return None, ["εr"]
return d.er, missing
def evaluate_trigger(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
unit: AfUnit,
) -> TriggerResult:
missing: list[str] = []
er_eff, er_miss = _er_eff(layout, unit)
missing.extend(er_miss)
tr_s, f_hz = tr_f_for_unit(graph, constraints_map, unit)
if f_hz is None and tr_s is not None:
f_hz = 0.35 / tr_s
if tr_s is None:
missing.append("tr")
if f_hz is None and tr_s is None:
missing.append("f")
elif f_hz is None:
missing.append("f")
l_m = unit.length_mm / 1000.0
lam10 = None
lcrit = None
af = False
if er_eff is not None and er_eff > 0:
tpd = math.sqrt(er_eff) / C_MPS
if f_hz is not None and f_hz > 0:
lam = C_MPS / (f_hz * math.sqrt(er_eff))
lam10 = lam / 10.0 * 1000.0
if l_m >= lam / 10.0:
af = True
if tr_s is not None and tr_s > 0:
lcrit_m = tr_s / (6.0 * tpd)
lcrit = lcrit_m * 1000.0
if l_m >= lcrit_m:
af = True
trigger_ready = er_eff is not None and (tr_s is not None or f_hz is not None)
if trigger_ready:
miss_trig = tuple()
else:
miss_trig = tuple(dict.fromkeys(missing))
return TriggerResult(
unit=unit,
af=bool(af and trigger_ready),
missing=miss_trig,
er_eff=er_eff,
tr_s=tr_s,
f_hz=f_hz,
lambda_10_mm=lam10,
l_crit_mm=lcrit,
)
def dielectric_height_mm(stack: LayoutStackup, layer: str) -> float | None:
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return None
if idx == 0:
d = stack.dielectrics[0] if stack.dielectrics else None
elif idx == len(stack.copper_layers) - 1:
d = stack.dielectrics[-1] if stack.dielectrics else None
else:
d = stack.dielectrics[idx] if idx < len(stack.dielectrics) else None
if d is None or d.height_mm <= 0:
return None
return d.height_mm
@@ -0,0 +1,87 @@
"""Antenna keepout and matching — only if an antenna is on the graph.
No invented 50 Ω. No FEM. Skip when the antenna is absent.
"""
from __future__ import annotations
import re
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
SOURCE = "antenna_layout_check"
_ANT_REF_RE = re.compile(r"^ANT", re.I)
_FEED_RE = re.compile(r"ANT_FEED|RF_ANT|ANTENNA_FEED", re.I)
_ZONE_RE = re.compile(r"antenna|ant_zone|rf_antenna", re.I)
def check_antenna_layout(
graph: DesignGraph,
layout: LayoutGraph | None,
) -> list[Finding]:
ants = [
c for c in graph.components.values()
if _ANT_REF_RE.match(c.reference or "")
]
feeds = [n for n in graph.nets if _FEED_RE.search(n)]
if not ants and not feeds:
return []
out: list[Finding] = []
keepout = []
if layout is not None:
keepout = [
z for z in layout.zones
if z.keepout and (z.net and _ZONE_RE.search(z.net) or _ZONE_RE.search(z.name or ""))
]
ref = ants[0].reference if ants else "ANT"
if keepout:
rec = "Keep copper out of the antenna keepout listed on the board."
names = [z.net or z.name or "keepout" for z in keepout]
out.append(Finding(
designator=ref, mpn="", aspect="antenna",
finding=f"Antenna keepout present: {', '.join(names)}.",
facts=f"keepout_zones={names}; antenna_refs={[c.reference for c in ants]}; feeds={feeds}.",
requirement="Report antenna keepout when that zone exists on the PCB.",
inference="No keepout millimetre is invented.",
why="Antenna checks run only when an antenna marker/net exists.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[],
))
else:
rec = "Add a keepout zone around the antenna if the antenna datasheet requires one."
out.append(Finding(
designator=ref, mpn="", aspect="antenna",
finding="Antenna is present; keepout zone is not evidenced.",
facts=f"antenna_refs={[c.reference for c in ants]}; feeds={feeds}; keepout_zones=0.",
requirement="Antenna keepout millimetres from the antenna datasheet (none on file).",
inference="INSUFFICIENT — not inventing a keepout distance.",
why="Missing keepout is not an invented millimetre FAIL.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="INSUFFICIENT", net=feeds[0] if feeds else None, pins=[],
))
match_refs = []
for name in feeds:
net = graph.nets.get(name)
if not net:
continue
for pin in net.pins:
c = graph.components.get(pin.component_ref)
if c and c.component_type in {ComponentType.INDUCTOR, ComponentType.CAPACITOR}:
match_refs.append(c.reference)
if match_refs:
rec = "No matching-network change required; this is a BOM FACT."
out.append(Finding(
designator=ref, mpn="", aspect="antenna",
finding=f"Antenna matching parts on feed: {', '.join(sorted(set(match_refs)))}.",
facts=f"matching={sorted(set(match_refs))}; feeds={feeds}.",
requirement="Report L/C matching that exists on the antenna feed.",
inference="Missing match network is a skip unless a datasheet matching FACT exists.",
why="Do not invent a 50 Ω match.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-ANT-002", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[],
))
return out
+348 -66
View File
@@ -1,12 +1,16 @@
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
Skip when a side is missing. No invented JEDEC land patterns or IEC
clearances. Thermal/EP pads may differ by one from pin_count.
clearances. PE-BOM-011 joins pintable × lib × PCB by pin name — never
by scalar pin_count. Pad ≠ via ≠ track ≠ zone. USB-C A5/B5 are pins,
not EP.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from backend.periscopex.models import (
AbsMaxRating,
@@ -27,6 +31,7 @@ from backend.periscopex.thermal_check import (
_specs_values,
)
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.parsers_kicad_pcb import parse_kicad_mod_pads
_FAMILIES = (
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
@@ -35,11 +40,23 @@ _FAMILIES = (
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
"TO-252", "TO252", "TO-263", "QFP",
)
# Same land family: VQFN/WQFN/HVQFN are QFN (24-QFN 4×4 ≠ a different package).
_FAMILY_CANON = {
"VQFN": "QFN",
"WQFN": "QFN",
"HVQFN": "QFN",
}
_QFN_PIN_N = re.compile(r"(?:HVQFN|VQFN|WQFN|QFN)[_-](\d+)\b", re.I)
_MODULE_FP = re.compile(
r"WROOM|WROVER|ESP-WROOM|RF[_-]?MODULE|CASTELLAT",
re.I,
)
_EP_PAD = re.compile(
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
re.I,
)
_EP_SPLIT = re.compile(r"^\d+_\d+$")
_SH_PAD = re.compile(r"^(?:SH|SHIELD)(?:[_-].*)?$", re.I)
_V_PARAM = re.compile(
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
re.I,
@@ -60,7 +77,7 @@ def _family(blob: str) -> str | None:
return None
for fam in _FAMILIES:
if fam.replace("-", "") in u.replace("-", ""):
return fam
return _FAMILY_CANON.get(fam, fam)
return None
@@ -74,9 +91,16 @@ def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
return str(row.get("footprint") or comp.footprint or "")
def _norm_pin(s: str) -> str:
return str(s).strip().upper()
def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None) -> bool:
"""Named EP / thermal / split / numeric pin_count+1. Not USB-C A5/B5."""
s = str(number).strip()
pf = str(pinfunction or "").strip()
if _SH_PAD.match(s) or (pf and _SH_PAD.match(pf)):
return False
if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)):
return True
if s.lower().replace(" ", "") in {"thermalpad", "thermal"}:
@@ -85,35 +109,187 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None
return True
if pin_count and s.isdigit() and int(s) == pin_count + 1:
return True
if pin_count and pin_count <= 48 and re.match(r"^[A-Z]\d+$", s):
return True
return False
def _signal_pads(numbers: list[str], pin_count: int | None = None) -> set[str]:
out: set[str] = set()
for n in numbers:
s = str(n).strip()
if not s or _is_ep_land(s, pin_count=pin_count):
continue
out.add(s)
return out
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
s = str(number).strip()
pf = str(pinfunction or "").strip()
return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf)))
def _pcb_signal_pads(fp_layout, pin_count: int | None = None) -> set[str]:
"""Component lands only. Vias are not in ``fp.pads``; EP/thermal names skip."""
out: set[str] = set()
if fp_layout is None:
return out
for pad in fp_layout.pads:
n = str(pad.number).strip()
pf = str(getattr(pad, "pinfunction", "") or "").strip()
if not n or _is_ep_land(n, pf, pin_count):
def _cad_blob(*parts: str) -> str:
return " ".join(p for p in parts if (p or "").strip())
def _is_module_land(blob: str) -> bool:
"""WROOM / RF module footprint — not the silicon die pintable."""
return bool(_MODULE_FP.search(blob or ""))
def _declared_io_pins(pkg: PackageInfo | None, *blobs: str) -> int | None:
"""Largest QFN-n / pin_count seen in CAD or package_info. Not a pad census."""
n: list[int] = []
if pkg and pkg.pin_count:
try:
n.append(int(pkg.pin_count))
except (TypeError, ValueError):
pass
for b in blobs:
for m in _QFN_PIN_N.finditer(b or ""):
n.append(int(m.group(1)))
return max(n) if n else None
def _drop_explainable_extras(
extra: list[str] | set[str],
*,
missing: list[str] | set[str],
is_module: bool,
declared: int | None,
) -> tuple[list[str], str]:
"""Module lands beyond the die, or QFN-n pads the pintable never listed.
Missing datasheet pins are never dropped. Pad ≠ via.
"""
leftover: list[str] = []
reasons: list[str] = []
miss = {str(x) for x in missing}
if miss:
return sorted(extra), ""
extras = [str(x) for x in extra]
if not extras:
return [], ""
if is_module:
reasons.append("module-footprint-vs-die-pintable")
return [], reasons[0]
if declared:
kept: list[str] = []
dropped = False
for e in extras:
if e.isdigit() and int(e) <= declared:
dropped = True
continue
kept.append(e)
if dropped and not kept:
reasons.append(f"pintable-truncated-vs-declared-{declared}")
return [], reasons[0]
extras = kept
return sorted(extras), (reasons[0] if reasons else "")
def _ds_join_keys(cons: ComponentConstraints | None) -> set[str]:
"""Pintable pin **numbers** (A5, 17, EP). Names like P1 are not pad ids."""
out: set[str] = set()
if not cons:
return out
for pin in cons.pintable or []:
n = _norm_pin(str(pin.number or ""))
if n:
out.add(n)
return out
def _join_keys_from_ids(
ids: list[tuple[str, str]],
*,
pin_count: int | None,
ds_keys: set[str],
) -> set[str]:
"""Contact pads enter the join. EP/SH only if the pintable names them."""
out: set[str] = set()
for number, pinfunction in ids:
n = str(number).strip()
if not n:
continue
pf = str(pinfunction or "")
key = _norm_pin(n)
if _is_shield_land(n, pf) or _is_ep_land(n, pf, pin_count):
if key in ds_keys:
out.add(key)
continue
out.add(key)
return out
def _pads_as_ids(fp_layout) -> list[tuple[str, str]]:
if fp_layout is None:
return []
return [
(str(p.number), str(getattr(p, "pinfunction", "") or ""))
for p in fp_layout.pads
]
def _kicad_footprint_env_dirs() -> list[Path]:
out: list[Path] = []
for key in (
"KICAD10_FOOTPRINT_DIR",
"KICAD9_FOOTPRINT_DIR",
"KICAD8_FOOTPRINT_DIR",
"KICAD_FOOTPRINT_DIR",
):
raw = os.environ.get(key) or ""
for part in raw.split(os.pathsep):
p = Path(part.strip())
if part.strip() and p.is_dir():
out.append(p)
return out
def resolve_kicad_mod(
footprint: str,
search_dirs: list[Path] | None = None,
) -> Path | None:
"""Locate ``Lib:Name`` as ``Lib.pretty/Name.kicad_mod``. None if missing."""
name = (footprint or "").strip()
if not name:
return None
rels: list[str] = []
if ":" in name:
lib, mod = name.split(":", 1)
rels.append(str(Path(f"{lib}.pretty") / f"{mod}.kicad_mod"))
rels.append(f"{mod}.kicad_mod")
else:
rels.append(f"{name}.kicad_mod")
rels.append(str(Path(f"{name}.pretty") / f"{name}.kicad_mod"))
dirs = list(search_dirs or []) + _kicad_footprint_env_dirs()
seen: set[str] = set()
for root in dirs:
try:
root_p = Path(root)
except TypeError:
continue
if not root_p.is_dir():
continue
for rel in rels:
cand = root_p / rel
key = str(cand)
if key in seen:
continue
seen.add(key)
if cand.is_file():
return cand
return None
def _lib_join_keys(
footprint: str,
*,
pin_count: int | None,
ds_keys: set[str],
search_dirs: list[Path] | None,
) -> tuple[set[str] | None, str]:
"""Lib pad set, or None when the ``.kicad_mod`` is not on disk."""
path = resolve_kicad_mod(footprint, search_dirs)
if path is None:
return None, ""
ids = parse_kicad_mod_pads(path)
if ids is None:
return None, str(path)
return _join_keys_from_ids(ids, pin_count=pin_count, ds_keys=ds_keys), str(path)
def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None:
if cons and cons.package_info:
return cons.package_info
@@ -160,8 +336,9 @@ def check_bom_pcb_datasheet(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
footprint_dirs: list[Path] | None = None,
) -> list[Finding]:
"""Three-way mismatches when BOM, PCB, and library all have numbers."""
"""Package family + named pin join. Skip a side when it is missing."""
cmap = constraints_map or {}
out: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
@@ -176,7 +353,9 @@ def check_bom_pcb_datasheet(
out.extend(_package_findings(
ref, comp, pkg, sch_name, bom_name, pcb_name,
))
out.extend(_pinout_findings(ref, comp, cons, fp_layout, pkg))
out.extend(_pinout_findings(
ref, comp, cons, fp_layout, pkg, footprint_dirs,
))
out.extend(_voltage_findings(graph, ref, comp, cons))
out.extend(_temp_findings(ref, comp, cons))
out.extend(_current_findings(graph, ref, comp, cons))
@@ -188,6 +367,7 @@ def _mismatch(
ref: str, mpn: str, rule_id: str, finding: str, facts: str,
requirement: str, rec: str, status: str = "ERROR",
inference: str = "", net: str | None = None,
evidence_status: str = "SUFFICIENT",
) -> Finding:
return Finding(
designator=ref,
@@ -203,7 +383,7 @@ def _mismatch(
action=rec,
source="bom_pcb_check",
rule_id=rule_id,
evidence_status="SUFFICIENT",
evidence_status=evidence_status,
net=net,
pins=[ref],
)
@@ -264,71 +444,173 @@ def _package_findings(
return out
def _pin_delta(
*,
ref: str,
mpn: str,
finding: str,
facts: str,
requirement: str,
rec: str,
status: str = "ERROR",
evidence_status: str = "SUFFICIENT",
) -> Finding:
return _mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=finding, facts=facts, requirement=requirement, rec=rec,
status=status, evidence_status=evidence_status,
)
def _pinout_findings(
ref: str,
comp: Component,
cons: ComponentConstraints | None,
fp_layout,
pkg: PackageInfo | None,
footprint_dirs: list[Path] | None = None,
) -> list[Finding]:
out: list[Finding] = []
"""Join pintable × lib × PCB by name. Never ERROR on pin_count alone."""
ds = _ds_join_keys(cons)
if not ds:
return []
mpn = comp.mpn or ""
pin_count = pkg.pin_count if pkg else None
sch_pins = _signal_pads(list(comp.pins.keys()), pin_count)
ds_pins = _signal_pads(
[str(p.number) for p in (cons.pintable if cons else [])],
pin_count,
pcb_ids = _pads_as_ids(fp_layout)
fp_name = ""
if fp_layout is not None:
fp_name = (fp_layout.footprint or "").strip()
if not fp_name:
fp_name = (comp.footprint or "").strip()
cad = _cad_blob(
fp_name, comp.footprint or "", mpn, comp.value or "",
)
pcb_pins = _pcb_signal_pads(fp_layout, pin_count)
ds_count = pkg.pin_count if pkg else (len(ds_pins) or None)
pcb_count = len(pcb_pins) if pcb_pins else None
if ds_count and pcb_count and abs(ds_count - pcb_count) > 1:
rec = (
f"Replace {ref}'s PCB footprint so pad count matches datasheet "
f"pin_count={ds_count}."
is_module = _is_module_land(cad)
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)
lib, lib_path = _lib_join_keys(
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
finding=(
f"{ref} datasheet pin_count={ds_count} vs PCB signal pads={pcb_count}."
),
facts=f"pin_count={ds_count}; PCB pads={sorted(pcb_pins)}; sch={sorted(sch_pins)}.",
requirement="Datasheet pin_count must match the PCB footprint (EP ±1 allowed).",
sch = _join_keys_from_ids(
[(str(n), "") for n in comp.pins.keys()],
pin_count=ep_count,
ds_keys=ds,
)
out: list[Finding] = []
def _join_issue(
*,
miss: list[str],
extra: list[str],
finding: str,
facts: str,
requirement: str,
rec: str,
) -> Finding | None:
extra, _why = _drop_explainable_extras(
extra, missing=miss, is_module=is_module, declared=declared,
)
if not miss and not extra:
return None
return _pin_delta(
ref=ref, mpn=mpn,
finding=finding,
facts=facts,
requirement=requirement,
rec=rec,
))
if ds_pins and pcb_pins:
missing_pcb = sorted(ds_pins - pcb_pins)
extra_pcb = sorted(pcb_pins - ds_pins)
if missing_pcb or extra_pcb:
rec = (
f"Fix {ref} pinout: pads vs pintable. Missing on PCB: "
f"{missing_pcb or ''}; extra on PCB: {extra_pcb or ''}."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
if lib is not None:
miss_lib = sorted(ds - lib)
extra_lib = sorted(lib - ds)
if miss_lib or extra_lib:
rec = (
f"Change {ref}'s KiCad footprint library so pad names match "
f"the datasheet pintable (not a PCB pad-count guess)."
)
fnd = _join_issue(
miss=miss_lib, extra=extra_lib,
finding=(
f"{ref} datasheet pintable vs library footprint differ "
f"(missing on lib {miss_lib}, extra on lib {extra_lib})."
),
facts=(
f"pintable={sorted(ds)}; lib={sorted(lib)}; "
f"mod={lib_path!r}; pin_count unused."
),
requirement=(
"The KiCad library footprint must cover the datasheet "
"pintable by pin name. pin_count is not authority."
),
rec=rec,
)
if fnd:
out.append(fnd)
miss_emb = sorted(lib - pcb) if pcb else sorted(lib)
extra_emb = sorted(pcb - lib) if pcb else []
if pcb and (miss_emb or extra_emb):
rec = (
f"Replace {ref}'s embedded PCB footprint so it matches the "
f"named library footprint {fp_name}."
)
fnd = _join_issue(
miss=miss_emb, extra=extra_emb,
finding=(
f"{ref} library footprint vs PCB embedded pads differ "
f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})."
),
facts=f"lib={sorted(lib)}; PCB={sorted(pcb)}; fp={fp_name!r}.",
requirement=(
"Embedded PCB pads must match the named .kicad_mod, "
"by pin name."
),
rec=rec,
)
if fnd:
out.append(fnd)
return out
if pcb:
miss = sorted(ds - pcb)
extra = sorted(pcb - ds)
if miss or extra:
rec = (
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
f"Missing on PCB: {miss or ''}; extra on PCB: {extra or ''}."
)
fnd = _join_issue(
miss=miss, extra=extra,
finding=(
f"{ref} pintable vs PCB pads differ "
f"(missing {missing_pcb}, extra {extra_pcb})."
f"(missing {miss}, extra {extra})."
),
facts=(
f"pintable={sorted(ds)}; PCB={sorted(pcb)}; "
f"sch={sorted(sch)}; lib .kicad_mod not found."
),
requirement=(
"Each datasheet pin name must exist as a PCB pad. "
"Do not compare pin_count to pad cardinality."
),
facts=f"pintable={sorted(ds_pins)}; PCB={sorted(pcb_pins)}; sch={sorted(sch_pins)}.",
requirement="Each datasheet pin number must exist as a PCB pad.",
rec=rec,
))
elif ds_pins and sch_pins and ds_pins != sch_pins:
missing = sorted(ds_pins - sch_pins)
extra = sorted(sch_pins - ds_pins)
if missing or extra:
)
if fnd:
out.append(fnd)
return out
if sch and ds != sch:
miss = sorted(ds - sch)
extra = sorted(sch - ds)
if miss or extra:
rec = f"Align {ref} schematic pins with the datasheet pintable."
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
out.append(_pin_delta(
ref=ref, mpn=mpn,
finding=(
f"{ref} pintable vs schematic pins differ "
f"(missing {missing}, extra {extra})."
f"(missing {miss}, extra {extra})."
),
facts=f"pintable={sorted(ds_pins)}; sch={sorted(sch_pins)}.",
facts=f"pintable={sorted(ds)}; sch={sorted(sch)}.",
requirement="Schematic pin numbers must match the datasheet pintable.",
rec=rec,
status="ERROR",
))
return out
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
import re
from backend.periscopex.models import (
@@ -210,3 +211,138 @@ def check_return_path(
pins=[],
))
return out
def _pad_xy(layout: LayoutGraph, ref: str) -> list[tuple[float, float, str]]:
fp = layout.footprints.get(ref)
if not fp:
return []
return [(p.x, p.y, p.net) for p in fp.pads]
def _min_pad_distance_mm(
a: list[tuple[float, float, str]],
b: list[tuple[float, float, str]],
net: str,
) -> float | None:
best = None
for ax, ay, an in a:
if an and not kicad_nets_match(an, net):
continue
for bx, by, bn in b:
if bn and not kicad_nets_match(bn, net):
continue
d = math.hypot(ax - bx, ay - by)
if best is None or d < best:
best = d
return best
def check_esd_distance(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None,
layout: LayoutGraph | None,
) -> list[Finding]:
"""TVSconnector pad distance. Vias are not pads. Skip without both lands."""
if layout is None:
return []
from backend.periscopex.constraints_lookup import match_constraints as match_cons
from backend.periscopex.si_check import _num, _quote
tvs = [
(ref, comp) for ref, comp in sorted(graph.components.items())
if _is_esd_part(comp)
]
connectors = [
(ref, comp) for ref, comp in sorted(graph.components.items())
if _is_j_connector(comp)
]
if not tvs or not connectors:
return []
out: list[Finding] = []
seen: set[tuple[str, str, str]] = set()
for j_ref, j_comp in connectors:
j_pads = _pad_xy(layout, j_ref)
if not j_pads:
continue
for d_ref, d_comp in tvs:
d_pads = _pad_xy(layout, d_ref)
if not d_pads:
continue
nets = sorted({
n for n in list(j_comp.pins.values()) + list(d_comp.pins.values()) if n
})
for net in nets:
if _skip_esd_net(net):
continue
key = (j_ref, d_ref, normalize_kicad_hierarchy_net(net))
if key in seen:
continue
dist = _min_pad_distance_mm(j_pads, d_pads, net)
if dist is None:
continue
seen.add(key)
lim = None
quote = ""
cons = match_cons(d_comp.mpn or d_comp.value, constraints_map or {})
if cons:
for rule in cons.layout_rules or []:
if str(rule.get("kind") or "") not in {"esd", "keepout"}:
continue
lim = _num(rule.get("max_distance_mm"))
quote = _quote(rule)
if lim is not None:
break
facts = (
f"pad_distance_mm={dist:.3f}; connector={j_ref}; tvs={d_ref}; "
f"net={net}; vias_not_used=1."
)
calc = f"min hypot between {j_ref} pads and {d_ref} pads = {dist:.3f} mm."
if lim is None:
rec = (
"Re-extract the TVS layout millimetre. "
"Do not assume a default ESD gap."
)
out.append(Finding(
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
finding=(
f"Unverified: {d_ref} is {dist:.2f} mm from {j_ref}"
"library has no ESD millimetre FACT."
),
facts=facts, calculation=calc,
requirement=(
"Datasheet ESD max_distance_mm from TVS to connector "
"(none on file)."
),
inference="INSUFFICIENT — not inventing an ESD keep-in millimetre.",
why="Distance is pad-to-pad; vias and tracks are not lands.",
status="INFO", recommendation=rec, action=rec,
source="esd_return_check", rule_id="PE-ESD-002",
finding_class="INFO", provenance="TYPICAL",
evidence_status="INSUFFICIENT", net=net, pins=[],
))
continue
fail = dist > lim + 1e-9
rec = (
f"Move {d_ref} within {lim:g} mm of {j_ref}."
if fail
else "TVS-to-connector distance meets the datasheet millimetre."
)
out.append(Finding(
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
finding=(
f"{'FAIL' if fail else 'PASS'}: {d_ref}{j_ref} "
f"{dist:.2f} mm (max {lim:g} mm)."
),
facts=facts, calculation=calc,
requirement=f"ESD max_distance_mm={lim:g} ({quote or 'layout_rules'}).",
inference="FAIL vs datasheet millimetre." if fail else "",
why="Pad-to-pad only; a via on the net is not the TVS land.",
status="WARNING" if fail else "INFO",
recommendation=rec, action=rec,
source="esd_return_check", rule_id="PE-ESD-002",
finding_class="RISK" if fail else "INFO",
provenance="RECOMMENDED" if fail else "TYPICAL",
evidence_status="SUFFICIENT", net=net, pins=[],
))
return out
@@ -152,6 +152,12 @@ def _seed() -> None:
requirement="Series resistor on the HS net vs datasheet ohms.")
_add("PE-SI-010", "TYPICAL", "INFO", domain="pcb",
requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.")
_add("PE-SI-007", "RECOMMENDED", "RISK", domain="pcb",
requirement="Dangling track stub vs datasheet millimetres; pad/via/zone are not stubs.")
_add("PE-SI-011", "TYPICAL", "REVIEW", domain="pcb",
requirement="Continuous GND pour under an HF pair when that pour exists (not IEC).")
_add("PE-SI-012", "TYPICAL", "INFO", domain="pcb",
requirement="BOM termination on an HF net is reported; missing terminator is a skip.")
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Keepout from layout_rules — not a DRC for pad copper.")
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
@@ -188,7 +194,7 @@ def _seed() -> None:
_add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb",
requirement="BOM / PCB footprint family must match datasheet package.")
_add("PE-BOM-011", "MANDATORY", "RULE", domain="pcb",
requirement="Datasheet pin_count / pintable must match PCB pads.")
requirement="Datasheet pintable names must join KiCad lib and PCB pads (not pin_count).")
_add("PE-BOM-012", "MANDATORY", "RULE", domain="pcb",
requirement="Operating voltage must not exceed the sourced rating / abs-max.")
_add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb",
@@ -224,6 +230,84 @@ def _seed() -> None:
requirement="PoE class/type from 802.3af/at/bt or Class n — never invented.")
_add("PE-POE-003", "MANDATORY", "RULE", domain="shared",
requirement="PoE requires magnetics/isolation (MagJack or LAN transformer).")
_add("PE-DDR-001", "TYPICAL", "INFO", domain="shared",
requirement="DDR class from a DRAM on this graph — never invented.")
_add("PE-DDR-002", "MANDATORY", "RULE", domain="shared",
requirement="DDR needs CK, DQS, and ≥8 DQ nets when the DRAM is fitted.")
_add("PE-DDR-003", "TYPICAL", "INFO", domain="shared",
requirement="VTT/VREF reported only when those nets exist.")
_add("PE-CPU-001", "TYPICAL", "INFO", domain="shared",
requirement="CPU data/address/control bus only if that parallel bus is on the graph.")
_add("PE-CPU-002", "TYPICAL", "INFO", domain="shared",
requirement="Parallel data bus ≥8 D/DQ/AD nets.")
_add("PE-CPU-003", "TYPICAL", "INFO", domain="shared",
requirement="Address or muxed AD nets when that CPU bus exists.")
_add("PE-CPU-004", "TYPICAL", "INFO", domain="shared",
requirement="CS/WE/OE (or FMC equivalents) on the CPU parallel bus.")
_add("PE-FPGA-001", "TYPICAL", "INFO", domain="shared",
requirement="FPGA class only when an FPGA is fitted.")
_add("PE-FPGA-002", "TYPICAL", "INFO", domain="shared",
requirement="FPGA config flash reported when a flash IC is on the graph.")
_add("PE-FPGA-003", "TYPICAL", "INFO", domain="shared",
requirement="FPGA core/IO rails reported only when VCCINT/VCCIO (named) exist.")
_add("PE-STK-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="Board copper thickness vs a sourced fab stackup FACT (never 1 oz).")
_add("PE-VIA-002", "TYPICAL", "INFO", domain="pcb",
requirement="Via count/drill with datasheet I; IPC via chart is not applied.")
_add("PE-ESD-002", "RECOMMENDED", "RISK", domain="pcb",
requirement="TVS-to-connector pad distance vs datasheet millimetres.")
_add("PE-PD-001", "TYPICAL", "INFO", domain="shared",
requirement="USB-PD contract only with a PD controller FACT (not USB2 Rd).")
_add("PE-POE-004", "TYPICAL", "INFO", domain="shared",
requirement="PoE isolation voltage only from a sourced number.")
_add("PE-ANT-001", "TYPICAL", "INFO", domain="pcb",
requirement="Antenna keepout reported when an antenna and that zone exist.")
_add("PE-ANT-002", "TYPICAL", "INFO", domain="pcb",
requirement="Antenna matching L/C reported when those parts exist on the feed.")
_add("PE-PDN-001", "TYPICAL", "INFO", domain="pcb",
requirement="PDN Z(f) only with frequency-domain FACT; SI Z0 is not PDN.")
_add("PE-AF-001", "TYPICAL", "INFO", domain="pcb",
requirement="AI Z0 flag needs stackup+track FACT — never 50/90 Ω.")
_add("PE-AF-002", "TYPICAL", "REVIEW", domain="pcb",
requirement="HF trace not checked: missing tr, f, εr, stackup, Z target, or via span.")
_add("PE-AF-020", "RECOMMENDED", "RISK", domain="pcb",
requirement="Right-angle track corner on an electrically long net.")
_add("PE-AF-021", "RECOMMENDED", "RISK", domain="pcb",
requirement="Abrupt track width step on an electrically long net.")
_add("PE-AF-030", "RECOMMENDED", "RISK", domain="pcb",
requirement="Reference plane missing under a triggered AF sample.")
_add("PE-AF-031", "RECOMMENDED", "RISK", domain="pcb",
requirement="Return copper farther than 3H under a triggered AF track.")
_add("PE-AF-032", "TYPICAL", "REVIEW", domain="pcb",
requirement="Reference-plane split nearby (ImpedenceFinder flag).")
_add("PE-AF-040", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Aggressor spacing < 3W on a triggered AF net (not the pair mate).")
_add("PE-AF-050", "RECOMMENDED", "RISK", domain="pcb",
requirement="Z0/Zdiff vs datasheet window; cascade RLGC uses that same Z_ref.")
_add("PE-AF-051", "TYPICAL", "REVIEW", domain="pcb",
requirement="CPWG/unknown topology: no invented ohm.")
_add("PE-AF-060", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Via series inductance from drill, span, and dielectric height.")
_add("PE-AF-061", "RECOMMENDED", "RISK", domain="pcb",
requirement="Via stub length vs λ/20 when span and f are FACT.")
_add("PE-PRT-L0-001", "MANDATORY", "RULE", domain="pcb",
requirement="Protocol nets required at L0 must exist and connect two components.")
_add("PE-PRT-L0-002", "TYPICAL", "INFO", domain="pcb",
requirement="Internal interconnect is NOT_APPLICABLE for PCB routing.")
_add("PE-PRT-L1-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="L1 geometric skip: missing PCB geometry or NUMERIC/DESIGN millimetre limit.")
_add("PE-PRT-L1-002", "TYPICAL", "REVIEW", domain="pcb",
requirement="L1 grouping (DQ/DQS / byte-lane) cannot be reconstructed — not PASS.")
_add("PE-PRT-L1-003", "MANDATORY", "RULE", domain="pcb",
requirement="L1 geometric length/via vs NUMERIC or DESIGN millimetre limit.")
_add("PE-PRT-L2-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="L2 electrical skip: Z/termination/levels need datasheet/stackup/fab/cited pack.")
_add("PE-PRT-L2-002", "MANDATORY", "RULE", domain="pcb",
requirement="L2 Z/termination vs cited pack or PHY datasheet window.")
_add("PE-PRT-L3-001", "TYPICAL", "REVIEW", domain="pcb",
requirement="L3 channel skip: missing existing IL/RL/crosstalk/budget FACT (no OpenEMS).")
_add("PE-PRT-L3-002", "MANDATORY", "RULE", domain="pcb",
requirement="L3 channel vs cited IL/RL/crosstalk/timing-budget FACT (no invented S-param).")
_seed()
@@ -0,0 +1,328 @@
"""DDR / CPU parallel / FPGA class certifiers — only if that device is on the graph.
Not SI Z0. Not invented VTT/DDR/FPGA. Same FACT/REQUIREMENT/INFERENCE contract
as USB-C / Ethernet / PoE (2.61.2).
"""
from __future__ import annotations
import re
from backend.periscopex.models import Component, ComponentType, DesignGraph, Finding
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
SOURCE = "hf_bus_class"
_MIN_PARALLEL_BITS = 8
_DDR_MPN_RE = re.compile(
r"DDR[234]|MT41|MT47|MT48|K4B|K4A|IS43TR|IS42S|W971|W972|EDB\d|H5TQ|H5TC",
re.I,
)
_SRAM_MPN_RE = re.compile(r"IS61|IS62|CY7C|AS6C|\bSRAM\b", re.I)
_FPGA_MPN_RE = re.compile(
r"XC[2367]|XCKU|XCZU|LFE5|ICE40|EP[1-4]C|10CL|10M\d|ECP5|"
r"ARTIX|KINTEX|SPARTAN|CYCLONE|MAX.?10|POLARFIRE|\bLATTICE\b",
re.I,
)
_FLASH_MPN_RE = re.compile(r"W25|S25|M25|MX25|GD25|AT25|SST25|N25Q", re.I)
_DATA_RE = re.compile(r"(?:^|[_/.])(?:DQ|AD|DATA|FMC_D|D)(\d+)(?:$|[_/.])", re.I)
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:ADDR|FMC_A|BA|A)(\d+)(?:$|[_/.])", re.I)
_CTRL_RE = re.compile(
r"(?:^|[_/.])(?:N?WE|N?OE|N?CS|N?LB|N?UB|RAS|CAS|CKE|ODT|"
r"FMC_NWE|FMC_NOE|FMC_NE\d|NE\d)(?:$|[_/.])",
re.I,
)
_CK_RE = re.compile(r"(?:DDR.*)?(?:CK|CLK)[_]?[PN](?:$|[_/.])|(?:^|[_/.])CK[_]?[PN]", re.I)
_DQS_RE = re.compile(r"DQS", re.I)
_VTT_RE = re.compile(r"VTT|VREF", re.I)
_FPGA_IO_RE = re.compile(r"VCCINT|VCCIO|VCCAUX|VCORE|VCCBRAM", re.I)
def check_memory_fpga_classes(graph: DesignGraph) -> list[Finding]:
"""Certify DDR / CPU parallel / FPGA only when that part exists on this graph."""
out: list[Finding] = []
out.extend(_check_ddr(graph))
out.extend(_check_cpu(graph))
out.extend(_check_fpga(graph))
return out
def _leaf(net: str) -> str:
n = normalize_kicad_hierarchy_net(net)
return n.split("/")[-1] if n else ""
def _blob(comp: Component) -> str:
return " ".join(str(x or "") for x in (comp.mpn, comp.value, comp.component_subtype))
def _nets_of(comp: Component) -> list[str]:
return [n for n in comp.pins.values() if n]
def _indices(nets: list[str], cre: re.Pattern[str]) -> set[int]:
found: set[int] = set()
for n in nets:
m = cre.search(_leaf(n))
if m:
found.add(int(m.group(1)))
return found
def _has_ctrl(nets: list[str]) -> list[str]:
return sorted({_leaf(n) for n in nets if _CTRL_RE.search(_leaf(n))})
def _is_ddr_ic(comp: Component) -> bool:
if comp.component_type != ComponentType.IC:
return False
sub = (comp.component_subtype or "").lower()
if sub == "ic.memory.ddr" or "ddr" in sub:
return True
return bool(_DDR_MPN_RE.search(_blob(comp)))
def _is_fpga_ic(comp: Component) -> bool:
if comp.component_type != ComponentType.IC:
return False
sub = (comp.component_subtype or "").lower()
if sub == "ic.fpga" or sub.startswith("ic.fpga."):
return True
return bool(_FPGA_MPN_RE.search(_blob(comp)))
def _is_sram_ic(comp: Component) -> bool:
if comp.component_type != ComponentType.IC:
return False
sub = (comp.component_subtype or "").lower()
if sub == "ic.memory.sram":
return True
return bool(_SRAM_MPN_RE.search(_blob(comp)))
def _is_flash_ic(comp: Component) -> bool:
if comp.component_type != ComponentType.IC:
return False
sub = (comp.component_subtype or "").lower()
if sub == "ic.memory.flash":
return True
return bool(_FLASH_MPN_RE.search(_blob(comp)))
def _is_mcu(comp: Component) -> bool:
if comp.component_type != ComponentType.IC:
return False
if _is_ddr_ic(comp) or _is_fpga_ic(comp) or _is_sram_ic(comp) or _is_flash_ic(comp):
return False
sub = (comp.component_subtype or "").lower()
return sub == "ic.mcu" or sub.startswith("ic.cpu") or "mcu" in (comp.value or "").lower()
def _parallel_groups(nets: list[str]) -> tuple[set[int], set[int], list[str]]:
data = _indices(nets, _DATA_RE)
addr = _indices(nets, _ADDR_RE)
return data, addr, _has_ctrl(nets)
def _check_ddr(graph: DesignGraph) -> list[Finding]:
chips = [c for c in graph.components.values() if _is_ddr_ic(c)]
if not chips:
return []
out: list[Finding] = []
for comp in sorted(chips, key=lambda c: c.reference):
nets = _nets_of(comp)
leaves = [_leaf(n) for n in nets]
dq = _indices(nets, _DATA_RE)
has_dqs = any(_DQS_RE.search(x) for x in leaves)
has_ck = any(_CK_RE.search(x) for x in leaves)
klass = "DDR"
blob = _blob(comp).upper()
if "DDR4" in blob:
klass = "DDR4"
elif "DDR3" in blob or "MT41" in blob or "K4B" in blob:
klass = "DDR3"
elif "DDR2" in blob:
klass = "DDR2"
out.append(_info(
comp.reference, "PE-DDR-001",
f"{comp.reference} class {klass} (device on this graph).",
f"mpn={comp.mpn!r}; subtype={comp.component_subtype!r}; dq_bits={len(dq)}.",
"DDR class from MPN/subtype on a DRAM that is actually fitted.",
mpn=comp.mpn or "",
))
if len(dq) >= _MIN_PARALLEL_BITS and has_dqs and has_ck:
out.append(_info(
comp.reference, "PE-DDR-002",
f"{comp.reference} has CK, DQS, and {len(dq)} DQ nets.",
f"ck={has_ck}; dqs={has_dqs}; dq_indices={sorted(dq)[:16]}.",
"DDR needs CK, DQS, and a data bus on the netlist.",
mpn=comp.mpn or "",
))
else:
out.append(_insufficient(
comp.reference, "PE-DDR-002",
f"{comp.reference} is DDR; CK/DQS/DQ bus is not fully evidenced.",
f"ck={has_ck}; dqs={has_dqs}; dq_bits={len(dq)} (need ≥{_MIN_PARALLEL_BITS}).",
"Do not invent DDR pinout; need CK, DQS, and ≥8 DQ nets.",
mpn=comp.mpn or "",
))
vtt = [n for n in nets if _VTT_RE.search(_leaf(n))]
extra = [n for n in graph.nets if _VTT_RE.search(_leaf(n))]
names = sorted({_leaf(n) for n in vtt + extra})
if names:
out.append(_info(
comp.reference, "PE-DDR-003",
f"{comp.reference} VTT/VREF nets present: {', '.join(names)}.",
f"vtt_vref={names}.",
"VTT/VREF is reported only when those nets exist — never invented.",
mpn=comp.mpn or "",
))
return out
def _cpu_host(graph: DesignGraph) -> Component | None:
for comp in sorted(graph.components.values(), key=lambda c: c.reference):
if not (_is_mcu(comp) or _is_fpga_ic(comp)):
continue
data, addr, ctrl = _parallel_groups(_nets_of(comp))
if len(data) >= _MIN_PARALLEL_BITS and ctrl and (addr or _muxed(data, addr)):
if _is_fpga_ic(comp) and not _is_mcu(comp):
continue
return comp
for comp in sorted(graph.components.values(), key=lambda c: c.reference):
if not _is_sram_ic(comp):
continue
data, addr, ctrl = _parallel_groups(_nets_of(comp))
if len(data) >= _MIN_PARALLEL_BITS and ctrl:
return comp
return None
def _muxed(data: set[int], addr: set[int]) -> bool:
return bool(data) and data == addr
def _check_cpu(graph: DesignGraph) -> list[Finding]:
host = _cpu_host(graph)
if host is None:
return []
if _is_ddr_ic(host):
return []
nets = _nets_of(host)
data, addr, ctrl = _parallel_groups(nets)
if _is_fpga_ic(host) and not _is_mcu(host):
return []
kind = "mux AD" if _muxed(data, addr) else "parallel"
if any("FMC" in _leaf(n).upper() for n in nets):
kind = "FMC"
elif _is_sram_ic(host) or any(_is_sram_ic(c) for c in graph.components.values()):
kind = "SRAM"
out = [_info(
host.reference, "PE-CPU-001",
f"{host.reference} class CPU {kind} bus (present on this graph).",
f"data_bits={len(data)}; addr_bits={len(addr)}; ctrl={ctrl}.",
"CPU data/address/control is certified only when that bus exists.",
mpn=host.mpn or "",
)]
out.append(_info(
host.reference, "PE-CPU-002",
f"{host.reference} data bus {len(data)} bits.",
f"dq_indices={sorted(data)[:16]}.",
"Parallel data bus needs ≥8 D/DQ/AD nets.",
mpn=host.mpn or "",
))
if addr and not _muxed(data, addr):
out.append(_info(
host.reference, "PE-CPU-003",
f"{host.reference} address bus {len(addr)} bits.",
f"a_indices={sorted(addr)[:16]}.",
"Address bus reported when A/ADDR nets exist (mux AD is not a second bus).",
mpn=host.mpn or "",
))
elif _muxed(data, addr):
out.append(_info(
host.reference, "PE-CPU-003",
f"{host.reference} multiplexed AD bus ({len(data)} bits).",
f"ad_indices={sorted(data)[:16]}.",
"Muxed AD is one bus, not invented separate D and A.",
mpn=host.mpn or "",
))
else:
return out
if ctrl:
out.append(_info(
host.reference, "PE-CPU-004",
f"{host.reference} control: {', '.join(ctrl)}.",
f"ctrl={ctrl}.",
"CS/WE/OE (or FMC equivalents) from net names on this bus.",
mpn=host.mpn or "",
))
return out
def _check_fpga(graph: DesignGraph) -> list[Finding]:
fpgas = [c for c in graph.components.values() if _is_fpga_ic(c)]
if not fpgas:
return []
flashes = [c for c in graph.components.values() if _is_flash_ic(c)]
out: list[Finding] = []
for comp in sorted(fpgas, key=lambda c: c.reference):
out.append(_info(
comp.reference, "PE-FPGA-001",
f"{comp.reference} class FPGA (device on this graph).",
f"mpn={comp.mpn!r}; subtype={comp.component_subtype!r}.",
"FPGA analysis runs only when an FPGA is fitted — never invented.",
mpn=comp.mpn or "",
))
if flashes:
names = ", ".join(sorted(c.reference for c in flashes))
out.append(_info(
comp.reference, "PE-FPGA-002",
f"{comp.reference} config flash present: {names}.",
f"flash={[c.reference + '=' + (c.mpn or '') for c in flashes]}.",
"Config flash is reported when a flash IC is on the graph.",
mpn=comp.mpn or "",
))
rails = sorted({
_leaf(n) for n in _nets_of(comp) if _FPGA_IO_RE.search(_leaf(n))
})
if rails:
out.append(_info(
comp.reference, "PE-FPGA-003",
f"{comp.reference} FPGA rails: {', '.join(rails)}.",
f"rails={rails}.",
"Core/IO supplies reported only when VCCINT/VCCIO (or named equivalent) exist.",
mpn=comp.mpn or "",
))
return out
def _info(
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
*, mpn: str = "",
) -> Finding:
rec = "No connection-integrity change required for this check."
return Finding(
designator=designator, mpn=mpn, aspect="interface_class",
finding=finding, why=requirement, facts=facts, requirement=requirement,
inference="Class/integrity FACT from netlist — not SI Z0 and not invented I/Z/mm.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id=rule_id, finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", confidence=0.9, pins=[],
)
def _insufficient(
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
*, mpn: str = "",
) -> Finding:
rec = "Add BOM/net evidence, then re-run. Do not invent DDR/CPU/FPGA buses."
return Finding(
designator=designator, mpn=mpn, aspect="interface_class",
finding=finding, why=requirement, facts=facts, requirement=requirement,
inference="Insufficient evidence — not inventing DDR pinout, VTT, Z, I, or mm.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id=rule_id, finding_class="INFO", provenance="TYPICAL",
evidence_status="INSUFFICIENT", confidence=0.3, pins=[],
)
@@ -0,0 +1,384 @@
"""HF pair geometry: stubs, reference split, BOM termination.
Track ≠ via ≠ pad ≠ zone. Skip without evidence. No invented 50/90 Ω or mm.
"""
from __future__ import annotations
import math
from collections import defaultdict
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.models import (
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
LayoutSegment,
ResistorSpecs,
)
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
from backend.periscopex.pcb_power_thermal import _is_gnd_name
from backend.periscopex.placement_check import _in_poly
from backend.periscopex.si_check import (
_bus_in_targets,
_expand_bus_token,
_num,
_quote,
bus_class,
partner_net,
skip_si_net,
)
SOURCE = "hf_line_check"
# Join tolerance for collinear KiCad segment ends — not an SI length limit.
ENDPOINT_SNAP_MM = 0.05
COORD_QUANT_MM = 0.01
def check_hf_lines(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
hs = _hs_nets(layout)
if not hs:
return []
out: list[Finding] = []
out.extend(_stub_findings(graph, constraints_map, layout, hs))
out.extend(_split_findings(layout, hs))
out.extend(_termination_findings(graph, hs))
return out
def _hs_nets(layout: LayoutGraph) -> list[str]:
names = sorted({s.net for s in layout.segments if s.net})
return [n for n in names if bus_class(n) and not skip_si_net(n)]
def _qxy(x: float, y: float) -> tuple[float, float]:
q = COORD_QUANT_MM
return (round(x / q) * q, round(y / q) * q)
def _edge_key(a: tuple[float, float], b: tuple[float, float]) -> tuple:
return tuple(sorted((a, b)))
def _track_graph(layout: LayoutGraph, net: str) -> tuple[
dict[tuple[float, float], set[tuple[float, float]]],
dict[tuple, float],
]:
nbrs: dict[tuple[float, float], set[tuple[float, float]]] = defaultdict(set)
elen: dict[tuple, float] = {}
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net):
continue
a = _qxy(*s.start)
b = _qxy(*s.end)
if a == b:
continue
nbrs[a].add(b)
nbrs[b].add(a)
elen[_edge_key(a, b)] = math.hypot(s.end[0] - s.start[0], s.end[1] - s.start[1])
return nbrs, elen
def _snap(pt: tuple[float, float], nodes: list[tuple[float, float]]) -> tuple[float, float] | None:
px, py = _qxy(*pt)
best = None
best_d = ENDPOINT_SNAP_MM
for n in nodes:
d = math.hypot(n[0] - px, n[1] - py)
if d <= best_d:
best_d = d
best = n
return best
def _terminals(layout: LayoutGraph, net: str, nodes: list[tuple[float, float]]) -> set[tuple[float, float]]:
found: set[tuple[float, float]] = set()
for fp in layout.footprints.values():
for pad in fp.pads:
if pad.net and kicad_nets_match(pad.net, net):
hit = _snap((pad.x, pad.y), nodes)
if hit:
found.add(hit)
for v in layout.vias:
if v.net and kicad_nets_match(v.net, net):
hit = _snap((v.x, v.y), nodes)
if hit:
found.add(hit)
for z in layout.zones:
if not z.net or not kicad_nets_match(z.net, net):
continue
for outline in z.outlines:
if len(outline) < 3:
continue
for node in nodes:
if _in_poly(node[0], node[1], outline):
found.add(node)
return found
def stub_lengths_mm(layout: LayoutGraph, net: str) -> list[float]:
"""Dangling track branches on ``net``. Pads, vias, and same-net zones end a stub."""
nbrs, elen = _track_graph(layout, net)
if not nbrs:
return []
nodes = list(nbrs)
terms = _terminals(layout, net, nodes)
stubs: list[float] = []
seen: set[tuple[float, float]] = set()
for node, friends in nbrs.items():
if len(friends) != 1 or node in terms or node in seen:
continue
prev = None
cur = node
total = 0.0
for _ in range(len(nbrs) + 1):
nxts = [n for n in nbrs[cur] if n != prev]
if not nxts:
break
nxt = nxts[0]
total += elen.get(_edge_key(cur, nxt), 0.0)
prev, cur = cur, nxt
if cur in terms or len(nbrs[cur]) != 2:
break
if total > COORD_QUANT_MM:
stubs.append(total)
seen.add(node)
return stubs
def _stub_limit(graph: DesignGraph, constraints_map: dict, bus: str) -> tuple[float | None, str]:
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
for rule in cons.layout_rules or []:
kind = str(rule.get("kind") or "")
if kind not in {"stub", "max_stub"}:
continue
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and not _bus_in_targets(bus, targets):
continue
lim = _num(rule.get("max_distance_mm"))
if lim is None:
continue
return lim, _quote(rule)
return None, ""
def _pair_seen(net: str, seen: set[tuple[str, str]]) -> bool:
p = partner_net(net)
key = tuple(sorted((net, p or "")))
if key in seen:
return True
seen.add(key) # type: ignore[arg-type]
return False
def _stub_findings(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
hs: list[str],
) -> list[Finding]:
out: list[Finding] = []
seen: set[tuple[str, str]] = set()
for net in hs:
if _pair_seen(net, seen):
continue
lengths = stub_lengths_mm(layout, net)
p = partner_net(net)
if p:
lengths.extend(stub_lengths_mm(layout, p))
if not lengths:
continue
stub_mm = max(lengths)
bc = bus_class(net) or "hs"
lim, quote = _stub_limit(graph, constraints_map, bc)
facts = f"stub_mm={stub_mm:.3f}; branches={ [round(x, 3) for x in lengths] }."
if lim is None:
rec = (
"Re-extract layout_rules stub millimetres from the datasheet. "
"Do not assume a default stub length."
)
out.append(Finding(
designator="layout", mpn="", aspect="si",
finding=(
f"Unverified: {bc} {net} stub {stub_mm:.2f} mm — "
"library has no stub millimetre FACT."
),
facts=facts,
requirement="Datasheet layout_rules stub max_distance_mm on this bus (none on file).",
inference="Not an invented stub limit; pads/vias/zones are not stubs.",
why="Insufficient library stub millimetres for this bus.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-SI-007", finding_class="INFO", provenance="TYPICAL",
evidence_status="INSUFFICIENT", net=net, pins=[],
))
continue
fail = stub_mm > lim + 1e-9
rec = "Shorten the dangling copper on the pair." if fail else "Stub length is within the datasheet millimetre."
out.append(Finding(
designator="layout", mpn="", aspect="si",
finding=(
f"{'FAIL' if fail else 'PASS'}: {net} stub {stub_mm:.2f} mm "
f"(max {lim:g} mm)."
),
facts=facts,
requirement=f"stub max_distance_mm={lim:g} ({quote or 'layout_rules'})",
inference="FAIL vs datasheet stub millimetre." if fail else "",
why="Stub is dangling track, not a via or pad.",
status="WARNING" if fail else "INFO",
recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-SI-007",
finding_class="RISK" if fail else "INFO",
provenance="RECOMMENDED" if fail else "TYPICAL",
evidence_status="SUFFICIENT", net=net, pins=[],
))
return out
def _ref_layer(layout: LayoutGraph, track_layer: str) -> str | None:
stack = layout.stackup
if stack is None or not stack.copper_layers:
return None
layers = stack.copper_layers
want = track_layer
idx = None
for i, name in enumerate(layers):
if name == want or name.lower() == want.lower():
idx = i
break
if idx is None:
return None
if idx + 1 < len(layers):
return layers[idx + 1]
if idx - 1 >= 0:
return layers[idx - 1]
return None
def _seg_samples(seg: LayoutSegment) -> list[tuple[float, float]]:
return [
seg.start,
((seg.start[0] + seg.end[0]) / 2.0, (seg.start[1] + seg.end[1]) / 2.0),
seg.end,
]
def _point_in_gnd_zone(layout: LayoutGraph, x: float, y: float, layer: str) -> bool:
for z in layout.zones:
if z.keepout or not z.net or not _is_gnd_name(z.net):
continue
if z.layer and z.layer.lower() != layer.lower():
continue
for outline in z.outlines:
if len(outline) >= 3 and _in_poly(x, y, outline):
return True
return False
def _split_findings(layout: LayoutGraph, hs: list[str]) -> list[Finding]:
if layout.stackup is None:
return []
gnd_layers = {
z.layer for z in layout.zones
if z.net and _is_gnd_name(z.net) and z.layer and not z.keepout and z.outlines
}
if not gnd_layers:
return []
out: list[Finding] = []
seen: set[tuple[str, str]] = set()
for net in hs:
if _pair_seen(net, seen):
continue
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
exposed = 0
checked = 0
ref = None
for s in segs:
if not s.layer:
continue
ref = _ref_layer(layout, s.layer)
if ref is None or ref not in gnd_layers:
continue
for x, y in _seg_samples(s):
checked += 1
if not _point_in_gnd_zone(layout, x, y, ref):
exposed += 1
if checked == 0 or exposed == 0:
continue
rec = f"Restore a continuous GND reference under {net}, then re-run PCB review."
out.append(Finding(
designator="layout", mpn="", aspect="si",
finding=(
f"{net} crosses a gap in the GND pour on reference layer "
f"{ref} ({exposed}/{checked} samples off pour)."
),
facts=(
f"ref_layer={ref}; samples={checked}; off_pour={exposed}; "
f"gnd_zone_layers={sorted(gnd_layers)}."
),
requirement="Continuous reference pour under an HF pair (typical, not IEC).",
inference="REVIEW — no creepage/IEC millimetre in context.",
why="Split under the line is geometry FACT; via/pad/track stay distinct.",
status="WARNING", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-SI-011", finding_class="REVIEW", provenance="TYPICAL",
evidence_status="SUFFICIENT", net=net, pins=[],
))
return out
def _termination_findings(graph: DesignGraph, hs: list[str]) -> list[Finding]:
out: list[Finding] = []
seen: set[str] = set()
hs_set = set(hs)
for net in hs:
if skip_si_net(net):
continue
for ref in refs_on_matched_net(graph, net):
if ref in seen:
continue
c = graph.components.get(ref)
if not c or c.component_type != ComponentType.RESISTOR:
continue
pins = [n for n in c.pins.values() if n]
if len(pins) < 2:
continue
others = [n for n in pins if not kicad_nets_match(n, net)]
if not others:
continue
other = others[0]
series = (not _is_gnd_name(other)) and (not skip_si_net(other) or other in hs_set)
parallel = _is_gnd_name(other)
if not series and not parallel:
continue
seen.add(ref)
ohms = None
if isinstance(c.specs, ResistorSpecs) and c.specs.value_ohms:
ohms = float(c.specs.value_ohms)
kind = "parallel-to-GND" if parallel else "series"
rec = "No termination change required; this is a BOM FACT."
facts = f"{ref} {kind} on {net}; other={other}; ohms={ohms}."
out.append(Finding(
designator=ref, mpn=c.mpn or "", aspect="si",
finding=f"{net} has {kind} termination {ref}"
+ (f" ({ohms:g} Ω)" if ohms is not None else "") + ".",
facts=facts,
requirement="Report matching parts that exist on the HF net; do not invent a terminator.",
inference="BOM FACT — missing terminator is a skip, not a folklore FAIL.",
why="Termination is certified only when the resistor is on the netlist.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-SI-012", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", net=net, pins=[ref],
))
return out
@@ -17,6 +17,7 @@ from backend.periscopex.models import (
Finding,
NetType,
ResistorSpecs,
SimpleComponentSpecs,
)
from backend.periscopex.pcb_net_match import (
is_no_connect_net,
@@ -424,9 +425,35 @@ def _poe_one(graph: DesignGraph, comp: Component, evidence: str) -> list[Finding
facts, req,
f"Use PoE magnetics / MagJack isolation on {ref}.",
mpn=comp.mpn or ""))
iso_v = _isolation_v(comp)
if iso_v is not None:
out.append(_info(
ref, "PE-POE-004",
f"{ref} PoE isolation {iso_v:g} V (datasheet FACT).",
f"isolation_v={iso_v:g}.",
"PoE isolation voltage is reported only from a sourced number — never IEC-invented.",
mpn=comp.mpn or "",
))
return out
def _isolation_v(comp: Component) -> float | None:
specs = comp.specs
if not isinstance(specs, SimpleComponentSpecs):
return None
for key in ("isolation_v", "isolation_voltage_v", "hipot_v"):
raw = specs.values.get(key)
if isinstance(raw, bool) or raw is None:
continue
try:
v = float(raw)
except (TypeError, ValueError):
continue
if v > 0:
return v
return None
def _blob(graph: DesignGraph | None, comp: Component) -> str:
parts = [
comp.value or "",
@@ -385,6 +385,8 @@ class ValidationReport(BaseModel):
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
# M0 protocol cert: empty instances / "nessun protocollo riconosciuto". No Z.
protocol_certification: dict[str, Any] | None = None
class FindingComment(BaseModel):
@@ -500,6 +502,7 @@ class LayoutVia(BaseModel):
y: float
net: str = ""
drill: float | None = None
layers: tuple[str, ...] = ()
class LayoutDielectric(BaseModel):
@@ -236,6 +236,38 @@ def _is_npth_pad(pad: object) -> bool:
return _pad_kind(pad) in {"np_thru_hole", "np_through_hole", "npth"}
def footprint_pad_ids(node: object) -> list[tuple[str, str]]:
"""``(number, pinfunction)`` lands. Via and NPTH are not pads."""
out: list[tuple[str, str]] = []
for pad in _kids(node, "pad"):
if _is_footprint_via(pad) or _is_npth_pad(pad):
continue
num = str(pad[1]) if len(pad) > 1 and not isinstance(pad[1], list) else ""
if not num:
continue
out.append((num, _val(pad, "pinfunction")))
return out
def parse_kicad_mod_pads(path: str | Path) -> list[tuple[str, str]] | None:
"""Pad ids from a ``.kicad_mod``. None if the file is missing or not a footprint."""
p = Path(path)
if not p.is_file():
return None
try:
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
except (OSError, ValueError):
return None
tag = _tag(tree)
if tag in {"footprint", "module"}:
return footprint_pad_ids(tree)
if tag == "kicad_pcb":
for node in tree[1:]:
if isinstance(node, list) and _tag(node) in {"footprint", "module"}:
return footprint_pad_ids(node)
return None
def _is_footprint_via(pad: object) -> bool:
"""Footprint child that is a via, never a component pin.
@@ -259,6 +291,15 @@ def _is_footprint_via(pad: object) -> bool:
return True
def _via_copper_layers(node: object) -> tuple[str, ...]:
"""KiCad ``(layers "F.Cu" "B.Cu")``. Empty if the file has no span."""
el = _kid(node, "layers")
if not el:
return ()
names = tuple(str(x) for x in el[1:] if not isinstance(x, list) and str(x).strip())
return names
def _via_from_node(
node: object,
nets: dict[str, int],
@@ -275,7 +316,7 @@ def _via_from_node(
rx, ry = _rotate(vx, vy, frot)
vx, vy = fx + rx, fy + ry
net = _net_name(node, nets)
return LayoutVia(x=vx, y=vy, net=net, drill=drill)
return LayoutVia(x=vx, y=vy, net=net, drill=drill, layers=_via_copper_layers(node))
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
@@ -376,7 +417,8 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
drill_el = _kid(pad, "drill")
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
vias.append(LayoutVia(
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad), drill=drill,
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad),
drill=drill, layers=_via_copper_layers(pad),
))
continue
if _is_npth_pad(pad):
+14 -1
View File
@@ -8,7 +8,7 @@ from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.emi_check import check_emi
from backend.periscopex.esd_return_check import check_esd, check_return_path
from backend.periscopex.esd_return_check import check_esd, check_esd_distance, check_return_path
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.hierarchy import check_hierarchy
@@ -29,6 +29,12 @@ from backend.periscopex.pcb_power_thermal import (
from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.placement_check import check_placement
from backend.periscopex.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes
from backend.periscopex.antenna_layout_check import check_antenna_layout
from backend.periscopex.hf_bus_class import check_memory_fpga_classes
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.pdn_check import check_pdn
from backend.periscopex.stackup_check import check_stackup
from backend.periscopex.usb_pd_check import check_usb_pd
from backend.periscopex.si_check import check_si
from backend.periscopex.spof_check import check_spof
from backend.periscopex.timing_check import check_timing
@@ -270,6 +276,7 @@ def run_pcb_checks(
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout, impedance_nets)),
("hf_line", lambda: check_hf_lines(graph, constraints_map, layout)),
("pcb_derating", lambda: check_pcb_derating(graph)),
("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)),
("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)),
@@ -280,12 +287,18 @@ def run_pcb_checks(
("timing_check", lambda: check_timing(graph, constraints_map)),
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
("esd_check", lambda: check_esd(graph, constraints_map)),
("esd_distance", lambda: check_esd_distance(graph, constraints_map, layout)),
("return_path", lambda: check_return_path(graph, layout)),
("stackup_check", lambda: check_stackup(graph, constraints_map, layout)),
("usb_pd", lambda: check_usb_pd(graph)),
("antenna_layout", lambda: check_antenna_layout(graph, layout)),
("pdn_check", lambda: check_pdn(graph, layout, impedance_nets)),
("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)),
("spof_check", lambda: check_spof(graph, constraints_map)),
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
("interface_class", lambda: check_interface_classes(graph)),
("hf_bus_class", lambda: check_memory_fpga_classes(graph)),
):
try:
out.extend(fn())
@@ -278,10 +278,37 @@ def check_pcb_via_current(
if key in seen:
continue
seen.add(key)
n, _drill = _via_stats(layout, net)
n, drill = _via_stats(layout, net)
if n > 0:
# Geometry is present; no via-ampacity table in the library — do not
# invent a rating or claim the vias were not parsed.
rec = (
"Supply a datasheet via current or plating thickness before judging "
"via ampacity. IPC via charts are not applied."
)
drill_txt = f"{drill:g}" if drill is not None else ""
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_power",
finding=(
f"{net} carries I_load={i_load:.3g} A on {n} via(s) "
f"(min drill {drill_txt} mm); no via ampacity table in the library."
),
why="I from datasheet; via count and drill from the board. No invented IPC via k.",
status="INFO",
recommendation=rec,
action=rec,
source="pcb_power_thermal",
rule_id="PE-VIA-002",
evidence_status="INSUFFICIENT",
facts=(
f"I_load={i_load:.3g} A; via_count={n}; min_drill_mm={drill_txt}; "
f"board_vias={len(layout.vias)}."
),
requirement="Via ampacity table is not in the library (IPC via chart not applied).",
inference="INSUFFICIENT — not inventing via ampacity from an IPC chart.",
net=net,
pins=[],
))
continue
out.append(Finding(
designator=ref,
@@ -0,0 +1,42 @@
"""PDN Z(f). Skip without a frequency-domain FACT. SI Z0 is not PDN."""
from __future__ import annotations
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
SOURCE = "pdn_check"
def check_pdn(
graph: DesignGraph,
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
"""Require explicit PDN Z(f) rows. ImpedenceFinder SI Z0 is not Z(f)."""
_ = graph
_ = layout
raw = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("pdn") or impedance_nets.get("pdn_nets") or [])
rows = [r for r in (raw or []) if isinstance(r, dict)]
pdn = [
r for r in rows
if str(r.get("kind") or "").lower() == "pdn"
or r.get("frequency_hz") is not None
or r.get("z_ohm_at_hz") is not None
]
if not pdn:
return []
rec = "Use measured PDN Z(f); do not invent a target impedance."
facts = f"pdn_rows={len(pdn)}."
return [Finding(
designator="PDN", mpn="", aspect="pdn",
finding=f"PDN Z(f) FACT present ({len(pdn)} row(s)).",
facts=facts,
requirement="PDN Z(f) is reported only with frequency-domain measurements.",
inference="No target Z(f) is invented.",
why="PDN is last; SI differential Z0 is a different check.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-PDN-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", pins=[],
)]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,440 @@
{
"$defs": {
"ConstraintSource": {
"properties": {
"document": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Document"
},
"organization": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Organization"
},
"revision": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Revision"
},
"section": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Section"
},
"table": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Table"
},
"page": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Page"
},
"url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Url"
}
},
"title": "ConstraintSource",
"type": "object"
},
"LogicalProtocol": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"bus_type": {
"enum": [
"MEMORY",
"PROCESSOR_INTERCONNECT",
"PERIPHERAL",
"CHIP_TO_CHIP",
"STORAGE",
"GRAPHICS",
"SERIAL",
"PARALLEL"
],
"title": "Bus Type",
"type": "string"
},
"family": {
"default": "",
"title": "Family",
"type": "string"
},
"notes": {
"default": "",
"title": "Notes",
"type": "string"
}
},
"required": [
"id",
"name",
"bus_type"
],
"title": "LogicalProtocol",
"type": "object"
},
"PhysicalInterface": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"logical_protocol_id": {
"title": "Logical Protocol Id",
"type": "string"
},
"bus_type": {
"enum": [
"MEMORY",
"PROCESSOR_INTERCONNECT",
"PERIPHERAL",
"CHIP_TO_CHIP",
"STORAGE",
"GRAPHICS",
"SERIAL",
"PARALLEL"
],
"title": "Bus Type",
"type": "string"
},
"physical_layer": {
"enum": [
"INTERNAL",
"PARALLEL_SINGLE_ENDED",
"PARALLEL_DIFFERENTIAL",
"SERIAL_SINGLE_ENDED",
"SERIAL_DIFFERENTIAL",
"MIXED"
],
"title": "Physical Layer",
"type": "string"
},
"pcb_relevant": {
"enum": [
"YES",
"NO",
"CONDITIONAL"
],
"title": "Pcb Relevant",
"type": "string"
},
"connector": {
"default": "none",
"title": "Connector",
"type": "string"
},
"recognition_hints": {
"items": {
"type": "string"
},
"title": "Recognition Hints",
"type": "array"
},
"required_checks": {
"items": {
"type": "string"
},
"title": "Required Checks",
"type": "array"
},
"constraints": {
"items": {
"$ref": "#/$defs/ProtocolConstraint"
},
"title": "Constraints",
"type": "array"
},
"notes": {
"default": "",
"title": "Notes",
"type": "string"
},
"incomplete": {
"default": false,
"title": "Incomplete",
"type": "boolean"
}
},
"required": [
"id",
"logical_protocol_id",
"bus_type",
"physical_layer",
"pcb_relevant"
],
"title": "PhysicalInterface",
"type": "object"
},
"ProtocolConstraint": {
"additionalProperties": false,
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"parameter": {
"title": "Parameter",
"type": "string"
},
"value_kind": {
"enum": [
"NUMERIC",
"UNKNOWN",
"CONTROLLER_DEPENDENT",
"PHY_DEPENDENT",
"VENDOR_DEPENDENT",
"NOT_APPLICABLE",
"MISSING_SOURCE"
],
"title": "Value Kind",
"type": "string"
},
"mandatory": {
"enum": [
"MANDATORY",
"RECOMMENDED",
"OPTIONAL",
"INFORMATIONAL"
],
"title": "Mandatory",
"type": "string"
},
"source_type": {
"enum": [
"STANDARD",
"CONNECTOR",
"PHY",
"VENDOR",
"CONTROLLER",
"MEMORY",
"COMPONENT",
"CABLE",
"PCB",
"DERIVED"
],
"title": "Source Type",
"type": "string"
},
"source_class": {
"enum": [
"NORMATIVE",
"VENDOR",
"IMPLEMENTATION",
"DERIVED"
],
"title": "Source Class",
"type": "string"
},
"value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Value"
},
"unit": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Unit"
},
"source": {
"anyOf": [
{
"$ref": "#/$defs/ConstraintSource"
},
{
"type": "null"
}
],
"default": null
},
"conditions": {
"items": {
"type": "string"
},
"title": "Conditions",
"type": "array"
},
"measurement_method": {
"anyOf": [
{
"enum": [
"DATASHEET",
"TDR",
"FIELD_SOLVER",
"PCB_STACKUP",
"FABRICATOR",
"SIMULATION",
"CALCULATION",
"EXTRACTION",
"MEASUREMENT"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Measurement Method"
},
"limit_kind": {
"anyOf": [
{
"enum": [
"STANDARD",
"DESIGN"
],
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Limit Kind"
},
"value_origin": {
"default": "SPEC",
"enum": [
"SPEC",
"TYPICAL",
"EXAMPLE",
"DERIVED"
],
"title": "Value Origin",
"type": "string"
},
"typical": {
"default": false,
"title": "Typical",
"type": "boolean"
},
"needed_document": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Needed Document"
}
},
"required": [
"id",
"parameter",
"value_kind",
"mandatory",
"source_type",
"source_class"
],
"title": "ProtocolConstraint",
"type": "object"
}
},
"additionalProperties": false,
"properties": {
"schema_version": {
"title": "Schema Version",
"type": "string"
},
"logical_protocols": {
"items": {
"$ref": "#/$defs/LogicalProtocol"
},
"title": "Logical Protocols",
"type": "array"
},
"physical_interfaces": {
"items": {
"$ref": "#/$defs/PhysicalInterface"
},
"title": "Physical Interfaces",
"type": "array"
}
},
"required": [
"schema_version",
"logical_protocols",
"physical_interfaces"
],
"title": "ProtocolCatalog",
"type": "object"
}
@@ -0,0 +1,319 @@
"""M8: DDR physical instance grouping. Controller-aware. No universal mm/ps/ohm.
DQ/DQS byte lanes are separate from ADDRESS / COMMAND / CONTROL / CLOCK.
HBM is package/interposer not classic DDR PCB DQ rules.
"""
from __future__ import annotations
import re
from backend.periscopex.models import Component, ComponentType, DesignGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_recognize import (
EvidenceKind,
PhysicalBusInstance,
SignalGroup,
_blob,
_EVIDENCE_SCORE,
)
PACK_MACROPHASE = "M8"
_DQ_RE = re.compile(r"(?:^|[_/.])DQ(\d+)(?:$|[_/.])", re.I)
_DQS_RE = re.compile(r"(?:^|[_/.])DQS(\d*)(?:[_#]?(P|N|PINS|PLUS|MINUS))?(?:$|[_/.])", re.I)
_DM_RE = re.compile(r"(?:^|[_/.])(?:DM|DBI)(\d+)(?:$|[_/.])", re.I)
_CK_RE = re.compile(r"(?:^|[_/.])(?:CK)(?:[_#]?(P|N))?(?:$|[_/.])", re.I)
_WCK_RE = re.compile(r"(?:^|[_/.])WCK", re.I)
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I)
_CA_RE = re.compile(r"(?:^|[_/.])CA(\d+)(?:$|[_/.])", re.I)
_CMD_RE = re.compile(r"(?:^|[_/.])(?:RAS|CAS|WE|ACT)(?:[#_N]*)(?:$|[_/.])", re.I)
_CTRL_RE = re.compile(
r"(?:^|[_/.])(?:CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])", re.I,
)
_HBM_RE = re.compile(
r"\bHBM(?:2E|3E|[23])?\b|H26M|MT54A|interposer",
re.I,
)
_DDR_GEN = [
("ddr5", re.compile(r"\bDDR5\b", re.I)),
("ddr4", re.compile(r"\bDDR4\b|MT41|K4A|IS43TR", re.I)),
("ddr3l", re.compile(r"\bDDR3L\b", re.I)),
("ddr3", re.compile(r"\bDDR3\b|K4B", re.I)),
("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)),
("ddr1", re.compile(r"\bDDR1\b", re.I)),
]
_LPDDR_GEN = [
("lpddr5x", re.compile(r"LPDDR5X", re.I)),
("lpddr5", re.compile(r"LPDDR5", re.I)),
("lpddr4x", re.compile(r"LPDDR4X", re.I)),
("lpddr4", re.compile(r"LPDDR4", re.I)),
("lpddr3", re.compile(r"LPDDR3", re.I)),
("lpddr2", re.compile(r"LPDDR2", re.I)),
("lpddr-family", re.compile(r"\bLPDDR\b", re.I)),
]
_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I)
_MEMORY_PART = re.compile(r"MT41|MT47|K4A|K4B|IS43TR|W631|EDY4016", re.I)
_CONTROLLER_HINT = re.compile(
r"FPGA|XC7|XCKU|Artix|Kintex|Zynq|STM32|i\.?MX|RK33|controller|MIG|\bSoC\b",
re.I,
)
def _leaf(name: str) -> str:
n = normalize_kicad_hierarchy_net(name)
return n.split("/")[-1] if n else ""
def is_hbm_blob(text: str) -> bool:
return bool(_HBM_RE.search(text or ""))
def ddr_gen_from_text(text: str) -> str | None:
if is_hbm_blob(text):
return None
for gen, cre in _LPDDR_GEN:
if cre.search(text):
return gen
if _DDR4_PART.search(text):
return "ddr4"
for gen, cre in _DDR_GEN:
if cre.search(text):
return gen
return None
def hbm_gen_from_text(text: str) -> str | None:
if not is_hbm_blob(text):
return None
t = re.sub(r"[\s_\-]", "", text.upper())
for gen in ("HBM3E", "HBM2E", "HBM3", "HBM2", "HBM"):
if gen in t:
return gen.lower()
return "hbm-family"
def byte_lanes(net_names: list[str]) -> list[SignalGroup]:
dq: dict[int, str] = {}
dqs: dict[int, dict[str, str]] = {}
dm: dict[int, str] = {}
for n in net_names:
leaf = _leaf(n)
m = _DQ_RE.search(leaf)
if m and "DQS" not in leaf.upper():
dq[int(m.group(1))] = n
continue
m = _DQS_RE.search(leaf)
if m:
idx = int(m.group(1) or "0")
pol = (m.group(2) or "P").upper()[:1]
dqs.setdefault(idx, {})[pol] = n
continue
m = _DM_RE.search(leaf)
if m:
dm[int(m.group(1))] = n
lanes: list[SignalGroup] = []
lane_ids = set(i // 8 for i in dq) | set(dqs) | set(dm)
for lane in sorted(lane_ids):
roles: dict[str, str] = {}
nets: list[str] = []
for bit in range(lane * 8, lane * 8 + 8):
if bit in dq:
roles[f"DQ{bit}"] = dq[bit]
nets.append(dq[bit])
if lane in dqs:
for pol, net in dqs[lane].items():
roles[f"DQS{lane}_{pol}"] = net
nets.append(net)
if lane in dm:
roles[f"DM{lane}"] = dm[lane]
nets.append(dm[lane])
if nets:
lanes.append(SignalGroup(
kind="BYTE_LANE", id=f"BYTE_LANE_{lane}", nets=nets, roles=roles,
))
return lanes
def memory_groups(net_names: list[str]) -> list[SignalGroup]:
"""DQ/DQS lanes vs ADDRESS/COMMAND/CONTROL vs CLOCK. No invented numbers."""
groups: list[SignalGroup] = list(byte_lanes(net_names))
data_nets = {n for g in groups for n in g.nets}
def _take(kind: str, gid: str, pred) -> None:
nets = [n for n in net_names if pred(_leaf(n)) and n not in data_nets]
if nets:
groups.append(SignalGroup(kind=kind, id=gid, nets=nets, roles={})) # type: ignore[arg-type]
_take("CLOCK_GROUP", "CK", lambda lf: _CK_RE.search(lf) and not _WCK_RE.search(lf))
_take("CLOCK_GROUP", "WCK", lambda lf: bool(_WCK_RE.search(lf)))
_take("ADDRESS_GROUP", "ADDRESS", lambda lf: bool(_ADDR_RE.search(lf)) and not _CA_RE.search(lf))
_take("ADDRESS_GROUP", "CA", lambda lf: bool(_CA_RE.search(lf)))
_take("COMMAND_GROUP", "COMMAND", lambda lf: bool(_CMD_RE.search(lf)))
_take("CONTROL_GROUP", "CONTROL", lambda lf: bool(_CTRL_RE.search(lf)))
return groups
def data_vs_addr_overlap(groups: list[SignalGroup]) -> list[str]:
data = {n for g in groups if g.kind == "BYTE_LANE" for n in g.nets}
other = {
n for g in groups
if g.kind in {"ADDRESS_GROUP", "COMMAND_GROUP", "CONTROL_GROUP", "CLOCK_GROUP"}
for n in g.nets
}
return sorted(data & other)
def find_controller(graph: DesignGraph, memory: Component, nets: list[str]) -> Component | None:
netset = set(nets)
for comp in graph.components.values():
if comp.reference == memory.reference:
continue
if comp.component_type != ComponentType.IC:
continue
blob = _blob(comp, graph)
if is_hbm_blob(blob):
continue
if ddr_gen_from_text(blob) and _MEMORY_PART.search(blob):
continue
pins = set(comp.pins.values())
if pins & netset:
return comp
return None
def phys_id_for_gen(gen: str, ifaces: dict) -> str:
if gen.startswith("lpddr"):
pid = f"{gen}-pcb"
return pid if pid in ifaces else "lpddr-family-pcb"
if gen == "ddr-family":
return "ddr-family-sdram-pcb"
pid = f"{gen}-sdram-pcb"
return pid if pid in ifaces else "ddr-family-sdram-pcb"
def build_ddr_instance(
graph: DesignGraph,
memory: Component,
gen: str,
evidence: EvidenceKind,
ifaces: dict,
) -> PhysicalBusInstance:
iface_id = phys_id_for_gen(gen, ifaces)
iface = ifaces[iface_id]
nets = [n for n in memory.pins.values() if n]
groups = memory_groups(nets)
overlap = data_vs_addr_overlap(groups)
controller = find_controller(graph, memory, nets)
lanes = [g for g in groups if g.kind == "BYTE_LANE"]
incomplete = not lanes
mixed = bool(overlap)
status = "REVIEW" if incomplete or mixed or gen in {"ddr-family", "lpddr-family"} else "RECOGNIZED"
notes = (
f"Controller-aware DDR instance. memory={memory.reference}"
f"{(' controller=' + controller.reference) if controller else ' (no controller IC on nets)'}."
" No universal mm/ps or Z."
)
if incomplete:
notes += " Byte-lane grouping incomplete — REVIEW, not PASS."
if mixed:
notes += f" Address/command mixed into DQ: {overlap}. Not certified as DDR data."
status = "REVIEW"
host = controller.reference if controller else memory.reference
peers = [memory.reference]
if controller:
peers = [memory.reference]
group_nets = sorted({n for g in groups for n in g.nets})
return PhysicalBusInstance(
instance_id=f"{iface_id}:{memory.reference}",
logical_protocol_id=gen,
physical_interface_id=iface_id,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE[evidence],
evidence_kind=evidence,
recognition_status=status,
nets=group_nets,
groups=groups,
host_ref=host,
peer_refs=peers,
ambiguous_logical_ids=["ddr-family"] if gen == "ddr-family" else [],
notes=notes,
)
def recognize_hbm(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
out: list[PhysicalBusInstance] = []
for comp in graph.components.values():
blob = _blob(comp, graph)
gen = hbm_gen_from_text(blob)
if not gen:
continue
pid = f"{gen}-package" if f"{gen}-package" in ifaces else "hbm-family-package"
iface = ifaces[pid]
out.append(PhysicalBusInstance(
instance_id=f"{pid}:{comp.reference}",
logical_protocol_id=gen if gen in {
"hbm", "hbm2", "hbm2e", "hbm3", "hbm3e", "hbm-family",
} else "hbm-family",
physical_interface_id=pid,
pcb_relevant="NO",
confidence=_EVIDENCE_SCORE["part" if comp.mpn else "silicon"],
evidence_kind="part" if comp.mpn else "silicon",
recognition_status="NOT_APPLICABLE",
nets=[],
groups=[],
host_ref=comp.reference,
notes=(
"HBM is package/interposer. Classic DDR PCB DQ rules do not apply. "
"pcb_relevant=NO."
),
))
return out
def recognize_ddr_instances(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
memories: list[tuple[Component, str, EvidenceKind]] = []
for comp in graph.components.values():
blob = _blob(comp, graph)
if is_hbm_blob(blob):
continue
gen = ddr_gen_from_text(blob)
if not gen:
continue
if _CONTROLLER_HINT.search(blob) and not _MEMORY_PART.search(blob):
continue
kind: EvidenceKind = "part" if (comp.mpn or _DDR4_PART.search(blob)) else "silicon"
memories.append((comp, gen, kind))
out = [build_ddr_instance(graph, m, gen, kind, ifaces) for m, gen, kind in memories]
if out:
return out
if any(is_hbm_blob(_blob(c, graph)) for c in graph.components.values()):
return []
net_gen = None
for n in graph.nets:
g = ddr_gen_from_text(_leaf(n))
if g:
net_gen = g
break
if not net_gen:
dq = [n for n in graph.nets if _DQ_RE.search(_leaf(n)) and "DQS" not in _leaf(n).upper()]
dqs = [n for n in graph.nets if "DQS" in _leaf(n).upper()]
if len(dq) >= 8 and dqs:
net_gen = "ddr-family"
else:
return []
groups = memory_groups(list(graph.nets))
pid = phys_id_for_gen(net_gen, ifaces)
iface = ifaces[pid]
return [PhysicalBusInstance(
instance_id=f"{pid}:nets",
logical_protocol_id=net_gen,
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["net_name"],
evidence_kind="net_name",
recognition_status="REVIEW",
nets=sorted({n for g in groups for n in g.nets}),
groups=groups,
notes="DDR nets without a memory MPN. Controller-aware grouping only. No universal mm/ps.",
)]
@@ -0,0 +1,361 @@
"""M2: L0 STRUCTURAL protocol certifier. Presence and connection only.
FAIL only when a MANDATORY structural net/group is missing or unconnected.
Internal interconnects (AXI4, AMBA, HBM DQ) are NOT_APPLICABLE, never FAIL.
RECOMMENDED never FAIL. No L1L3. No invented ohms/mm/ps.
"""
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_catalog import (
SCHEMA_VERSION,
PhysicalInterface,
ProtocolCatalog,
ProtocolCertificationSection,
ProtocolConstraint,
empty_protocol_section,
load_catalog,
map_protocol_outcome,
)
from backend.periscopex.protocol_recognize import (
PhysicalBusInstance,
recognize_physical_buses,
)
PACK_MACROPHASE = "M2"
SOURCE = "protocol_l0"
LEVEL = "L0"
L0_CHECKS = frozenset({
"topology",
"byte_lane_mapping",
"cmd_dat_grouping",
"clk_reference",
"cc_rd_rp",
"vbus_gnd",
"pcb_routing",
"pcb_individual_dq_routing",
"dq_group",
"superspeed_pairs",
})
_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I)
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
class L0CheckResult(BaseModel):
check: str
level: Literal["L0"] = "L0"
result: Literal[
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE", "VENDOR_DEPENDENT",
]
mandatory: MandatoryClass
nets: list[str] = Field(default_factory=list)
notes: str = ""
finding_class: str = ""
status: str = ""
def _leaf(name: str) -> str:
n = normalize_kicad_hierarchy_net(name)
return n.split("/")[-1] if n else ""
def _net_present(graph: DesignGraph, name: str) -> bool:
return name in graph.nets
def _net_connected(graph: DesignGraph, name: str) -> bool:
net = graph.nets.get(name)
if not net:
return False
refs = {p.component_ref for p in net.pins if p.component_ref}
return len(refs) >= 2
def _constraint_for(iface: PhysicalInterface, check: str) -> ProtocolConstraint | None:
aliases = {
"cc_rd_rp": "cc_termination",
"pcb_routing": "pcb_routing",
"pcb_individual_dq_routing": "pcb_individual_dq_routing",
"superspeed_pairs": "superspeed_pairs",
"topology": "topology",
"byte_lane_mapping": "topology",
"byte_lane_skew": "byte_to_byte_skew",
"ck_to_command_skew": "ck_to_command_skew",
"ck_to_address_skew": "ck_to_address_skew",
"length": "max_length",
"clock_length": "max_length",
"data_length": "max_length",
"via_count": "vias",
"stub_length": "stub_length",
}
want = aliases.get(check, check)
for c in iface.constraints:
if c.parameter == want or c.parameter == check:
return c
return None
def _mandatory(iface: PhysicalInterface, check: str) -> MandatoryClass:
c = _constraint_for(iface, check)
if c:
return c.mandatory
if check in {"superspeed_pairs", "pcb_routing", "pcb_individual_dq_routing"}:
return "INFORMATIONAL"
return "MANDATORY"
def _pack(check: str, result: str, mandatory: MandatoryClass, *,
nets: list[str] | None = None, notes: str = "") -> L0CheckResult:
if result == "FAIL" and mandatory != "MANDATORY":
result = "WARNING"
cls, status, _ev = map_protocol_outcome(result, mandatory) # type: ignore[arg-type]
return L0CheckResult(
check=check, result=result, mandatory=mandatory, # type: ignore[arg-type]
nets=nets or [], notes=notes,
finding_class=cls, status=status,
)
def _structural_nets_ok(graph: DesignGraph, names: list[str]) -> tuple[bool, str]:
missing = [n for n in names if not _net_present(graph, n)]
if missing:
return False, f"missing net(s): {', '.join(missing)}"
dangling = [n for n in names if not _net_connected(graph, n)]
if dangling:
return False, f"unconnected net(s): {', '.join(dangling)}"
return True, "present and connected"
def certify_instance_l0(
graph: DesignGraph,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
) -> list[L0CheckResult]:
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
return [_pack(
"pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
notes="Internal interconnect — not PCB routing. Not FAIL.",
)]
out: list[L0CheckResult] = []
checks = [c for c in iface.required_checks if c in L0_CHECKS]
if not checks:
checks = ["topology"]
for check in checks:
mand = _mandatory(iface, check)
out.append(_run_l0_check(graph, inst, check, mand))
return out
def _run_l0_check(
graph: DesignGraph,
inst: PhysicalBusInstance,
check: str,
mand: MandatoryClass,
) -> L0CheckResult:
if check in {"pcb_routing", "pcb_individual_dq_routing"}:
return _pack(check, "NOT_APPLICABLE", mand,
notes="Not a PCB-routed individual channel at L0.")
if check == "superspeed_pairs":
ss = [n for n in graph.nets if _SS_RE.search(_leaf(n))]
return _pack(
check, "NOT_APPLICABLE", mand, nets=ss,
notes="USB2-only physical interface: SuperSpeed pairs not required.",
)
if check == "topology":
names: list[str] = []
for g in inst.groups:
if g.kind == "DIFFERENTIAL_PAIR":
names.extend(g.nets)
if not names:
names = list(inst.nets)
if len(names) < 2:
return _pack(
check, "FAIL", mand, nets=names,
notes="MANDATORY differential pair or bus nets not present.",
)
ok, msg = _structural_nets_ok(graph, names)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
if check == "byte_lane_mapping" or check == "dq_group":
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
if not lanes:
return _pack(
check, "FAIL", mand,
notes="MANDATORY byte-lane / DQ group not reconstructed.",
)
names = [n for g in lanes for n in g.nets]
ok, msg = _structural_nets_ok(graph, names)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
if check == "cmd_dat_grouping":
names = [n for g in inst.groups for n in g.nets]
if not names:
return _pack(check, "FAIL", mand, notes="No CMD/DAT group nets.")
ok, msg = _structural_nets_ok(graph, names)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
if check == "clk_reference":
clocks = [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets]
if not clocks:
return _pack(check, "FAIL", mand, notes="Clock reference net missing.")
ok, msg = _structural_nets_ok(graph, clocks)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg)
if check == "cc_rd_rp":
ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))]
if len(ccs) < 1:
return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.")
ok, msg = _structural_nets_ok(graph, ccs)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg)
if check == "vbus_gnd":
vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
names = vbus + gnd
if not vbus or not gnd:
return _pack(
check, "FAIL", mand, nets=names,
notes="VBUS and/or GND net not present.",
)
ok, msg = _structural_nets_ok(graph, names)
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
return _pack(check, "NOT_APPLICABLE", mand, notes="Not an L0 structural check.")
def l0_findings(
inst: PhysicalBusInstance,
results: list[L0CheckResult],
) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
for r in results:
if r.result == "PASS":
continue
if r.result == "FAIL" and r.mandatory != "MANDATORY":
continue
status = r.status if r.status in {"ERROR", "WARNING", "INFO"} else "INFO"
if r.result == "FAIL":
rule_id = "PE-PRT-L0-001"
finding = (
f"L0 structural: {r.check} {r.notes or 'missing MANDATORY net/connection'}."
)
else:
rule_id = "PE-PRT-L0-002"
finding = f"L0 {r.result}: {r.check}{r.notes}".strip()
f = Finding(
designator=designator,
mpn="",
aspect="protocol_l0",
finding=finding,
why=r.notes,
status=status,
source=SOURCE,
net=r.nets[0] if r.nets else None,
rule_id=rule_id,
facts=finding,
requirement=(
"MANDATORY protocol nets must exist and connect at least two components."
if r.result == "FAIL"
else "Internal protocol is not certified as PCB routing."
),
inference=f"{LEVEL} {r.result} (not electrical; no invented Z).",
provenance="MANDATORY" if r.mandatory == "MANDATORY" else "TYPICAL",
finding_class=r.finding_class if r.finding_class in {"RULE", "RISK", "REVIEW", "INFO"} else "INFO",
)
out.append(complete_finding(f))
return out
def certify_l0(
graph: DesignGraph,
instances: list[PhysicalBusInstance],
catalog: ProtocolCatalog | None = None,
) -> tuple[dict[str, list[L0CheckResult]], list[Finding]]:
cat = catalog or load_catalog()
ifaces = {p.id: p for p in cat.physical_interfaces}
by_id: dict[str, list[L0CheckResult]] = {}
findings: list[Finding] = []
for inst in instances:
iface = ifaces.get(inst.physical_interface_id)
if iface is None:
continue
rows = certify_instance_l0(graph, inst, iface)
by_id[inst.instance_id] = rows
findings.extend(l0_findings(inst, rows))
return by_id, findings
def protocol_exam(
graph: DesignGraph | None,
catalog: ProtocolCatalog | None = None,
layout: LayoutGraph | None = None,
constraints_map: dict | None = None,
impedance_nets: list[dict] | dict | None = None,
channel_data: dict | None = None,
) -> tuple[ProtocolCertificationSection, list[Finding]]:
from backend.periscopex.protocol_l1 import certify_l1
from backend.periscopex.protocol_l2 import certify_l2
from backend.periscopex.protocol_l3 import LEVEL as L3_LEVEL, certify_l3
from backend.periscopex.protocol_report import (
PACK_MACROPHASE as REPORT_MACRO,
attach_instance_report,
result_rank,
)
if graph is None:
return empty_protocol_section(), []
cat = catalog or load_catalog()
instances = recognize_physical_buses(graph, cat)
if not instances:
sec = empty_protocol_section()
sec.macrophase = REPORT_MACRO
return sec, []
l0, findings = certify_l0(graph, instances, cat)
l1, l1_findings_out = certify_l1(graph, instances, layout, cat)
findings.extend(l1_findings_out)
l2, l2_findings_out = certify_l2(
graph, instances, cat,
layout=layout,
constraints_map=constraints_map,
impedance_nets=impedance_nets,
)
findings.extend(l2_findings_out)
l3, l3_findings_out = certify_l3(
instances, cat, channel_data=channel_data,
)
findings.extend(l3_findings_out)
ifaces = {p.id: p for p in cat.physical_interfaces}
dumps: list[dict[str, Any]] = []
for inst in instances:
row = inst.model_dump()
row["l0_checks"] = [c.model_dump() for c in l0.get(inst.instance_id, [])]
row["l1_checks"] = [c.model_dump() for c in l1.get(inst.instance_id, [])]
row["l2_checks"] = [c.model_dump() for c in l2.get(inst.instance_id, [])]
row["l3_checks"] = [c.model_dump() for c in l3.get(inst.instance_id, [])]
row.update(attach_instance_report(
inst,
ifaces.get(inst.physical_interface_id),
l0.get(inst.instance_id, []),
l1.get(inst.instance_id, []),
l2.get(inst.instance_id, []),
l3.get(inst.instance_id, []),
))
dumps.append(row)
dumps.sort(key=lambda r: result_rank(str(r.get("worst_result") or "UNKNOWN")))
sec = ProtocolCertificationSection(
schema_version=SCHEMA_VERSION,
macrophase=REPORT_MACRO,
recognized_instances=dumps,
message="",
max_level_reached=L3_LEVEL,
)
return sec, findings
@@ -0,0 +1,548 @@
"""M3: L1 GEOMETRIC protocol certifier. Length, vias, layer, grouping, skew.
Skew is compared only when the constraint is NUMERIC (length unit) or an
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.protocol_catalog import (
PhysicalInterface,
ProtocolCatalog,
ProtocolConstraint,
load_catalog,
map_protocol_outcome,
)
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
from backend.periscopex.protocol_recognize import PhysicalBusInstance, SignalGroup
from backend.periscopex.si_check import net_length_mm
PACK_MACROPHASE = "M3"
SOURCE = "protocol_l1"
LEVEL = "L1"
L1_CHECKS = frozenset({
"length",
"clock_length",
"data_length",
"intra_pair_skew",
"pair_skew",
"dq_to_dqs_skew",
"byte_lane_skew",
"ck_to_command_skew",
"ck_to_address_skew",
"clock_to_data_skew",
"vias",
"via_count",
"stubs",
"stub_length",
"topology",
"byte_lane_mapping",
"dq_group",
"dqs_relationship_if_present",
"cmd_dat_grouping",
"lane_relationships",
})
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
L1Result = Literal[
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
]
class L1CheckResult(BaseModel):
check: str
level: Literal["L1"] = "L1"
result: L1Result
mandatory: MandatoryClass
nets: list[str] = Field(default_factory=list)
measured_mm: float | None = None
limit_mm: float | None = None
margin_mm: float | None = None
value_kind: str = ""
notes: str = ""
finding_class: str = ""
status: str = ""
def _pack(
check: str,
result: str,
mandatory: MandatoryClass,
*,
nets: list[str] | None = None,
measured_mm: float | None = None,
limit_mm: float | None = None,
value_kind: str = "",
notes: str = "",
) -> L1CheckResult:
if result == "FAIL" and mandatory != "MANDATORY":
result = "WARNING"
mapped = result
if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
mapped = "VENDOR_DEPENDENT"
cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
margin = None
if measured_mm is not None and limit_mm is not None:
margin = limit_mm - measured_mm
return L1CheckResult(
check=check,
result=result, # type: ignore[arg-type]
mandatory=mandatory,
nets=nets or [],
measured_mm=measured_mm,
limit_mm=limit_mm,
margin_mm=margin,
value_kind=value_kind,
notes=notes,
finding_class=cls,
status=status,
)
def geometric_verdict(constraint: ProtocolConstraint | None, measured_mm: float | None) -> str:
"""PASS/FAIL only with NUMERIC length limit or DESIGN LIMIT. Never invent ps."""
if constraint is None:
return "UNKNOWN"
kind = constraint.value_kind
unit = (constraint.unit or "").strip().lower()
design = constraint.limit_kind == "DESIGN" and constraint.value is not None
numeric = kind == "NUMERIC" and constraint.value is not None
if numeric or design:
if unit in _TIME_UNITS:
return "UNKNOWN"
if measured_mm is None:
return "UNKNOWN"
if measured_mm <= constraint.value:
return "PASS"
return "FAIL"
if kind in {
"UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
"VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE",
}:
return kind
return "UNKNOWN"
def _len(layout: LayoutGraph, net: str) -> float:
return net_length_mm(layout, net)
def _via_count(layout: LayoutGraph, net: str) -> int:
return sum(1 for v in layout.vias if v.net == net)
def _layers(layout: LayoutGraph, net: str) -> list[str]:
return sorted({s.layer for s in layout.segments if s.net == net and s.layer})
def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
names: list[str] = []
for g in inst.groups:
names.extend(g.nets)
names.extend(inst.nets)
# preserve order, unique
seen: set[str] = set()
out: list[str] = []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
return out
def _skip_no_geometry(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult:
return _pack(
check, "UNKNOWN", mand,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per mancanza di geometria PCB (piste). L1 is geometric, not electrical."
),
)
def _skip_grouping(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult:
return _pack(
check, "UNKNOWN", mand,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per mancanza di grouping (DQ/DQS). Not PASS."
),
)
def _grouping_recon(
inst: PhysicalBusInstance,
check: str,
mand: MandatoryClass,
kind: str,
) -> L1CheckResult:
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
addr_cmd = [
g for g in inst.groups
if g.kind in {"ADDRESS_GROUP", "COMMAND_GROUP", "CONTROL_GROUP", "CLOCK_GROUP"}
]
data_nets = {n for g in lanes for n in g.nets}
other_nets = {n for g in addr_cmd for n in g.nets}
mixed = sorted(data_nets & other_nets)
if mixed:
return _pack(
check, "UNKNOWN", mand, nets=mixed, value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per grouping misto DQ e ADDRESS/COMMAND ({mixed}). Not PASS. "
"No universal mm/ps."
),
)
if check == "cmd_dat_grouping":
if not lanes or not addr_cmd:
return _skip_grouping(check, mand, inst)
return _pack(
check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
notes=(
"DQ/DQS vs ADDRESS/COMMAND/CONTROL/CLOCK reconstructed separately. "
"Controller-aware. L1 is not electrical. No universal mm/ps."
),
)
if not lanes:
return _skip_grouping(check, mand, inst)
has_dqs = any(
k.upper().startswith("DQS") for g in lanes for k in g.roles
)
if check == "dqs_relationship_if_present" and not has_dqs:
return _skip_grouping(check, mand, inst)
if check == "byte_lane_mapping" and not has_dqs:
return _skip_grouping(check, mand, inst)
return _pack(
check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
notes=(
"Byte-lane DQ/DQS grouping reconstructed. Not a global length-match PASS. "
"L1 is not electrical certification. No universal mm/ps."
),
)
def certify_instance_l1(
graph: DesignGraph,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
layout: LayoutGraph | None,
) -> list[L1CheckResult]:
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
return [_pack(
"pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
notes="Internal interconnect — L1 geometric N/A, not FAIL, not electrical.",
)]
checks = [c for c in iface.required_checks if c in L1_CHECKS]
if not checks:
checks = ["length", "topology"]
return [_run_l1_check(graph, inst, iface, layout, c) for c in checks]
def _run_l1_check(
graph: DesignGraph,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
layout: LayoutGraph | None,
check: str,
) -> L1CheckResult:
mand = _mandatory(iface, check)
cons = _constraint_for(iface, check)
kind = cons.value_kind if cons else "UNKNOWN"
grouping_recon = {
"byte_lane_mapping", "dq_group", "dqs_relationship_if_present",
"cmd_dat_grouping",
}
skew_checks = {
"dq_to_dqs_skew", "byte_lane_skew", "ck_to_command_skew",
"ck_to_address_skew", "clock_to_data_skew", "lane_relationships",
"intra_pair_skew", "pair_skew",
}
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
if check in grouping_recon:
return _grouping_recon(inst, check, mand, kind)
if check in skew_checks:
if check in {
"dq_to_dqs_skew", "byte_lane_skew",
} and not lanes:
return _skip_grouping(check, mand, inst)
if layout is None:
dep = kind if kind in {
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "VENDOR_DEPENDENT",
"UNKNOWN", "MISSING_SOURCE",
} else "CONTROLLER_DEPENDENT"
return _pack(
check, dep, mand, nets=_instance_nets(inst), value_kind=kind,
notes=(
"Controller/PHY dependent skew — no universal mm/ps. "
"L1 grouping is not a length-match PASS. Not electrical."
),
)
if layout is None:
return _skip_no_geometry(check, mand, inst)
if check == "topology":
nets = _instance_nets(inst)
routed = [n for n in nets if _len(layout, n) > 0]
if not routed:
return _skip_no_geometry(check, mand, inst)
layers = sorted({ly for n in routed for ly in _layers(layout, n)})
return _pack(
check, "PASS", mand, nets=routed, value_kind=kind,
notes=f"Routed on layers {layers}. Geometric topology only, not electrical.",
)
if check in {"length", "clock_length", "data_length"}:
nets = _clock_or_data_nets(inst, check)
if not nets:
nets = _instance_nets(inst)
if not nets:
return _skip_no_geometry(check, mand, inst)
measured = max(_len(layout, n) for n in nets)
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
verdict = geometric_verdict(cons, measured)
return _pack(
check, verdict, mand, nets=nets, measured_mm=measured,
limit_mm=limit, value_kind=kind,
notes="Geometric length (mm). Not delay; not electrical Z.",
)
if check in {"vias", "via_count"}:
nets = _instance_nets(inst)
count = sum(_via_count(layout, n) for n in nets)
measured = float(count)
verdict = geometric_verdict(cons, measured)
return _pack(
check, verdict, mand, nets=nets, measured_mm=measured,
limit_mm=cons.value if cons and cons.value_kind == "NUMERIC" else None,
value_kind=kind,
notes=f"Via count {count} (geometric). No invented via ampacity.",
)
if check in {"stubs", "stub_length"}:
verdict = geometric_verdict(cons, None)
if verdict == "FAIL":
verdict = "UNKNOWN"
return _pack(
check, verdict if verdict != "PASS" else "UNKNOWN", mand,
nets=_instance_nets(inst), value_kind=kind,
notes="Stub length not certified without a NUMERIC/DESIGN millimetre limit. Not invented.",
)
if check in {"intra_pair_skew", "pair_skew"}:
return _pair_skew(inst, layout, cons, mand, check, kind)
if check == "dq_to_dqs_skew":
return _dq_dqs_skew(inst, lanes, layout, cons, mand, kind)
if check == "byte_lane_skew":
return _lane_spread(lanes, layout, cons, mand, kind, "byte_lane_skew")
if check in {"ck_to_command_skew", "ck_to_address_skew", "clock_to_data_skew", "lane_relationships"}:
measured = _group_spread(inst, layout)
verdict = geometric_verdict(cons, measured)
return _pack(
check, verdict, mand, nets=_instance_nets(inst),
measured_mm=measured, value_kind=kind,
notes="Geometric length spread. No invented ps. Not electrical.",
)
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L1 geometric check.")
def _clock_or_data_nets(inst: PhysicalBusInstance, check: str) -> list[str]:
if check == "clock_length":
return [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets]
if check == "data_length":
return [
n for g in inst.groups
if g.kind in {"BYTE_LANE", "DATA_LANE", "DIFFERENTIAL_PAIR"}
for n in g.nets
]
return _instance_nets(inst)
def _pair_skew(
inst: PhysicalBusInstance,
layout: LayoutGraph,
cons: ProtocolConstraint | None,
mand: MandatoryClass,
check: str,
kind: str,
) -> L1CheckResult:
pairs = [g for g in inst.groups if g.kind == "DIFFERENTIAL_PAIR"]
if not pairs:
return _pack(
check, "UNKNOWN", mand, value_kind=kind,
notes="No differential pair group for geometric skew.",
)
deltas: list[float] = []
nets: list[str] = []
for g in pairs:
if len(g.nets) < 2:
continue
a, b = g.nets[0], g.nets[1]
deltas.append(abs(_len(layout, a) - _len(layout, b)))
nets.extend([a, b])
measured = max(deltas) if deltas else None
verdict = geometric_verdict(cons, measured)
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
return _pack(
check, verdict, mand, nets=nets, measured_mm=measured,
limit_mm=limit, value_kind=kind,
notes="Intra-pair geometric length delta (mm). Not ps, not electrical Z.",
)
def _dq_dqs_skew(
inst: PhysicalBusInstance,
lanes: list[SignalGroup],
layout: LayoutGraph,
cons: ProtocolConstraint | None,
mand: MandatoryClass,
kind: str,
) -> L1CheckResult:
deltas: list[float] = []
nets: list[str] = []
for lane in lanes:
dqs = [n for k, n in lane.roles.items() if k.upper().startswith("DQS")]
dqs_len = [_len(layout, n) for n in dqs]
ref = sum(dqs_len) / len(dqs_len) if dqs_len else None
if ref is None:
continue
for key, net in lane.roles.items():
if key.upper().startswith("DQ") and not key.upper().startswith("DQS"):
deltas.append(abs(_len(layout, net) - ref))
nets.append(net)
nets.extend(dqs)
measured = max(deltas) if deltas else None
if measured is None:
return _skip_grouping("dq_to_dqs_skew", mand, inst)
verdict = geometric_verdict(cons, measured)
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
return _pack(
"dq_to_dqs_skew", verdict, mand, nets=nets, measured_mm=measured,
limit_mm=limit, value_kind=kind,
notes="DQ-to-DQS geometric length delta (mm). No invented ps.",
)
def _lane_spread(
lanes: list[SignalGroup],
layout: LayoutGraph,
cons: ProtocolConstraint | None,
mand: MandatoryClass,
kind: str,
check: str,
) -> L1CheckResult:
spreads: list[float] = []
nets: list[str] = []
for lane in lanes:
lens = [_len(layout, n) for n in lane.nets]
if not lens:
continue
spreads.append(max(lens) - min(lens))
nets.extend(lane.nets)
measured = max(spreads) if spreads else None
verdict = geometric_verdict(cons, measured)
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
return _pack(
check, verdict, mand, nets=nets, measured_mm=measured,
limit_mm=limit, value_kind=kind,
notes="Byte-lane geometric length spread (mm). Not a global length-match PASS.",
)
def _group_spread(inst: PhysicalBusInstance, layout: LayoutGraph) -> float | None:
nets = _instance_nets(inst)
lens = [_len(layout, n) for n in nets if _len(layout, n) > 0]
if len(lens) < 2:
return None
return max(lens) - min(lens)
def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
for r in results:
if r.result == "PASS":
continue
if r.result == "FAIL" and r.mandatory != "MANDATORY":
continue
skip_family = r.result in {
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
"PHY_DEPENDENT", "MISSING_SOURCE",
}
fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
na = r.result == "NOT_APPLICABLE"
if not skip_family and not fail and not na:
continue
if fail:
rule_id = "PE-PRT-L1-003"
status = "ERROR"
finding = f"L1 geometric {r.check}: {r.notes}"
elif skip_family:
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
f"per {r.check} ({r.result}). Not electrical."
)
else:
rule_id = "PE-PRT-L0-002"
status = "INFO"
finding = r.notes
f = Finding(
designator=designator,
mpn="",
aspect="protocol_l1",
finding=finding,
why=r.notes,
status=status,
source=SOURCE,
net=r.nets[0] if r.nets else None,
rule_id=rule_id,
facts=finding,
requirement="L1 geometric vs NUMERIC/DESIGN length only; never invent mm/ps.",
inference="L1 is geometric, not electrical certification.",
provenance="MANDATORY" if fail else "TYPICAL",
finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
)
out.append(complete_finding(f))
return out
def certify_l1(
graph: DesignGraph,
instances: list[PhysicalBusInstance],
layout: LayoutGraph | None,
catalog: ProtocolCatalog | None = None,
) -> tuple[dict[str, list[L1CheckResult]], list[Finding]]:
cat = catalog or load_catalog()
ifaces = {p.id: p for p in cat.physical_interfaces}
by_id: dict[str, list[L1CheckResult]] = {}
findings: list[Finding] = []
for inst in instances:
iface = ifaces.get(inst.physical_interface_id)
if iface is None:
continue
rows = certify_instance_l1(graph, inst, iface, layout)
by_id[inst.instance_id] = rows
findings.extend(l1_findings(inst, rows))
return by_id, findings
@@ -0,0 +1,624 @@
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
Z only from datasheet / stackup / fabricator / cited pack never invented
90 Ω USB. USB-IF/IEEE numbers only if the pack has a cite. Silicon cross is
max PCB × PHY subset when datasheet windows exist. Length is not delay.
Not L3 / OpenEMS. L0/L1 are not electrical certification.
RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent.
Visible skip (PE-AF-002 style) when Z cannot be obtained.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.constraints_lookup import match_constraints
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
)
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.protocol_catalog import (
PhysicalInterface,
ProtocolCatalog,
ProtocolConstraint,
load_catalog,
map_protocol_outcome,
)
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
from backend.periscopex.protocol_recognize import PhysicalBusInstance
PACK_MACROPHASE = "M4"
SOURCE = "protocol_l2"
LEVEL = "L2"
L2_CHECKS = frozenset({
"differential_impedance",
"impedance",
"impedance_if_required",
"termination",
"cc_termination",
"voltage",
"levels",
"rise_time",
"fall_time",
"return_path",
"phy_requirements",
"magnetics",
"timing",
"ac_coupling",
})
_OHM_UNITS = frozenset({"ohm", "ohms", "ω", "Ω", ""})
_VOLT_UNITS = frozenset({"v", "volt", "volts"})
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
L2Result = Literal[
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
]
class L2CheckResult(BaseModel):
check: str
level: Literal["L2"] = "L2"
result: L2Result
mandatory: MandatoryClass
nets: list[str] = Field(default_factory=list)
measured_ohm: float | None = None
limit_ohm: float | None = None
limit_ohm_min: float | None = None
limit_ohm_max: float | None = None
margin_ohm: float | None = None
value_kind: str = ""
notes: str = ""
finding_class: str = ""
status: str = ""
def _pack(
check: str,
result: str,
mandatory: MandatoryClass,
*,
nets: list[str] | None = None,
measured_ohm: float | None = None,
limit_ohm: float | None = None,
limit_ohm_min: float | None = None,
limit_ohm_max: float | None = None,
value_kind: str = "",
notes: str = "",
) -> L2CheckResult:
if result == "FAIL" and mandatory != "MANDATORY":
result = "WARNING"
mapped = result
if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
mapped = "VENDOR_DEPENDENT"
cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
margin = None
hi = limit_ohm_max if limit_ohm_max is not None else limit_ohm
if measured_ohm is not None and hi is not None:
margin = hi - measured_ohm
return L2CheckResult(
check=check,
result=result, # type: ignore[arg-type]
mandatory=mandatory,
nets=nets or [],
measured_ohm=measured_ohm,
limit_ohm=limit_ohm,
limit_ohm_min=limit_ohm_min,
limit_ohm_max=limit_ohm_max,
margin_ohm=margin,
value_kind=value_kind,
notes=notes,
finding_class=cls,
status=status,
)
def _num(v: Any) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _z_window_from_rule(rule: dict) -> tuple[float, float] | None:
lo = _num(rule.get("z_min_ohm"))
hi = _num(rule.get("z_max_ohm"))
if lo is not None and hi is not None and hi >= lo:
return (lo, hi)
nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
tol = _num(rule.get("tolerance_pct"))
if nom is not None and tol is not None and tol > 0:
return (nom * (1 - tol / 100.0), nom * (1 + tol / 100.0))
if nom is not None:
return (nom, nom)
return None
def intersect_windows(windows: list[tuple[float, float]]) -> tuple[float, float] | None:
"""Silicon cross: max of mins × min of maxes (PHY subset)."""
if not windows:
return None
lo = max(w[0] for w in windows)
hi = min(w[1] for w in windows)
if lo > hi:
return None
return (lo, hi)
def electrical_verdict(
constraint: ProtocolConstraint | None,
measured: float | None,
window: tuple[float, float] | None,
) -> str:
"""PASS/FAIL only with a cited NUMERIC ohm/volt or a datasheet window."""
if window is not None:
if measured is None:
return "UNKNOWN"
if window[0] <= measured <= window[1]:
return "PASS"
return "FAIL"
if constraint is None:
return "UNKNOWN"
kind = constraint.value_kind
if kind == "NUMERIC" and constraint.value is not None:
unit = (constraint.unit or "").strip().lower()
if unit in _TIME_UNITS:
return "UNKNOWN"
if measured is None:
return "UNKNOWN"
if unit in _OHM_UNITS:
return "PASS" if abs(measured - constraint.value) < 1e-6 else "FAIL"
return "PASS" if measured <= constraint.value else "FAIL"
if kind in {
"UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
"VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE",
}:
return kind
return "UNKNOWN"
def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
names: list[str] = []
for g in inst.groups:
names.extend(g.nets)
names.extend(inst.nets)
seen: set[str] = set()
out: list[str] = []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
return out
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
raw: Any = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("nets") or [])
return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")]
def _row_for(rows: list[dict], net: str) -> dict | None:
for row in rows:
name = str(row.get("net_name") or row.get("name") or "")
if name and kicad_nets_match(name, net):
return row
return None
def _z_from_row(row: dict | None) -> float | None:
if not row:
return None
for k in ("zdiff_ohm", "zdiff_avg_ohms", "z0_avg_ohms", "z0_ohm", "mean_z0", "z0"):
v = _num(row.get(k))
if v is not None:
return v
return None
def measured_z_ohm(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None:
rows = _z_rows(impedance_nets)
vals = [_z_from_row(_row_for(rows, n)) for n in nets]
have = [v for v in vals if v is not None]
if not have:
return None
return sum(have) / len(have)
def _phy_z_windows(
graph: DesignGraph,
inst: PhysicalBusInstance,
constraints_map: dict[str, ComponentConstraints] | None,
) -> list[tuple[float, float]]:
if not constraints_map:
return []
refs = [r for r in [inst.host_ref, *inst.peer_refs] if r]
inst_nets = set(_instance_nets(inst))
for ref, comp in graph.components.items():
if ref in refs:
continue
pins = set(comp.pins.values())
if pins & inst_nets:
refs.append(ref)
windows: list[tuple[float, float]] = []
seen: set[tuple[float, float]] = set()
for ref in refs:
comp = graph.components.get(ref)
if comp is None or comp.component_type != ComponentType.IC:
continue
cons = match_constraints(comp.mpn or comp.value, constraints_map)
if cons is None:
continue
for rule in cons.layout_rules or []:
kind = str(rule.get("kind") or "").lower()
param = str(rule.get("parameter") or "").lower()
if kind not in {"impedance", "zdiff", "z0", "si"} and param not in {
"impedance", "zdiff", "z0",
}:
continue
w = _z_window_from_rule(rule)
if w and w not in seen:
seen.add(w)
windows.append(w)
return windows
def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | None:
if cons is None or cons.value_kind != "NUMERIC" or cons.value is None:
return None
unit = (cons.unit or "").strip().lower()
if unit in _TIME_UNITS or unit in _VOLT_UNITS:
return None
if unit in _OHM_UNITS or unit in {"", "ohm"}:
return (cons.value, cons.value)
return None
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
return _pack(
check, "UNKNOWN" if kind not in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "NOT_APPLICABLE",
} else kind,
mand,
value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di Z (datasheet/stackup/fab/cited pack). "
f"Not USB/IEC folklore. L0/L1 are not electrical certification."
),
)
def certify_instance_l2(
graph: DesignGraph,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
*,
layout: LayoutGraph | None = None,
constraints_map: dict[str, ComponentConstraints] | None = None,
impedance_nets: list[dict] | dict | None = None,
) -> list[L2CheckResult]:
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
return [_pack(
"pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
notes="Internal interconnect — L2 electrical N/A, not FAIL. L0/L1 are not electrical.",
)]
checks = [c for c in iface.required_checks if c in L2_CHECKS]
if not checks:
return []
return [
_run_l2_check(
graph, inst, iface, c,
layout=layout,
constraints_map=constraints_map,
impedance_nets=impedance_nets,
)
for c in checks
]
def _run_l2_check(
graph: DesignGraph,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
check: str,
*,
layout: LayoutGraph | None,
constraints_map: dict[str, ComponentConstraints] | None,
impedance_nets: list[dict] | dict | None,
) -> L2CheckResult:
mand = _mandatory(iface, check)
cons = _constraint_for(iface, check)
kind = cons.value_kind if cons else "UNKNOWN"
nets = _instance_nets(inst)
if check in {
"differential_impedance", "impedance", "impedance_if_required",
}:
return _z_check(
check, mand, cons, kind, inst, graph, nets,
constraints_map, impedance_nets,
)
if check in {"termination", "cc_termination"}:
return _termination_check(check, mand, cons, kind, inst, graph, nets, constraints_map)
if check in {"voltage", "levels"}:
return _levels_check(check, mand, cons, kind, inst, constraints_map, graph)
if check in {"rise_time", "fall_time", "timing"}:
return _pack(
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
nets=nets, value_kind=kind,
notes=(
"Length is not delay. Rise/fall/timing UNKNOWN without a cited "
"time source (no tpd invented from mm). Not L3."
),
)
if check in {"return_path", "magnetics", "phy_requirements", "ac_coupling"}:
verdict = kind if kind not in {"NUMERIC"} else "UNKNOWN"
if verdict == "NUMERIC":
verdict = "UNKNOWN"
return _pack(
check, verdict, mand, nets=nets, value_kind=kind,
notes=(
f"{check} not certified without a cited electrical source. "
f"Not invented. L0/L1 are not electrical certification."
),
)
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
def _z_check(
check: str,
mand: MandatoryClass,
cons: ProtocolConstraint | None,
kind: str,
inst: PhysicalBusInstance,
graph: DesignGraph,
nets: list[str],
constraints_map: dict[str, ComponentConstraints] | None,
impedance_nets: list[dict] | dict | None,
) -> L2CheckResult:
phy = _phy_z_windows(graph, inst, constraints_map)
pack_w = _pack_ohm_window(cons)
windows: list[tuple[float, float]] = []
if pack_w:
windows.append(pack_w)
windows.extend(phy)
window = intersect_windows(windows) if windows else None
measured = measured_z_ohm(nets, impedance_nets)
if window is None:
# no cited pack number and no datasheet PHY window → never invent 90 Ω
if measured is None:
return _skip_z(check, mand, inst, kind)
return _pack(
check,
kind if kind in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "UNKNOWN",
} else "UNKNOWN",
mand,
nets=nets, measured_ohm=measured, value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di Z target (cited pack or PHY datasheet). "
f"Measured stackup/fab Z is not compared to invented USB/IEC ohms. "
f"L0/L1 are not electrical certification."
),
)
if measured is None:
return _skip_z(check, mand, inst, kind)
verdict = electrical_verdict(cons, measured, window)
source_note = (
"PHY datasheet subset × cited pack"
if pack_w and phy
else ("cited pack" if pack_w else "PHY datasheet subset")
)
return _pack(
check, verdict, mand, nets=nets, measured_ohm=measured,
limit_ohm=window[1],
limit_ohm_min=window[0],
limit_ohm_max=window[1],
value_kind=kind if not pack_w else "NUMERIC",
notes=(
f"L2 Z vs {source_note} {window[0]:g}{window[1]:g} Ω. "
f"Not folklore. Length is not delay. Not L3."
),
)
def _termination_check(
check: str,
mand: MandatoryClass,
cons: ProtocolConstraint | None,
kind: str,
inst: PhysicalBusInstance,
graph: DesignGraph,
nets: list[str],
constraints_map: dict[str, ComponentConstraints] | None,
) -> L2CheckResult:
spec_ohm: float | None = None
if cons and cons.value_kind == "NUMERIC" and cons.value is not None:
unit = (cons.unit or "").strip().lower()
if unit in _OHM_UNITS:
spec_ohm = cons.value
if spec_ohm is None and constraints_map:
for ref in [inst.host_ref, *inst.peer_refs]:
comp = graph.components.get(ref)
if not comp:
continue
ds = match_constraints(comp.mpn or comp.value, constraints_map)
if not ds:
continue
for rule in ds.layout_rules or []:
if str(rule.get("kind") or "") in {"series_resistor", "termination"}:
spec_ohm = _num(rule.get("value_ohms")) or spec_ohm
if spec_ohm is None:
verdict = kind if kind in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
} else "UNKNOWN"
return _pack(
check, verdict, mand, nets=nets, value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per mancanza di termination cite. No invented Rd/ohm."
),
)
measured = None
netset = set(nets)
for comp in graph.components.values():
if comp.component_type != ComponentType.RESISTOR:
continue
if not (set(comp.pins.values()) & netset):
continue
specs = getattr(comp, "specs", None)
ohms = getattr(specs, "value_ohms", None) if specs is not None else None
if isinstance(ohms, (int, float)):
measured = float(ohms)
break
if measured is None:
return _pack(
check, "UNKNOWN", mand, nets=nets, limit_ohm=spec_ohm, value_kind=kind,
notes="Termination spec present but resistor value not FACT on the graph.",
)
verdict = "PASS" if abs(measured - spec_ohm) < 1e-3 else "FAIL"
return _pack(
check, verdict, mand, nets=nets, measured_ohm=measured, limit_ohm=spec_ohm,
value_kind=kind, notes="Termination vs cited/datasheet ohms. Not invented.",
)
def _levels_check(
check: str,
mand: MandatoryClass,
cons: ProtocolConstraint | None,
kind: str,
inst: PhysicalBusInstance,
constraints_map: dict[str, ComponentConstraints] | None,
graph: DesignGraph,
) -> L2CheckResult:
if cons and cons.value_kind == "NUMERIC" and cons.value is not None:
unit = (cons.unit or "").strip().lower()
if unit in _VOLT_UNITS:
return _pack(
check, "UNKNOWN", mand, value_kind="NUMERIC",
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V.",
)
if constraints_map:
for ref in [inst.host_ref, *inst.peer_refs]:
comp = graph.components.get(ref)
if not comp:
continue
ds = match_constraints(comp.mpn or comp.value, constraints_map)
if ds and ds.absolute_maximum_ratings:
return _pack(
check, "UNKNOWN", mand, value_kind=kind,
notes=(
"Silicon abs-max present; L2 levels need a cited operating "
"window vs measured rail — not assumed. Not L3."
),
)
verdict = kind if kind in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
} else "UNKNOWN"
return _pack(
check, verdict, mand, value_kind=kind,
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented.",
)
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
for r in results:
if r.result == "PASS":
continue
if r.result == "FAIL" and r.mandatory != "MANDATORY":
continue
skip_family = r.result in {
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
"PHY_DEPENDENT", "MISSING_SOURCE",
}
fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
na = r.result == "NOT_APPLICABLE"
if not skip_family and not fail and not na:
continue
if fail:
rule_id = "PE-PRT-L2-002"
status = "ERROR"
finding = f"L2 electrical {r.check}: {r.notes}"
elif skip_family:
rule_id = "PE-PRT-L2-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
f"per {r.check} ({r.result})."
)
else:
rule_id = "PE-PRT-L0-002"
status = "INFO"
finding = r.notes
f = Finding(
designator=designator,
mpn="",
aspect="protocol_l2",
finding=finding,
why=r.notes,
status=status,
source=SOURCE,
net=r.nets[0] if r.nets else None,
rule_id=rule_id,
facts=finding,
requirement="L2 Z/termination/levels only from datasheet/stackup/fab/cited pack.",
inference="L2 is electrical. L0/L1 are not. Length is not delay. Not L3.",
provenance="MANDATORY" if fail else "TYPICAL",
finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
)
out.append(complete_finding(f))
return out
def certify_l2(
graph: DesignGraph,
instances: list[PhysicalBusInstance],
catalog: ProtocolCatalog | None = None,
*,
layout: LayoutGraph | None = None,
constraints_map: dict[str, ComponentConstraints] | None = None,
impedance_nets: list[dict] | dict | None = None,
) -> tuple[dict[str, list[L2CheckResult]], list[Finding]]:
cat = catalog or load_catalog()
ifaces = {p.id: p for p in cat.physical_interfaces}
by_id: dict[str, list[L2CheckResult]] = {}
findings: list[Finding] = []
for inst in instances:
iface = ifaces.get(inst.physical_interface_id)
if iface is None:
continue
rows = certify_instance_l2(
graph, inst, iface,
layout=layout,
constraints_map=constraints_map,
impedance_nets=impedance_nets,
)
by_id[inst.instance_id] = rows
findings.extend(l2_findings(inst, rows))
return by_id, findings
@@ -0,0 +1,601 @@
"""M9: L3 CHANNEL protocol certifier. Timing budget / IL / RL / crosstalk.
Numerical execution only when channel FACT already exists (extracted dB/ps).
Never invent S-parameters. Never OpenEMS/FEM. Never M6 pack numbers.
L0/L1 are not electrical; this module does not call L0/L1 electrical.
RECOMMENDED never FAIL. Internals (pcb_relevant=NO) are NOT_APPLICABLE.
Visible skip: «Interfaccia X non certificata a L3 per mancanza di ».
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import DesignGraph, Finding
from backend.periscopex.protocol_catalog import (
PhysicalInterface,
ProtocolCatalog,
ProtocolConstraint,
load_catalog,
map_protocol_outcome,
)
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
from backend.periscopex.protocol_recognize import PhysicalBusInstance
PACK_MACROPHASE = "M9"
SOURCE = "protocol_l3"
LEVEL = "L3"
L3_CHECKS = frozenset({
"timing_budget",
"insertion_loss",
"return_loss",
"crosstalk",
"channel",
"connector",
"via_transitions",
"via_transition",
"package",
})
# Always structured on PCB-relevant instances even if the catalog omits them.
L3_CORE = (
"timing_budget",
"insertion_loss",
"return_loss",
"crosstalk",
"channel",
)
_DB_UNITS = frozenset({"db", "dbm", "dbi"})
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
_FEM_MARKERS = ("openems", "fem", "field_solver", "field-solver", "full-wave")
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
L3Result = Literal[
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
]
class L3CheckResult(BaseModel):
check: str
level: Literal["L3"] = "L3"
result: L3Result
mandatory: MandatoryClass
nets: list[str] = Field(default_factory=list)
measured_db: float | None = None
limit_db: float | None = None
margin_db: float | None = None
measured_ps: float | None = None
limit_ps: float | None = None
margin_ps: float | None = None
total_budget_ps: float | None = None
used_budget_ps: float | None = None
remaining_margin_ps: float | None = None
value_kind: str = ""
notes: str = ""
finding_class: str = ""
status: str = ""
def _num(v: Any) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _pack(
check: str,
result: str,
mandatory: MandatoryClass,
*,
nets: list[str] | None = None,
measured_db: float | None = None,
limit_db: float | None = None,
measured_ps: float | None = None,
limit_ps: float | None = None,
total_budget_ps: float | None = None,
used_budget_ps: float | None = None,
remaining_margin_ps: float | None = None,
value_kind: str = "",
notes: str = "",
) -> L3CheckResult:
if result == "FAIL" and mandatory != "MANDATORY":
result = "WARNING"
mapped = result
if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
mapped = "VENDOR_DEPENDENT"
cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
margin_db = None
if measured_db is not None and limit_db is not None:
margin_db = _db_margin(check, measured_db, limit_db)
margin_ps = remaining_margin_ps
if margin_ps is None and measured_ps is not None and limit_ps is not None:
margin_ps = measured_ps - limit_ps if limit_ps < 0 else limit_ps - measured_ps
return L3CheckResult(
check=check,
result=result, # type: ignore[arg-type]
mandatory=mandatory,
nets=nets or [],
measured_db=measured_db,
limit_db=limit_db,
margin_db=margin_db,
measured_ps=measured_ps,
limit_ps=limit_ps,
margin_ps=margin_ps,
total_budget_ps=total_budget_ps,
used_budget_ps=used_budget_ps,
remaining_margin_ps=remaining_margin_ps,
value_kind=value_kind,
notes=notes,
finding_class=cls,
status=status,
)
def _db_margin(check: str, measured: float, limit: float) -> float:
if check == "return_loss":
if limit < 0:
return limit - measured
return measured - limit
if limit < 0:
return measured - limit
return limit - measured
def _db_pass(check: str, measured: float, limit: float) -> bool:
"""IL/crosstalk: max magnitude (pos) or min S-param (neg). RL: min (pos) or max S11 (neg)."""
if check == "return_loss":
if limit < 0:
return measured <= limit
return measured >= limit
if limit < 0:
return measured >= limit
return measured <= limit
def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
names: list[str] = []
for g in inst.groups:
names.extend(g.nets)
names.extend(inst.nets)
seen: set[str] = set()
out: list[str] = []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
return out
def _kind_skip(kind: str) -> str:
if kind in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
}:
return kind
return "UNKNOWN"
def _blob_mentions_fem(obj: Any) -> bool:
text = str(obj).lower()
return any(m in text for m in _FEM_MARKERS)
def _looks_like_raw_sparam(facts: dict[str, Any]) -> bool:
"""Raw Touchstone / unevaluated S-matrix — we do not reduce it here."""
if facts.get("sparam_invented") is True:
return True
if facts.get("invent_sparam") is True:
return True
for key in ("touchstone", "sparam_file", "s2p", "s4p", "snp_path"):
if facts.get(key):
has_num = any(
_num(facts.get(k)) is not None
for k in (
"insertion_loss_db", "il_db", "s21_db",
"return_loss_db", "rl_db", "s11_db",
)
)
if not has_num:
return True
return False
def _as_fact_map(channel_data: Any) -> dict[str, Any]:
if channel_data is None:
return {}
if isinstance(channel_data, dict):
return channel_data
return {}
def lookup_channel_facts(
inst: PhysicalBusInstance,
channel_data: Any,
) -> dict[str, Any]:
"""Return FACT already on disk/in-memory. Never synthesize S-parameters."""
root = _as_fact_map(channel_data)
if not root:
return {}
candidates: list[Any] = []
for bag_key in ("instances", "physical_interfaces", "logical_protocols"):
bag = root.get(bag_key)
if isinstance(bag, dict):
if bag_key == "instances":
candidates.append(bag.get(inst.instance_id))
elif bag_key == "physical_interfaces":
candidates.append(bag.get(inst.physical_interface_id))
else:
candidates.append(bag.get(inst.logical_protocol_id))
candidates.append(root.get(inst.instance_id))
candidates.append(root.get(inst.physical_interface_id))
candidates.append(root.get(inst.logical_protocol_id))
default = root.get("default")
if isinstance(default, dict):
candidates.append(default)
records = root.get("records")
if isinstance(records, list):
for rec in records:
if not isinstance(rec, dict):
continue
if rec.get("instance_id") == inst.instance_id:
candidates.append(rec)
elif rec.get("physical_interface_id") == inst.physical_interface_id:
candidates.append(rec)
elif rec.get("logical_protocol_id") == inst.logical_protocol_id:
candidates.append(rec)
merged: dict[str, Any] = {}
for c in candidates:
if isinstance(c, dict):
merged.update(c)
return merged
def remaining_margin_ps(
total: float | None,
used: float | None,
remaining: float | None = None,
) -> float | None:
"""TOTAL USED = remaining margin. Prefer explicit remaining if given."""
if remaining is not None:
return remaining
if total is None or used is None:
return None
return total - used
def _used_from_parts(facts: dict[str, Any]) -> float | None:
keys = (
"package_delay_ps",
"pcb_trace_delay_ps",
"via_delay_ps",
"connector_delay_ps",
"cable_delay_ps",
"component_delay_ps",
)
parts = [_num(facts.get(k)) for k in keys]
present = [p for p in parts if p is not None]
if not present:
return _num(facts.get("used_budget_ps"))
extra = _num(facts.get("used_budget_ps"))
if extra is not None and extra >= sum(present):
return extra
return sum(present)
def _il_measured(facts: dict[str, Any]) -> float | None:
for k in ("insertion_loss_db", "il_db", "s21_db"):
n = _num(facts.get(k))
if n is not None:
return n
return None
def _rl_measured(facts: dict[str, Any]) -> float | None:
for k in ("return_loss_db", "rl_db", "s11_db"):
n = _num(facts.get(k))
if n is not None:
return n
return None
def _xt_measured(facts: dict[str, Any]) -> float | None:
for k in ("crosstalk_db", "xtalk_db", "next_db", "fext_db"):
n = _num(facts.get(k))
if n is not None:
return n
return None
def _limit_from_constraint(cons: ProtocolConstraint | None, units: set[str]) -> float | None:
if cons is None or cons.value_kind != "NUMERIC" or cons.value is None:
return None
unit = (cons.unit or "").strip().lower()
if unit in units or unit == "":
return cons.value
return None
def _skip(
check: str,
mand: MandatoryClass,
inst: PhysicalBusInstance,
kind: str,
missing: str,
*,
nets: list[str] | None = None,
) -> L3CheckResult:
return _pack(
check,
_kind_skip(kind),
mand,
nets=nets,
value_kind=kind,
notes=(
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
f"per mancanza di {missing}. OpenEMS/FEM out of product; S-parameters "
f"are not invented. L0/L1 are not electrical certification. Not PASS."
),
)
def _run_l3_check(
inst: PhysicalBusInstance,
iface: PhysicalInterface,
check: str,
facts: dict[str, Any],
) -> L3CheckResult:
mand = _mandatory(iface, check)
cons = _constraint_for(iface, check)
kind = cons.value_kind if cons else "UNKNOWN"
nets = _instance_nets(inst)
if facts and (_looks_like_raw_sparam(facts) or _blob_mentions_fem(facts.get("method") or facts.get("solver") or "")):
return _skip(
check, mand, inst, kind,
"dati canale ridotti a FACT (Touchstone/OpenEMS/FEM non eseguiti nel prodotto)",
nets=nets,
)
if check == "timing_budget":
total = _num(facts.get("total_budget_ps"))
used = _used_from_parts(facts)
remaining = remaining_margin_ps(total, used, _num(facts.get("remaining_margin_ps")))
limit = _limit_from_constraint(cons, _TIME_UNITS)
if remaining is None or total is None:
return _skip(
check, mand, inst, kind,
"timing budget FACT (total_budget_ps e used/remaining)",
nets=nets,
)
if cons is None or cons.value_kind != "NUMERIC":
# Measured budget exists but no cited min-margin — report numbers, skip cert.
return _pack(
check, _kind_skip(kind), mand, nets=nets,
total_budget_ps=total, used_budget_ps=used,
remaining_margin_ps=remaining, measured_ps=remaining,
value_kind=kind,
notes=(
f"Timing budget FACT total={total:g} ps used={used if used is not None else ''} "
f"remaining={remaining:g} ps. Interfaccia {inst.physical_interface_id} "
f"non certificata a L3 per mancanza di limite citato. Not invented. Not OpenEMS."
),
)
min_margin = limit if limit is not None else 0.0
verdict = "PASS" if remaining >= min_margin else "FAIL"
return _pack(
check, verdict, mand, nets=nets,
total_budget_ps=total, used_budget_ps=used,
remaining_margin_ps=remaining, measured_ps=remaining,
limit_ps=min_margin, value_kind="NUMERIC",
notes=(
f"L3 timing budget TOTALUSED=MARGIN ({total:g}{used if used is not None else 0:g}={remaining:g} ps). "
f"Cited min margin {min_margin:g} ps. Not L0/L1 electrical. Not OpenEMS."
),
)
if check in {"insertion_loss", "return_loss", "crosstalk"}:
if check == "insertion_loss":
measured = _il_measured(facts)
missing = "insertion loss FACT (dB) — S-parameters non inventati"
elif check == "return_loss":
measured = _rl_measured(facts)
missing = "return loss FACT (dB) — S-parameters non inventati"
else:
measured = _xt_measured(facts)
missing = "crosstalk FACT (dB) — S-parameters non inventati"
limit = _limit_from_constraint(cons, _DB_UNITS)
if measured is None:
return _skip(check, mand, inst, kind, missing, nets=nets)
if cons is None or cons.value_kind != "NUMERIC" or limit is None:
return _pack(
check, _kind_skip(kind), mand, nets=nets,
measured_db=measured, value_kind=kind,
notes=(
f"Channel FACT {check}={measured:g} dB. Interfaccia "
f"{inst.physical_interface_id} non certificata a L3 per mancanza di "
f"limite citato (pack NUMERIC). Not invented S-param. Not OpenEMS."
),
)
verdict = "PASS" if _db_pass(check, measured, limit) else "FAIL"
return _pack(
check, verdict, mand, nets=nets,
measured_db=measured, limit_db=limit, value_kind="NUMERIC",
notes=(
f"L3 {check} FACT {measured:g} dB vs cited {limit:g} dB. "
f"Not invented. Not OpenEMS. L0/L1 are not electrical."
),
)
if check == "channel":
if not facts:
return _skip(
check, mand, inst, kind,
"modello di canale completo (IL/RL/budget FACT già presenti)",
nets=nets,
)
if facts.get("channel_complete") is True:
il = _il_measured(facts)
rl = _rl_measured(facts)
total = _num(facts.get("total_budget_ps"))
used = _used_from_parts(facts)
remaining = remaining_margin_ps(total, used, _num(facts.get("remaining_margin_ps")))
if il is not None and rl is not None and remaining is not None:
il_c = _constraint_for(iface, "insertion_loss")
rl_c = _constraint_for(iface, "return_loss")
tb_c = _constraint_for(iface, "timing_budget")
il_lim = _limit_from_constraint(il_c, _DB_UNITS)
rl_lim = _limit_from_constraint(rl_c, _DB_UNITS)
tb_lim = _limit_from_constraint(tb_c, _TIME_UNITS)
if il_lim is None or rl_lim is None:
return _skip(
check, mand, inst, kind,
"limiti citati per IL/RL sul canale completo",
nets=nets,
)
ok = _db_pass("insertion_loss", il, il_lim) and _db_pass("return_loss", rl, rl_lim)
min_m = tb_lim if tb_lim is not None else 0.0
ok = ok and remaining >= min_m
return _pack(
check, "PASS" if ok else "FAIL", mand, nets=nets,
measured_db=il, remaining_margin_ps=remaining, value_kind="NUMERIC",
notes=(
"L3 complete channel from existing FACT (not OpenEMS). "
f"IL={il:g} dB RL={rl:g} dB margin={remaining:g} ps."
),
)
return _skip(
check, mand, inst, kind,
"canale completo (channel_complete + IL/RL/budget FACT)",
nets=nets,
)
# connector / via / package at L3 — FACT only, never FEM
if check in {"connector", "via_transitions", "via_transition", "package"}:
return _skip(
check, mand, inst, kind,
f"{check} channel FACT (no OpenEMS/FEM)",
nets=nets,
)
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L3 channel check.")
def certify_instance_l3(
inst: PhysicalBusInstance,
iface: PhysicalInterface,
channel_data: Any = None,
) -> list[L3CheckResult]:
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
return [_pack(
"channel", "NOT_APPLICABLE", "INFORMATIONAL",
notes=(
"Internal interconnect — L3 channel N/A, not FAIL. "
"L0/L1 are not electrical. Not OpenEMS."
),
)]
checks: list[str] = []
for c in L3_CORE:
if c not in checks:
checks.append(c)
for c in iface.required_checks:
if c in L3_CHECKS and c not in checks:
checks.append(c)
facts = lookup_channel_facts(inst, channel_data)
return [_run_l3_check(inst, iface, c, facts) for c in checks]
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
for r in results:
if r.result == "PASS":
continue
if r.result == "FAIL" and r.mandatory != "MANDATORY":
continue
skip_family = r.result in {
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
"PHY_DEPENDENT", "MISSING_SOURCE",
}
fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
na = r.result == "NOT_APPLICABLE"
if not skip_family and not fail and not na:
continue
if fail:
rule_id = "PE-PRT-L3-002"
status = "ERROR"
finding = f"L3 channel {r.check}: {r.notes}"
elif skip_family:
rule_id = "PE-PRT-L3-001"
status = "WARNING"
finding = r.notes or (
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
f"per {r.check} ({r.result})."
)
else:
rule_id = "PE-PRT-L0-002"
status = "INFO"
finding = r.notes
f = Finding(
designator=designator,
mpn="",
aspect="protocol_l3",
finding=finding,
why=r.notes,
status=status,
source=SOURCE,
net=r.nets[0] if r.nets else None,
rule_id=rule_id,
facts=finding,
requirement="L3 channel only from existing FACT (dB/ps). No OpenEMS. No invented S-param.",
inference="L3 is channel. L0/L1 are not electrical. Timing budget = totalused=margin.",
provenance="MANDATORY" if fail else "TYPICAL",
finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
)
out.append(complete_finding(f))
return out
def certify_l3(
instances: list[PhysicalBusInstance],
catalog: ProtocolCatalog | None = None,
*,
channel_data: Any = None,
) -> tuple[dict[str, list[L3CheckResult]], list[Finding]]:
cat = catalog or load_catalog()
ifaces = {p.id: p for p in cat.physical_interfaces}
by_id: dict[str, list[L3CheckResult]] = {}
findings: list[Finding] = []
for inst in instances:
iface = ifaces.get(inst.physical_interface_id)
if iface is None:
continue
rows = certify_instance_l3(inst, iface, channel_data=channel_data)
by_id[inst.instance_id] = rows
findings.extend(l3_findings(inst, rows))
return by_id, findings
def load_channel_data_file(path: Any) -> dict[str, Any] | None:
"""Load existing channel_data.json. Does not parse Touchstone or run FEM."""
from pathlib import Path
import json
p = Path(path) if path is not None else None
if p is None or not p.is_file():
return None
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(raw, dict):
return None
return raw
@@ -0,0 +1,814 @@
"""M1: physical_bus_instance recognition. No L0L3 certifier. No invented Z.
Priority: declared > silicon/BOM > pinout > net/pin name > pair geometry.
Ambiguity is REVIEW, never FAIL. AXI4 internal does not attach PCB nets.
"""
from __future__ import annotations
import re
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.models import Component, ComponentType, DesignGraph
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
from backend.periscopex.protocol_catalog import (
ProtocolCatalog,
ProtocolCertificationSection,
load_catalog,
)
PACK_MACROPHASE = "M1"
EvidenceKind = Literal[
"declared",
"silicon",
"part",
"pinout",
"net_name",
"pair_geometry",
]
GroupKind = Literal[
"DIFFERENTIAL_PAIR",
"BYTE_LANE",
"DATA_LANE",
"CLOCK_GROUP",
"ADDRESS_GROUP",
"COMMAND_GROUP",
"CONTROL_GROUP",
"STROBE_GROUP",
"CHANNEL",
"SUBCHANNEL",
]
RecognitionStatus = Literal["RECOGNIZED", "REVIEW", "NOT_APPLICABLE"]
_EVIDENCE_SCORE: dict[str, float] = {
"declared": 1.0,
"silicon": 0.9,
"part": 0.85,
"pinout": 0.7,
"net_name": 0.55,
"pair_geometry": 0.4,
}
_USB_C_RE = re.compile(r"USB[\s_\-]*C(?:[\s_\-]|\b|$)|TYPE[\s_\-]*C|USB4085", re.I)
_USB2_RE = re.compile(r"USB[\s_\-]*2|16P", re.I)
_USB3_RE = re.compile(r"USB[\s_\-]*3|SUPER\s*SPEED|SSTX|SSRX|SS_T[XR]", re.I)
_USB4_RE = re.compile(r"USB[\s_\-]*4|\bUSB4\b|TBT4|THUNDERBOLT\s*4", re.I)
_USB_HS_RE = re.compile(r"HIGH\s*SPEED|\b480\b", re.I)
_USB_LSFS_RE = re.compile(r"LOW\s*SPEED|FULL\s*SPEED|\b12\s*MB|\b1\.5\s*MB", re.I)
_USB_SILICON_RE = re.compile(
r"CH340|CP210|FT232|USB\s*PHY|USB\s*CTRL|USB3300|TUSB|ISP150|MAX3421", re.I,
)
_RJ45_RE = re.compile(r"RJ45|8P8C|MAGJACK", re.I)
_ETH_PHY_RE = re.compile(
r"88E\d|LAN87|DP838|RTL821|KSZ90|BCM54|ETHERNET\s*PHY|\bPHY\b.*ETH|ETH.*PHY", re.I,
)
_ETH_MAG_RE = re.compile(r"MAGNETIC|PULSE\s*JACK|ETHERNET\s*XFR|LAN_XFR", re.I)
_HDMI_MINI_RE = re.compile(r"MINI\s*HDMI|HDMI[\s_\-]*C\b|HDMI[\s_\-]*MINI", re.I)
_HDMI_RE = re.compile(r"\bHDMI\b", re.I)
_HDMI_FRL_RE = re.compile(r"\bFRL\b|HDMI\s*2\.1", re.I)
_HDMI_TMDS_NET = re.compile(r"HDMI[_]?TX|HDMI[_]?CLK|HDMI[_]?D\d|TMDS", re.I)
_DVI_RE = re.compile(r"\bDVI\b|DVI[\s_\-]*[DAI]", re.I)
_DDR_GEN = [
("ddr5", re.compile(r"\bDDR5\b", re.I)),
("ddr4", re.compile(r"\bDDR4\b|MT41|K4A|IS43TR", re.I)),
("ddr3l", re.compile(r"\bDDR3L\b", re.I)),
("ddr3", re.compile(r"\bDDR3\b|MT41|K4B|IS43TR", re.I)),
("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)),
("ddr1", re.compile(r"\bDDR1\b|\bDDR\b", re.I)),
]
_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I)
_AXI_RE = re.compile(r"\bAXI4(?:-LITE|-STREAM)?\b|\bAXI\b", re.I)
_AXI_C2C_RE = re.compile(r"CHIP[\s\-]*TO[\s\-]*CHIP|C2C|AXI.*PHY", re.I)
_DECLARE_RE = re.compile(
r"^(?:protocol|logical_protocol|physical_interface|bus)$", re.I,
)
_DQ_RE = re.compile(r"(?:^|[_/.])DQ(\d+)(?:$|[_/.])", re.I)
_DQS_RE = re.compile(r"(?:^|[_/.])DQS(\d*)(?:[_#]?(P|N|PINS|PLUS|MINUS))?(?:$|[_/.])", re.I)
_DM_RE = re.compile(r"(?:^|[_/.])(?:DM|DBI)(\d+)(?:$|[_/.])", re.I)
_CK_RE = re.compile(r"(?:^|[_/.])(?:CK|CLK)(?:[_#]?(P|N))?(?:$|[_/.])", re.I)
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I)
_CMD_RE = re.compile(
r"(?:^|[_/.])(?:RAS|CAS|WE|ACT|CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])",
re.I,
)
_USB_DP_RE = re.compile(
r"(?:^|[_/.])(?:USB[_]?D\+|USB[_]?DP|D\+)$", re.I,
)
_USB_DN_RE = re.compile(
r"(?:^|[_/.])(?:USB[_]?D\-|USB[_]?DM|D\-)$", re.I,
)
class SignalGroup(BaseModel):
kind: GroupKind
id: str
nets: list[str] = Field(default_factory=list)
roles: dict[str, str] = Field(default_factory=dict)
class PhysicalBusInstance(BaseModel):
instance_id: str
logical_protocol_id: str
physical_interface_id: str
pcb_relevant: Literal["YES", "NO", "CONDITIONAL"]
confidence: float
evidence_kind: EvidenceKind
recognition_status: RecognitionStatus
nets: list[str] = Field(default_factory=list)
groups: list[SignalGroup] = Field(default_factory=list)
host_ref: str = ""
peer_refs: list[str] = Field(default_factory=list)
ambiguous_logical_ids: list[str] = Field(default_factory=list)
notes: str = ""
connector: str = ""
physical_implementation: str = ""
def _leaf(name: str) -> str:
n = normalize_kicad_hierarchy_net(name)
return n.split("/")[-1] if n else ""
def _blob(comp: Component, graph: DesignGraph) -> str:
extra = []
for bag in (
(graph.bom_fields or {}).get(comp.reference) or {},
(graph.schematic_fields or {}).get(comp.reference) or {},
):
extra.extend(str(v) for v in bag.values() if v)
specs = ""
if comp.specs is not None and getattr(comp.specs, "values", None):
specs = " ".join(str(v) for v in comp.specs.values.values() if v)
return " ".join(
str(x or "")
for x in (
comp.mpn, comp.value, comp.footprint, comp.component_subtype, specs, *extra,
)
)
def _declared_ids(graph: DesignGraph) -> list[tuple[str, str, str]]:
"""(ref, logical_or_physical_id, raw)."""
hits: list[tuple[str, str, str]] = []
bags: list[tuple[str, dict]] = []
for ref, row in (graph.bom_fields or {}).items():
bags.append((ref, row or {}))
for ref, row in (graph.schematic_fields or {}).items():
bags.append((ref, row or {}))
for ref, comp in graph.components.items():
if comp.specs is not None and getattr(comp.specs, "values", None):
bags.append((ref, dict(comp.specs.values)))
for ref, row in bags:
for key, val in row.items():
if val is None:
continue
if not _DECLARE_RE.match(str(key)):
continue
hits.append((ref, str(val).strip().lower(), str(val)))
return hits
def _pn_pairs(net_names: list[str]) -> list[tuple[str, str]]:
by_leaf = {_leaf(n): n for n in net_names if n}
pairs: list[tuple[str, str]] = []
used: set[str] = set()
for leaf, full in by_leaf.items():
if full in used:
continue
mate_leaf = None
if leaf.endswith("+"):
mate_leaf = leaf[:-1] + "-"
elif leaf.endswith("_P") or leaf.endswith("_p"):
mate_leaf = leaf[:-2] + "_N"
elif leaf.upper().endswith("DP"):
stem = leaf[:-2]
mate_leaf = stem + ("DM" if leaf[-2].isupper() else "dm")
if mate_leaf and mate_leaf in by_leaf:
other = by_leaf[mate_leaf]
pairs.append((full, other))
used.add(full)
used.add(other)
return pairs
def _usb_pairs(net_names: list[str]) -> list[tuple[str, str]]:
plus = [n for n in net_names if _USB_DP_RE.search(_leaf(n))]
minus = [n for n in net_names if _USB_DN_RE.search(_leaf(n))]
found: list[tuple[str, str]] = []
used: set[str] = set()
generic = _pn_pairs(net_names)
for a, b in generic:
la, lb = _leaf(a).upper(), _leaf(b).upper()
if "USB" in la or "USB" in lb or la.startswith("D") or lb.startswith("D"):
found.append((a, b))
used.add(a)
used.add(b)
for p in plus:
if p in used:
continue
pl = _leaf(p)
for m in minus:
if m in used:
continue
if pl.replace("+", "-").replace("DP", "DM").replace("_P", "_N") == _leaf(m):
found.append((p, m))
used.add(p)
used.add(m)
break
if not found and len(plus) == 1 and len(minus) == 1:
found.append((plus[0], minus[0]))
return found
def _byte_lanes(net_names: list[str]) -> list[SignalGroup]:
dq: dict[int, str] = {}
dqs: dict[int, dict[str, str]] = {}
dm: dict[int, str] = {}
for n in net_names:
leaf = _leaf(n)
m = _DQ_RE.search(leaf)
if m and "DQS" not in leaf.upper():
dq[int(m.group(1))] = n
continue
m = _DQS_RE.search(leaf)
if m:
idx = int(m.group(1) or "0")
pol = (m.group(2) or "P").upper()[:1]
dqs.setdefault(idx, {})[pol] = n
continue
m = _DM_RE.search(leaf)
if m:
dm[int(m.group(1))] = n
lanes: list[SignalGroup] = []
lane_ids = set(i // 8 for i in dq) | set(dqs) | set(dm)
for lane in sorted(lane_ids):
roles: dict[str, str] = {}
nets: list[str] = []
for bit in range(lane * 8, lane * 8 + 8):
if bit in dq:
roles[f"DQ{bit}"] = dq[bit]
nets.append(dq[bit])
if lane in dqs:
for pol, net in dqs[lane].items():
roles[f"DQS{lane}_{pol}"] = net
nets.append(net)
if lane in dm:
roles[f"DM{lane}"] = dm[lane]
nets.append(dm[lane])
if nets:
lanes.append(SignalGroup(
kind="BYTE_LANE", id=f"BYTE_LANE_{lane}", nets=nets, roles=roles,
))
return lanes
def _iface_map(catalog: ProtocolCatalog) -> dict[str, Any]:
return {p.id: p for p in catalog.physical_interfaces}
def _logical_ids(catalog: ProtocolCatalog) -> set[str]:
return {p.id for p in catalog.logical_protocols}
def recognize_physical_buses(
graph: DesignGraph,
catalog: ProtocolCatalog | None = None,
) -> list[PhysicalBusInstance]:
cat = catalog or load_catalog()
ifaces = _iface_map(cat)
logical = _logical_ids(cat)
out: list[PhysicalBusInstance] = []
out.extend(_recognize_declared(graph, ifaces, logical))
out.extend(_recognize_usb(graph, ifaces))
out.extend(_recognize_ethernet(graph, ifaces))
out.extend(_recognize_dvi(graph, ifaces))
out.extend(_recognize_hdmi(graph, ifaces))
out.extend(_recognize_hbm(graph, ifaces))
out.extend(_recognize_ddr(graph, ifaces))
out.extend(_recognize_axi(graph, ifaces))
return _dedupe_instances(out)
def _recognize_declared(
graph: DesignGraph,
ifaces: dict,
logical: set[str],
) -> list[PhysicalBusInstance]:
out: list[PhysicalBusInstance] = []
for ref, ident, raw in _declared_ids(graph):
ident = ident.replace("_", "-")
phys_id = None
log_id = None
if ident in ifaces:
phys_id = ident
log_id = ifaces[ident].logical_protocol_id
elif ident in logical:
log_id = ident
for pid, iface in ifaces.items():
if iface.logical_protocol_id == ident and iface.pcb_relevant == "NO":
phys_id = pid
break
if phys_id is None:
for pid, iface in ifaces.items():
if iface.logical_protocol_id == ident:
phys_id = pid
break
if not phys_id or not log_id:
continue
iface = ifaces[phys_id]
nets: list[str] = []
if iface.pcb_relevant != "NO":
comp = graph.components.get(ref)
if comp:
nets = sorted({n for n in comp.pins.values() if n})
out.append(PhysicalBusInstance(
instance_id=f"{phys_id}:{ref}",
logical_protocol_id=log_id,
physical_interface_id=phys_id,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["declared"],
evidence_kind="declared",
recognition_status=(
"NOT_APPLICABLE" if iface.pcb_relevant == "NO" else "RECOGNIZED"
),
nets=nets,
host_ref=ref,
notes=f"declared {raw}",
))
return out
def _recognize_usb(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
instances: list[PhysicalBusInstance] = []
type_c = []
other_usb_conn = []
for comp in graph.components.values():
if comp.component_type != ComponentType.CONNECTOR:
continue
blob = _blob(comp, graph)
if _USB_C_RE.search(blob) or _USB_C_RE.search(comp.footprint or ""):
type_c.append(comp)
elif re.search(r"USB", blob, re.I) or re.search(r"USB", comp.footprint or "", re.I):
other_usb_conn.append(comp)
pairs = _usb_pairs(list(graph.nets))
silicon = [
c for c in graph.components.values()
if c.component_type == ComponentType.IC and (
_USB_SILICON_RE.search(_blob(c, graph))
or (pairs and any(
n in (c.pins or {}).values() for n in [p for pair in pairs for p in pair]
))
)
]
ss_nets = [n for n in graph.nets if _USB3_RE.search(_leaf(n)) or _USB3_RE.search(n)]
usb4_ev = any(_USB4_RE.search(_blob(c, graph)) for c in graph.components.values())
usb3_ev = bool(ss_nets) or any(
_USB3_RE.search(_blob(c, graph)) for c in [*type_c, *silicon]
)
if not type_c and not other_usb_conn and not pairs:
return []
hs = any(_USB_HS_RE.search(_blob(c, graph)) for c in [*type_c, *other_usb_conn, *silicon])
lsfs = any(_USB_LSFS_RE.search(_blob(c, graph)) for c in [*type_c, *other_usb_conn, *silicon])
if hs and not lsfs:
usb2_log, usb2_phys = "usb2-hs", "usb2-hs-dpair"
elif lsfs and not hs:
usb2_log, usb2_phys = "usb2-ls-fs", "usb2-ls-fs-dpair"
else:
usb2_log, usb2_phys = "usb2-hs", "usb2-hs-dpair"
groups = [
SignalGroup(
kind="DIFFERENTIAL_PAIR",
id=f"USB2_D_{i}",
nets=[a, b],
roles={"D+": a, "D-": b},
)
for i, (a, b) in enumerate(pairs)
]
nets = sorted({n for g in groups for n in g.nets})
if type_c:
host = type_c[0].reference
pin_notes = _usb_c_orientation_note(type_c[0], graph)
combo = bool(pairs) and bool(silicon)
# Connector alone does not declare USB 2.0 / 3.x / USB4.
if not combo:
iface = ifaces["usb-c-receptacle"]
instances.append(PhysicalBusInstance(
instance_id=f"usb-c-receptacle:{host}",
logical_protocol_id="usb-c",
physical_interface_id="usb-c-receptacle",
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in type_c],
notes=(
"USB Type-C connector only; protocol not inferred "
"(need nets + silicon + topology). Not USB3. Not USB4. "
+ pin_notes
),
connector="Type-C",
physical_implementation="",
))
return instances
iface = ifaces["usb-c-usb2-receptacle"]
amb = [] if hs ^ lsfs else ["usb2-ls", "usb2-fs", "usb2-hs"]
status: RecognitionStatus = "REVIEW" if amb or usb3_ev else "RECOGNIZED"
notes = (
"USB 2.0 over USB Type-C (connector+nets+silicon). "
"Type-C is the connector, USB 2.0 is the protocol. "
+ pin_notes
)
if usb3_ev:
notes += " SuperSpeed evidence present — USB3 is a separate implementation, not implied by Type-C."
amb = list(dict.fromkeys(amb + ["usb3-x"]))
instances.append(PhysicalBusInstance(
instance_id=f"usb-c-usb2-receptacle:{host}",
logical_protocol_id=usb2_log,
physical_interface_id="usb-c-usb2-receptacle",
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["silicon"],
evidence_kind="silicon",
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in type_c] + [s.reference for s in silicon],
ambiguous_logical_ids=amb,
notes=notes,
connector="Type-C",
physical_implementation="USB 2.0 over USB Type-C",
))
if usb3_ev and "usb3-over-usb-c" in ifaces:
instances.append(PhysicalBusInstance(
instance_id=f"usb3-over-usb-c:{host}",
logical_protocol_id="usb3-x",
physical_interface_id="usb3-over-usb-c",
pcb_relevant="YES",
confidence=_EVIDENCE_SCORE["net_name"],
evidence_kind="net_name",
recognition_status="REVIEW",
nets=sorted(ss_nets),
host_ref=host,
notes="USB 3.x over Type-C from SS evidence; not USB4; not implied by connector alone.",
connector="Type-C",
physical_implementation="USB 3.x over USB Type-C",
))
if usb4_ev and "usb4-over-usb-c" in ifaces:
instances.append(PhysicalBusInstance(
instance_id=f"usb4-over-usb-c:{host}",
logical_protocol_id="usb4",
physical_interface_id="usb4-over-usb-c",
pcb_relevant="YES",
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
host_ref=host,
notes="USB4 evidence distinct from Type-C and USB3.",
connector="Type-C",
physical_implementation="USB4 over USB Type-C",
))
return instances
if not pairs:
return []
host = other_usb_conn[0].reference if other_usb_conn else (
silicon[0].reference if silicon else (nets[0] if nets else "usb")
)
if not other_usb_conn and not silicon:
# Pairs without connector or silicon: net-name only, REVIEW.
evidence: EvidenceKind = "net_name"
conf = _EVIDENCE_SCORE["net_name"]
status = "REVIEW"
notes = "USB 2.0 inferred from D+/D nets only; connector+silicon missing."
else:
evidence = "silicon" if silicon else "part"
conf = _EVIDENCE_SCORE[evidence]
status = "REVIEW" if not (hs ^ lsfs) else "RECOGNIZED"
notes = "USB 2.0 from connector+nets+silicon/topology. No invented Z."
iface = ifaces[usb2_phys]
instances.append(PhysicalBusInstance(
instance_id=f"{usb2_phys}:{host}",
logical_protocol_id=usb2_log,
physical_interface_id=usb2_phys,
pcb_relevant=iface.pcb_relevant,
confidence=conf,
evidence_kind=evidence,
recognition_status=status,
nets=nets,
groups=groups,
host_ref=host,
peer_refs=[c.reference for c in other_usb_conn] + [s.reference for s in silicon],
ambiguous_logical_ids=["usb2-ls-fs", "usb2-hs"] if status == "REVIEW" else [],
notes=notes,
connector="",
physical_implementation="USB 2.0",
))
return instances
def _usb_c_orientation_note(comp: Component, graph: DesignGraph) -> str:
"""D+/D from schematic pins; do not assume a mux mapping."""
a_side = []
b_side = []
for pin, net in (comp.pins or {}).items():
pu = pin.upper()
if pu in {"A6", "A7"} and net:
a_side.append(f"{pin}={net}")
if pu in {"B6", "B7"} and net:
b_side.append(f"{pin}={net}")
if a_side and b_side:
return (
f" D+/D both orientations on schematic ({', '.join(a_side + b_side)}); "
f"mux/switch not assumed."
)
if a_side or b_side:
return (
f" D+/D orientation from schematic pins {', '.join(a_side or b_side)}; "
f"opposite-side mux not assumed."
)
return " D+/D orientation taken from schematic; mux not assumed."
def _recognize_ethernet(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
rj45 = [
c for c in graph.components.values()
if c.component_type == ComponentType.CONNECTOR
and (_RJ45_RE.search(_blob(c, graph)) or _RJ45_RE.search(c.footprint or ""))
]
phys = [
c for c in graph.components.values()
if c.component_type == ComponentType.IC and _ETH_PHY_RE.search(_blob(c, graph))
]
mags = [c for c in graph.components.values() if _ETH_MAG_RE.search(_blob(c, graph))]
named = " ".join(_blob(c, graph) for c in graph.components.values()) + " " + " ".join(graph.nets)
if not rj45 and not phys and not re.search(r"RGMII|SGMII|1000BASE|10BASE|100BASE", named, re.I):
return []
if rj45 and not phys and not mags and not re.search(
r"1000BASE-T|100BASE-TX|10BASE-T|SGMII|RGMII", named, re.I,
):
iface = ifaces.get("ethernet-rj45-unspecified")
if iface is None:
return []
host = rj45[0].reference
return [PhysicalBusInstance(
instance_id=f"ethernet-rj45-unspecified:{host}",
logical_protocol_id="ethernet-unspecified",
physical_interface_id="ethernet-rj45-unspecified",
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
host_ref=host,
peer_refs=[c.reference for c in rj45],
notes="RJ45 connector alone is not 100BASE-TX or 1000BASE-T.",
connector="RJ45",
physical_implementation="",
)]
lid, pid = "100base-tx", "100base-tx-mdi"
if re.search(r"1000BASE-T|\bGBE\b|GIGABIT", named, re.I):
lid, pid = "1000base-t", "1000base-t-mdi"
elif re.search(r"10GBASE-T", named, re.I):
lid, pid = "10gbase-t", "10gbase-t-mdi"
elif re.search(r"2\.5GBASE", named, re.I):
lid, pid = "2.5gbase-t", "2.5gbase-t-mdi"
elif re.search(r"5GBASE-T", named, re.I):
lid, pid = "5gbase-t", "5gbase-t-mdi"
elif re.search(r"SGMII", named, re.I):
lid, pid = "sgmii", "sgmii-pcb"
elif re.search(r"1000BASE-X", named, re.I):
lid, pid = "1000base-x", "1000base-x-pcb"
elif re.search(r"100BASE-FX", named, re.I):
lid, pid = "100base-fx", "100base-fx-pcb"
elif re.search(r"10GBASE-R", named, re.I):
lid, pid = "10gbase-r", "10gbase-r-pcb"
elif re.search(r"10BASE-T", named, re.I):
lid, pid = "10base-t", "10base-t-mdi"
elif re.search(r"100BASE-TX", named, re.I):
lid, pid = "100base-tx", "100base-tx-mdi"
else:
return []
iface = ifaces.get(pid)
if iface is None:
return []
host = (phys[0].reference if phys else (rj45[0].reference if rj45 else lid))
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id=lid,
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["silicon"] if phys else _EVIDENCE_SCORE["part"],
evidence_kind="silicon" if phys else "part",
recognition_status="REVIEW",
host_ref=host,
notes="Ethernet variant from PHY/magnetics/naming; IEEE vs PHY vs PCB vs magnetics stay distinct. Incomplete without IEEE 802.3 on file.",
connector=iface.connector,
physical_implementation=lid,
)]
def _recognize_dvi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
conns = [
c for c in graph.components.values()
if c.component_type == ComponentType.CONNECTOR and (
_DVI_RE.search(_blob(c, graph)) or _DVI_RE.search(c.footprint or "")
)
]
if not conns:
return []
pid = "dvi-1.0-tmds"
iface = ifaces.get(pid)
if iface is None:
return []
host = conns[0].reference
tmds = [
n for n in graph.nets
if _HDMI_TMDS_NET.search(n) or _HDMI_TMDS_NET.search(_leaf(n)) or _DVI_RE.search(n)
]
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id="dvi",
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW" if not tmds else "RECOGNIZED",
nets=tmds,
host_ref=host,
notes="DVI 1.0 TMDS. Not HDMI. Connector is not HDMI Type A.",
connector="DVI",
physical_implementation="DVI 1.0 TMDS",
)]
def _recognize_hdmi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
conns = [
c for c in graph.components.values()
if c.component_type == ComponentType.CONNECTOR and (
_HDMI_RE.search(_blob(c, graph)) or _HDMI_RE.search(c.footprint or "")
or _HDMI_MINI_RE.search(_blob(c, graph))
)
]
tmds = [n for n in graph.nets if _HDMI_TMDS_NET.search(n) or _HDMI_TMDS_NET.search(_leaf(n))]
blob = " ".join(_blob(c, graph) for c in graph.components.values())
dvi_present = any(
_DVI_RE.search(_blob(c, graph)) or _DVI_RE.search(c.footprint or "")
for c in graph.components.values()
)
if dvi_present and not re.search(r"\bHDMI\b", blob, re.I):
return []
frl = bool(_HDMI_FRL_RE.search(blob) or any(_HDMI_FRL_RE.search(n) for n in graph.nets))
mini = any(
_HDMI_MINI_RE.search(_blob(c, graph)) or _HDMI_MINI_RE.search(c.footprint or "")
for c in conns
)
if not conns and not tmds and not frl:
return []
if conns and not tmds and not frl:
host = conns[0].reference
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
pid = "hdmi-1.4-tmds-type-c-mini" if mini else "hdmi-1.4-tmds-type-a"
iface = ifaces.get(pid)
if iface is None:
return []
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id="hdmi-1.4",
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["part"],
evidence_kind="part",
recognition_status="REVIEW",
host_ref=host,
notes=(
"HDMI connector only; Mini HDMI is CONNECTOR not a protocol. "
"Version/mode not assumed from connector. TMDS vs FRL not inferred."
),
connector=conn_name,
physical_implementation="",
)]
if frl:
lid, pid = "hdmi-frl", "hdmi-frl-pcb"
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
else:
lid = "hdmi-1.4"
pid = "hdmi-1.4-tmds-type-c-mini" if mini else "hdmi-1.4-tmds-type-a"
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
if re.search(r"HDMI\s*2\.0|HDMI2\.0", blob, re.I):
lid = "hdmi-2.0"
pid = "hdmi-2.0-tmds-type-c-mini" if mini else "hdmi-2.0-tmds-type-a"
iface = ifaces.get(pid)
if iface is None:
return []
host = conns[0].reference if conns else (tmds[0] if tmds else lid)
mode = "FRL" if frl else "TMDS"
return [PhysicalBusInstance(
instance_id=f"{pid}:{host}",
logical_protocol_id=lid,
physical_interface_id=pid,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["net_name"],
evidence_kind="net_name",
recognition_status="REVIEW",
nets=tmds,
host_ref=host,
notes=(
f"HDMI {mode} electrical; connector={conn_name}. "
f"Mini HDMI is not a protocol id. TMDS rules are not applied to FRL."
),
connector=conn_name,
physical_implementation=f"HDMI {mode}",
)]
def _ddr_gen_from_text(text: str) -> str | None:
if _DDR4_PART.search(text):
return "ddr4"
for gen, cre in _DDR_GEN:
if cre.search(text):
return gen
return None
def _recognize_ddr(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
from backend.periscopex.protocol_ddr import recognize_ddr_instances
return recognize_ddr_instances(graph, ifaces)
def _recognize_hbm(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
from backend.periscopex.protocol_ddr import recognize_hbm
return recognize_hbm(graph, ifaces)
def _recognize_axi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
hits: list[Component] = []
chip_to_chip = False
for comp in graph.components.values():
blob = _blob(comp, graph)
if _AXI_RE.search(blob):
hits.append(comp)
if _AXI_C2C_RE.search(blob):
chip_to_chip = True
declared = any(i == "axi4" or i.startswith("axi4") for _, i, _ in _declared_ids(graph))
if not hits and not declared:
# Net names alone (AXI_AWVALID) do not invent a PCB AXI bus.
return []
if chip_to_chip:
iface = ifaces["axi4-chip-to-chip-phy"]
host = hits[0].reference if hits else "axi4"
return [PhysicalBusInstance(
instance_id=f"{iface.id}:{host}",
logical_protocol_id="axi4",
physical_interface_id=iface.id,
pcb_relevant=iface.pcb_relevant,
confidence=_EVIDENCE_SCORE["silicon"],
evidence_kind="silicon",
recognition_status="REVIEW",
nets=[],
host_ref=host,
notes="AXI chip-to-chip is CONDITIONAL; PHY docs required. No AXI nets assumed.",
)]
iface = ifaces["axi4-internal"]
host = hits[0].reference if hits else "axi4"
return [PhysicalBusInstance(
instance_id=f"{iface.id}:{host}",
logical_protocol_id="axi4",
physical_interface_id=iface.id,
pcb_relevant="NO",
confidence=_EVIDENCE_SCORE["silicon"],
evidence_kind="silicon",
recognition_status="NOT_APPLICABLE",
nets=[],
groups=[],
host_ref=host,
peer_refs=[c.reference for c in hits],
notes="AXI4 is an on-chip interconnect. No PCB nets.",
)]
def _dedupe_instances(items: list[PhysicalBusInstance]) -> list[PhysicalBusInstance]:
by_id: dict[str, PhysicalBusInstance] = {}
for inst in items:
prev = by_id.get(inst.instance_id)
if prev is None or inst.confidence > prev.confidence:
by_id[inst.instance_id] = inst
return sorted(by_id.values(), key=lambda i: (-i.confidence, i.instance_id))
def protocol_section_for_graph(
graph: DesignGraph | None,
catalog: ProtocolCatalog | None = None,
) -> ProtocolCertificationSection:
from backend.periscopex.protocol_l0 import protocol_exam
return protocol_exam(graph, catalog)[0]
@@ -0,0 +1,210 @@
"""M5/M9: protocol report view — checks, chain, FAIL before WARNING, visible skips.
Does not invent Z or S-parameters. L3 uses existing channel FACT only.
OpenEMS/FEM out of product. Does not add pack numbers.
L0/L1 are not electrical certification.
"""
from __future__ import annotations
from typing import Any
from backend.periscopex.protocol_catalog import PhysicalInterface, ProtocolConstraint
from backend.periscopex.protocol_l0 import _constraint_for
from backend.periscopex.protocol_recognize import PhysicalBusInstance
PACK_MACROPHASE = "M9"
_SKIP = frozenset({
"UNKNOWN", "MISSING_SOURCE", "VENDOR_DEPENDENT",
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
})
L3_SKIP_NOTE = (
"L3 channel not run (no channel/S-param data; OpenEMS out of scope). Not PASS."
)
def result_rank(result: str) -> int:
"""FAIL is always first. Never bury FAIL under WARNING (HubAudio)."""
if result == "FAIL":
return 0
if result == "WARNING" or result in _SKIP:
return 1
if result == "NOT_APPLICABLE":
return 2
return 3
def instance_worst_result(results: list[str]) -> str:
if not results:
return "UNKNOWN"
return min(results, key=result_rank)
def _unit(dump: dict[str, Any]) -> str:
if dump.get("measured_mm") is not None or dump.get("limit_mm") is not None:
return "mm"
if dump.get("measured_ohm") is not None or dump.get("limit_ohm") is not None:
return "ohm"
if dump.get("measured_db") is not None or dump.get("limit_db") is not None:
return "dB"
if (
dump.get("measured_ps") is not None
or dump.get("limit_ps") is not None
or dump.get("remaining_margin_ps") is not None
):
return "ps"
return ""
def _measured(dump: dict[str, Any]) -> float | None:
for k in ("measured_ohm", "measured_mm", "measured_db", "measured_ps", "remaining_margin_ps"):
v = dump.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _limit(dump: dict[str, Any]) -> float | None:
for k in ("limit_ohm", "limit_mm", "limit_ohm_max", "limit_db", "limit_ps"):
v = dump.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _margin(dump: dict[str, Any]) -> float | None:
for k in ("margin_ohm", "margin_mm", "margin_db", "margin_ps", "remaining_margin_ps"):
v = dump.get(k)
if isinstance(v, (int, float)):
return float(v)
return None
def _group_for_net(inst: PhysicalBusInstance, net: str | None) -> str:
if not net:
if inst.groups:
return inst.groups[0].id or inst.groups[0].kind
return ""
for g in inst.groups:
if net in g.nets:
return g.id or g.kind
return ""
def _cite(cons: ProtocolConstraint | None) -> tuple[str, str, str]:
if cons is None:
return "", "", ""
src = cons.source
doc = ""
section = ""
if src is not None:
doc = src.document or src.organization or ""
section = src.section or src.table or (src.page or "")
method = cons.measurement_method or ""
return doc, section, method
def check_to_report_row(
dump: dict[str, Any],
inst: PhysicalBusInstance,
iface: PhysicalInterface | None,
) -> dict[str, Any]:
check = str(dump.get("check") or "")
result = str(dump.get("result") or "")
cons = _constraint_for(iface, check) if iface is not None else None
doc, section, method = _cite(cons)
nets = list(dump.get("nets") or [])
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
group = _group_for_net(inst, net or None)
skip = result in _SKIP or "non certificata" in str(dump.get("notes") or "")
row = {
"check": check,
"level": dump.get("level") or "",
"result": result,
"mandatory": dump.get("mandatory") or "",
"measured": _measured(dump),
"limit": _limit(dump),
"margin": _margin(dump),
"unit": _unit(dump),
"source": doc,
"method": method,
"notes": dump.get("notes") or "",
"skip_visible": skip,
"rank": result_rank(result),
"chain": {
"net": net,
"group": group,
"physical_interface_id": inst.physical_interface_id,
"logical_protocol_id": inst.logical_protocol_id,
"constraint_id": cons.id if cons else "",
"document": doc,
"section": section,
},
}
return row
def l3_not_run_row(inst: PhysicalBusInstance) -> dict[str, Any]:
return {
"check": "channel",
"level": "L3",
"result": "UNKNOWN",
"mandatory": "INFORMATIONAL",
"measured": None,
"limit": None,
"margin": None,
"unit": "",
"source": "",
"method": "",
"notes": L3_SKIP_NOTE,
"skip_visible": True,
"rank": result_rank("UNKNOWN"),
"chain": {
"net": inst.nets[0] if inst.nets else "",
"group": inst.groups[0].id if inst.groups else "",
"physical_interface_id": inst.physical_interface_id,
"logical_protocol_id": inst.logical_protocol_id,
"constraint_id": "",
"document": "",
"section": "",
},
}
def format_chain(chain: dict[str, Any]) -> str:
keys = (
"net", "group", "physical_interface_id", "logical_protocol_id",
"constraint_id", "document", "section",
)
parts = [str(chain.get(k) or "") for k in keys]
return "".join(parts)
def attach_instance_report(
inst: PhysicalBusInstance,
iface: PhysicalInterface | None,
l0: list[Any],
l1: list[Any],
l2: list[Any],
l3: list[Any] | None = None,
) -> dict[str, Any]:
dumps = (
[c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l0]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l1]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l2]
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in (l3 or [])]
)
rows = [check_to_report_row(d, inst, iface) for d in dumps]
if not any(r["level"] == "L3" for r in rows):
rows.append(l3_not_run_row(inst))
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
return {
"report_checks": rows,
"worst_result": worst,
"fail_count": sum(1 for r in rows if r["result"] == "FAIL"),
"skip_count": sum(1 for r in rows if r.get("skip_visible")),
"chain_example": format_chain(rows[0]["chain"]) if rows else "",
}
+22 -3
View File
@@ -1,9 +1,9 @@
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder.
USB2 / USB3 SuperSpeed / Ethernet MDI / RGMII / SGMII / DDR3 / HDMI /
PCIe / LVDS are checked only against a rule whose quote or ``net_class``
names that bus. I2C, GPIO, EN, analog REGN, USB CC, and strap/EN RC are
not HS pairs. No invented USB/IEC 90 Ω.
PCIe / LVDS / MIPI / HF clock are checked only against a rule whose quote
or ``net_class`` names that bus. I2C, GPIO, EN, analog REGN, USB CC,
strap/EN RC, and crystal XTAL nets are not HS pairs. No invented USB/IEC 90 Ω.
"""
from __future__ import annotations
@@ -30,6 +30,11 @@ _SKIP_RE = re.compile(
re.I,
)
_XTAL_RE = re.compile(
r"XTAL|OSCIN|OSCOUT|HFXIN|LFXIN|\bXIN\b|\bXOUT\b",
re.I,
)
_HS_CLASS_RE = (
("usb3", re.compile(
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
@@ -43,6 +48,7 @@ _HS_CLASS_RE = (
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
re.I,
)),
("mipi", re.compile(r"MIPI|\bDSI\b|\bCSI\b|CSI[_-]|DSI[_-]", re.I)),
("lvds", re.compile(r"LVDS", re.I)),
("ddr3_clk", re.compile(r"DDR3?.*CK|(?:^|[_/])CK[_]?[PN](?:$|[_/])", re.I)),
("ddr3_dqs", re.compile(r"DQS", re.I)),
@@ -51,6 +57,10 @@ _HS_CLASS_RE = (
r"DDR3?.*(?:A\d+|ADDR|BA\d+|RAS|CAS|WE|ODT|CKE|(?:^|[_/])CS)",
re.I,
)),
("hf_clk", re.compile(
r"GTX_CLK|(?:^|[_/])CLK[_]?[PN](?:$|[_/])|(?:^|[_/])MCLK[_]?[PN]",
re.I,
)),
)
_STRAP_RULE_RE = re.compile(
@@ -94,6 +104,11 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
"hdmi": frozenset({"hdmi"}),
"pcie": frozenset({"pcie"}),
"lvds": frozenset({"lvds"}),
"mipi": frozenset({"mipi"}),
"dsi": frozenset({"mipi"}),
"csi": frozenset({"mipi"}),
"hf_clk": frozenset({"hf_clk"}),
"clock": frozenset({"hf_clk"}),
}
_SI_KINDS = frozenset({
@@ -151,6 +166,8 @@ def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if _XTAL_RE.search(leaf):
return None
u = leaf.upper()
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
return "usb3"
@@ -217,6 +234,8 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
(r"hdmi", "hdmi"),
(r"pcie|pci[\s-]*express", "pcie"),
(r"lvds", "lvds"),
(r"mipi|\bdsi\b|\bcsi\b", "mipi"),
(r"gtx_clk|hf\s*clock|ref\s*clk", "hf_clk"),
(r"usb\s*2|d\s*\+|d\s*|d\s*-|dp\s*/\s*dm|dp/dm", "usb2"),
(r"\busb\b", "usb2"),
(r"\bethernet\b|\beth\b", "eth_mdi"),
@@ -0,0 +1,63 @@
"""Stackup vs fab spec. Skip without a sourced fab FACT. Never default 1 oz."""
from __future__ import annotations
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
from backend.periscopex.si_check import _num, _quote
SOURCE = "stackup_check"
def check_stackup(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or layout.stackup is None:
return []
board_t = layout.stackup.copper_thickness_mm
fab_t = None
quote = ""
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
for rule in cons.layout_rules or []:
if str(rule.get("kind") or "") != "stackup":
continue
fab_t = _num(rule.get("copper_thickness_mm"))
quote = _quote(rule)
if fab_t is not None:
break
if fab_t is not None:
break
if fab_t is None or board_t is None or board_t <= 0:
return []
same = abs(board_t - fab_t) <= 1e-9
rec = (
"Match the board stackup copper thickness to the fab spec, then re-run."
if not same else "Board copper thickness matches the fab spec."
)
return [Finding(
designator="layout", mpn="", aspect="stackup",
finding=(
f"{'PASS' if same else 'REVIEW'}: board copper_thickness_mm={board_t:g} "
f"vs fab {fab_t:g} mm."
),
facts=(
f"board_copper_thickness_mm={board_t:g}; fab_copper_thickness_mm={fab_t:g}; "
f"copper_layers={layout.stackup.copper_layers}."
),
requirement=f"Fab stackup copper thickness {fab_t:g} mm ({quote or 'layout_rules'}).",
inference="" if same else "REVIEW — no invented 1 oz default or IPC copper weight.",
why="Compare parsed KiCad stackup to a sourced fab thickness only.",
status="INFO" if same else "WARNING",
recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-STK-001",
finding_class="INFO" if same else "REVIEW",
provenance="TYPICAL",
evidence_status="SUFFICIENT", pins=[],
)]
@@ -0,0 +1,41 @@
"""USB-PD power contract. Skip without a PD controller. Not SI. Not USB2 Rd."""
from __future__ import annotations
import re
from backend.periscopex.models import ComponentType, DesignGraph, Finding
SOURCE = "usb_pd_check"
_PD_RE = re.compile(
r"FUSB302|STUSB|IP2721|TPS257|CYPD|CH224|HUSB238|"
r"USB[\s\-]?PD|POWER\s*DELIVERY",
re.I,
)
def check_usb_pd(graph: DesignGraph) -> list[Finding]:
chips = [
c for c in graph.components.values()
if c.component_type == ComponentType.IC and _PD_RE.search(
" ".join(str(x or "") for x in (c.mpn, c.value, c.component_subtype))
)
]
if not chips:
return []
out: list[Finding] = []
rec = "No USB-PD contract change from this class FACT."
for comp in sorted(chips, key=lambda c: c.reference):
out.append(Finding(
designator=comp.reference, mpn=comp.mpn or "", aspect="usb_pd",
finding=f"{comp.reference} USB-PD controller is on this graph.",
facts=f"mpn={comp.mpn!r}; value={comp.value!r}.",
requirement="USB-PD contract runs only with a PD IC FACT — not USB2 Rd/CC.",
inference="PDO voltages are not invented; missing PDO is a skip.",
why="USB-PD is a power contract, not SuperSpeed SI.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-PD-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", pins=[],
))
return out
+122
View File
@@ -0,0 +1,122 @@
"""Standalone component library API — browse, import PDF, update cards.
No project id. Not admin/users. Analysis pipeline keeps using library_has_*.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from backend.routers.deps import get_storage
from backend.services import library as lib_svc
from backend.services.library import LibraryError, MAX_DATASHEET_BYTES
log = logging.getLogger(__name__)
router = APIRouter(tags=["library"])
class LibraryCheckRequest(BaseModel):
ic_mpns: list[str] = []
passive_mpns: list[str] = []
simple_mpns: list[str] = []
@router.post("/library/check")
async def check_library(body: LibraryCheckRequest, request: Request):
storage = get_storage(request)
ics = [mpn for mpn in body.ic_mpns if lib_svc.library_has_extraction(storage, mpn)]
patterns = lib_svc.load_library_patterns(storage) if body.passive_mpns else []
passives: list[str] = []
if body.passive_mpns:
from backend.periscopex.resolve_passives import resolve_mpn
passives = [
mpn for mpn in body.passive_mpns
if resolve_mpn(mpn, patterns) is not None
or lib_svc.library_has_passive_model(storage, mpn) is not None
]
simples = [mpn for mpn in body.simple_mpns if lib_svc.library_has_model(storage, mpn)]
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
sheets = [
mpn for mpn in wanted
if lib_svc.library_has_datasheet(storage, mpn, patterns=patterns)
]
return {
"ic_resolved": ics,
"passive_resolved": passives,
"simple_resolved": simples,
"datasheets_available": sheets,
}
@router.get("/library")
async def get_library(request: Request):
storage = get_storage(request)
return JSONResponse(
content=lib_svc.list_library_catalog(storage),
headers={"Cache-Control": "no-store"},
)
@router.get("/library/datasheet/{mpn:path}")
async def get_library_datasheet(mpn: str, request: Request):
storage = get_storage(request)
key = lib_svc.library_has_datasheet(storage, mpn)
if not key:
raise HTTPException(404, f"Datasheet not in library: {mpn}")
return Response(
content=storage.read_bytes(key),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
)
@router.post("/library/datasheets")
async def post_library_datasheet(
request: Request,
mpn: str = Form(...),
file: UploadFile = File(...),
kind: str = Form("ic"),
):
raw = await file.read()
if len(raw) > MAX_DATASHEET_BYTES:
raise HTTPException(
413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
)
try:
row = lib_svc.import_component_from_datasheet(
get_storage(request), mpn, raw, kind=kind,
)
except LibraryError as exc:
raise HTTPException(400, str(exc)) from exc
return row
@router.get("/library/components/{component_type}/{name:path}")
async def get_library_component(component_type: str, name: str, request: Request):
try:
data = lib_svc.get_component(get_storage(request), component_type, name)
except LibraryError as exc:
raise HTTPException(400, str(exc)) from exc
if data is None:
raise HTTPException(404, f"Component not found: {name}")
return JSONResponse(content=data, headers={"Cache-Control": "no-store"})
@router.put("/library/components/{component_type}/{name:path}")
async def put_library_component(
component_type: str, name: str, request: Request,
):
payload = await request.json()
try:
body = lib_svc.update_component(
get_storage(request), component_type, name, payload,
)
except LibraryError as exc:
raise HTTPException(400, str(exc)) from exc
return JSONResponse(content=body)
+2 -61
View File
@@ -1,4 +1,4 @@
"""Project CRUD, library lookup, and file ingestion."""
"""Project CRUD and file ingestion."""
from __future__ import annotations
@@ -181,65 +181,6 @@ def _passive_hit_json(mpn: str, safe: str, model_data: dict, *, cached: bool, lc
}
# --- Library ---
class LibraryCheckRequest(BaseModel):
ic_mpns: list[str] = []
passive_mpns: list[str] = []
simple_mpns: list[str] = []
@router.post("/library/check")
async def check_library(body: LibraryCheckRequest, request: Request):
storage = get_storage(request)
ics = [mpn for mpn in body.ic_mpns if proj_svc.library_has_extraction(storage, mpn)]
patterns = proj_svc.load_library_patterns(storage) if body.passive_mpns else []
passives: list[str] = []
if body.passive_mpns:
from backend.periscopex.resolve_passives import resolve_mpn
passives = [
mpn for mpn in body.passive_mpns
if resolve_mpn(mpn, patterns) is not None
or proj_svc.library_has_passive_model(storage, mpn) is not None
]
simples = [mpn for mpn in body.simple_mpns if proj_svc.library_has_model(storage, mpn)]
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
sheets = [
mpn for mpn in wanted
if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns)
]
return {
"ic_resolved": ics,
"passive_resolved": passives,
"simple_resolved": simples,
"datasheets_available": sheets,
}
@router.get("/library")
async def get_library(request: Request):
storage = get_storage(request)
return JSONResponse(
content=proj_svc.list_library_catalog(storage),
headers={"Cache-Control": "no-store"},
)
@router.get("/library/datasheet/{mpn:path}")
async def get_library_datasheet(mpn: str, request: Request):
storage = get_storage(request)
key = proj_svc.library_has_datasheet(storage, mpn)
if not key:
raise HTTPException(404, f"Datasheet not in library: {mpn}")
return Response(
content=storage.read_bytes(key),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
)
# --- CRUD ---
@@ -775,7 +716,7 @@ async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = No
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
)
except Exception:
pass
log.exception("library remember after auto-fetch failed for %s", mpn)
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
+425
View File
@@ -0,0 +1,425 @@
"""Shared component library — catalog, datasheet import, component JSON.
Not project CRUD. Analysis reads this store; it is not the only writer.
"""
from __future__ import annotations
import logging
from pathlib import Path
from backend.periscopex.library_gate import should_promote_extraction
from backend.periscopex.utils import safe_mpn
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet, store_datasheet_bytes
from backend.services.storage import LocalStorageBackend, StorageBackend
log = logging.getLogger(__name__)
MAX_DATASHEET_BYTES = 30 * 1024 * 1024
COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple")
# Import-from-PDF kinds. "passive" is a series regex, not a single part.
IMPORT_KINDS = ("ic", "passive_part", "simple")
INBOX_PREFIX = "library/inbox/"
class LibraryError(ValueError):
"""Caller-visible library failure (bad MPN, not a PDF, empty pintable)."""
def _specs_param_count(specs: dict) -> int:
if not isinstance(specs, dict):
return 0
values = specs.get("values")
if isinstance(values, dict):
return sum(1 for v in values.values() if v not in (None, "", []))
skip = {"specs_type", "component_subtype"}
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
specs = data.get("specs", {}) or {}
return {
"mpn": mpn,
"type": row_type,
"specs_type": specs.get("specs_type", ""),
"subtype": specs.get("component_subtype", ""),
"param_count": _specs_param_count(specs),
}
def component_key(component_type: str, name: str) -> str:
if component_type not in COMPONENT_TYPES:
raise LibraryError(f"Unknown component type: {component_type}")
safe = safe_mpn(name)
if not safe:
raise LibraryError("Invalid MPN")
if component_type == "ic":
return f"library/extracted/{safe}.json"
if component_type == "passive":
return f"library/patterns/{safe}.json"
if component_type == "passive_part":
return f"library/passives/{safe}.json"
return f"library/models/{safe}.json"
def library_has_extraction(
storage: StorageBackend, mpn: str, min_version: str | None = None,
) -> str | None:
key = f"library/extracted/{safe_mpn(mpn)}.json"
if not storage.exists(key):
return None
data = storage.read_json(key)
if not data.get("pintable"):
return None
if min_version:
from backend.services.admin_settings import version_is_stale
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
return None
return key
def library_has_datasheet(
storage: StorageBackend, mpn: str, patterns: list | None = None,
) -> str | None:
hit = resolve_datasheet(storage, mpn)
if hit:
return hit
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
if storage.exists(legacy):
return legacy
if patterns:
from backend.periscopex.resolve_passives import resolve_mpn
match = resolve_mpn(mpn, patterns)
if match is not None:
ds = match[0].datasheet_key
if ds and storage.exists(ds):
return ds
return None
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
key = f"library/models/{safe_mpn(mpn)}.json"
return key if storage.exists(key) else None
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
safe = safe_mpn(mpn)
key = f"library/passives/{safe}.json"
if storage.exists(key):
return key
legacy = f"library/models/{safe}.json"
return legacy if storage.exists(legacy) else None
def inbox_key(name: str) -> str:
safe = safe_mpn(name)
if not safe:
raise LibraryError("Invalid MPN")
return f"{INBOX_PREFIX}{safe}.json"
def drop_inbox(storage: StorageBackend, name: str) -> None:
"""Remove the pending IC card once a real pintable exists."""
try:
key = inbox_key(name)
except LibraryError:
return
if storage.exists(key):
storage.delete_key(key)
def save_to_library(
storage: StorageBackend, src_key: str, category: str, filename: str,
) -> str:
dst = f"library/{category}/{filename}"
storage.copy_object(src_key, dst)
if category == "extracted":
drop_inbox(storage, filename.replace(".json", ""))
return dst
def import_datasheet(
storage: StorageBackend,
mpn: str,
data: bytes,
extra_mpns: list[str] | None = None,
) -> dict:
"""Store a PDF under the shared library. No project and no exam required."""
name = (mpn or "").strip()
if not name:
raise LibraryError("MPN is required")
if not data:
raise LibraryError("Empty file")
if len(data) > MAX_DATASHEET_BYTES:
raise LibraryError(
f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
)
if not data.startswith(b"%PDF"):
raise LibraryError("File is not a PDF")
blob = store_datasheet_bytes(storage, data, name, extra_mpns=extra_mpns)
return {
"mpn": name,
"blob_key": blob,
"has_extraction": library_has_extraction(storage, name) is not None,
"has_model": (
library_has_model(storage, name) is not None
or library_has_passive_model(storage, name) is not None
),
}
def _register_ic_card(storage: StorageBackend, name: str) -> dict:
"""Pending IC card. Empty pintable never goes to library/extracted."""
if library_has_extraction(storage, name) is not None:
return {"component_type": "ic", "status": "extracted"}
key = inbox_key(name)
if not storage.exists(key):
storage.write_json(key, {
"mpn": name,
"kind": "ic",
"status": "needs_pintable",
"source": "library_import",
"pintable": [],
"absolute_maximum_ratings": [],
"rules": [],
})
return {"component_type": "ic", "status": "needs_pintable"}
def _register_model_card(
storage: StorageBackend, name: str, kind: str,
) -> dict:
key = component_key(kind, name)
if kind == "passive_part" and not storage.exists(key):
legacy = f"library/models/{safe_mpn(name)}.json"
if storage.exists(legacy):
return {"component_type": kind, "status": "registered"}
if not storage.exists(key):
specs_type = "passive" if kind == "passive_part" else "discrete"
storage.write_json(key, {
"mpn": name,
"specs": {"specs_type": specs_type, "values": {}},
})
return {"component_type": kind, "status": "registered"}
def import_component_from_datasheet(
storage: StorageBackend,
mpn: str,
data: bytes,
kind: str = "ic",
extra_mpns: list[str] | None = None,
) -> dict:
"""PDF + MPN → library component. No project, no MODE=run/pcb."""
kind_n = (kind or "ic").strip().lower()
if kind_n not in IMPORT_KINDS:
raise LibraryError(f"Unknown component kind: {kind}")
row = import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
if kind_n == "ic":
extra = _register_ic_card(storage, row["mpn"])
else:
extra = _register_model_card(storage, row["mpn"], kind_n)
row.update(extra)
row["has_extraction"] = library_has_extraction(storage, row["mpn"]) is not None
row["has_model"] = (
library_has_model(storage, row["mpn"]) is not None
or library_has_passive_model(storage, row["mpn"]) is not None
)
return row
def remember_datasheet(
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
) -> None:
"""Exam/pipeline path: log and keep going if the shared store fails."""
try:
import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
except Exception:
log.exception("library datasheet store failed for %s", mpn)
def get_component(storage: StorageBackend, component_type: str, name: str) -> dict | None:
key = component_key(component_type, name)
if component_type == "passive_part" and not storage.exists(key):
key = f"library/models/{safe_mpn(name)}.json"
if storage.exists(key):
return storage.read_json(key)
if component_type == "ic" and storage.exists(inbox_key(name)):
return storage.read_json(inbox_key(name))
return None
def update_component(
storage: StorageBackend, component_type: str, name: str, payload: dict,
) -> dict:
if not isinstance(payload, dict):
raise LibraryError("Component body must be a JSON object")
body = dict(payload)
body.setdefault("mpn", name.strip())
if str(body.get("mpn") or "").strip() != name.strip():
raise LibraryError("Body MPN must match the path")
if component_type == "ic":
ok, reason = should_promote_extraction(body)
if not ok:
raise LibraryError(f"IC rejected: {reason}")
body.pop("kind", None)
body.pop("status", None)
if body.get("source") == "library_import":
body.pop("source")
key = component_key(component_type, name)
storage.write_json(key, body)
if component_type == "ic":
drop_inbox(storage, name)
return body
def list_library_catalog(storage: StorageBackend) -> dict:
ics: list[dict] = []
seen_ic: set[str] = set()
for key in storage.list_prefix("library/extracted/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ic:
continue
seen_ic.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype", ""),
"pin_count": len(data.get("pintable", [])),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
"library_status": "extracted",
})
except Exception:
log.exception("skip unreadable IC catalog key %s", key)
seen_inbox: set[str] = set()
for key in storage.list_prefix(INBOX_PREFIX):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if not mpn or mpn in seen_ic:
continue
seen_ic.add(mpn)
seen_inbox.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype") or "",
"pin_count": len(data.get("pintable") or []),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
"library_status": data.get("status") or "needs_pintable",
})
except Exception:
log.exception("skip unreadable inbox catalog key %s", key)
passives: list[dict] = []
seen_p: set[str] = set()
for key in storage.list_prefix("library/patterns/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
if name in seen_p:
continue
seen_p.add(name)
passives.append({
"mpn": name,
"type": "passive",
"subtype": data.get("component_type", ""),
"description": data.get("description", ""),
"regex": data.get("regex", ""),
})
except Exception:
log.exception("skip unreadable pattern catalog key %s", key)
simple_models: list[dict] = []
passive_parts: list[dict] = []
seen_m: set[str] = set()
for prefix, row_type, dest in (
("library/passives/", "passive_part", passive_parts),
("library/models/", "simple", simple_models),
):
for key in storage.list_prefix(prefix):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
row = _catalog_model_row(data, key, row_type=row_type)
if row["mpn"] in seen_m:
continue
seen_m.add(row["mpn"])
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
dest.append(row)
except Exception:
log.exception("skip unreadable model catalog key %s", key)
datasheets: list[dict] = []
seen_d: set[str] = set()
for key in storage.list_prefix(REF_PREFIX):
if not key.endswith(".json"):
continue
try:
ref = storage.read_json(key)
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_d:
continue
seen_d.add(mpn)
datasheets.append({
"mpn": mpn,
"hash": ref.get("hash"),
"has_extraction": mpn in seen_ic and mpn not in seen_inbox,
"has_model": mpn in seen_m,
"has_component": mpn in seen_ic or mpn in seen_m,
})
except Exception:
log.exception("skip unreadable datasheet ref %s", key)
ics.sort(key=lambda r: r["mpn"].lower())
passives.sort(key=lambda r: r["mpn"].lower())
passive_parts.sort(key=lambda r: r["mpn"].lower())
simple_models.sort(key=lambda r: r["mpn"].lower())
datasheets.sort(key=lambda r: r["mpn"].lower())
return {
"ics": ics,
"passives": passives,
"passive_parts": passive_parts,
"simple": simple_models,
"datasheets": datasheets,
}
def list_library_patterns(storage: StorageBackend) -> list[str]:
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
def load_library_patterns(storage: StorageBackend):
from backend.periscopex.resolve_passives import load_patterns
if isinstance(storage, LocalStorageBackend):
d = storage._path("library/patterns")
if not d.is_dir():
return []
return load_patterns(str(d))
import tempfile
keys = list_library_patterns(storage)
if not keys:
return []
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "patterns"
dest.mkdir()
for key in keys:
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
return load_patterns(str(dest))
+75 -2
View File
@@ -1,8 +1,9 @@
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
Stages: ensure_graph parse_pcb classify inventory checks
ai_review write_report. Uses ``pcb_status`` so analysis ``status`` is
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
af_trace af_ai ai_review write_report. Uses ``pcb_status`` so analysis ``status`` is
untouched. ``af_trace`` and ``af_ai`` are additive and do not replace
``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
"""
from __future__ import annotations
@@ -27,6 +28,9 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh
from backend.periscopex.models import (
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
)
from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows
from backend.periscopex.af_trace_check import check_af_traces
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
from backend.periscopex.pcb_inventory import build_pcb_inventory
from backend.services import projects as proj_svc
@@ -107,6 +111,17 @@ def _load_constraints_map(
return result
def _load_channel_data(ws: PipelineWorkspace) -> dict | None:
"""Use existing channel_data.json only. Do not parse Touchstone or run OpenEMS."""
from backend.periscopex.protocol_l3 import load_channel_data_file
for rel in ("channel_data.json", "uploads/channel_data.json"):
loaded = load_channel_data_file(ws.local_path(rel))
if loaded is not None:
return loaded
return None
def si_extract_needed_skips(
graph: DesignGraph,
cmap: dict,
@@ -162,6 +177,29 @@ def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
_publish(project_id, "pcb_step_update", payload)
def _run_af_ai_section(
ws: PipelineWorkspace,
graph: DesignGraph,
cmap: dict,
layout: LayoutGraph | None,
zrep,
findings: list,
) -> int:
"""AI-flag-then-investigate. Does not replace ``run_pcb_checks``."""
raw: list = []
hyp_path = ws.local_path("hf_hypotheses.json")
if hyp_path.is_file():
try:
loaded = json.loads(hyp_path.read_text(encoding="utf-8"))
if isinstance(loaded, list):
raw = loaded
except Exception:
logger.exception("hf_hypotheses.json unreadable — AF+AI flags empty")
hyps = hypotheses_from_rows(raw)
added = append_investigated(findings, graph, cmap, layout, hyps, zrep)
return len(added)
async def run_pcb_pipeline(
storage: StorageBackend, user_id: str, project_id: str,
) -> None:
@@ -280,6 +318,34 @@ async def run_pcb_pipeline(
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
try:
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
findings.extend(extra_af)
n_trace = len(extra_af)
except Exception:
logger.exception("AF trace analysis failed — keeping PCB checks")
n_trace = 0
_step(
project_id, "af_trace", "complete",
f"{n_trace} AF trace findings (SI/HF checks kept)",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
_step(
project_id, "af_ai", "complete",
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
@@ -321,6 +387,12 @@ async def run_pcb_pipeline(
return
_step(project_id, "write_report", "running")
proto_section, proto_findings = protocol_exam(
graph, layout=layout,
constraints_map=cmap, impedance_nets=zrep,
channel_data=_load_channel_data(ws),
)
findings.extend(proto_findings)
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
dec_path = ws.local_path("decisions.json")
@@ -339,6 +411,7 @@ async def run_pcb_pipeline(
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
protocol_certification=proto_section.model_dump(),
)
report_path = ws.local_path("pcb_report.json")
report_path.write_text(report.model_dump_json(indent=2) + "\n")
+11 -221
View File
@@ -15,6 +15,17 @@ from typing import Any
from pydantic import AliasChoices, BaseModel, Field
from backend.periscopex.utils import natural_sort_key, safe_mpn
from backend.services.library import (
library_has_datasheet,
library_has_extraction,
library_has_model,
library_has_passive_model,
list_library_catalog,
list_library_patterns,
load_library_patterns,
remember_datasheet,
save_to_library,
)
from backend.services.storage import StaleGeneration, StorageBackend
log = logging.getLogger(__name__)
@@ -692,17 +703,6 @@ def save_pcb(
return key
def remember_datasheet(
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
) -> None:
try:
from backend.services.datasheet_store import store_datasheet_bytes
store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns)
except Exception:
log.exception("library datasheet store failed for %s", mpn)
def save_datasheet(
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes,
) -> str:
@@ -738,213 +738,3 @@ def get_datasheet_key(
) -> str | None:
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe_mpn(mpn)}.pdf"
return key if storage.exists(key) else None
def library_has_extraction(
storage: StorageBackend, mpn: str, min_version: str | None = None,
) -> str | None:
key = f"library/extracted/{safe_mpn(mpn)}.json"
if not storage.exists(key):
return None
data = storage.read_json(key)
if not data.get("pintable"):
return None
if min_version:
from backend.services.admin_settings import version_is_stale
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
return None
return key
def library_has_datasheet(
storage: StorageBackend, mpn: str, patterns: list | None = None,
) -> str | None:
from backend.services.datasheet_store import resolve_datasheet
hit = resolve_datasheet(storage, mpn)
if hit:
return hit
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
if storage.exists(legacy):
return legacy
if patterns:
from backend.periscopex.resolve_passives import resolve_mpn
match = resolve_mpn(mpn, patterns)
if match is not None:
ds = match[0].datasheet_key
if ds and storage.exists(ds):
return ds
return None
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
key = f"library/models/{safe_mpn(mpn)}.json"
return key if storage.exists(key) else None
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
safe = safe_mpn(mpn)
key = f"library/passives/{safe}.json"
if storage.exists(key):
return key
legacy = f"library/models/{safe}.json"
return legacy if storage.exists(legacy) else None
def save_to_library(
storage: StorageBackend, src_key: str, category: str, filename: str,
) -> str:
dst = f"library/{category}/{filename}"
storage.copy_object(src_key, dst)
return dst
def _specs_param_count(specs: dict) -> int:
if not isinstance(specs, dict):
return 0
values = specs.get("values")
if isinstance(values, dict):
return sum(1 for v in values.values() if v not in (None, "", []))
skip = {"specs_type", "component_subtype"}
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
specs = data.get("specs", {}) or {}
return {
"mpn": mpn,
"type": row_type,
"specs_type": specs.get("specs_type", ""),
"subtype": specs.get("component_subtype", ""),
"param_count": _specs_param_count(specs),
}
def list_library_catalog(storage: StorageBackend) -> dict:
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
ics: list[dict] = []
seen_ic: set[str] = set()
for key in storage.list_prefix("library/extracted/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ic:
continue
seen_ic.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype", ""),
"pin_count": len(data.get("pintable", [])),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
})
except Exception:
continue
passives: list[dict] = []
seen_p: set[str] = set()
for key in storage.list_prefix("library/patterns/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
if name in seen_p:
continue
seen_p.add(name)
passives.append({
"mpn": name,
"type": "passive",
"subtype": data.get("component_type", ""),
"description": data.get("description", ""),
"regex": data.get("regex", ""),
})
except Exception:
continue
simple_models: list[dict] = []
passive_parts: list[dict] = []
seen_m: set[str] = set()
for prefix, row_type, dest in (
("library/passives/", "passive_part", passive_parts),
("library/models/", "simple", simple_models),
):
for key in storage.list_prefix(prefix):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
row = _catalog_model_row(data, key, row_type=row_type)
if row["mpn"] in seen_m:
continue
seen_m.add(row["mpn"])
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
dest.append(row)
except Exception:
continue
datasheets: list[dict] = []
seen_d: set[str] = set()
for key in storage.list_prefix(REF_PREFIX):
if not key.endswith(".json"):
continue
try:
ref = storage.read_json(key)
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_d:
continue
seen_d.add(mpn)
datasheets.append({
"mpn": mpn,
"hash": ref.get("hash"),
"has_extraction": mpn in seen_ic,
"has_model": mpn in seen_m,
})
except Exception:
continue
ics.sort(key=lambda r: r["mpn"].lower())
passives.sort(key=lambda r: r["mpn"].lower())
passive_parts.sort(key=lambda r: r["mpn"].lower())
simple_models.sort(key=lambda r: r["mpn"].lower())
datasheets.sort(key=lambda r: r["mpn"].lower())
return {
"ics": ics,
"passives": passives,
"passive_parts": passive_parts,
"simple": simple_models,
"datasheets": datasheets,
}
def list_library_patterns(storage: StorageBackend) -> list[str]:
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
def load_library_patterns(storage: StorageBackend):
from backend.periscopex.resolve_passives import load_patterns
from backend.services.storage import LocalStorageBackend
if isinstance(storage, LocalStorageBackend):
d = storage._path("library/patterns")
if not d.is_dir():
return []
return load_patterns(str(d))
import tempfile
keys = list_library_patterns(storage)
if not keys:
return []
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "patterns"
dest.mkdir()
for key in keys:
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
return load_patterns(str(dest))
@@ -28,6 +28,7 @@ from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.led_current_check import check_led_current
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.periscopex.models import ComponentType, DesignGraph, Finding, ValidationReport
from backend.periscopex.protocol_l0 import protocol_exam
from backend.periscopex.nc_pin_check import check_nc_pins
from backend.periscopex.parsers import ic_mpn_skip_reason
from backend.periscopex.passive_rail_check import (
@@ -208,6 +209,8 @@ async def validate_design_async(
return clean
def _write_report(paused: bool = False) -> ValidationReport:
proto_section, proto_findings = protocol_exam(graph)
all_findings.extend(proto_findings)
annotate_findings_cad(all_findings, graph.cad_index)
assign_finding_ids(all_findings)
dec_path = existing_path.with_name("decisions.json")
@@ -228,6 +231,7 @@ async def validate_design_async(
coverage=_sanitize_coverage(all_coverage),
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
except Exception:
report = ValidationReport(
@@ -238,6 +242,7 @@ async def validate_design_async(
coverage={},
review_errors=dict(review_errors),
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
@@ -17,7 +17,10 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S
| PE-USBC-001…005 | c’è un receptacle USB-C | classe USB2/USB3, CC1/CC2, Rd/Rp 5.1 kΩ, VBUS/GND, SuperSpeed solo se USB3 |
| PE-ETH-001…004 | c’è un RJ45 | 10/100 vs GbE, coppie TX/RX o 4 MDI, magnetics se GbE, Bob Smith REVIEW |
| PE-POE-001…003 | evidenza PoE sul jack | classe/tipo se citati; isolamento/magnetics; **skip** se RJ45 nudo |
| PE-POE-004 | PoE + `isolation_v` | tensione di isolamento solo se sourced; niente IEC inventato |
| PE-DDR-* | solo se c’è un dispositivo DDR | non inventato su HubAudio (nessun DDR in BOM) |
| PE-CPU-* | solo se c’è un bus parallelo D/A/controllo | SRAM/NOR/FMC/mux AD; niente bus inventato |
| PE-FPGA-* | solo se c’è un FPGA | bank/IO rails e flash di config se presenti; niente FPGA inventata |
Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. LIA spiega i FACT, non certifica. Nessun I inventato: PE-PWR **skip** se il datasheet non riporta I_load/Imax/I_abs.
+5
View File
@@ -57,7 +57,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
| PE-SI-001 | `length_match` mm on USB/HDMI/PCIe/ETH/LVDS/DDR nets (not I2C/GPIO/CC) |
| PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window |
| PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number |
| PE-SI-007 | dangling **track** stub vs datasheet mm; via ≠ pad ≠ zone; INFO if no mm |
| PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) |
| PE-SI-011 | GND-pour gap under HF pair when stackup + pour exist (REVIEW, not IEC) |
| PE-SI-012 | BOM terminator on HF net (FACT); skip if none |
| PE-PWR-001 | I_load/Imax/I_abs + width; **skip** se manca I (no 500 mA, no INFO); IPC solo con thickness e Tjmax |
| PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR |
| PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN |
@@ -115,6 +118,8 @@ Gate: library `layout_rules` kind + `net_class`/quote on that bus. Empty cell =
| SGMII | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| DDR3 CLK/DQS/DQ/ADDR | gated | gated | gated | gated | gated | gated | gated | gated | per-subclass if no Z |
| HDMI / PCIe / LVDS | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| MIPI DSI/CSI | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| HF clock (not XTAL) | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| I2C / GPIO / EN / CC | skip | skip | skip | skip | skip | skip | skip | skip | skip |
Emmaforo USB: extract with **no Z number** → Zavg ~88 Ω is **PE-SI-010** INFO (insufficient), **not** a 90 Ω FAIL. If library later has 90 Ω ±10% (8199), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists. PE-SI-009 must not fire on USB from ESP32 EN RC 10 kΩ / 1 µF p.28.
+224
View File
@@ -2,6 +2,230 @@
What's new in Periscope.
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe.
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
- [Changed] Findings tree grouping + protocol section inside the tree.
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
## 2.82.0 — 2026-09-22 — HDMI 2.0+ TI fields use source_type VENDOR
Michele exception: HDMI 2.0 and FRL electrical/PCB numbers from TMDS1204 / SLLA633 use `source_type=VENDOR` (not HDMI Forum STANDARD). TMDS1204-specific limits (skew, vias, FRL data_rate, SIGDET) are PHY_DEPENDENT with a document/rev/section cite and no number. HDMI 1.4 TMDS stays UNKNOWN. pcbsync.com not packed.
- [Changed] `SourceType` includes `VENDOR`; `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.81.0 — 2026-09-22 — Protocol PDF archive + HDMI 2.0/FRL TI VENDOR overlay
On-disk archive `standards/protocol-specs/` (PDFs gitignored). INDEX.md lists cites. HDMI 2.0 and FRL take TMDS1204 Table 8-6 layout as VENDOR/PHY RECOMMENDED — not HDMI Forum STANDARD. HDMI 1.4 TMDS Zdiff stays UNKNOWN. pcbsync.com not packed.
- [New] `standards/protocol-specs/INDEX.md`; `.gitignore` `standards/protocol-specs/*.pdf`.
- [Changed] HDMI 2.0 / FRL constraints; `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest pdf-pack / m6.
## 2.80.0 — 2026-09-22 — TI TMDS1204 HDMI layout as VENDOR/PHY only
TMDS1204 datasheet SLLSF57A fills sink/source PCB layout (ZPCB 90 Ω / 75 Ω ranges, skew, length, vias, XTALK 24 dB, 5W spacing, RINT, AC cap) on physical ids `tmds1204-hdmi-sink` / `tmds1204-hdmi-source`. source_type PHY, source_class VENDOR, RECOMMENDED. Generic `hdmi-*` TMDS/FRL Zdiff stays UNKNOWN. SLLA633 is SIGDET wakeup (PHY_DEPENDENT), no layout ohms. pcbsync.com blog not packed.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_catalog_m0.py`.
## 2.79.0 — 2026-09-22 — HDMI CEC electrical from HDMI Specification 1.3
`CEC_HDMI_Specification.pdf` is High-Definition Multimedia Interface Specification Version 1.3 (22 June 2006), not a CEC-only pamphlet. CEC pull-up 27 kΩ, VOL 0.6 V, VOH 2.5 V, device 200 pF / cable 700 pF, interconnect 5 Ω are packed. TMDS/FRL Zdiff/skew stay UNKNOWN (Adopter spec still required). DDC/HPD not packed.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
## 2.78.0 — 2026-09-22 — 1000BASE-T from IEEE 802.3-2012 Section Three
IEEE Std 802.3-2012 Section Three Clause 40 fills 1000BASE-T: 1000 Mb/s, 100 m Class D link, cable 100 Ω, MDI return loss 16 dB (140 MHz), pair skew 50 ns, isolation 2 MΩ / 1500 V rms, MDI peak 0.67 V, link delay 570 ns. Cable 100 Ω is **CABLE**; L2 PCB Zdiff stays UNKNOWN. Clause 36 fills 1000BASE-X 1000 Mb/s (optical Zdiff N/A). Insertion-loss formula not packed as a single dB.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
## 2.77.0 — 2026-09-22 — 10BASE-T from IEEE 802.3-2015 Section One
IEEE Std 802.3-2015 Section One Clause 14 fills 10BASE-T: 10 Mb/s, 100 m design-objective link, cable 100 Ω ± 15 Ω, 11.5 dB link insertion loss, MAU TD return loss 15 dB, isolation 2 MΩ / 1500 V rms, TD peak 2.2 V. Cable 100 Ω is **CABLE**, not L2 PCB Zdiff. 1000BASE-T (Clause 40) is not in this volume.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
## 2.76.0 — 2026-09-22 — Type-C CabCon R2.0 hole-fill (CC shall-detect; VBUS from R2.5)
USB Type-C Cable and Connector Specification Release 2.0 (August 2019) fills Source CC shall-detect voltages that R2.5 only illustrates as variance examples (Table 4-32 p.240: vRd 1.50 V, vOPEN 1.65 V, vRa 0.15 V). Type-C Current charger VBUS 4.755.5 V no-load / 4.05.5 V at 3 A, zOPEN 126 kΩ, tCCDebounce 100200 ms, and tVBUSON 275 ms stay on newer CabCon R2.5 (Figure 4-39 / Tables 4-30, 4-32, 4-34). Rp/Rd/Ra unchanged. vSafe5V volt range still needs USB PD.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.75.0 — 2026-09-22 — Protocol packs from USB 2.0 Rev 2.0 and Type-C CabCon R2.5
USB 2.0 Base Specification (April 27, 2000) fills LS 1.50 Mb/s, FS 12 Mb/s, HS 480 Mb/s, pull-up/down, VBUS 4.755.25 V, LS/FS edges. Cable Z0 90 Ω is `source_type=CABLE`; HS PCB traces *should* 90 Ω is RECOMMENDED PCB — L2 `differential_impedance` stays UNKNOWN so 45/50 Ω fixtures are not treated as Zdiff. Type-C Cable and Connector Specification Release 2.5 fills Rp/Rd/Ra from Tables 4-27/4-28/4-29. VBUS volts still need USB PD vSafe5V. IEEE 802.3 Section One/Three and HDMI Adopter remain missing.
- [Changed] `protocol_catalog.py` + `catalog.json`.
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.74.0 — 2026-09-22 — Protocol packs from recovered PDFs (USB CTS, Type-C FTS, DVI 1.0, IEEE 802.3-2012 §2)
NUMERIC constraints now carry title, revision, section/table, and page from the recovered PDFs. USB 2.0 HS fills data rate / rise-fall / 1.5 kΩ pull-up / DCR from Electrical CTS v1.08; Zdiff stays UNKNOWN (CTS does not state 90 Ω). Type-C Rp/Rd/VBUS volts stay UNKNOWN (Functional Test Spec points at Cable and Connector tables). DVI 1.0 is a separate protocol from HDMI. IEEE 802.3-2012 Section Two fills 100BASE-TX cabling 100 Ω / 100 m / isolation and 100 Mb/s; 10BASE-T Clause 14 and 1000BASE-T Clause 40 are not in Section Two.
- [Changed] `protocol_catalog.py` + `catalog.json`; DVI recognition.
- [New] pytest `tests/pcb/test_protocol_pdf_pack.py`.
## 2.73.0 — 2026-09-22 — Protocol certification M6/M7 (USB-C / Ethernet / HDMI packs)
USB 2.0 (LS/FS/HS) is the protocol; USB Type-C is a connector/interface system; USB 2.0 over Type-C is the physical implementation. Type-C does not imply USB3 or USB4. Mini HDMI is a connector, not a protocol. Ethernet variants (10/100/1000/2.5G/5G/10G, SGMII, 1000BASE-X, 100BASE-FX, 10GBASE-R) are incomplete skeletons. All listed numeric fields stay **UNKNOWN** with `needed_document` (USB-IF / IEEE 802.3 / HDMI Adopter). No invented ohms, no typical-as-standard, no OpenEMS.
- [Changed] `protocol_catalog.py` + `catalog.json`; `needed_document` on UNKNOWN/MISSING_SOURCE.
- [Changed] USB/Ethernet/HDMI recognition: connector alone is not the protocol.
- [New] pytest `tests/pcb/test_protocol_m6_m7.py`.
## 2.72.0 — 2026-09-22 — Protocol certification M10 (catalog skeletons)
Catalog skeletons and `required_checks` for **PCIe / CXL** (logical vs physical), **I2C**, **CAN**, **RGMII**, plus missing high-speed processor ids (PCI, PCI-X, CCIX, OpenCAPI, UPI, DMI, Infinity Fabric, HyperTransport, UCIe). Numbers stay **UNKNOWN / MISSING_SOURCE / PHY_DEPENDENT** until an official cite is in the pack. `pcb_relevant` is YES or CONDITIONAL as appropriate. No invented ohms, no USB-IF fill, no OpenEMS.
- [Changed] `protocol_catalog.py` + committed `catalog.json`.
- [New] pytest `tests/pcb/test_protocol_catalog_m10.py`.
## 2.71.0 — 2026-09-22 — Protocol certification M9 (L3 channel + visible skip)
L3 **CHANNEL** certifier: structure for timing budget (TOTALUSED=MARGIN), insertion loss, return loss, crosstalk, and complete channel. Numerical PASS/FAIL only when **existing channel FACT** (dB/ps) is already present — never invented S-parameters, never OpenEMS/FEM. Otherwise a visible skip: «Interfaccia X non certificata a L3 per mancanza di …». Internals (AXI/HBM) stay NOT_APPLICABLE. L0/L1 are not electrical. No M6 numeric packs.
- [New] `protocol_l3.py`; `PE-PRT-L3-001` / `PE-PRT-L3-002`.
- [New] pytest `tests/pcb/test_protocol_l3_m9.py`.
## 2.70.0 — 2026-09-22 — Protocol certification M8 (DDR instance + L1 grouping)
DDR **physical instances** are controller-aware: one instance per memory device, DQ/DQS **byte lanes** separate from ADDRESS / COMMAND / CONTROL / CLOCK. Skew/length stay **CONTROLLER_DEPENDENT / PHY_DEPENDENT / UNKNOWN** — no universal mm/ps/ohm. HBM is package/interposer (`pcb_relevant: NO`); classic DDR PCB DQ rules are not applied. M6 numeric packs and L3 solver are not in this release.
- [New] `protocol_ddr.py`; L1 grouping reconstruction without PCB geometry.
- [New] pytest `tests/pcb/test_protocol_ddr_m8.py`.
## 2.69.0 — 2026-09-22 — Protocol certification M5 (Protocolli report)
Report section **Protocolli**: recognised instances, max level L0L3 (L3 is a visible not-run skip, no solver), each check with measured / limit / margin / source / method, traceability chain net → group → physical interface → protocol → constraint → document → section. FAIL is listed first and styled as Error — not hidden under Warning (HubAudio). Visible skips stay on the page. No pack numbers, no invented Z, no OpenEMS.
- [New] `protocol_report.py`; Protocolli table UI.
- [New] pytest `tests/pcb/test_protocol_report_m5.py`; `protocol-report.test.ts`.
## 2.68.0 — 2026-09-22 — Protocol certification M4 (L2 electrical)
L2 ELECTRICAL certifier: impedance, termination, voltage/levels, rise/fall when sources exist. Z only from datasheet / stackup / fabricator / **cited pack** — never invented 90 Ω USB. USB-IF/IEEE only if the pack has a cite. Silicon cross is max PCB × PHY subset when datasheet windows exist. Length is not delay. No L3/OpenEMS. L0/L1 are **not** electrical. RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent. Visible skip when Z cannot be obtained.
- [New] `protocol_l2.py`; `PE-PRT-L2-001` / `PE-PRT-L2-002`.
- [New] pytest `tests/pcb/test_protocol_l2_m4.py`.
## 2.67.0 — 2026-09-22 — Protocol certification M3 (L1 geometric)
L1 GEOMETRIC certifier: length, topology, vias, stubs, layer, grouping. Geometric skew only vs **NUMERIC** millimetre or an explicit **DESIGN LIMIT**. Otherwise UNKNOWN / VENDOR_DEPENDENT / CONTROLLER_DEPENDENT / PHY_DEPENDENT — never invent mm/ps. L1 is **not** electrical. No L2/L3. RECOMMENDED never FAIL. Internal interfaces stay NOT_APPLICABLE. Missing DQ/DQS grouping is a visible skip, not PASS.
- [New] `protocol_l1.py`; `PE-PRT-L1-001` / `PE-PRT-L1-002` / `PE-PRT-L1-003`.
- [New] pytest `tests/pcb/test_protocol_l1_m3.py`.
## 2.66.0 — 2026-09-22 — Protocol certification M2 (L0 structural)
L0 STRUCTURAL certifier: nets must exist and connect at least two components. FAIL only for missing **MANDATORY** structure. AXI4 / internal interconnects are **NOT_APPLICABLE** (not FAIL). RECOMMENDED never FAIL. No L1L3, no invented ohms/mm/ps.
- [New] `protocol_l0.py`; `PE-PRT-L0-001` / `PE-PRT-L0-002`.
- [New] pytest `tests/pcb/test_protocol_l0_m2.py`.
## 2.65.0 — 2026-09-22 — Protocol certification M1 (instance recognition)
`physical_bus_instance` from declaration / silicon / part / pin / net / pairs. Output: instances, signal groups (DIFF pair, byte lane, clock/address), confidence. Ambiguity is **REVIEW**, never FAIL. AXI4 internal is recognised with `pcb_relevant: NO` and **no PCB nets**. No L0L3 certifier. No invented ohms.
- [New] `protocol_recognize.py`; report Protocolli lists recognised instances or stays empty.
- [New] pytest `tests/pcb/test_protocol_recognize_m1.py` (USB/DDR fixtures; AXI has no nets).
## 2.64.0 — 2026-09-22 — Protocol certification M0 (catalog, no numbers)
Versioned JSON protocol pack + Python loader/validator. Catalog skeletons for USB2 / USB-C-USB2 / 100BASE-TX / HDMI (connector distinct) / DDR / LPDDR / HBM / HyperBus / QSPI-octal / eMMC / UFS / AMBA / Wishbone / Avalon / TileLink / PCIe-CXL. Constraints without an official cite are UNKNOWN / CONTROLLER_DEPENDENT / PHY_DEPENDENT / MISSING_SOURCE — **no invented 90/100/50 Ω**. `logical_protocol_id``physical_interface_id`. `pcb_relevant` is YES|NO|CONDITIONAL. RECOMMENDED is not FAIL-mandatory. Typical-as-standard is rejected by the parser. Report section **Protocolli** is empty (`nessun protocollo riconosciuto`). Recognition and L0L3 certifiers are not in this release.
- [New] `protocol_catalog.py`, `protocol_data/catalog.json`, schema 1.0.0.
- [New] `protocol_certification` on `ValidationReport`; UI section Protocolli.
- [New] pytest `tests/pcb/test_protocol_catalog_m0.py`.
- [Fixed] Native `periscope/src` wins over inherited `periscopex` on the package path.
## 2.63.5 — 2026-09-22 — AF trace analysis (trigger, cascade, visible skips)
PCB exam adds **AF trace analysis** after unchanged `run_pcb_checks` (SI/HF stay). Trigger is λ/10 or tr/(6 tpd) from FACT only. Differential pairs are one unit. Z0/Zdiff only vs a **datasheet** window. Missing tr/f/εr/stackup/Z target/via span is a visible INSUFFICIENT finding («Pista ad alta frequenza non controllata per mancanza di …»), not invented 50/90 Ω. Trigger false → no AF finding. Numerical cascade is lossless RLGC sections (ImpedenceFinder Z0); not OpenEMS/FEM. Python. PE-AF-001 AI path unchanged.
- [New] `check_af_traces` / `af_trigger` / `af_rlgc`; pipeline stage `af_trace`.
- [New] `PE-AF-002` visible skip; `PE-AF-020``061` on triggered nets.
- [New] Via `(layers …)` parsed on `LayoutVia` (span; size is not a track).
- [Changed] Changelog / PCB progress lists AF trace before AF+AI.
## 2.63.4 — 2026-09-22 — Report Error group, J2, U18 max severity
The findings tree always has an **Error** folder when ERROR findings are visible. Group badge is the worst child (U18 PE-PLC-002 ERROR is not labeled WARNING). Connectors (J2) sit in the same sidebar as U*. PE-PLC stays in the PCB exam bucket. No auth change.
- [Fixed] Status folders Error / Warning / Info; severity = max of children.
- [Fixed] J2 and other non-U refs listed with ICs.
- [Fixed] `isPcbExamFinding` matches layout findings (PE-PLC with PE-BOM).
## 2.63.3 — 2026-09-22 — Module die, truncated QFN pintable, QFN=VQFN
HubAudio software leftovers on PE-BOM-010/011: a WROOM module is not the ESP32 die pintable; a QFN-48 land is not wrong because extraction stopped at pins 124; VQFN-24 4×4 is the same QFN family as datasheet QFN-24. Extra signal pads above the declared QFN-n still ERROR. Pad ≠ via. Kelvin unchanged.
- [Fixed] PE-BOM-011 skips die-pintable extras on WROOM/WROVER module footprints (U11).
- [Fixed] PE-BOM-011 skips QFN-n pads the pintable never listed (U12 2548).
- [Fixed] PE-BOM-010 treats VQFN/WQFN/HVQFN as QFN (U19).
## 2.63.2 — 2026-09-22 — PE-BOM-011 joins pins by name, not pin_count
Datasheet `pin_count` and PCB pad cardinality are incommensurable (USB-C 16P vs SH/mount/EP). PE-BOM-011 joins the pintable × KiCad `.kicad_mod` × embedded PCB pads by **pin name**. USB-C A5/B5 are CC pins, not EP. Missing pintable is a skip, never ERROR on `pin_count=16`. The library footprint is checked against the datasheet; lib vs embedded PCB is a separate mismatch. AI still extracts the table and runs review/class/SI — it does not count pads. Pad ≠ via.
- [Fixed] PE-BOM-011 no longer treats `[A-Z]\\d+` as EP (HubAudio J2 16 vs 1 SH).
- [Fixed] Scalar `package_info.pin_count` is not an ERROR authority.
- [Changed] Named join: datasheet pintable vs lib vs PCB; SH/EP/mount classified first.
## 2.63.1 — 2026-09-21 — Library component from PDF; AF+AI beside PCB checks
Library import registers a **component** (not only a PDF blob) with no exam. AF Board+AI is a pipeline section **after** unchanged `run_pcb_checks` (PE-SI / HF line stay in 2.62.1).
- [Fix] `POST /api/library/datasheets` writes an IC inbox card or a passive/discrete model. Empty pintable never lands in `library/extracted/`.
- [Fix] PCB pipeline stage `af_ai` calls `append_investigated` beside `run_pcb_checks`. Duplicate SI clones are not double-counted. `PE-AF-001` stays additive.
- [New] Report tree root “AF Board + AI”. Progress step between deterministic checks and PCB AI exam.
## 2.63.0 — 2026-09-21 — Library as its own product door
Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad.
- [New] `POST /api/library/datasheets` stores a PDF by MPN with no project id.
- [New] `GET`/`PUT /api/library/components/{type}/{mpn}` for extracted cards.
- [New] Library page import form; empty library no longer says “run a review”.
- [New] `investigate_hf_hypotheses` (`PE-AF-001`) — AI Z0 flag without stackup is INSUFFICIENT, never 50/90 Ω. Existing `run_pcb_checks` unchanged.
## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B)
Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVSconnector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM.
- [New] `PE-STK-001` board copper thickness vs sourced fab stackup (never 1 oz).
- [New] `PE-VIA-002` I + via count/drill FACT; IPC via ampacity is not applied.
- [New] `PE-ESD-002` TVSconnector **pad** distance (via ≠ pad); INFO if no mm.
- [New] `PE-PD-001` USB-PD only with a PD controller (not USB2 Rd).
- [New] `PE-POE-004` isolation voltage only from a sourced number.
- [New] `PE-ANT-001`/`002` antenna keepout and matching if an antenna exists.
- [New] `PE-PDN-001` Z(f) only with frequency-domain FACT; SI Z0 is not PDN.
## 2.62.0 — 2026-09-21 — HF lines/buses in PCB checks (Phase A)
USB / Eth / DDR / MIPI / PCIe / HDMI / LVDS / HF clock in `run_pcb_checks` at the same finding quality as 2.61.2. Pair integrity: stub (track only; via ≠ pad ≠ zone), reference-split under the line, BOM termination. CPU data/address/control and FPGA only if that device is on the graph. Skip if no evidence. Not DRC. Not FEM. Datasheet millimetres only; no invented Z/I.
- [New] `PE-SI-007` dangling track stub vs datasheet mm (INFO if no mm).
- [New] `PE-SI-011` GND-pour gap under an HF pair (REVIEW; skip without stackup/zone).
- [New] `PE-SI-012` BOM series/parallel terminator FACT (skip if none).
- [New] `PE-DDR-001``003` DRAM class / CK-DQS-DQ / VTT only if DRAM present.
- [New] `PE-CPU-001``004` parallel CPU bus only if D/A/control exist.
- [New] `PE-FPGA-001``003` FPGA class / config flash / VCCINT·VCCIO only if FPGA present.
- [Changed] `bus_class` adds MIPI and leftover HF clocks; XTAL/OSCIN stay out.
## 2.61.2 — 2026-09-21 — Certifiers only for connectors on this board
USB-C / RJ45 / PoE / DDR3 are the method, not a catalog. If this graph has a USB-C receptacle, run USB-C questions. If it has RJ45, run Ethernet. PoE only with PoE evidence — never because RJ45 exists. No connector → no that certifier (not N/A). HubAudio has USB-C USB2 + PoE 10/100 MagJack and no DDR: no DDR findings. Skip-I (2.61.1) unchanged. No FEM, no via IPC, no invented I/Z/PoE/SuperSpeed.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.61.2",
"version": "2.83.0",
"private": true,
"scripts": {
"sync-version": "node scripts/sync-version.mjs",
@@ -9,7 +9,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
LayoutGrid,
List,
Loader2,
X,
} from "lucide-react";
import { useCredits } from "@/components/billing/credits-context";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -23,13 +21,10 @@ import {
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/types";
type LayoutKind = "cards" | "table";
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
const LAYOUT_KEY = "periscopex:dashboard:view";
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
export default function DashboardPage() {
@@ -62,28 +57,9 @@ function DashboardBody() {
const [bannerVisible, setBannerVisible] = useState(false);
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [layout, setLayout] = useState<LayoutKind>("cards");
const ordered = useMemo(() => newestFirst(projects), [projects]);
useEffect(() => {
try {
const stored = localStorage.getItem(LAYOUT_KEY);
if (stored === "cards" || stored === "table") setLayout(stored);
} catch {
/* storage blocked */
}
}, []);
function persistLayout(next: LayoutKind) {
setLayout(next);
try {
localStorage.setItem(LAYOUT_KEY, next);
} catch {
/* ignore */
}
}
function stripCheckoutQuery() {
const url = new URL(window.location.href);
url.searchParams.delete("topup");
@@ -208,7 +184,7 @@ function DashboardBody() {
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
<OnboardingSurvey />
{bannerVisible && topupPhase !== "dismissed" && (
<TopupBanner
@@ -217,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,
@@ -14,7 +14,7 @@ export default function AppShellLayout({
<AuthGate>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
{children}
</main>
</div>
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
fetchLibraryComponent,
updateLibraryComponent,
type LibraryComponentType,
} from "@/lib/api";
export function LibraryEditor({
componentType,
name,
onClose,
onSaved,
}: {
componentType: LibraryComponentType;
name: string;
onClose: () => void;
onSaved: () => void;
}) {
const [text, setText] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetchLibraryComponent(componentType, name)
.then((data) => {
if (!cancelled) setText(JSON.stringify(data, null, 2));
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to load");
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [componentType, name]);
async function save() {
setError(null);
let payload: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(text);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
setError("Body must be a JSON object.");
return;
}
payload = parsed as Record<string, unknown>;
} catch {
setError("Invalid JSON.");
return;
}
setBusy(true);
try {
await updateLibraryComponent(componentType, name, payload);
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setBusy(false);
}
}
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium font-mono">{name}</p>
<button
type="button"
onClick={onClose}
className="text-xs text-muted-foreground hover:underline"
>
Close
</button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full min-h-64 font-mono text-xs rounded-md border border-input bg-background p-2"
spellCheck={false}
/>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="button" onClick={save} disabled={busy || loading}>
{busy ? "Saving…" : "Save"}
</Button>
</div>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { importLibraryDatasheet, type LibraryComponentType } from "@/lib/api";
const KINDS: { value: "ic" | "passive_part" | "simple"; label: string }[] = [
{ value: "ic", label: "IC" },
{ value: "passive_part", label: "Passive" },
{ value: "simple", label: "Discrete" },
];
export function LibraryImport({
onImported,
}: {
onImported: (row: { type: LibraryComponentType; name: string }) => void;
}) {
const [mpn, setMpn] = useState("");
const [kind, setKind] = useState<"ic" | "passive_part" | "simple">("ic");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!mpn.trim()) {
setError("MPN is required.");
return;
}
if (!file) {
setError("Choose a datasheet PDF.");
return;
}
setBusy(true);
try {
const row = await importLibraryDatasheet(mpn.trim(), file, kind);
const name = row.mpn;
setMpn("");
setFile(null);
onImported({ type: kind, name });
} catch (err) {
setError(err instanceof Error ? err.message : "Import failed");
} finally {
setBusy(false);
}
}
return (
<form
onSubmit={submit}
className="rounded-lg border border-border bg-card p-4 space-y-3"
>
<div>
<p className="text-sm font-medium">Add a component from a datasheet</p>
<p className="text-xs text-muted-foreground mt-0.5">
PDF + MPN on this page. No exam, no PCB run, no schematic run.
Pin tables are not invented: fill them here or wait for extraction
evidence.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Input
placeholder="MPN"
value={mpn}
onChange={(e) => setMpn(e.target.value)}
className="sm:w-56 font-mono text-sm"
autoComplete="off"
/>
<select
value={kind}
onChange={(e) =>
setKind(e.target.value as "ic" | "passive_part" | "simple")
}
className="h-9 rounded-md border border-input bg-background px-3 text-sm sm:w-36"
aria-label="Component kind"
>
{KINDS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
<Input
type="file"
accept="application/pdf,.pdf"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="flex-1"
/>
<Button type="submit" disabled={busy}>
{busy ? "Saving…" : "Add to library"}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</form>
);
}
@@ -17,7 +17,10 @@ import {
fetchLibrary,
fetchLibraryDatasheetUrl,
type LibraryCatalog,
type LibraryComponentType,
} from "@/lib/api";
import { LibraryImport } from "./library-import";
import { LibraryEditor } from "./library-editor";
export default function LibraryPage() {
const [data, setData] = useState<LibraryCatalog | null>(null);
@@ -25,8 +28,20 @@ export default function LibraryPage() {
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [opening, setOpening] = useState<string | null>(null);
const [editor, setEditor] = useState<{
type: LibraryComponentType;
name: string;
} | null>(null);
function reload() {
setError(null);
fetchLibrary()
.then(setData)
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load library"));
}
useEffect(() => {
setLoading(true);
fetchLibrary()
.then(setData)
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load library"))
@@ -128,20 +143,36 @@ export default function LibraryPage() {
<div>
<h1 className="text-lg font-semibold">Component library</h1>
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
Datasheets, pin tables, and passive specs are stored once and reused
on every later project. A second board with the same CH340E will not
re-download the PDF or re-extract the pin table.
Shared component store. Add a part from a manufacturer PDF here
this page is not the exam. Analysis still uses the library; it is
not the only way to fill it.
</p>
</div>
<LibraryImport
onImported={(row) => {
setEditor(row);
reload();
}}
/>
{editor && (
<LibraryEditor
componentType={editor.type}
name={editor.name}
onClose={() => setEditor(null)}
onSaved={reload}
/>
)}
{empty ? (
<div className="rounded-lg border border-border bg-card p-12 text-center space-y-3">
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
<p className="text-sm font-medium">Library is empty</p>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Create a project and run a review. Fetched datasheets land here
immediately; pin tables and passive patterns arrive after the first
extraction.
Drop a manufacturer PDF and its MPN above. The part is in the
library immediately. Pin tables are not invented from an empty
PDF parse fill the card or wait for extraction evidence.
</p>
</div>
) : (
@@ -215,7 +246,15 @@ export default function LibraryPage() {
<tbody className="divide-y divide-border">
{ics.map((ic) => (
<tr key={ic.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">{ic.mpn}</td>
<td className="px-3 py-2 font-mono text-xs">
<button
type="button"
className="hover:underline"
onClick={() => setEditor({ type: "ic", name: ic.mpn })}
>
{ic.mpn}
</button>
</td>
<td className="px-3 py-2">
{ic.subtype ? (
<Badge variant="secondary">{ic.subtype}</Badge>
@@ -226,7 +265,12 @@ export default function LibraryPage() {
<td className="px-3 py-2 text-center">{ic.pin_count}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{ic.library_status === "needs_pintable" ||
ic.pin_count === 0 ? (
<Badge variant="outline">needs pin table</Badge>
) : (
<Badge variant="outline">pin table</Badge>
)}
{ic.has_datasheet && (
<DatasheetLink
mpn={ic.mpn}
@@ -272,7 +316,15 @@ export default function LibraryPage() {
{passiveParts.map((p) => (
<tr key={p.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">
<button
type="button"
className="hover:underline"
onClick={() =>
setEditor({ type: "passive_part", name: p.mpn })
}
>
{p.mpn}
</button>
</td>
<td className="px-3 py-2">
<Badge variant="secondary">
@@ -369,7 +421,15 @@ export default function LibraryPage() {
<tbody className="divide-y divide-border">
{simple.map((s) => (
<tr key={s.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">{s.mpn}</td>
<td className="px-3 py-2 font-mono text-xs">
<button
type="button"
className="hover:underline"
onClick={() => setEditor({ type: "simple", name: s.mpn })}
>
{s.mpn}
</button>
</td>
<td className="px-3 py-2">
<Badge variant="secondary">
{s.subtype || s.specs_type || "discrete"}
@@ -417,9 +477,11 @@ export default function LibraryPage() {
<Badge variant="outline">pin table ready</Badge>
) : d.has_model ? (
<Badge variant="outline">specs ready</Badge>
) : d.has_component ? (
<Badge variant="outline">in library</Badge>
) : (
<span className="text-xs text-muted-foreground">
PDF saved extract on next review
PDF saved
</span>
)}
</td>
@@ -18,6 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
@@ -239,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
return (
<div
ref={rootRef}
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
className={cn("mx-auto w-full px-6 py-6", focus ? "max-w-[1920px]" : "max-w-[720px]")}
>
<div className={cn("space-y-6", focus && "hidden")}>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Validation Report</h1>
<p className="text-sm text-muted-foreground">
{report.findings.length} findings &middot;{" "}
{new Date(report.timestamp).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<div className={cn("space-y-5", focus && "hidden")}>
<PageHeader
title={projectName || "Esame"}
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -296,7 +293,8 @@ function ReportContent({ projectId }: { projectId: string }) {
Sign release
</Button>
</div>
</div>
}
/>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
@@ -342,12 +340,12 @@ function ReportContent({ projectId }: { projectId: string }) {
</ul>
</div>
)}
{report.summary.total === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
<p className="text-sm font-medium">No findings</p>
<p className="text-xs text-muted-foreground">
No IC datasheets were available for review. Upload datasheets for each IC
on the project page and re-run the pipeline to get findings.
{report.summary.total === 0 &&
!(report.protocol_certification?.recognized_instances?.length) ? (
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun finding</p>
<p className="mt-1 text-[13px] text-neutral-500">
Carica i datasheet degli IC sulla pagina progetto e rilancia lesame.
</p>
</div>
) : (
@@ -368,6 +366,7 @@ function ReportContent({ projectId }: { projectId: string }) {
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
protocolSection={report.protocol_certification}
/>
)}
</div>
+26 -24
View File
@@ -6,10 +6,11 @@
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
@theme inline {
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
system-ui, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
--font-heading: var(--font-sans);
--font-headline: var(--font-dm-serif-display), Georgia, serif;
--font-headline: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -53,15 +54,15 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--radius: 0.6875rem;
--background: #f5f5f7;
--foreground: #1d1d1f;
--card: #ffffff;
--card-foreground: #1d1d1f;
--popover: #ffffff;
--popover-foreground: #1d1d1f;
--primary: #007aff;
--primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -69,22 +70,22 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: rgba(0, 0, 0, 0.1);
--input: rgba(0, 0, 0, 0.1);
--ring: #007aff;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar: #f5f5f7;
--sidebar-foreground: #1d1d1f;
--sidebar-primary: #007aff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: rgba(0, 122, 255, 0.12);
--sidebar-accent-foreground: #007aff;
--sidebar-border: rgba(0, 0, 0, 0.1);
--sidebar-ring: #007aff;
}
.dark {
@@ -129,7 +130,8 @@
@apply font-sans;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-feature-settings: "tnum" 1;
}
}
+1 -18
View File
@@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next";
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
@@ -13,22 +12,6 @@ import {
TWITTER_HANDLE,
} from "@/lib/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const dmSerifDisplay = DM_Serif_Display({
variable: "--font-dm-serif-display",
weight: "400",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
@@ -109,7 +92,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
className="h-full antialiased"
>
<body className="h-full">
<ThemeProvider>
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function PageHeader({
title,
meta,
actions,
}: {
title: string;
meta?: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
<div className="min-w-0">
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
{title}
</h1>
{meta ? (
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
@@ -4,20 +4,15 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ArrowLeft,
Boxes,
CircuitBoard,
BookOpen,
ClipboardList,
LayoutDashboard,
Folder,
Library,
Loader2,
MessageSquareWarning,
Ruler,
ScrollText,
ScanLine,
Settings,
Shield,
TableProperties,
Zap,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
@@ -72,15 +67,15 @@ export function Sidebar() {
const isAdmin = user?.isAdmin ?? false;
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
<div className="px-4 py-4 border-b border-border">
<aside className="flex w-[196px] shrink-0 flex-col border-r border-black/10 bg-[#f5f5f7]/90 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90">
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
<span className="text-[13px] font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{projectId ? (
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
<ProjectNav
@@ -95,23 +90,23 @@ export function Sidebar() {
)}
</div>
<div className="border-t border-border">
<div className="border-t border-black/10 dark:border-white/10">
<SidebarCredits />
<div className="px-2 py-1.5 border-b border-border/60">
<div className="border-b border-black/[0.06] px-2 py-1.5 dark:border-white/10">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-2.5">
<SidebarUserButton />
<Link
href="/changelog"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
className="text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900"
>
v{APP_VERSION}
</Link>
@@ -125,36 +120,35 @@ export function Sidebar() {
function navItemClass(active: boolean) {
return cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
"flex items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] transition-colors duration-150 ease-out",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
? "bg-[#007aff]/12 text-[#007aff]"
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
);
}
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 px-2 py-3 space-y-0.5">
<nav className="flex-1 space-y-0.5 px-2 py-3">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<LayoutDashboard className="h-4 w-4" />
Projects
<Folder className="h-3.5 w-3.5" />
Progetti
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-4 w-4" />
Library
<Library className="h-3.5 w-3.5" />
Libreria
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-4 w-4" />
My Feedback
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-4 w-4" />
<Shield className="h-3.5 w-3.5" />
Admin
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
</Link>
)}
</nav>
@@ -162,19 +156,18 @@ function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
const PROJECT_NAV_ITEMS: NavItem[] = [
const PRIMARY_NAV: NavItem[] = [
{ type: "tab", tab: "bom", label: "Esame", icon: ScanLine },
{ type: "route", path: "/report", label: "Protocolli", icon: BookOpen },
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
const SECONDARY_NAV: NavItem[] = [
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
{ type: "tab", tab: "settings", label: "Impostazioni", icon: Settings },
];
function NavLink({
@@ -220,8 +213,11 @@ function ProjectNav({
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
const onReport = pathname === `${base}/report`;
function isActive(item: NavItem): boolean {
if (item.label === "Protocolli") return onReport;
if (item.label === "Report") return onReport;
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
@@ -232,35 +228,12 @@ function ProjectNav({
}
function getHref(item: NavItem): string {
if (item.label === "Protocolli") return `${base}/report`;
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
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) => {
function renderItem(item: NavItem) {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
@@ -268,26 +241,53 @@ function ProjectNav({
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"
className="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] text-neutral-400"
>
<item.icon className="h-4 w-4" />
<item.icon className="h-3.5 w-3.5" />
{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.icon className="h-3.5 w-3.5" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</NavLink>
);
})}
}
return (
<nav className="flex-1 space-y-1 px-2 py-3">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<Folder className="h-3.5 w-3.5" />
Progetti
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-3.5 w-3.5" />
Libreria
</NavLink>
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
{project?.name ?? "…"}
</p>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Esame in corso
</NavLink>
)}
{PRIMARY_NAV.map(renderItem)}
</div>
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
{SECONDARY_NAV.map(renderItem)}
{isAdmin ? (
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
<Shield className="h-3.5 w-3.5" />
Log
</NavLink>
) : null}
</div>
</nav>
);
@@ -12,6 +12,7 @@ import type {
FindingStatus,
DesignGraph,
Collaborator,
ProtocolCertificationSection,
} from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
@@ -33,6 +34,7 @@ interface FindingsListProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
}
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
@@ -54,6 +56,7 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
}: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -161,8 +164,11 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
};
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
return (
<div className="space-y-4">
<ReportFilters
@@ -180,7 +186,7 @@ export function FindingsList({
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators}
/>
{filtered.length > 0 ? (
{showTree ? (
<FindingsTree findings={filtered} {...treeProps} />
) : reviewedFindings.length === 0 && findings.length > 0 ? (
<FindingsTreeEmpty />
@@ -1,21 +1,30 @@
"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,
FindingReview,
FindingStatus,
Collaborator,
Component,
DesignGraph,
FindingStatus,
ProtocolCertificationSection,
} from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
import { isPcbExamFinding } from "@/lib/layout-finding";
import {
STATUS_TINT,
forestHasCertifications,
groupByWorstStatus,
isProtocolFinding,
protocolInstancesFromSection,
protocolInstancesForStatus,
worstStatus,
type DomainGroup,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
interface FindingsTreeProps {
findings: Finding[];
@@ -35,12 +44,9 @@ interface FindingsTreeProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
function worstStatus(findings: Finding[]): FindingStatus {
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
return "INFO";
protocolSection?: ProtocolCertificationSection | null;
selectedKey?: string | null;
onSelect?: (key: string) => void;
}
function pinNetLabel(f: Finding): string {
@@ -52,69 +58,126 @@ function pinNetLabel(f: Finding): string {
return f.rule_id || "Finding";
}
function DisclosureMark({ open }: { open: boolean }) {
return (
<span
aria-hidden
className={cn(
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
"border border-black/20 text-[9px] leading-none text-neutral-600",
"dark:border-white/25 dark:text-neutral-300",
)}
>
{open ? "" : "+"}
</span>
);
}
function TreeBranch({
label,
extra,
findings,
children,
tint,
defaultOpen = false,
}: {
label: string;
extra?: string;
findings: Finding[];
findings: { status: FindingStatus }[];
children: ReactNode;
tint?: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const worst = worstStatus(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
>
<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 schematic = sorted.filter((f) => !isPcbExamFinding(f));
const pcb = sorted.filter(isPcbExamFinding);
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 });
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>
);
})}
@@ -122,29 +185,18 @@ export function FindingsTree(props: FindingsTreeProps) {
);
}
function DesignatorForest({
findings,
graph,
...cardProps
}: FindingsTreeProps) {
const byDes = new Map<string, Finding[]>();
for (const f of sortFindings(findings)) {
const list = byDes.get(f.designator) ?? [];
list.push(f);
byDes.set(f.designator, list);
}
const designators = [...byDes.keys()].sort((a, b) => {
const wa = worstStatus(byDes.get(a)!);
const wb = worstStatus(byDes.get(b)!);
const order = { ERROR: 0, WARNING: 1, INFO: 2 } as const;
return order[wa] - order[wb] || a.localeCompare(b);
});
function DomainBranch({
domain,
...props
}: { domain: DomainGroup } & FindingsTreeProps) {
return (
<div>
{designators.map((d) => {
const group = byDes.get(d)!;
const component: Component | undefined = graph.components[d];
<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 = props.graph.components[d];
const extra = [
component?.mpn,
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
@@ -152,10 +204,10 @@ function DesignatorForest({
.filter(Boolean)
.join(" · ");
const byLeaf = new Map<string, Finding[]>();
for (const f of group) {
const label = pinNetLabel(f);
for (const finding of group) {
const label = pinNetLabel(finding);
const list = byLeaf.get(label) ?? [];
list.push(f);
list.push(finding);
byLeaf.set(label, list);
}
const leaves = [...byLeaf.entries()].sort((a, b) => a[0].localeCompare(b[0]));
@@ -163,31 +215,31 @@ function DesignatorForest({
<TreeBranch key={d} label={d} extra={extra} findings={group}>
{leaves.map(([label, items]) => (
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
<div className="space-y-3 py-2">
{sortFindings(items).map((f) => {
const key = cardProps.findingKeys.get(f);
<div className="space-y-2 py-1.5">
{sortFindings(items).map((item) => {
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={f}
onViewReference={cardProps.onViewReference}
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
finding={item}
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={f.finding_id ? cardProps.comments?.[f.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={!!(f.finding_id && cardProps.reportedFindingIds?.has(f.finding_id))}
review={f.finding_id ? cardProps.reviews?.[f.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}
/>
);
})}
@@ -197,14 +249,90 @@ function DesignatorForest({
</TreeBranch>
);
})}
</TreeBranch>
))}
</TreeBranch>
);
}
function CertificationsBranch({
status,
instances,
findings,
...props
}: {
status: FindingStatus;
instances: ProtocolTreeInstance[];
findings: Finding[];
} & FindingsTreeProps) {
const all = [...findings, ...instances.map(() => ({ status }))];
return (
<TreeBranch label="Certificazioni" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch
label="Protocolli"
findings={all.length ? all : [{ status }]}
defaultOpen
>
{instances.map((inst) => (
<TreeBranch
key={inst.instanceId}
label={`${inst.logicalId || "protocol"}${inst.physicalId || "—"}`}
extra={inst.recognition}
findings={[{ status: inst.status }]}
>
<ProtocolInstanceBody instance={inst} />
</TreeBranch>
))}
{findings.length > 0 ? (
<TreeBranch label="Findings" findings={findings}>
<div className="space-y-2 py-1.5">
{sortFindings(findings).map((item) => {
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</TreeBranch>
) : null}
{instances.length === 0 && findings.length === 0 ? (
<p className="px-2 py-2 text-[13px] text-neutral-500">
{props.protocolSection?.message || "Nessun protocollo riconosciuto."}
</p>
) : null}
</TreeBranch>
</TreeBranch>
);
}
export function FindingsTreeEmpty() {
return (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
<p className="text-[15px] font-medium tracking-tight">Nessun finding visibile</p>
<p className="mt-1 text-[13px] text-neutral-500">
I filtri nascondono lelenco. Togli un filtro, oppure apri i findings già rivisti sotto.
</p>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import type { ProtocolCertificationSection } from "@/lib/types";
import {
formatLimit,
formatMargin,
formatMeasured,
formatProtocolChain,
sortProtocolChecks,
type ProtocolReportCheck,
} from "@/lib/protocol-report";
import {
protocolInstancesFromSection,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
import { cn } from "@/lib/utils";
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
const checks = sortProtocolChecks(instance.checks);
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
const logical = instance.logicalId;
const physical = instance.physicalId;
return (
<div className="space-y-2 py-2 pr-2">
<p className="text-[11px] tabular-nums text-neutral-500">
{logical} {physical}
{instance.recognition ? ` · ${instance.recognition}` : ""}
{instance.worst ? ` · ${instance.worst}` : ""}
</p>
{fail ? (
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa non sotto Warning.</p>
) : null}
<div className="overflow-x-auto">
<table className="w-full text-[11px] tabular-nums">
<thead>
<tr className="text-left text-neutral-500">
<th className="py-1 pr-2 font-medium">Lv</th>
<th className="py-1 pr-2 font-medium">Check</th>
<th className="py-1 pr-2 font-medium">Result</th>
<th className="py-1 pr-2 font-medium">Measured</th>
<th className="py-1 pr-2 font-medium">Limit</th>
<th className="py-1 pr-2 font-medium">Margin</th>
<th className="py-1 pr-2 font-medium">Source</th>
<th className="py-1 font-medium">Method</th>
</tr>
</thead>
<tbody>
{checks.map((c, j) => (
<ProtocolCheckRow
key={`${c.level}-${c.check}-${j}`}
check={c}
logicalId={logical}
physicalId={physical}
/>
))}
</tbody>
</table>
</div>
{instance.chainExample ? (
<p className="break-all text-[11px] text-neutral-500">Catena: {instance.chainExample}</p>
) : null}
{checks
.filter((c) => c.result === "FAIL" || c.skip_visible)
.map((c, j) =>
c.chain ? (
<p key={`chain-${j}`} className="break-all text-[11px] text-neutral-500">
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
</p>
) : null,
)}
</div>
);
}
function ProtocolCheckRow({
check: c,
logicalId,
physicalId,
}: {
check: ProtocolReportCheck;
logicalId: string;
physicalId: string;
}) {
const isFail = c.result === "FAIL";
const unknown = c.result === "UNKNOWN";
const measured = formatMeasured(c, logicalId, physicalId);
const limit = formatLimit(c, logicalId, physicalId);
const margin = formatMargin(c, logicalId, physicalId);
return (
<tr
className={cn(
"border-t border-black/[0.06] dark:border-white/10",
isFail && "font-medium text-[rgb(180,40,35)]",
unknown && !isFail && "text-neutral-600",
)}
>
<td className="py-1 pr-2 font-mono">{c.level}</td>
<td className="py-1 pr-2">{c.check}</td>
<td className="py-1 pr-2">{c.result}{c.skip_visible && c.result !== "FAIL" ? " · skip" : ""}</td>
<td className="py-1 pr-2 font-mono">{measured}</td>
<td className="py-1 pr-2 font-mono">{limit}</td>
<td className="py-1 pr-2 font-mono">{margin}</td>
<td className="py-1 pr-2">{c.source || "—"}</td>
<td className="py-1">{c.method || "—"}</td>
</tr>
);
}
/** Standalone block — used only if the tree is empty of findings. */
export function ProtocolSection({
section,
}: {
section?: ProtocolCertificationSection | null;
}) {
const instances = protocolInstancesFromSection(section);
const message = section?.message || "Nessun protocollo riconosciuto.";
const maxLevel = section?.max_level_reached || "—";
return (
<section className="space-y-2">
<p className="text-[11px] text-neutral-500">
Livello massimo {maxLevel} (L0L3). HDMI 1.4 TMDS resta UNKNOWN niente Zdiff inventato.
</p>
{instances.length === 0 ? (
<p className="text-[13px] text-neutral-600">{message}</p>
) : (
<ul className="space-y-3">
{instances.map((inst) => (
<li key={inst.instanceId}>
<ProtocolInstanceBody instance={inst} />
</li>
))}
</ul>
)}
</section>
);
}
@@ -1,6 +1,5 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ReportSummaryProps {
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
totalCostUsd?: number | null;
}
const CELLS = [
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
];
export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
const grid =
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total || errors + warnings + infos;
const outcome =
errors > 0 ? "Error" : warnings > 0 ? "Attenzione" : total > 0 ? "Completato" : "Senza findings";
const bits = [
`${errors} Error`,
`${warnings} Warning`,
`${infos} Info`,
`${reviewedCount} rivisti`,
];
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
bits.push(`$${totalCostUsd.toFixed(2)}`);
}
if (typeof creditsSpent === "number" && creditsSpent > 0) {
bits.push(`${creditsSpent.toFixed(2)} crediti`);
}
return (
<div className="space-y-4">
<div className={cn("grid gap-4", grid)}>
{CELLS.map(({ key, label, color }) => (
<Card key={key}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
{summary[key] ?? 0}
<p 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>
);
}
@@ -10,7 +10,9 @@ const PCB_STAGES = [
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, power traces, thermal, Kelvin" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, HF lines, power traces, thermal, Kelvin" },
{ id: "af_trace", title: "AF trace analysis", description: "λ/10 and tr trigger, closed-form Z0, RLGC cascade (not OpenEMS)" },
{ id: "af_ai", title: "AF Board + AI", description: "AI flags HF issues, then deterministic investigate (does not replace SI/HF)" },
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
] as const;
+51
View File
@@ -369,6 +369,7 @@ export interface LibraryIC {
pin_count: number;
has_ratings: boolean;
has_datasheet: boolean;
library_status?: string;
}
export interface LibraryPassive {
@@ -402,6 +403,7 @@ export interface LibraryDatasheet {
hash: string | null;
has_extraction: boolean;
has_model: boolean;
has_component?: boolean;
}
export interface LibraryCatalog {
@@ -423,6 +425,55 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
return URL.createObjectURL(await res.blob());
}
export async function importLibraryDatasheet(
mpn: string,
file: File,
kind: "ic" | "passive_part" | "simple" = "ic",
): Promise<{
mpn: string;
blob_key: string;
has_extraction: boolean;
has_model: boolean;
component_type?: string;
status?: string;
}> {
const body = new FormData();
body.append("mpn", mpn);
body.append("file", file);
body.append("kind", kind);
const res = await send("/api/library/datasheets", { method: "POST", body });
return jsonOrThrow(res, "Failed to add component to the library");
}
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
export async function fetchLibraryComponent(
componentType: LibraryComponentType,
name: string,
): Promise<Record<string, unknown>> {
const res = await send(
`/api/library/components/${componentType}/${encodeURIComponent(name)}`,
{ cache: "no-store" },
);
return jsonOrThrow(res, "Failed to load component");
}
export async function updateLibraryComponent(
componentType: LibraryComponentType,
name: string,
payload: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const res = await send(
`/api/library/components/${componentType}/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
return jsonOrThrow(res, "Failed to update component");
}
async function postPipeline(path: string, fallback: string, body?: unknown) {
const res = await send(path, {
method: "POST",
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { groupByWorstStatus, groupDomains, isProtocolFinding, 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 {
finding_id: null,
mpn: "",
aspect: null,
finding: partial.finding ?? "",
why: "",
source_page: null,
reference: "",
...partial,
};
}
test("U18 worst status is ERROR when PE-PLC-002 is inside", () => {
const items = [
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
f({ designator: "U18", status: "WARNING" }),
f({ designator: "U18", status: "WARNING" }),
f({ designator: "U18", status: "WARNING" }),
f({ designator: "U18", status: "WARNING" }),
f({ designator: "U18", status: "WARNING" }),
];
assert.equal(worstStatus(items), "ERROR");
const groups = groupByWorstStatus(items);
assert.deepEqual(
groups.map((g) => g.label),
["Error"],
);
assert.equal(groups[0].designators[0].designator, "U18");
assert.equal(groups[0].findings.length, 6);
assert.equal(groups[0].status, "ERROR");
});
test("Error folder lists J2 connectors, not only U*", () => {
const groups = groupByWorstStatus([
f({ designator: "J2", status: "ERROR", rule_id: "PE-BOM-011" }),
f({ designator: "U11", status: "ERROR" }),
f({ designator: "U5", status: "WARNING" }),
]);
assert.equal(groups[0].label, "Error");
const refs = groups[0].designators.map((d) => d.designator);
assert.deepEqual(refs, ["J2", "U11"]);
assert.ok(!refs.every((r) => r.startsWith("U")));
assert.equal(groups[1].label, "Warning");
});
test("Error folder lists domains and rules after status, protocol stays out of those domains", () => {
const groups = groupByWorstStatus([
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
f({ designator: "U18", status: "WARNING", rule_id: "PE-PLC-001" }),
f({
designator: "J1",
status: "ERROR",
rule_id: "PE-PRT-L0-001",
source: "protocol_l0",
}),
]);
assert.equal(groups[0].label, "Error");
assert.deepEqual(
groups[0].domains.map((d) => d.id),
["PLC"],
);
assert.equal(groups[0].domains[0].rules.map((r) => r.ruleId).sort().join(","), "PE-PLC-001,PE-PLC-002");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
assert.ok(isProtocolFinding({ rule_id: "PE-PRT-L0-001", source: "protocol_l0" }));
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
});
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
assert.equal(
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
true,
);
assert.equal(
isPcbExamFinding({ source: "bom_pcb_check", rule_id: "PE-BOM-011" }),
true,
);
});
@@ -0,0 +1,268 @@
/** Group report findings for the tree: status folders, then domains/rules, then Certificazioni. */
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
import {
instanceWorstResult,
protocolResultToFindingStatus,
type ProtocolReportCheck,
} from "./protocol-report";
export const STATUS_FOLDER: Record<FindingStatus, string> = {
ERROR: "Error",
WARNING: "Warning",
INFO: "Info",
};
export const STATUS_TINT: Record<FindingStatus, string> = {
ERROR: "bg-[rgba(255,59,48,0.07)]",
WARNING: "bg-[rgba(255,214,10,0.08)]",
INFO: "bg-[rgba(142,142,147,0.08)]",
};
const DOMAIN_LABEL: Record<string, string> = {
PLC: "Placement",
LAY: "Layout",
SI: "Signal integrity",
BOM: "BOM",
PWR: "Power",
THM: "Thermal",
VIA: "Vias",
KEL: "Kelvin",
STCH: "Stitching",
DDR: "DDR",
CPU: "CPU",
FPGA: "FPGA",
DRT: "Derating",
HIER: "Hierarchy",
TIM: "Timing",
PI: "Power integrity",
ESD: "ESD",
RET: "Return path",
SPOF: "SPOF",
EMI: "EMI",
STK: "Stackup",
ANT: "Antenna",
PDN: "PDN",
PD: "USB-PD",
AF: "AF Board + AI",
PRT: "Protocolli",
MUX: "Pin mux",
NC: "NC pins",
DEC: "Decoupling",
SCH: "Schematic",
PCB: "PCB exam",
};
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
return "INFO";
}
export type DesignatorGroup = {
designator: string;
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. */
export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
const byDes = new Map<string, Finding[]>();
for (const f of findings) {
const d = (f.designator || "").trim() || "(no ref)";
const list = byDes.get(d) ?? [];
list.push(f);
byDes.set(d, list);
}
return [...byDes.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([designator, items]) => ({ designator, findings: items }));
}
export function isProtocolFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const src = f.source || "";
const rid = f.rule_id || "";
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
}
export function ruleDomain(f: Finding): { id: string; label: string } {
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocolli" };
const rid = f.rule_id || "";
const m = rid.match(/^PE-([A-Z]+)/);
if (m) {
const id = m[1];
return { id, label: DOMAIN_LABEL[id] || id };
}
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
return { id: "PCB", label: DOMAIN_LABEL.PCB };
}
return { id: "SCH", label: DOMAIN_LABEL.SCH };
}
export function groupRules(findings: Finding[]): RuleGroup[] {
const byRule = new Map<string, Finding[]>();
for (const f of findings) {
const id = (f.rule_id || "").trim() || "(no rule)";
const list = byRule.get(id) ?? [];
list.push(f);
byRule.set(id, list);
}
return [...byRule.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ruleId, items]) => ({
ruleId,
findings: items,
designators: groupByDesignator(items),
}));
}
export function groupDomains(findings: Finding[]): DomainGroup[] {
const byId = new Map<string, Finding[]>();
for (const f of findings) {
const { id } = ruleDomain(f);
const list = byId.get(id) ?? [];
list.push(f);
byId.set(id, list);
}
return [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, items]) => ({
id,
label: DOMAIN_LABEL[id] || id,
findings: items,
rules: groupRules(items),
}));
}
/**
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
* U18 with PE-PLC-002 ERROR + five WARNING Error, badge ERROR, count 6.
* Protocol findings (PE-PRT) are excluded from domain folders they sit under Certificazioni.
*/
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
ERROR: [],
WARNING: [],
INFO: [],
};
for (const row of groupByDesignator(findings)) {
buckets[worstStatus(row.findings)].push(row);
}
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
return order
.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: 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);
}
@@ -18,9 +18,23 @@ export function isLayoutFinding(f: {
f.source === "bom_pcb_check" ||
f.source === "spof_check" ||
f.source === "emi_check" ||
f.source === "hf_line_check" ||
f.source === "hf_bus_class" ||
f.source === "stackup_check" ||
f.source === "usb_pd_check" ||
f.source === "antenna_layout_check" ||
f.source === "pdn_check" ||
f.source === "af_ai_hf" ||
f.source === "af_trace_check" ||
f.source === "protocol_l0" ||
f.source === "protocol_l1" ||
f.source === "protocol_l2" ||
rid.startsWith("PE-PLC") ||
rid.startsWith("PE-LAY") ||
rid.startsWith("PE-SI") ||
rid.startsWith("PE-DDR") ||
rid.startsWith("PE-CPU") ||
rid.startsWith("PE-FPGA") ||
rid.startsWith("PE-DRT") ||
rid.startsWith("PE-PWR") ||
rid.startsWith("PE-THM") ||
@@ -34,37 +48,28 @@ export function isLayoutFinding(f: {
rid.startsWith("PE-RET") ||
rid.startsWith("PE-SPOF") ||
rid.startsWith("PE-EMI") ||
rid.startsWith("PE-STK") ||
rid.startsWith("PE-PD-") ||
rid.startsWith("PE-ANT") ||
rid.startsWith("PE-PDN") ||
rid.startsWith("PE-AF") ||
rid.startsWith("PE-PRT") ||
/^PE-BOM-01[0-4]$/.test(rid)
);
}
export function isPcbExamFinding(f: {
finding_id?: string | null;
source?: string | null;
rule_id?: string | null;
}): boolean {
return isLayoutFinding(f) && !isAfAiFinding(f);
}
export function isAfAiFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const rid = f.rule_id || "";
return (
f.source === "pcb_review" ||
f.source === "pcb_power_thermal" ||
f.source === "hierarchy_check" ||
f.source === "timing_check" ||
f.source === "pi_check" ||
f.source === "esd_return_check" ||
f.source === "bom_pcb_check" ||
f.source === "spof_check" ||
f.source === "emi_check" ||
rid.startsWith("PE-PWR") ||
rid.startsWith("PE-THM") ||
rid.startsWith("PE-VIA") ||
rid.startsWith("PE-KEL") ||
rid.startsWith("PE-STCH") ||
rid.startsWith("PE-HIER") ||
rid.startsWith("PE-TIM") ||
rid.startsWith("PE-PI") ||
rid.startsWith("PE-ESD") ||
rid.startsWith("PE-RET") ||
rid.startsWith("PE-SPOF") ||
rid.startsWith("PE-EMI") ||
/^PE-BOM-01[0-4]$/.test(rid)
);
return f.source === "af_ai_hf" || f.source === "af_trace_check" || rid.startsWith("PE-AF");
}
@@ -0,0 +1,114 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
formatLimit,
formatMeasured,
formatProtocolChain,
isHdmi14TmdsElectrical,
instanceWorstResult,
protocolResultRank,
protocolResultToFindingStatus,
sortProtocolChecks,
} from "./protocol-report";
import { groupByWorstStatus } from "./findings-forest";
import type { Finding } from "./types";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
finding_id: null,
mpn: "",
aspect: null,
finding: partial.finding ?? "",
why: "",
source_page: null,
reference: "",
...partial,
};
}
test("FAIL ranks before WARNING and is not buried", () => {
assert.ok(protocolResultRank("FAIL") < protocolResultRank("WARNING"));
assert.ok(protocolResultRank("FAIL") < protocolResultRank("UNKNOWN"));
const sorted = sortProtocolChecks([
{ check: "z", result: "UNKNOWN", level: "L2" },
{ check: "topology", result: "FAIL", level: "L0" },
{ check: "length", result: "PASS", level: "L1" },
]);
assert.equal(sorted[0].result, "FAIL");
assert.equal(sorted[0].check, "topology");
assert.equal(instanceWorstResult(["UNKNOWN", "FAIL", "PASS"]), "FAIL");
assert.equal(protocolResultToFindingStatus("FAIL"), "ERROR");
});
test("chain is net → group → physical → protocol → constraint → document → section", () => {
const text = formatProtocolChain({
net: "USB_D+",
group: "DIFF_USB",
physical_interface_id: "usb2-hs-dpair",
logical_protocol_id: "usb2-hs",
constraint_id: "usb2-hs-dpair-differential_impedance",
document: "",
section: "",
});
assert.equal(
text,
"USB_D+ → DIFF_USB → usb2-hs-dpair → usb2-hs → usb2-hs-dpair-differential_impedance → — → —",
);
assert.ok(!text.includes("90"));
});
test("protocol FAIL finding stays in Error folder with WARNING skips on same ref", () => {
const groups = groupByWorstStatus([
f({
designator: "U1",
status: "ERROR",
rule_id: "PE-PRT-L0-001",
source: "protocol_l0",
finding: "L0 FAIL topology",
}),
f({
designator: "U1",
status: "WARNING",
rule_id: "PE-PRT-L2-001",
source: "protocol_l2",
finding: "non certificata a L2 per mancanza di Z",
}),
]);
assert.equal(groups[0].label, "Error");
assert.equal(groups[0].status, "ERROR");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
});
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
const row = {
check: "differential_impedance",
level: "L2",
result: "UNKNOWN",
measured: 100,
limit: 100,
margin: 0,
unit: "ohm",
chain: {
logical_protocol_id: "hdmi-1.4",
physical_interface_id: "hdmi-1.4-tmds-type-a",
},
};
assert.equal(isHdmi14TmdsElectrical(row), true);
assert.equal(formatLimit(row), "UNKNOWN");
assert.equal(formatMeasured(row), "UNKNOWN");
assert.ok(!formatLimit(row).includes("100"));
assert.ok(!formatMeasured(row).includes("Ω"));
});
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
const row = {
check: "cec",
level: "L2",
result: "PASS",
limit: 27000,
unit: "ohm",
chain: { logical_protocol_id: "hdmi-1.4" },
};
assert.equal(isHdmi14TmdsElectrical(row), false);
assert.equal(formatLimit(row), "27000 ohm");
});
@@ -0,0 +1,174 @@
/** Protocol report ranking and chain. FAIL never ranks under WARNING. */
export const PROTOCOL_LEVELS = ["L0", "L1", "L2", "L3"] as const;
export type ProtocolCheckResult =
| "PASS"
| "FAIL"
| "WARNING"
| "UNKNOWN"
| "NOT_APPLICABLE"
| "VENDOR_DEPENDENT"
| "CONTROLLER_DEPENDENT"
| "PHY_DEPENDENT"
| "MISSING_SOURCE";
export type ProtocolChain = {
net?: string;
group?: string;
physical_interface_id?: string;
logical_protocol_id?: string;
constraint_id?: string;
document?: string;
section?: string;
};
export type ProtocolReportCheck = {
check: string;
level: string;
result: string;
mandatory?: string;
measured?: number | null;
limit?: number | null;
margin?: number | null;
unit?: string;
source?: string;
method?: string;
notes?: string;
skip_visible?: boolean;
rank?: number;
chain?: ProtocolChain;
};
const SKIP = new Set([
"UNKNOWN",
"MISSING_SOURCE",
"VENDOR_DEPENDENT",
"CONTROLLER_DEPENDENT",
"PHY_DEPENDENT",
]);
export function protocolResultRank(result: string): number {
if (result === "FAIL") return 0;
if (result === "WARNING" || SKIP.has(result)) return 1;
if (result === "NOT_APPLICABLE") return 2;
return 3;
}
export function sortProtocolChecks<T extends { result: string; level?: string; check?: string }>(
rows: T[],
): T[] {
return [...rows].sort((a, b) => {
const d = protocolResultRank(a.result) - protocolResultRank(b.result);
if (d !== 0) return d;
return String(a.level).localeCompare(String(b.level)) || String(a.check).localeCompare(String(b.check));
});
}
export function instanceWorstResult(results: string[]): string {
if (!results.length) return "UNKNOWN";
return results.reduce((best, r) =>
protocolResultRank(r) < protocolResultRank(best) ? r : best,
);
}
export function protocolResultToFindingStatus(
result: string,
): "ERROR" | "WARNING" | "INFO" {
if (result === "FAIL") return "ERROR";
if (result === "WARNING" || SKIP.has(result)) return "WARNING";
return "INFO";
}
export function formatProtocolChain(chain?: ProtocolChain | null): string {
if (!chain) return "—";
const keys: (keyof ProtocolChain)[] = [
"net",
"group",
"physical_interface_id",
"logical_protocol_id",
"constraint_id",
"document",
"section",
];
return keys.map((k) => chain[k] || "—").join(" → ");
}
const HDMI14_TMDS_ELECTRICAL = new Set([
"differential_impedance",
"impedance_tolerance",
"intra_pair_skew",
"inter_lane_skew",
"clock_data_relationship",
"data_rate",
"transition_rate",
"channel_loss",
"insertion_loss",
"return_loss",
"crosstalk",
]);
export function isHdmi14Protocol(id?: string | null): boolean {
const s = (id || "").toLowerCase();
return s === "hdmi-1.4" || s.startsWith("hdmi-1.4-");
}
/** HDMI 1.4 TMDS electrical is UNKNOWN until the Adopter spec is on file. CEC from HDMI 1.3 stays. */
export function isHdmi14TmdsElectrical(
row: Pick<ProtocolReportCheck, "check" | "chain">,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
const logical = logicalId || row.chain?.logical_protocol_id || "";
const physical = physicalId || row.chain?.physical_interface_id || "";
if (!isHdmi14Protocol(logical) && !isHdmi14Protocol(physical)) return false;
const check = (row.check || "").toLowerCase();
if (check === "cec" || check.startsWith("cec_")) return false;
if (HDMI14_TMDS_ELECTRICAL.has(check)) return true;
return check.includes("impedance") || check.includes("zdiff");
}
export function formatUnknownNotGuessed(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return true;
return row.result === "UNKNOWN";
}
export function formatMeasured(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.measured == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.measured}${unit}`;
}
export function formatLimit(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.limit == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.limit}${unit}`;
}
export function formatMargin(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.margin == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.margin}${unit}`;
}
+9
View File
@@ -75,6 +75,15 @@ export interface ValidationReport {
comments?: Record<string, FindingComment[]>;
review_states?: Record<string, FindingReview>;
release?: ReportRelease;
protocol_certification?: ProtocolCertificationSection | null;
}
export interface ProtocolCertificationSection {
schema_version: string;
macrophase: string;
recognized_instances: Record<string, unknown>[];
message: string;
max_level_reached: string | null;
}
export type NetType = "power" | "ground" | "signal" | "unknown";
+2 -2
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.61.0";
export const APP_VERSION_DATE = "2026-09-21";
export const APP_VERSION = "2.83.0";
export const APP_VERSION_DATE = "2026-09-22";
+12
View File
@@ -44,6 +44,18 @@
"ic.memory.eeprom": {
"description": "EEPROM"
},
"ic.memory.ddr": {
"description": "DDR SDRAM",
"example_mpn": "MT41K256M16TW"
},
"ic.memory.sram": {
"description": "Parallel SRAM",
"example_mpn": "IS61WV51216"
},
"ic.fpga": {
"description": "Field-programmable gate array",
"example_mpn": "XC7A35T"
},
"ic.logic.buffer": {
"description": "Buffer or line driver"
},
+27
View File
@@ -0,0 +1,27 @@
# Protocol specification archive
PDFs live **on disk** in this folder. They are **not** committed to GitHub/Gitea (license). See `.gitignore` `standards/protocol-specs/*.pdf`.
Mirror: agent-store `internal/protocol-specs/`.
TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync.com/hdmi-pcb-layout/` is a blog — **not packed**.
| Filename | Title | Revision | Cited by (physical / notes) | Class |
|---|---|---|---|---|
| `usb_20.pdf` | Universal Serial Bus Specification | Revision 2.0, April 27, 2000 | `usb2-ls-dpair`, `usb2-fs-dpair`, `usb2-hs-dpair` (data_rate, pull-up/down, VBUS, cable Z0, HS PCB *should*) | STANDARD |
| `USB_2.0_Electrical_Compliance_Test_Spec.pdf` | USB 2.0 Electrical Compliance Test Specification | Version 1.08, April 2026 | `usb2-hs-dpair` DCR 17/23/13 Ω, HS edges | STANDARD |
| `USB Type-C Spec R2.5 - March 2026.pdf` | USB Type-C Cable and Connector Specification | Release 2.5, March 2026 | `usb-c-receptacle` Rp/Rd/Ra, charger VBUS Fig 4-39, zOPEN, tCCDebounce, tVBUSON | STANDARD |
| `USB Type-C Spec R2.0 - August 2019.pdf` | USB Type-C Cable and Connector Specification | Release 2.0, August 2019 | `usb-c-receptacle` Source CC shall-detect Table 4-32 (vRd/vOPEN/vRa) | STANDARD |
| `USB_Type-C_Functional_Test_Spec.pdf` | USB Type-C Functional Test Specification Chapters 4 and 5 | Revision 0.91, March 3, 2024 | `usb-c-receptacle` cable IR drop 250/500 mV OPTIONAL CABLE | STANDARD (OPTIONAL CABLE) |
| `DVI_Specification_Revision_1.0.pdf` | Digital Visual Interface (DVI) | Revision 1.0, 02 April 1999 | `dvi-1.0-tmds` termination / Zdiff | STANDARD |
| `802.3-2012_section2.pdf` | IEEE Std 802.3-2012 IEEE Standard for Ethernet (Section Two) | 2012 | `100base-tx-mdi`, `100base-fx-pcb` | STANDARD |
| `802.3-2015_SECTION1.pdf` | IEEE Std 802.3-2015 IEEE Standard for Ethernet (Section One) | 2015 | `10base-t-mdi` Clause 14 | STANDARD |
| `802.3-2012_section3.pdf` | IEEE Std 802.3-2012 IEEE Standard for Ethernet (Section Three) | 2012 | `1000base-t-mdi` Clause 40; `1000base-x-pcb` Clause 36 | STANDARD |
| `CEC_HDMI_Specification.pdf` | High-Definition Multimedia Interface Specification | Version 1.3, June 22, 2006 | HDMI `cec*` fields (Supplement 1 + Table 4-27). **Not** used for TMDS/FRL Zdiff | STANDARD (CEC only) |
| `tmds1204.pdf` | TMDS1204 12Gbps HDMI Hybrid Redriver | SLLSF57A August 2022 / revised April 2024 | `tmds1204-hdmi-sink`, `tmds1204-hdmi-source`; HDMI **2.0 / FRL** common layout `source_type=VENDOR` | **VENDOR** |
| `slla633.pdf` | TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204 | SLLA633 May 2024 | HDMI 2.0+/`tmds1204-*` `sigdet_wakeup` PHY_DEPENDENT (no layout ohms) | **VENDOR** |
| `HDMI-FRL-Software-Datasheet-61W6169600.pdf` | HDMI 2.1 FRL Compliance Test Solution Datasheet (Tektronix) | (on file; 4 pages) | **not packed** — test-equipment datasheet, not HDMI Forum Adopter | archived only |
| `snla027b.pdf` | AN-807 Reflections: Computations and Waveforms | SNLA027B May 2004 / revised May 2004 | **not packed** for USB-C vSafe — TI SI, no vSafe5V/vSafe0V | archived only |
| `sdaa499.pdf` | Simple Methods to Reduce EMI from a PCB Trace | SDAA499 September 2026 | **not packed** for USB-C vSafe — TI EMI, no vSafe | archived only |
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
View File
+208
View File
@@ -0,0 +1,208 @@
"""AF board + AI: hypotheses of probable HF issues, then deterministic checks.
Does not replace run_pcb_checks. Not DRC. Never invents Z.
"""
from __future__ import annotations
from backend.periscopex.af_ai_hf import (
HfHypothesis,
append_investigated,
hypotheses_from_rows,
investigate_hf_hypotheses,
)
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutDielectric,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.pcb_checks import run_pcb_checks
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> 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 _usb_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-"},
),
},
nets={
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
},
)
def _fp(ref: str, pads: list[LayoutPad], x: float = 0.0) -> LayoutFootprint:
return LayoutFootprint(reference=ref, footprint="P", x=x, y=0.0, pads=pads)
def _stub_layout() -> LayoutGraph:
return LayoutGraph(
footprints={
"U1": _fp("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
def _stub_constraints() -> dict:
return {
"PHY": ComponentConstraints(
mpn="PHY", pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0,
"note": "USB stub < 1 mm", "source_page": 12,
}],
),
}
def test_ai_stub_flag_then_deterministic_pe_si_007():
graph = _usb_graph()
layout = _stub_layout()
cons = _stub_constraints()
flags = [HfHypothesis(net="USB_D+", issue_class="stub", why="looks long")]
out = investigate_hf_hypotheses(graph, cons, layout, flags)
codes = [f.rule_id for f in out]
assert "PE-SI-007" in codes
hit = next(f for f in out if f.rule_id == "PE-SI-007")
assert "FAIL" in hit.finding
assert hit.evidence_status == "SUFFICIENT"
def test_ai_z0_flag_without_stackup_is_insufficient_no_ohm():
graph = _usb_graph()
layout = _stub_layout()
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm")]
out = investigate_hf_hypotheses(graph, {}, layout, flags)
assert len(out) == 1
f = out[0]
assert f.rule_id == "PE-AF-001"
assert f.evidence_status == "INSUFFICIENT"
assert "90" not in f.finding
assert "50" not in f.finding
assert "Ω" not in f.finding and "ohm" not in f.finding.lower()
def test_ai_flag_on_missing_superspeed_net_is_silence():
graph = _usb_graph()
layout = _stub_layout()
flags = [HfHypothesis(net="USB3_SSTX_P", issue_class="stub", why="SS pair")]
out = investigate_hf_hypotheses(graph, _stub_constraints(), layout, flags)
assert out == []
def test_run_pcb_checks_still_finds_stub_without_af_ai():
"""AF+AI is extra: the PCB check list must still emit PE-SI-007 alone."""
graph = _usb_graph()
layout = _stub_layout()
pcb = run_pcb_checks(graph, _stub_constraints(), layout)
assert any(f.rule_id == "PE-SI-007" for f in pcb)
hf = check_hf_lines(graph, _stub_constraints(), layout)
assert any(f.rule_id == "PE-SI-007" for f in hf)
def test_z0_with_stackup_does_not_invent_ohm_number():
graph = _usb_graph()
layout = _stub_layout()
layout.stackup = LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
)
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="check Z0")]
out = investigate_hf_hypotheses(graph, {}, layout, flags)
for f in out:
assert "50 Ω" not in (f.finding or "")
assert "90 Ω" not in (f.finding or "")
def test_append_investigated_keeps_pcb_checks_and_adds_pe_af():
graph = _usb_graph()
layout = _stub_layout()
cons = _stub_constraints()
pcb = run_pcb_checks(graph, cons, layout)
n_si = sum(1 for f in pcb if f.rule_id == "PE-SI-007")
assert n_si >= 1
flags = [
HfHypothesis(net="USB_D+", issue_class="stub", why="looks long"),
HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm"),
]
added = append_investigated(pcb, graph, cons, layout, flags)
assert any(f.rule_id == "PE-AF-001" for f in added)
assert sum(1 for f in pcb if f.rule_id == "PE-SI-007") == n_si
assert "si_check" in _pcb_checks_source()
assert "hf_line" in _pcb_checks_source()
def test_hypotheses_from_rows_drops_unknown_class():
rows = [
{"net": "USB_D+", "issue_class": "stub", "why": "x"},
{"net": "USB_D+", "issue_class": "clearance", "why": "drc"},
{"net": "", "issue_class": "z0", "why": "empty"},
]
hyps = hypotheses_from_rows(rows)
assert len(hyps) == 1
assert hyps[0].issue_class == "stub"
def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
import inspect
from backend.periscopex import pcb_checks
from backend.services import pcb_pipeline
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
assert "run_pcb_checks" in src
assert "check_af_traces" in src
assert "_run_af_ai_section" in src
assert src.find("run_pcb_checks") < src.find("check_af_traces")
assert src.find("check_af_traces") < src.find("_run_af_ai_section")
checks = inspect.getsource(pcb_checks.run_pcb_checks)
assert '("si_check"' in checks or '"si_check"' in checks
assert '"hf_line"' in checks
assert "af_ai" not in checks
assert "check_af_traces" not in checks
assert "investigate_hf_hypotheses" not in checks
def _pcb_checks_source() -> str:
import inspect
from backend.periscopex.pcb_checks import run_pcb_checks
return inspect.getsource(run_pcb_checks)
+4 -1
View File
@@ -16,7 +16,10 @@ for p in (
REPO_ROOT,
):
s = str(p)
if p.is_dir() and s not in sys.path:
if not p.is_dir():
continue
if s in sys.path:
sys.path.remove(s)
sys.path.insert(0, s)
from backend.vendor_path import ensure_impedancefinder
View File

Some files were not shown because too many files have changed in this diff Show More