Compare commits

...
50 Commits
Author SHA1 Message Date
michele a0f513ce3d UI reorder 2.87.5: one report tree, KiCad-only ingress, uniform shell.
Kill the dual report (old FindingCard chrome inside the tree), drop dead
ComponentGroup/PcbExamSection and the Protocols duplicate tab, skip BOM
csv/xlsx entry UI, and align Exam/Settings/filters with the iPad top bar.
2026-09-23 09:04:35 +02:00
michele 06ebb5a42d Gate PE-PLC-002 thermal vias on mandatory datasheet cite (2.87.4).
ADAU1467 p.203 Fig.87 "shows 49 vias" is an example grid, not a
mandatory minimum — ERROR only for "at least N" language with cite;
figure counts become REVIEW+quote (same ESD cite gate).
2026-09-23 08:47:31 +02:00
michele bb293414d7 Fix CI: align protocol tests + package version with packed catalog.
Update SGMII skeleton test for Cisco NUMERIC 1.25 Gbaud; expect cited
USB 90 Ω in report/catalog; sync periscope-web to changelog 2.87.3.
2026-09-23 08:38:05 +02:00
michele 7fd03607ea Soft-bump 2.87.3: HubAudio AF verify + USB 90 Ω regression.
HubAudio's last exam was still on 2.87.0 while live carried 2.87.2 AF
fixes. Cue a re-run and lock USB_D+ skipping PE-AF-002 in favour of the
English protocol 90 Ω cert line.
2026-09-23 08:29:10 +02:00
michele 78a7da2dbf Fix HubAudio PE-AF-002 noise: MDIO≠MDI, RD≡RX, USB/RMII cert (2.87.2).
Exclude ETH_MDIO/MDC from IEEE MDI 100 Ω. Deduplicate RD+/RX and TD+/TX
pair aliases to one cert per channel; inherit LAN8720A 10/100 when the
magnetics-side nets lack the PHY. USB D+/D− skip AF tr/f (90 Ω cert owns
them); RMII stays on the English PE-AF-003 AN-133 line.
2026-09-23 08:05:24 +02:00
michele 5c87d706c2 Remove coffee/email notice from progress UI (2.87.1).
Replace the running-pipeline coffee banner and email promise with a
neutral status line so the notice never shows again.
2026-09-23 07:46:13 +02:00
michele 299ffaf396 Document GitHub as primary source of truth for Periscope.
Gitea remains a secondary mirror. No version bump — README-only.
2026-09-23 07:40:30 +02:00
michele 09a7b85770 Note README rewrite in 2.87.0 changelog. 2026-09-23 07:33:43 +02:00
michele 065fb508a4 Rewrite README for 2.87.0 as Periscope product copy.
Describe PCB analysis, protocol cert (USB/Ethernet/MAC–PHY), findings
tree, top-bar UI, and Markdown export. Gitea is the source of truth.
No predecessor-product mentions.
2026-09-23 07:33:31 +02:00
michele 7ad5a692a7 Merge MAC–PHY pack into integrate; soft-bump live to 2.87.0.
Resolves changelog conflict: one coherent release above 2.86.0 covering
iPad top-bar / Markdown export, MAC–PHY catalog pack, USB/RMII cert lines,
and not_reviewed fix. Cert branch content already present via identical
patch on mac-phy (627db2e5359729).
2026-09-23 07:26:15 +02:00
michele 085273e356 Merge iPad top-bar / Save as MD (2.85.0 UI). 2026-09-23 07:25:27 +02:00
michele 35cbc7ffd5 Pack MAC–PHY bus specs (AN-133, OPEN RGMII, SGMII) and fix fake RMII cert (2.86.0).
Recognition alone is not certified; PE-AF-003 requires a cited calculable check.
RJ45/MDI 100 Ω stays separate from RMII/MII/RGMII/SGMII.
2026-09-23 07:04:57 +02:00
michele 5b2d3cd613 iPad top-bar shell, light theme default, Markdown report export (2.85.0).
Replace the left sidebar with a thin top toolbar and tab/sheet navigation.
Light is default; dark mode lives in Settings and persists. Report export
is Save as Markdown (.md) with the findings-tree hierarchy — not Excel.
2026-09-23 07:03:16 +02:00
michele 627db2eba6 USB/RMII cert lines and not_reviewed count fix (2.85.0).
USB D+/D− is one English certified/not-certified line vs the packed 90 Ω
cite. RMII ETH_RXD/TXD/TXEN is identified as PHY–MAC, not RJ45 100 Ω.
J2 missing-field L1/L2/L3 noise is silent. PCB AI failures go to
review_errors; not_reviewed count matches one designator per row.
2026-09-23 06:51:47 +02:00
michele 5ab69a835c English findings-tree labels and PCB stage timings (2.84.0).
Certifications, Protocols, domains, empty states, and the protocol empty message are English. Error / Warning / Info stay. Sidebar version is stamped 2.84.0. A PCB exam records extract, pcb_checks, protocol, af, and total seconds.
2026-09-22 22:15:23 +02:00
michele f79c722e68 Match exposed pads by land, cite ESD, follow CC to Rd (2.84.0).
PE-BOM-011 treats EP/EPAD/THERMAL PAD and the footprint exposed land as one pad when both sides have one. PE-ESD-001 quotes a datasheet only with part and section, and does not warn on a 3V3 jack rail, uncitable VBUS, or optical S/PDIF. CC Rd is the packed 5.1 kΩ on the Type-C CC pin net. Ethernet MDI pair Z is PASS, FAIL, or ERROR against the cited 10/100 or gigabit clause.
2026-09-22 21:56:09 +02:00
michele 7e872cfbc3 Drop .ts suffixes from frontend imports for Next typecheck.
Production next build rejects allowImportingTsExtensions-style paths.
2026-09-22 18:54:25 +02:00
michele 3046c4aeee Fix findings-tree TS: do not pass findings twice.
Spread FindingsTreeProps first, then override protocol findings for CertificationsBranch so Next production typecheck can build 2.83.0.
2026-09-22 18:53:13 +02:00
michele b32d581d27 Merge protocol-cert-m0 (vSafe UNKNOWN 2.83) into ui-apple-shell.
Keep Apple shell + findings tree; include USB-C vSafe UNKNOWN from catalog (no invented volts). Changelog 2.83.0 covers both.
2026-09-22 18:49:52 +02:00
michele d5fff7c6dd Apple-like UI shell: findings tree and HDMI 1.4 UNKNOWN.
Keep Error/Warning/Info folders with + disclosure, then domains/rules,
then Certificazioni/Protocolli. FAIL stays first. HDMI 1.4 TMDS Zdiff
renders UNKNOWN — catalog JSON unchanged.
2026-09-22 18:41:20 +02:00
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
209 changed files with 39662 additions and 1867 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
+73 -55
View File
@@ -1,55 +1,49 @@
# 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 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: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
Version: **2.87.2** (see `periscope/src/frontend/content/changelog.md`)
Operator: Michele Bigi
## What changed for DeepSeek
## What you get
DeepSeek's Chat Completions API is OpenAI-compatible but **does not accept native PDF documents**. Periscope therefore:
- **PCB + schematic review** — same finding object for `MODE=run` (schematic) and `MODE=pcb` (layout). Findings keep FACT, REQUIREMENT, and INFERENCE apart.
- **Protocol-aware certification** — gated on parts that are present. USB 2.0 D+/D (pair Z vs packed 90 Ω), Ethernet MDI / RJ45, and MACPHY buses (RMII / MII / RGMII / SGMII) with catalog cites from packed specs. Missing evidence is silence or `not certified — because …`, not invented ohms or timing.
- **Findings tree** — Error / Warning / Info first, then domains, rules, Certifications, and Protocols. Report export is **Save as Markdown** (`.md`).
- **Apple / iPad-style shell** — thin top bar, tab navigation, light theme by default, dark mode in Settings.
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.
## What it will not invent
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 or sitting inside a courtyard 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). It does not invent current, impedance, copper weight, geometry, or “typical” bus amps.
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the part reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip.
**Certifiers are gated on parts that are present.** No USB-C receptacle → no USB-C findings. A bare RJ45 is Ethernet, not PoE. RMII PHYMAC nets are not treated as RJ45/MDI 100 Ω pairs. Recognition alone is not certification.
Periscope does **not** claim FEM, thermal spreading, OpenEMS, or a field solver. Closed-form Z0 comes from vendored ImpedenceFinder (`vendor/impedancefinder/`; upstream license UNKNOWN).
## 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/` | Product code — finding engine, PCB/placement, protocol catalog, DeepSeek pipelines, Next.js UI, Dockerfiles, KiCad plugin |
| `periscope/dependency/` | Shared AGPL overlays (parsers, extraction shell, UI primitives) merged at build/runtime — do not delete |
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
| `standards/protocol-specs/` | On-disk protocol PDF archive (PDFs gitignored; `INDEX.md` committed) |
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
| `LICENSE` | GNU AGPL v3 |
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 +52,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 — materialize the UI tree, then start Next.js:
```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`.
Tests from the repo root:
```bash
pip install pytest pytest-asyncio
pytest tests/ -q
python3 scripts/smoke_simple_project.py # offline
```
## Docker
@@ -73,27 +81,37 @@ 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`**:
```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 of truth
**GitHub** is the primary remote for this product. Push `main` there first.
| Remote | URL | Role |
| --- | --- | --- |
| GitHub | [https://github.com/manvalan/periscope](https://github.com/manvalan/periscope) (`git@github.com:manvalan/periscope.git`) | **Primary** — source of truth |
| Gitea | [http://192.168.1.71:3000/michele/periscope](http://192.168.1.71:3000/michele/periscope) (`git@gitea:michele/periscope.git`, SSH port **2223**) | Secondary / mirror — do not delete |
```bash
git push github main
git push gitea main # optional mirror
```
Do not force-push `main`.
## License
AGPL-3.0 (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.
For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
+24 -4
View File
@@ -10,9 +10,29 @@ from pathlib import Path
from pkgutil import extend_path
_REPO = Path(__file__).resolve().parents[1]
for _p in (_REPO / "periscope" / "src", _REPO / "periscope" / "dependency"):
_SRC = _REPO / "periscope" / "src"
_DEP = _REPO / "periscope" / "dependency"
# Last insert is searched first: native src overlays inherited dependency.
for _p in (_DEP, _SRC):
_s = str(_p)
if _p.is_dir() and _s not in sys.path:
sys.path.insert(0, _s)
if not _p.is_dir():
continue
if _s in sys.path:
sys.path.remove(_s)
sys.path.insert(0, _s)
__path__ = list(extend_path(__path__, __name__))
def _prefer_native_backend(paths: list[str]) -> list[str]:
src, other, dep = [], [], []
for p in paths:
norm = p.replace("\\", "/")
if "/periscope/src/" in norm:
src.append(p)
elif "/periscope/dependency/" in norm:
dep.append(p)
else:
other.append(p)
return src + other + dep
__path__ = _prefer_native_backend(list(extend_path(__path__, __name__)))
+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
```
+5 -5
View File
@@ -1,14 +1,14 @@
# Periscope tree layout
Physical split (not a rewrite). AGPL `LICENSE` stays at the git root. The GitHub fork is not detached.
Physical split under the git root. AGPL `LICENSE` stays at the repository root.
| Tree | Path | Role |
| --- | --- | --- |
| Native Periscope | `periscope/src/` | Finding engine clamp, PCB/placement/antenna, DeepSeek, local auth, KiCad plugin, deploy Dockerfiles |
| Inherited PinScope | `periscope/dependency/` | Graph/parsers/`validate.py`, pipeline/extraction, OSS Next.js shell, skills, taxonomy, `simple_project` |
| Product | `periscope/src/` | Finding engine, PCB/placement, protocol catalog + certifiers, DeepSeek pipelines, local auth, Next.js UI, KiCad plugin, deploy Dockerfiles |
| Overlays | `periscope/dependency/` | Shared AGPL packages (graph/parsers, extraction pipeline, UI primitives, skills, taxonomy, `simple_project`) merged at build/runtime |
| Third party | `vendor/impedancefinder/` | ImpedenceFinder (license UNKNOWN) |
| Glue | `backend/__init__.py` | Merges the two `backend` packages for local imports |
**Do not empty-delete `periscope/dependency/`.** Later phases replace PinScope modules incrementally in `periscope/src` (Fase C). `validate.py` stays until a native reviewer exists.
**Do not empty-delete `periscope/dependency/`.** Native modules land in `periscope/src/` over time; overlays stay until each piece has a product replacement.
Docker overlays `dependency` then `src` into `/app`. Local frontend: `cd periscope/dependency/frontend && npm run dev` (native files are symlinked from `periscope/src/frontend`).
Docker overlays `dependency` then `src` into `/app`. Local frontend materialization: `./scripts/materialize-frontend.sh` (see root `README.md`).
+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
@@ -26,7 +26,6 @@ import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
@@ -248,23 +247,23 @@ export default function ProgressPage({
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">
Feel free to grab a coffee
Pipeline running
</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
Stages update below as the run proceeds.
</p>
</div>
</div>
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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
+409 -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,24 @@ _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+)?$",
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|"
r"THERMAL[\s_]?PAD|EXPOSED[\s_]?PAD)(?:[_-]?\d+)?$",
re.I,
)
_EP_SPLIT = re.compile(r"^\d+_\d+$")
_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 +78,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 +92,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 +110,236 @@ 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_exposed_alias(name: str) -> bool:
"""EP, EPAD, THERMAL PAD, exposed pad. Not a signal pin number."""
s = str(name or "").strip()
if not s:
return False
if _EP_PAD.match(s):
return True
compact = re.sub(r"[\s_\-]+", "", s).lower()
return compact in {"thermalpad", "exposedpad", "epad", "thermal"}
def _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."""
def _ids_have_exposed(
ids: list[tuple[str, str]] | None,
pin_count: int | None,
) -> bool:
if not ids:
return False
return any(_is_ep_land(n, pf, pin_count) for n, pf in ids)
def _drop_exposed_name_mismatch(
miss: list[str],
extra: list[str],
*,
left_has: bool,
right_has: bool,
) -> tuple[list[str], list[str]]:
"""Same exposed pad under two names is not a missing/extra pin.
Datasheet EP with no exposed land on the other side stays a miss.
"""
if not (left_has and right_has):
return miss, extra
return (
[m for m in miss if not _is_exposed_alias(m)],
[e for e in extra if not _is_exposed_alias(e)],
)
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
s = str(number).strip()
pf = str(pinfunction or "").strip()
return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf)))
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 fp_layout is None:
if not cons:
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):
continue
out.add(n)
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_pad_ids(
footprint: str,
search_dirs: list[Path] | None,
) -> list[tuple[str, str]] | None:
path = resolve_kicad_mod(footprint, search_dirs)
if path is None:
return None
return parse_kicad_mod_pads(path)
def _lib_join_keys(
footprint: str,
*,
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 +386,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 +403,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 +417,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 +433,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 +494,184 @@ 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)
pcb_has_ep = _ids_have_exposed(pcb_ids, ep_count)
ds_has_ep = any(_is_exposed_alias(k) for k in ds)
lib_raw = _lib_pad_ids(fp_name, footprint_dirs)
lib_has_ep = _ids_have_exposed(lib_raw, ep_count)
lib, lib_path = _lib_join_keys(
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
)
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,
)
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).",
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:
)
if lib is not None:
miss_lib, extra_lib = _drop_exposed_name_mismatch(
sorted(ds - lib), sorted(lib - ds),
left_has=ds_has_ep, right_has=lib_has_ep,
)
if miss_lib or extra_lib:
rec = (
f"Fix {ref} pinout: pads vs pintable. Missing on PCB: "
f"{missing_pcb or ''}; extra on PCB: {extra_pcb or ''}."
f"Change {ref}'s KiCad footprint library so pad names match "
f"the datasheet pintable (not a PCB pad-count guess)."
)
out.append(_mismatch(
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
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, extra_emb = _drop_exposed_name_mismatch(
sorted(lib - pcb) if pcb else sorted(lib),
sorted(pcb - lib) if pcb else [],
left_has=lib_has_ep, right_has=pcb_has_ep,
)
if pcb and (miss_emb or extra_emb):
rec = (
f"Replace {ref}'s embedded PCB footprint so it matches the "
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, extra = _drop_exposed_name_mismatch(
sorted(ds - pcb), sorted(pcb - ds),
left_has=ds_has_ep, right_has=pcb_has_ep,
)
if miss or extra:
rec = (
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
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 (
@@ -26,7 +27,14 @@ _NC_NET_RE = re.compile(
re.I,
)
_ONBOARD_POWER_RE = re.compile(
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|3V3|\+3V3|3\.3V)$",
r"^(?:GND|AGND|DGND|PGND|GNDA|VSYS|VCC\d*|VDD\d*|3V3|\+3V3|3\.3V)"
r"(?:[_./-].*)?$",
re.I,
)
_VBUS_LEAF_RE = re.compile(r"(?:^|[_./-])VBUS(?:$|[_./-])", re.I)
_SPDIF_RE = re.compile(r"SP(?:DIF|IF)", re.I)
_OPTICAL_RE = re.compile(
r"TOSLINK|OPTOCOUPL|OPTICAL|OPTODEVICE|\bAFBR\b|PHOTO[-_ ]?COUPL",
re.I,
)
@@ -56,6 +64,50 @@ def _skip_esd_net(net: str) -> bool:
return False
def _optical_evidence(graph: DesignGraph, comp) -> bool:
"""Schematic/BOM/footprint shows TOSLINK, optocoupler, or optical."""
parts = [
comp.value or "",
comp.footprint or "",
comp.mpn or "",
comp.component_subtype or "",
]
row = (graph.bom_fields or {}).get(comp.reference) or {}
for key in ("description", "Description", "value", "Value", "footprint", "Footprint"):
if row.get(key):
parts.append(str(row.get(key)))
return bool(_OPTICAL_RE.search(" ".join(parts)))
def _esd_cite(cons, *, need_vbus: bool) -> str | None:
"""Part + section/page quote. None when the datasheet is not cited."""
if cons is None:
return None
for rule in cons.layout_rules or []:
if not isinstance(rule, dict):
continue
blob = " ".join(
str(rule.get(k) or "")
for k in ("kind", "parameter", "note", "net", "pin", "requirement")
)
if "esd" not in blob.lower():
continue
if need_vbus and not re.search(r"vbus", blob, re.I):
continue
doc = str(
rule.get("document") or rule.get("part") or rule.get("source") or ""
).strip()
section = str(rule.get("section") or "").strip()
page = str(rule.get("page") or "").strip()
if not doc or not (section or page):
continue
loc = section
if page:
loc = f"{loc} p.{page}".strip()
return f"{doc} {loc}".strip()
return None
def _is_j_connector(comp) -> bool:
if comp.component_type != ComponentType.CONNECTOR:
return False
@@ -67,7 +119,8 @@ def check_esd(
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
"""One REVIEW per J* connector → IC net with no ESD part. No GPIO/NC/rail spam."""
_ = constraints_map
from backend.periscopex.constraints_lookup import match_constraints
out: list[Finding] = []
seen: set[tuple[str, str, str]] = set()
@@ -105,28 +158,64 @@ def check_esd(
(str(p) for p, n in ic.pins.items() if n and kicad_nets_match(n, net)),
"",
)
rec = (
f"Add a datasheet-specified ESD device on {net} between "
f"{j_ref} and {ic_ref}."
)
leaf = _net_leaf(net)
is_vbus = bool(_VBUS_LEAF_RE.search(leaf))
is_rail = bool(_ONBOARD_POWER_RE.match(leaf))
is_spdif = bool(_SPDIF_RE.search(leaf))
optical = _optical_evidence(graph, j_comp)
cons = match_constraints(ic.mpn or ic.value, constraints_map or {})
quote = _esd_cite(cons, need_vbus=is_vbus)
if is_spdif and optical:
continue
if is_vbus and not quote:
continue
if is_rail and not quote:
continue
if is_spdif and not optical:
finding = (
f"{net} ({j_ref}{ic_ref}) was not verified as optical "
"S/PDIF."
)
rec = (
f"Confirm whether {net} is photo-coupled "
"(TOSLINK/optocoupler). The path was not verified as optical."
)
requirement = (
"Optical S/PDIF is not an external copper signal. "
"Without TOSLINK/optocoupler evidence the path stays visible."
)
else:
finding = (
f"{net} is a {j_ref}{ic_ref} path with no ESD/"
"protection part on the net."
)
if quote:
rec = (
f"Add a datasheet-specified ESD device ({quote}) on "
f"{net} between {j_ref} and {ic_ref}."
)
requirement = f"Cited ESD requirement: {quote}."
else:
rec = (
f"Review board ESD on {net} between {j_ref} and {ic_ref}."
)
requirement = (
"ConnectorIC nets are reviewed for a board ESD part. "
"No datasheet section is cited for this net."
)
out.append(Finding(
designator=ic_ref,
mpn=ic.mpn or "",
aspect="esd",
finding=(
f"{net} is a {j_ref}{ic_ref} path with no ESD/"
"protection part on the net."
),
finding=finding,
facts=(
f"Net {net}; connector={j_ref}.{j_pin}; IC={ic_ref}; "
"ESD parts on net: 0."
+ (f" cite={quote}." if quote else " cite=none.")
),
requirement=(
"External ESD is recommended on connectorIC nets unless "
"the extraction lists a mandatory clamp with a measured FACT."
),
requirement=requirement,
inference="REVIEW — on-die esd_clamp_pins are not a board RULE.",
why="Only J* ∩ IC nets; NC, unconnected, VSYS/GND/3V3 excluded.",
why="Only J* ∩ IC nets; NC, unconnected, onboard rails excluded.",
status="WARNING",
recommendation=rec,
action=rec,
@@ -210,3 +299,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
@@ -131,7 +131,7 @@ def _seed() -> None:
requirement="KiCad /net vs flattened schematic net is the same net.")
for rid, req in (
("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."),
("PE-PLC-002", "Thermal via min_via_count from layout_rules."),
("PE-PLC-002", "Thermal via min_via_count — ERROR only for a MANDATORY datasheet cite."),
("PE-PLC-003", "same_layer decoupling from layout_rules."),
("PE-SI-001", "Differential skew vs length_match mm."),
):
@@ -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 / "No protocol recognized.". 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):
+69 -3
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
@@ -143,6 +149,52 @@ def check_pcb_derating(graph: DesignGraph) -> list[Finding]:
return out
def _expand_not_reviewed(rows: list) -> list[dict]:
"""One designator per entry. Comma-joined refs become separate rows."""
out: list[dict] = []
for row in rows:
if not isinstance(row, dict):
continue
raw = str(row.get("designator") or "").strip()
reason = str(row.get("reason") or "").strip() or "not reviewed"
if not raw:
continue
parts = [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()]
if not parts:
parts = [raw]
for ref in parts:
out.append({"designator": ref, "reason": reason})
return out
def _merge_not_reviewed(
schema_nr: list,
pcb_nr: list,
covered: set[str],
review_errors: dict[str, str] | None = None,
) -> list[dict]:
"""Dedupe by designator. Drop ICs that were reviewed. Count == list length.
Legacy ``pcb_review error`` rows move into ``review_errors`` when possible
and are not kept as not-reviewed.
"""
errors = review_errors if review_errors is not None else {}
merged: dict[str, str] = {}
for row in _expand_not_reviewed(list(schema_nr or []) + list(pcb_nr or [])):
ref = row["designator"]
if ref in covered:
continue
reason = row["reason"]
if reason == "pcb_review error":
errors.setdefault(ref, "pcb_review error")
continue
prev = merged.get(ref)
if prev and prev != "pcb_review error" and reason == "pcb_review error":
continue
merged[ref] = reason
return [{"designator": k, "reason": merged[k]} for k in sorted(merged)]
def merge_schema_pcb_reports(
schema: dict | None, pcb: dict | None,
) -> dict | None:
@@ -183,10 +235,17 @@ def merge_schema_pcb_reports(
if st in summary:
summary[st] = summary.get(st, 0) + 1
out["summary"] = summary
covered = set((schema or {}).get("coverage") or {}) | set((pcb or {}).get("coverage") or {})
schema_err = dict((schema or {}).get("review_errors") or {})
pcb_err = dict((pcb or {}).get("review_errors") or {})
merged_err = {**schema_err, **pcb_err}
schema_nr = list((schema or {}).get("not_reviewed") or [])
pcb_nr = list((pcb or {}).get("not_reviewed") or [])
if schema_nr or pcb_nr:
out["not_reviewed"] = schema_nr + pcb_nr
out["not_reviewed"] = _merge_not_reviewed(schema_nr, pcb_nr, covered, merged_err)
if merged_err:
out["review_errors"] = merged_err
elif "review_errors" in out and not out["review_errors"]:
out.pop("review_errors", None)
return out
@@ -270,6 +329,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 +340,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=[],
)]
@@ -3,17 +3,21 @@
Runs only when a LayoutGraph is present and a decoupling_proximity rule
has a numeric max_distance_mm. Null millimetres skip — no 3 mm default.
Thermal vias (`PE-PLC-002`) skip without courtyard vertices and without
min_via_count — no invented pad radius. same_layer (`PE-PLC-003`) uses
the boolean parameter plus footprint layers from the PCB. Crystals use
the same decoupling_proximity rule. Track length is shortest path on
segments vs max_distance_mm — no invented “much larger than euclidean”.
Keepout (`PE-PLC-004`) is a foreign net endpoint inside the courtyard.
min_via_count — no invented pad radius. ERROR only when the datasheet
cites a MANDATORY via count (“at least N”); figure examples (e.g.
“shows 49 vias … example”) are REVIEW + cite, same gate as PE-ESD-001.
same_layer (`PE-PLC-003`) uses the boolean parameter plus footprint
layers from the PCB. Crystals use the same decoupling_proximity rule.
Track length is shortest path on segments vs max_distance_mm — no
invented “much larger than euclidean”. Keepout (`PE-PLC-004`) is a
foreign net endpoint inside the courtyard.
"""
from __future__ import annotations
import heapq
import math
import re
from backend.periscopex.models import (
ComponentConstraints,
@@ -281,6 +285,31 @@ def _in_poly(x: float, y: float, poly: list[tuple[float, float]]) -> bool:
return inside
def _thermal_via_mandatory_language(note: str) -> bool:
"""True only for explicit minimum counts — not figure “shows N vias” examples."""
text = (note or "").lower()
if not text:
return False
if re.search(
r"\b(for an example|layout example|example[—–-]?\s*(top|side)?\s*view|"
r"shows\s+\d+\s+vias|arranged in\s+a?\s*\d+\s*[x×]\s*\d+)\b",
text,
):
return False
return bool(
re.search(
r"\b(at least|minimum of|no fewer than|not less than|"
r"must (?:use|place|have)|shall (?:use|place|have)|"
r"required(?:\s+to)?)\b.{0,40}\b\d+\b.{0,20}\bvia",
text,
)
or re.search(
r"\b\d+\b.{0,12}\b(?:or more|minimum)\b.{0,20}\bvia",
text,
)
)
def _thermal_via_finding(ref, comp, cons, rule, layout: LayoutGraph) -> list[Finding]:
min_n = rule.get("min_via_count")
if min_n is None:
@@ -291,22 +320,80 @@ def _thermal_via_finding(ref, comp, cons, rule, layout: LayoutGraph) -> list[Fin
n = sum(1 for v in layout.vias if _in_poly(v.x, v.y, fp.courtyard))
if n >= int(min_n):
return []
note = str(rule.get("note") or "").strip()
page = rule.get("source_page")
# Same EP/ESD cite gate: no "datasheet-required" without a page or quote.
if page is None and not note:
return []
pin = str(rule.get("pin") or "").strip()
quote = note or f"layout_rules min_via_count={int(min_n)}"
cite = f"p.{page}" if isinstance(page, int) else "cited layout note"
mandatory = _thermal_via_mandatory_language(note)
if mandatory:
rec = (
f"Add at least {int(min_n)} thermal vias in the exposed-pad "
f"courtyard ({cite}: {quote})."
)
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=(
f"{n} thermal vias in courtyard of {ref} "
f"(min_via_count {int(min_n)})."
),
facts=(
f"Courtyard vias={n}; layout_rules min_via_count={int(min_n)}; "
f"cite={cite}."
),
requirement=f"Mandatory thermal-via count: {quote} ({cite}).",
why=f"layout_rules min_via_count={int(min_n)} ({cite}).",
status="ERROR",
recommendation=rec,
action=rec,
source="placement_check",
rule_id="PE-PLC-002",
finding_class="RULE",
provenance="MANDATORY",
evidence_status="SUFFICIENT",
pins=[pin] if pin else [],
source_page=page if isinstance(page, int) else None,
source_quote=quote[:500],
)]
# Example / recommended figure count (ADAU1467 Fig. 87 “shows 49 vias”).
rec = (
f"Review thermal vias under {ref} against the datasheet layout "
f"guidance ({cite}). The cited count is not a mandatory minimum."
)
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=(
f"{n} thermal vias in courtyard of {ref} "
f"(min_via_count {int(min_n)})."
f"(example/guidance count {int(min_n)}, not a mandatory minimum)."
),
why=f"layout_rules min_via_count={int(min_n)}.",
status="ERROR",
recommendation="Add vias in the thermal pad courtyard.",
facts=(
f"Courtyard vias={n}; extracted min_via_count={int(min_n)}; "
f"cite={cite}; language=example/recommended."
),
requirement=(
f"Datasheet layout guidance ({cite}): {quote}. "
"Figure/example via counts are not RULE ERROR."
),
inference="REVIEW — example land-pattern via grids are not MANDATORY FACT.",
why=f"layout_rules min_via_count={int(min_n)} is not mandatory language.",
status="WARNING",
recommendation=rec,
action=rec,
source="placement_check",
rule_id="PE-PLC-002",
finding_class="REVIEW",
provenance="EXAMPLE",
evidence_status="SUFFICIENT",
pins=[pin] if pin else [],
source_page=rule.get("source_page"),
source_page=page if isinstance(page, int) else None,
source_quote=quote[:500],
)]
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,518 @@
"""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 (
Component,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
ResistorSpecs,
)
from backend.periscopex.pcb_net_match import (
normalize_kicad_hierarchy_net,
refs_on_matched_net,
)
from backend.periscopex.protocol_catalog import (
SCHEMA_VERSION,
PhysicalInterface,
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",
})
_OHM_EMBEDDED = re.compile(r"^(\d+)([RKMG])(\d+)$", re.I)
_OHM_SUFFIX = re.compile(r"^(\d*\.?\d+)([RKMG])$", re.I)
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
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 _parse_ohms(raw: str | None) -> float | None:
t = (raw or "").strip().upper().replace("OHMS", "").replace("OHM", "")
t = t.replace("Ω", "").replace(" ", "")
if not t:
return None
m = _OHM_EMBEDDED.match(t)
if m:
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
return (float(m.group(1)) + float(f"0.{m.group(3)}")) * mult
m = _OHM_SUFFIX.match(t)
if m:
mult = {"R": 1.0, "K": 1e3, "M": 1e6, "G": 1e9}[m.group(2)]
return float(m.group(1)) * mult
try:
return float(t)
except ValueError:
return None
def _resistor_ohms(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
return float(specs.value_ohms)
return _parse_ohms(comp.value)
def _rd_cite() -> tuple[float | None, str]:
"""CabCon Rd already packed. No other USB number is introduced here."""
cat = load_catalog()
chosen = None
fallback = None
for iface in cat.physical_interfaces:
if iface.id not in {"usb-c-usb2-receptacle", "usb-c-receptacle"}:
continue
for c in iface.constraints:
if c.parameter == "rd" and c.value is not None:
if iface.id == "usb-c-usb2-receptacle":
chosen = c
elif fallback is None:
fallback = c
chosen = chosen or fallback
if chosen is None or chosen.value is None:
return None, "CabCon Rd cite is not in the protocol pack."
src = chosen.source
bits = []
if src and src.document:
bits.append(src.document)
if src and src.revision:
bits.append(src.revision)
if src and src.section:
bits.append(f"§{src.section}")
if src and src.table:
bits.append(src.table)
if src and src.page:
bits.append(f"p.{src.page}")
nominal = "nominal 5.1 kΩ" if any(
"5.1" in (cond or "") for cond in (chosen.conditions or [])
) else f"{chosen.value:g} {chosen.unit or 'ohm'}"
cite = ", ".join(bits) if bits else chosen.id
return float(chosen.value), f"{cite}; {nominal}"
def _cc_pin_pairs(comp: Component) -> list[tuple[str, str | None]]:
"""Receptacle CC pins. A5/B5 are the Type-C CC contacts, not net names."""
pins = {str(k).upper(): v for k, v in comp.pins.items()}
if "A5" in pins or "B5" in pins:
return [("A5", pins.get("A5")), ("B5", pins.get("B5"))]
if "CC1" in pins or "CC2" in pins:
return [("CC1", pins.get("CC1")), ("CC2", pins.get("CC2"))]
return []
def _typec_receptacle(graph: DesignGraph, inst: PhysicalBusInstance) -> Component | None:
found: list[Component] = []
for comp in graph.components.values():
if comp.component_type != ComponentType.CONNECTOR:
continue
if _cc_pin_pairs(comp):
found.append(comp)
if not found:
return None
for comp in found:
if comp.reference == inst.host_ref:
return comp
inst_nets = set(inst.nets)
for g in inst.groups:
inst_nets.update(g.nets)
for comp in found:
if inst_nets.intersection(v for v in comp.pins.values() if v):
return comp
return found[0]
def _rd_refs_on_net(graph: DesignGraph, net: str, nominal: float) -> list[str]:
"""Resistors on this net whose value is the packed Rd (float slack 1 Ω)."""
hits: list[str] = []
for ref in refs_on_matched_net(graph, net):
comp = graph.components.get(ref)
if comp is None or comp.component_type != ComponentType.RESISTOR:
continue
ohms = _resistor_ohms(comp)
if ohms is not None and abs(ohms - nominal) <= 1.0:
hits.append(ref)
return hits
def _cc_rd_on_pins(
graph: DesignGraph,
inst: PhysicalBusInstance,
mand: MandatoryClass,
) -> L0CheckResult:
"""Pin → net → Rd. The net does not have to be named CC1/CC2."""
nominal, cite = _rd_cite()
jack = _typec_receptacle(graph, inst)
if jack is None or nominal is None:
return _pack(
"cc_rd_rp", "FAIL", mand,
notes=(
"Type-C CC pins were not followed to an Rd. "
f"{cite}"
),
)
pairs = _cc_pin_pairs(jack)
nets: list[str] = []
missing: list[str] = []
present: list[str] = []
for pin, net in pairs:
if not net:
missing.append(f"{jack.reference}.{pin} has no net")
continue
nets.append(net)
refs = _rd_refs_on_net(graph, net, nominal)
if refs:
present.append(f"{jack.reference}.{pin} net {net} Rd {', '.join(refs)}")
else:
missing.append(
f"{jack.reference}.{pin} net {net} has no {nominal:g} Ω Rd"
)
if missing or len(present) < 2:
detail = "; ".join(missing) if missing else "both CC pins need an Rd"
return _pack(
"cc_rd_rp", "FAIL", mand, nets=nets,
notes=f"{detail}. {cite}. Net name is not required.",
)
return _pack(
"cc_rd_rp", "PASS", mand, nets=nets,
notes=f"{'; '.join(present)}. {cite}.",
)
def _run_l0_check(
graph: DesignGraph,
inst: PhysicalBusInstance,
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":
return _cc_rd_on_pins(graph, inst, mand)
if check == "vbus_gnd":
vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
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,553 @@
"""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 is a visible skip, not PASS. A geometric check
with no NUMERIC or DESIGN millimetre limit does not warn: the standard
did not set a mm limit, and one is not invented.
"""
from __future__ import annotations
from 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()
if not grouping and r.limit_mm is None:
# No NUMERIC/DESIGN millimetre limit in the pack. Silence.
continue
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
status = "WARNING"
finding = r.notes or (
f"{inst.physical_interface_id} L1 {r.check} is {r.result}. "
"Geometric only, 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,897 @@
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
Z only from datasheet / stackup / fabricator / cited pack. USB 2.0 HS
D+/D pair Z is compared to the packed ``pcb_trace_nominal_zdiff``
(RECOMMENDED). That number is not invented here and is not a MANDATORY
FAIL. Silicon cross is max PCB × PHY subset when datasheet windows exist.
A missing field (termination cite, rise/fall, return path, voltage) does
not emit a warning. Length is not delay. Not L3 / OpenEMS.
"""
from __future__ import annotations
import math
from typing import Any, Literal
from pydantic import BaseModel, Field
from backend.periscopex.constraints_lookup import match_constraints
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.impedance import GeometryError, TraceGeometry, coupled_diff_z
from backend.periscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
LayoutGraph,
)
from backend.periscopex.protocol_catalog import (
PhysicalInterface,
ProtocolCatalog,
ProtocolConstraint,
load_catalog,
map_protocol_outcome,
)
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
from backend.periscopex.protocol_recognize import PhysicalBusInstance
from backend.periscopex.si_check import single_ended_eth_mac
PACK_MACROPHASE = "M4"
SOURCE = "protocol_l2"
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 = ""
# "" legacy, "silent" no finding, "warn" one WARNING, "error" one ERROR.
emit: 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 = "",
emit: 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,
emit=emit,
)
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
_NOISE_CHECKS = frozenset({
"differential_impedance",
"impedance",
"impedance_if_required",
"termination",
"cc_termination",
"voltage",
"levels",
"rise_time",
"fall_time",
"timing",
"return_path",
"magnetics",
"phy_requirements",
"ac_coupling",
})
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
return _pack(
check, "UNKNOWN" if kind not in {
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
"VENDOR_DEPENDENT", "NOT_APPLICABLE",
} else kind,
mand,
value_kind=kind,
emit="silent",
notes=(
f"{inst.physical_interface_id} has no cited Z window "
"(datasheet, stackup, fabricator, or pack). Not a warning."
),
)
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, iface, graph, nets,
constraints_map, impedance_nets, layout,
)
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, emit="silent",
notes=(
"Length is not delay. Rise/fall/timing stays UNKNOWN without a cited "
"time source (no tpd invented from mm). Not L3. Not a warning."
),
)
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, emit="silent",
notes=(
f"{check} has no cited electrical source. "
"Not invented. Not a warning."
),
)
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
def _pcb_zdiff_cite(iface: PhysicalInterface) -> ProtocolConstraint | None:
"""Packed board Zdiff recommendation. Never a number typed in this module."""
for c in iface.constraints:
if c.parameter != "pcb_trace_nominal_zdiff":
continue
if c.value_kind != "NUMERIC" or c.value is None:
continue
unit = (c.unit or "").strip().lower()
if unit not in _OHM_UNITS:
continue
return c
return None
def _usb_dp_dm(inst: PhysicalBusInstance) -> tuple[str, str] | None:
for g in inst.groups:
if g.kind != "DIFFERENTIAL_PAIR" or len(g.nets) < 2:
continue
dp = g.roles.get("D+") or g.nets[0]
dm = g.roles.get("D-") or g.nets[1]
if single_ended_eth_mac(dp) or single_ended_eth_mac(dm):
return None
return dp, dm
return None
def _cite_label(cons: ProtocolConstraint) -> str:
src = cons.source
bits: list[str] = []
if src is not None:
if src.document:
bits.append(src.document)
if src.section:
bits.append(f"§{src.section}")
if src.page:
bits.append(f"p.{src.page}")
return ", ".join(bits) if bits else "cited pack"
def _zdiff_measured(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None:
"""Pair Z only. Single-ended Z0 is not a differential result."""
rows = _z_rows(impedance_nets)
vals: list[float] = []
for net in nets:
row = _row_for(rows, net)
if not row:
continue
for key in ("zdiff_ohm", "zdiff_avg_ohms", "zdiff_ohms"):
v = _num(row.get(key))
if v is not None and v > 0:
vals.append(v)
break
if not vals:
return None
return sum(vals) / len(vals)
def _median_width_mm(layout: LayoutGraph, net: str) -> float | None:
widths = sorted(
s.width for s in layout.segments
if s.net and kicad_nets_match(s.net, net) and s.width > 0
)
if not widths:
return None
return widths[len(widths) // 2]
def _pair_gap_mm(layout: LayoutGraph, a: str, b: str) -> float | None:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a) and s.width > 0]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b) and s.width > 0]
gaps: list[float] = []
for x in sa:
for y in sb:
if x.layer and y.layer and x.layer != y.layer:
continue
for px, py in (
(x.start, y.start), (x.start, y.end),
(x.end, y.start), (x.end, y.end),
):
edge = math.hypot(px[0] - py[0], px[1] - py[1]) - x.width / 2.0 - y.width / 2.0
if edge > 1e-6:
gaps.append(edge)
if not gaps:
return None
return min(gaps)
def usb_pair_z_ohm(
layout: LayoutGraph | None,
nets: tuple[str, str],
impedance_nets: list[dict] | dict | None,
) -> tuple[float | None, str]:
"""Calculated differential Z for one pair.
Returns (ohms, missing). missing is empty when ohms is set, otherwise
``stackup``, ``geometry``, or ``stackup/geometry``.
"""
stored = _zdiff_measured(list(nets), impedance_nets)
if stored is not None:
return stored, ""
has_copper = bool(
layout is not None and any(
s.net and s.width > 0 and (
kicad_nets_match(s.net, nets[0]) or kicad_nets_match(s.net, nets[1])
)
for s in layout.segments
)
)
stack = layout.stackup if layout is not None else None
dielectric = stack.dielectrics[0] if stack and stack.dielectrics else None
stack_ok = (
stack is not None
and dielectric is not None
and dielectric.er > 0
and dielectric.height_mm > 0
and stack.copper_thickness_mm is not None
and stack.copper_thickness_mm > 0
)
if not stack_ok:
return None, "stackup/geometry" if not has_copper else "stackup"
assert layout is not None and stack is not None and dielectric is not None
wa = _median_width_mm(layout, nets[0])
wb = _median_width_mm(layout, nets[1])
gap = _pair_gap_mm(layout, nets[0], nets[1])
if wa is None or wb is None or gap is None:
return None, "geometry"
try:
_zodd, _zeven, zdiff = coupled_diff_z(TraceGeometry(
h=dielectric.height_mm,
er=dielectric.er,
t=stack.copper_thickness_mm,
w=(wa + wb) / 2.0,
s=gap,
))
except GeometryError:
return None, "geometry"
if zdiff <= 0:
return None, "geometry"
return zdiff, ""
def _matches_cited(measured: float, cited: float) -> bool:
return abs(measured - cited) <= 1e-6 * max(1.0, abs(cited))
def _usb_line(kind: str, *, measured: float | None, cited: float, label: str) -> str:
"""One report line. The ohm value is the packed cite, not a second number."""
if kind == "certified" and measured is not None:
return (
f"USB certified — calculated Z {measured:g} Ω vs cited {cited:g} Ω ({label})."
)
if measured is not None:
return (
f"USB not certified — because Z {measured:g} Ω is outside cited "
f"{cited:g} Ω ({label})."
)
return (
"USB not certified — because Z cannot be calculated from the PCB "
f"stackup/geometry. Cited {cited:g} Ω ({label})."
)
def _usb_board_z(
check: str,
inst: PhysicalBusInstance,
cite: ProtocolConstraint,
pair: tuple[str, str],
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None,
) -> L2CheckResult:
"""One line: calculated pair Z vs the packed board cite."""
cited = float(cite.value) # type: ignore[arg-type]
label = _cite_label(cite)
measured, _missing = usb_pair_z_ohm(layout, pair, impedance_nets)
mand: MandatoryClass = "RECOMMENDED"
if measured is None:
return L2CheckResult(
check=check, result="FAIL", mandatory=mand, nets=list(pair),
limit_ohm=cited, value_kind="NUMERIC", emit="not_certified",
notes=_usb_line("missing", measured=None, cited=cited, label=label),
finding_class="RULE", status="ERROR",
)
if _matches_cited(measured, cited):
return L2CheckResult(
check=check, result="PASS", mandatory=mand, nets=list(pair),
measured_ohm=measured, limit_ohm=cited,
limit_ohm_min=cited, limit_ohm_max=cited,
value_kind="NUMERIC", emit="certified",
notes=_usb_line("certified", measured=measured, cited=cited, label=label),
finding_class="INFO", status="INFO",
)
return L2CheckResult(
check=check, result="FAIL", mandatory=mand, nets=list(pair),
measured_ohm=measured, limit_ohm=cited,
limit_ohm_min=cited, limit_ohm_max=cited,
value_kind="NUMERIC", emit="not_certified",
notes=_usb_line("outside", measured=measured, cited=cited, label=label),
finding_class="RULE", status="ERROR",
)
def _z_check(
check: str,
mand: MandatoryClass,
cons: ProtocolConstraint | None,
kind: str,
inst: PhysicalBusInstance,
iface: PhysicalInterface,
graph: DesignGraph,
nets: list[str],
constraints_map: dict[str, ComponentConstraints] | None,
impedance_nets: list[dict] | dict | None,
layout: LayoutGraph | None,
) -> L2CheckResult:
pair = _usb_dp_dm(inst)
cite = _pcb_zdiff_cite(iface)
phy = _phy_z_windows(graph, inst, constraints_map)
if cite is not None and pair is not None and not phy:
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
pack_w = _pack_ohm_window(cons)
windows: list[tuple[float, float]] = []
if pack_w:
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:
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, emit="silent",
notes=(
f"{inst.physical_interface_id} has measured Z and no cited target. "
"Not compared to an invented ohm. Not a warning."
),
)
if measured is None:
if cite is not None and pair is not None:
return _usb_board_z(check, inst, cite, pair, layout, impedance_nets)
return _skip_z(check, mand, inst, kind)
verdict = electrical_verdict(cons, measured, window)
source_note = (
"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, emit="silent",
notes=(
f"{inst.physical_interface_id} has no termination cite. "
"No invented resistor. Not a warning."
),
)
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", emit="silent",
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V. Not a warning.",
)
if constraints_map:
for ref in [inst.host_ref, *inst.peer_refs]:
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, emit="silent",
notes=(
"Silicon abs-max present; L2 levels need a cited operating "
"window vs measured rail — not assumed. Not L3. Not a warning."
),
)
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, emit="silent",
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented. Not a warning.",
)
def _pair_line_finding(inst: PhysicalBusInstance, r: L2CheckResult) -> Finding:
"""Certified or not certified. English. Not the datasheet boilerplate."""
designator = inst.host_ref or inst.physical_interface_id
certified = r.emit == "certified"
text = r.notes
if certified:
status = "INFO"
cls = "INFO"
action = "Calculated pair Z matches the cited limit."
else:
status = "ERROR"
cls = "RULE"
action = "Calculated pair Z is not certified. The finding states the reason."
f = Finding(
designator=designator,
mpn="",
aspect="protocol_l2",
finding=text,
why=text,
status=status, # type: ignore[arg-type]
source=SOURCE,
net=r.nets[0] if r.nets else None,
rule_id="PE-PRT-L2-002",
facts=text,
requirement=text,
inference="Calculated differential Z compared with the packed cite.",
provenance="MANDATORY",
finding_class=cls, # type: ignore[arg-type]
evidence_status="SUFFICIENT",
recommendation=action,
action=action,
)
return complete_finding(f)
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
for r in results:
if r.emit in {"certified", "not_certified"}:
out.append(_pair_line_finding(inst, r))
continue
if r.emit == "silent" or (r.result in {
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
"PHY_DEPENDENT", "MISSING_SOURCE",
} and r.check in _NOISE_CHECKS):
continue
if r.result == "PASS":
continue
if r.result == "FAIL" and r.mandatory != "MANDATORY":
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"{inst.physical_interface_id} L2 {r.check} is {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,616 @@
"""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)
if not _channel_fact_present(facts):
return []
return [_run_l3_check(inst, iface, c, facts) for c in checks]
def _channel_fact_present(facts: dict[str, Any]) -> bool:
"""True only when reduced channel FACT is already on hand."""
if not facts:
return False
if _looks_like_raw_sparam(facts):
return False
if _blob_mentions_fem(facts.get("method") or facts.get("solver") or ""):
return False
if _il_measured(facts) is not None or _rl_measured(facts) is not None:
return True
if _xt_measured(facts) is not None:
return True
if _num(facts.get("total_budget_ps")) is not None:
return True
return facts.get("channel_complete") is True
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
out: list[Finding] = []
designator = inst.host_ref or inst.physical_interface_id
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:
# No channel FACT, or a field with nothing to certify. Omit the warning.
continue
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,230 @@
"""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)
if check == "differential_impedance" and not doc and iface is not None:
for alt in iface.constraints:
if alt.parameter == "pcb_trace_nominal_zdiff" and alt.source is not None:
doc, section, method = _cite(alt)
cons = alt
break
nets = list(dump.get("nets") or [])
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
group = _group_for_net(inst, net or None)
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 "",
"emit": dump.get("emit") 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 _noise_row(row: dict[str, Any]) -> bool:
"""Missing-field skips are not a certification line."""
if row.get("emit") == "silent":
return True
result = str(row.get("result") or "")
if result not in _SKIP:
return False
notes = str(row.get("notes") or "").lower()
if "grouping" in notes or "dq/dqs" in notes:
return False
if row.get("measured") is not None or row.get("limit") is not None:
return False
return True
def attach_instance_report(
inst: PhysicalBusInstance,
iface: PhysicalInterface | None,
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 = [r for r in (check_to_report_row(d, inst, iface) for d in dumps) if not _noise_row(r)]
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
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 "",
}
+78 -8
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,14 @@ _SKIP_RE = re.compile(
re.I,
)
_XTAL_RE = re.compile(
r"XTAL|OSCIN|OSCOUT|HFXIN|LFXIN|\bXIN\b|\bXOUT\b",
re.I,
)
# MDIO/MDC management (often single-ended). Not RJ45/MDI 100 Ω differential.
_MDIO_MGMT_RE = re.compile(r"(?:^|[_/.])(?:MDIO|MDC)$", re.I)
_HS_CLASS_RE = (
("usb3", re.compile(
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
@@ -37,12 +45,19 @@ _HS_CLASS_RE = (
("hdmi", re.compile(r"HDMI", re.I)),
("pcie", re.compile(r"PCIE|PEX_", re.I)),
("sgmii", re.compile(r"SGMII", re.I)),
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII", re.I)),
("rgmii", re.compile(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", re.I)),
("rmii", re.compile(
r"(?:^|[_/])RMII|"
r"(?:ETH|MAC).*(?:TXD|RXD|TXEN|RXDV|CRS_DV|REF_CLK)",
re.I,
)),
("eth_mdi", re.compile(
r"(?:^|[_/])(MDI|TRD[0-3]|TCT|RCT)|"
# MDI(?!O) — RJ45 Media Dependent Interface, never MDIO management.
r"(?:^|[_/])(MDI(?!O)|TRD[0-3]|TCT|RCT)|"
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
re.I,
)),
("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 +66,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(
@@ -82,8 +101,10 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
"magnetics": frozenset({"eth_mdi"}),
"rgmii": frozenset({"rgmii"}),
"gmii": frozenset({"rgmii"}),
"mac_phy": frozenset({"rgmii", "sgmii"}),
"mac": frozenset({"rgmii", "sgmii"}),
"rmii": frozenset({"rmii"}),
"mii": frozenset({"rmii"}),
"mac_phy": frozenset({"rmii", "rgmii", "sgmii"}),
"mac": frozenset({"rmii", "rgmii", "sgmii"}),
"sgmii": frozenset({"sgmii"}),
"ddr": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
"ddr3": frozenset({"ddr3_clk", "ddr3_dqs", "ddr3_dq", "ddr3_addr"}),
@@ -94,6 +115,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({
@@ -128,6 +154,31 @@ def partner_net(name: str) -> str | None:
return None
# RMII/MII data and TX enable. Not an MDI pair. Not a 100 Ω differential net.
_SINGLE_ENDED_ETH_RE = re.compile(
r"(?:^|[^A-Za-z0-9])(?:ETH_|MAC_)?(?:RXD|TXD|TXEN|RXDV|CRS_DV)(?:\d+)?(?:[^A-Za-z0-9]|$)",
re.I,
)
def single_ended_eth_mac(net: str) -> bool:
"""ETH_RXD / ETH_TXD / ETH_TXEN are single-ended. Not a differential pair."""
leaf = _leaf(net)
return bool(_SINGLE_ENDED_ETH_RE.search(leaf) or _SINGLE_ENDED_ETH_RE.search(net or ""))
def phy_mac_kind(net: str) -> str | None:
"""``rmii``, ``rgmii``, or ``mii`` for a PHYMAC net. Never ``eth_mdi``."""
leaf = _leaf(net).upper()
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", leaf):
return "rgmii"
if single_ended_eth_mac(net) or "RMII" in leaf:
if re.search(r"(?:^|[_/])MII(?:$|[_/])", leaf) and "RMII" not in leaf:
return "mii"
return "rmii"
return None
def _leaf(net: str) -> str:
n = normalize_kicad_hierarchy_net(net)
return n.split("/")[-1] if n else ""
@@ -147,17 +198,29 @@ def skip_si_net(net: str) -> bool:
return bool(_SKIP_RE.search(leaf))
def is_mdio_mgmt(net: str) -> bool:
"""ETH_MDIO / ETH_MDC / MDIO — management, not IEEE MDI cabling."""
leaf = _leaf(net)
return bool(_MDIO_MGMT_RE.search(leaf) or _MDIO_MGMT_RE.search(net or ""))
def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if _XTAL_RE.search(leaf):
return None
if is_mdio_mgmt(net):
return None
u = leaf.upper()
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
return "usb3"
if "USB" in u and re.search(r"(D\+|D-|DP|DM)", leaf, re.I):
return "usb2"
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TX_CTL|RX_CTL|TXEN|RXDV|GTX)", u):
if re.search(r"RGMII|(?:^|[_/])GMII|TX_CTL|RX_CTL|(?:ETH|MAC).*GTX", u):
return "rgmii"
if re.search(r"(?:ETH|MAC).*(TXD|RXD|TXC|RXC|TX_CLK|RX_CLK|TXEN|RXDV|CRS_DV|REF_CLK)|(?:^|[_/])RMII", u):
return "rmii"
if re.search(r"(?:^|[_/])ETH(?:$|[_/])", u) and re.search(r"[+\-]|_P$|_N$|_P/|_N/", leaf):
return "eth_mdi"
for cls, cre in _HS_CLASS_RE:
@@ -211,12 +274,15 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
scans: tuple[tuple[str, str], ...] = (
(r"super\s*speed|usb\s*3|sstx|ssrx", "usb3"),
(r"rgmii|gtx_clk|tx_ctl|rx_ctl", "rgmii"),
(r"\brmii\b|\bmii\b|txen|rxdv|crs_dv", "rmii"),
(r"sgmii", "sgmii"),
(r"mdi|rj-?45|magnetics|trd[0-3]|1000\s*base", "eth_mdi"),
(r"ddr3|\bddr\b", "ddr3"),
(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"),
@@ -339,6 +405,8 @@ def _rule_nets(layout: LayoutGraph, rule: dict, graph: DesignGraph) -> list[str]
if targets:
if not _bus_in_targets(bc, targets):
continue
if single_ended_eth_mac(net) and "eth_mdi" in targets:
continue
else:
# No bus on the quote: pin-scoped only. Never paint USB/DDR/PHY.
if not pin:
@@ -511,6 +579,8 @@ def check_si(
want_diff = _num(rule.get("zdiff_ohm")) is not None or kind == "zdiff"
paired: set[tuple[str, str]] = set()
for net in nets:
if want_diff and single_ended_eth_mac(net):
continue
zrow = _z_row(rows, net)
partner = _partner_on_board(layout, net, zrow) if want_diff else None
key_net = _pair_key(net, partner) if partner else (net, "")
@@ -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))
+134 -16
View File
@@ -1,14 +1,16 @@
"""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
import json
import logging
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -27,6 +29,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 +112,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,
@@ -138,16 +154,15 @@ def si_extract_needed_skips(
}
if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver):
continue
refs = ",".join(sorted(refs_by_mpn.get(mpn, []))) or mpn
out.append({
"designator": refs,
"reason": (
f"SI layout_rules empty at model_version="
f"{cons.model_version}; re-run schematic review "
f"to re-extract pintable {scan_ver} "
"(PCB does not re-read the PDF)."
),
})
refs = sorted(refs_by_mpn.get(mpn, [])) or [mpn]
reason = (
f"SI layout_rules empty at model_version="
f"{cons.model_version}; re-run schematic review "
f"to re-extract pintable {scan_ver} "
"(PCB does not re-read the PDF)."
)
for ref in refs:
out.append({"designator": ref, "reason": reason})
return out
@@ -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:
@@ -182,6 +220,14 @@ async def run_pcb_pipeline(
pcb_state=None,
)
t_total = time.perf_counter()
stage_seconds: dict[str, float] = {}
def _lap(name: str, started: float) -> float:
seconds = round(time.perf_counter() - started, 3)
stage_seconds[name] = seconds
return seconds
try:
async with PipelineWorkspace(storage, user_id, project_id) as ws:
if _cancelled(storage, user_id, project_id):
@@ -209,7 +255,10 @@ async def run_pcb_pipeline(
return
_step(project_id, "classify", "running", "domains and groups")
t_extract = time.perf_counter()
cmap = _load_constraints_map(ws.local_path("extracted"), storage)
extract_s = _lap("extract", t_extract)
_step(project_id, "extract", "complete", f"loaded stored extractions; {extract_s}s")
plan = build_placement_plan(graph, cmap)
fg_path = ws.local_path("functional_groups.json")
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
@@ -268,7 +317,9 @@ async def run_pcb_pipeline(
return
_step(project_id, "checks", "running")
t_checks = time.perf_counter()
findings = run_pcb_checks(graph, cmap, layout, plan, zrep)
checks_s = _lap("pcb_checks", t_checks)
scan_ver = app_settings.get_default_model_version()
si_skip = si_extract_needed_skips(graph, cmap, scan_ver)
sipath = ws.local_path("si_extract_needed.json")
@@ -277,7 +328,41 @@ async def run_pcb_pipeline(
_step(
project_id, "checks", "complete",
f"{len(findings)} deterministic findings"
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
+ (f"; {len(si_skip)} SI re-extract" if si_skip else "")
+ f"; {checks_s}s",
)
if _cancelled(storage, user_id, project_id):
_finish_cancelled(storage, user_id, project_id)
return
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
t_af_trace = time.perf_counter()
try:
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
findings.extend(extra_af)
n_trace = len(extra_af)
except Exception:
logger.exception("AF trace analysis failed — keeping PCB checks")
n_trace = 0
af_trace_s = _lap("af_trace", t_af_trace)
_step(
project_id, "af_trace", "complete",
f"{n_trace} AF trace findings (SI/HF checks kept); {af_trace_s}s",
)
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")
t_af_ai = time.perf_counter()
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
af_ai_s = _lap("af_ai", t_af_ai)
stage_seconds["af"] = round(stage_seconds.get("af_trace", 0) + af_ai_s, 3)
_step(
project_id, "af_ai", "complete",
f"{n_af} extra AF+AI findings (SI/HF checks kept); {af_ai_s}s; af={stage_seconds['af']}s",
)
if _cancelled(storage, user_id, project_id):
@@ -287,6 +372,7 @@ async def run_pcb_pipeline(
_step(project_id, "ai_review", "running", "layout vs shared library extraction")
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
review_errors: dict[str, str] = {}
try:
from backend.services.api_logs import ApiLogger
from backend.services.pcb_validation import review_pcb_ics
@@ -301,7 +387,7 @@ async def run_pcb_pipeline(
f"{ref} {tool} {detail}".strip(),
)
ai_findings, coverage, skipped = await review_pcb_ics(
ai_findings, coverage, skipped, review_errors = await review_pcb_ics(
graph, cmap, layout, plan, inventory,
pdf_dir, storage=storage, api_logger=logger_api,
on_progress=_prog,
@@ -310,6 +396,7 @@ async def run_pcb_pipeline(
logger_api.flush(storage, user_id, project_id)
detail = (
f"{len(ai_findings)} AI findings, {len(skipped)} skipped"
+ (f", {len(review_errors)} review errors" if review_errors else "")
)
except Exception:
logger.exception("PCB AI review failed — keeping deterministic findings")
@@ -321,6 +408,15 @@ async def run_pcb_pipeline(
return
_step(project_id, "write_report", "running")
t_protocol = time.perf_counter()
proto_section, proto_findings = protocol_exam(
graph, layout=layout,
constraints_map=cmap, impedance_nets=zrep,
channel_data=_load_channel_data(ws),
)
protocol_s = _lap("protocol", t_protocol)
_step(project_id, "protocol", "complete", f"{protocol_s}s")
findings.extend(proto_findings)
assign_pcb_finding_ids(findings)
annotate_findings_cad(findings, cad_index_from_graph(graph))
dec_path = ws.local_path("decisions.json")
@@ -332,13 +428,21 @@ async def run_pcb_pipeline(
summary: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
for f in findings:
summary[f.status] = summary.get(f.status, 0) + 1
# Drop not_reviewed entries for ICs that were actually reviewed.
covered = set(coverage)
not_reviewed = [
row for row in (skipped + si_skip)
if str(row.get("designator") or "") not in covered
]
report = ValidationReport(
project=project_id,
timestamp=datetime.now(timezone.utc).isoformat(),
findings=findings,
summary=summary,
coverage=coverage,
not_reviewed=skipped + si_skip,
review_errors=review_errors,
not_reviewed=not_reviewed,
protocol_certification=proto_section.model_dump(),
)
report_path = ws.local_path("pcb_report.json")
report_path.write_text(report.model_dump_json(indent=2) + "\n")
@@ -349,10 +453,22 @@ async def run_pcb_pipeline(
ws._upload_file("periscope-findings.json")
_step(project_id, "write_report", "complete", f"{len(findings)} findings")
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
_step(
project_id, "timing", "complete",
"extract={extract}s pcb_checks={pcb_checks}s protocol={protocol}s af={af}s total={total}s".format(
extract=stage_seconds.get("extract"),
pcb_checks=stage_seconds.get("pcb_checks"),
protocol=stage_seconds.get("protocol"),
af=stage_seconds.get("af"),
total=stage_seconds.get("total"),
),
)
_publish(project_id, "pcb_complete", {
"findings": len(findings),
"domains": len(plan.domains),
"groups": len(plan.groups),
"stage_seconds": stage_seconds,
})
proj_svc.update_project(
storage, user_id, project_id,
@@ -362,15 +478,17 @@ async def run_pcb_pipeline(
"domains": len(plan.domains),
"groups": len(plan.groups),
"skipped": len(skipped),
"stage_seconds": stage_seconds,
},
pcb_cancel_requested=False,
)
except Exception as e:
logger.exception("pcb pipeline failed for %s", project_id)
stage_seconds["total"] = round(time.perf_counter() - t_total, 3)
proj_svc.update_project(
storage, user_id, project_id,
pcb_status="error",
pcb_state={"error": str(e)},
pcb_state={"error": str(e), "stage_seconds": stage_seconds},
)
_publish(project_id, "pcb_error", {"error": str(e)})
@@ -54,15 +54,20 @@ async def review_pcb_ics(
storage=None,
api_logger: ApiLogger | None = None,
on_progress=None,
) -> tuple[list[Finding], dict[str, list[str]], list[dict]]:
) -> tuple[list[Finding], dict[str, list[str]], list[dict], dict[str, str]]:
"""Layout-only AI exam using ``library/extracted`` (or project extracted/).
Does not attach a datasheet PDF. ICs without a library pintable are skipped
with a reason to run schematic review first.
Returns ``(findings, coverage, not_reviewed, review_errors)``.
Real review exceptions go in ``review_errors`` never as
``not_reviewed`` with the blanket label ``pcb_review error``.
"""
findings: list[Finding] = []
coverage: dict[str, list[str]] = {}
skipped: list[dict] = []
review_errors: dict[str, str] = {}
cache: dict = {}
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
@@ -102,15 +107,21 @@ async def review_pcb_ics(
system_prompt=PCB_SYSTEM_PROMPT,
log_stage="pcb_review",
)
except Exception:
log.exception("PCB AI review failed for %s — skipping", ref)
skipped.append({"designator": ref, "reason": "pcb_review error"})
except Exception as exc:
msg = f"{type(exc).__name__}: {exc}"
log.exception("PCB AI review failed for %s — recording review_errors", ref)
review_errors[ref] = msg
continue
if not isinstance(result, ReviewResult):
# Prefer a visible false positive over a silent miss.
skipped.append({
"designator": ref,
"reason": "PCB review returned no result",
})
continue
_ensure_recs(result.findings)
annotate_findings_cad(result.findings, cad_index_from_graph(graph))
findings.extend(result.findings)
if result.checked_areas:
coverage[ref] = list(result.checked_areas)
return findings, coverage, skipped
# Empty checked_areas still means the IC was reviewed (no issues).
coverage[ref] = list(result.checked_areas or ["layout"])
return findings, coverage, skipped, review_errors
+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:
+1 -1
View File
@@ -1,5 +1,5 @@
{
"default_model_version": "1.15.0",
"default_model_version": "1.16.0",
"extract-pintable": {
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
"latest_version": "1784798970179642",
@@ -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.
+311
View File
@@ -2,6 +2,317 @@
What's new in Periscope.
## 2.87.5 — 2026-09-23 — UI reorder: one shell, one report tree, KiCad-only ingress
Full frontend cleanup after iPad top-bar landed beside leftover sidebar-era report chrome. Report shows **only** the findings tree (+ Save as Markdown). Project create/replace is **KiCad folder only** — no BOM csv/xlsx or PADS/EDIF entry UI. Soft-bump on the 2.87 line (FPGA 2.88.x stays separate). No deploy until Michele says so.
- [Removed] Dual report look: old FindingCard dashboard chrome inside the tree; dead `ComponentGroup` / `PcbExamSection`; top-bar Protocols tab (duplicate of Report).
- [Removed] Sidebar stub; BOM/non-KiCad upload options at project ingress.
- [Changed] Uniform iPad language: top bar chips, tree leaf rows, filters, Exam/Settings surfaces, English Error/Warning/Info labels.
- [Changed] Ingress copy: Open / Replace KiCad project; BOM derived from schematic fields on the server.
## 2.87.4 — 2026-09-23 — U18 PE-PLC-002 thermal vias cite gate
ADAU1467WBCPZ300R (U18) was ERROR for `9 thermal vias … (min_via_count 49)`. The `49` came from datasheet p.203 Figure 87 — an **example** 7×7 via grid (“shows 49 vias”), not a mandatory minimum. Same EP/ESD cite rule: ERROR only for a MANDATORY count with cite; figure/example counts are REVIEW + quote, or skip with no cite.
- [Changed] `placement_check.py` PE-PLC-002: mandatory-language gate; EXAMPLE → WARNING/REVIEW with `source_quote`.
- [Changed] `extract-pintable` skill: `min_via_count` only for “at least N”; figure grids stay note-only. Skills manifest **1.16.0**.
- [Changed] pytest: ADAU Fig.87 REVIEW; “at least 4” ERROR; uncited skip.
## 2.87.3 — 2026-09-23 — HubAudio AF verify + re-run cue
Live already carried the 2.87.2 PE-AF-002 cleanup, but HubAudio's last PCB exam was still stamped **2.87.0** — so Michele still saw duplicate RD/TD “speed not identified”, ETH_MDIO 100 Ω, Italian RMII/USB tr/f. Replay of the saved HubAudio design/layout graphs confirms the fix. Soft-bump so the report version moves; **re-run HubAudio PCB exam** on live.
- [Changed] Regression: USB_D+ skips PE-AF-002; protocol owns the English 90 Ω cert line.
- [Changed] Changelog cue: HubAudio must be re-examined after AF noise fixes.
## 2.87.2 — 2026-09-23 — HubAudio AF noise: MDIO≠MDI, RD≡RX, USB/RMII cert
HubAudio PE-AF-002 cleanup on live 2.87.x. Ethernet MDI aliases (`RD+/``RXP/RXN`, `TD+/``TXP/TXN`) are one cert per pair; LAN8720A supplies 10/100 when the magnetics-side nets lack the PHY MPN. `ETH_MDIO` / `MDC` are management — not IEEE 100BASE-TX 25.4.9. USB D+/D leave the AF tr/f skip (protocol 90 Ω cert owns them). RMII stays on the AN-133 PE-AF-003 English line.
- [Changed] `si_check.py`: `is_mdio_mgmt`; `MDI(?!O)` so MDIO is not eth_mdi.
- [Changed] `af_trace_check.py`: MDI channel dedupe; graph-wide PHY speed; USB skips PE-AF-002.
- [Changed] pytest `test_hubaudio_af_noise.py`; AF geometry tests use HDMI (USB is cert-owned).
## 2.87.1 — 2026-09-23 — Remove coffee/email progress notice
Drop the running-pipeline “grab a coffee / well email you” banner. Progress shows a neutral “Pipeline running…” status instead.
- [Removed] Coffee icon + email promise on project progress while a run is active.
## 2.87.0 — 2026-09-23 — Live integrate: iPad top-bar, MACPHY pack, USB/RMII cert
Combines the iPad top-bar shell (light default, Save as Markdown), the MACPHY bus catalog pack (Micrel AN-133, OPEN Alliance RGMII EPL V2.2, Cisco SGMII ENG-46158), USB 2.0 D+/D cert lines versus the packed 90 Ω cite, RMII not-certified-on-recognition, and the `not_reviewed` count fix. One coherent live version above 2.86.0.
- [Changed] App shell: `TopBar` instead of sidebar; theme default `light`; Markdown report export.
- [Changed] `protocol_catalog.py` / `catalog.json`: `rmii-pcb`, `mii-pcb`, `rgmii-pcb`, `sgmii-pcb`.
- [Changed] PE-AF-003 / USB / Ethernet cert lines; J2 silence on missing L1/L2/L3 fields.
- [Changed] `not_reviewed` one designator per row; AI failures → `review_errors`.
- [Changed] pytest mac-phy / USB / RMII / not_reviewed; frontend shell + export tests.
- [Changed] Root `README.md` (+ `periscope/README.md`): product copy for 2.87.0 — PCB analysis, protocol cert, findings tree, top-bar UI, Markdown export; Gitea as source of truth.
## 2.86.0 — 2026-09-23 — MACPHY bus pack (RMII/MII/RGMII/SGMII)
Packed Micrel AN-133, OPEN Alliance RGMII EPL V2.2, and Cisco SGMII ENG-46158 into the protocol catalog. Only explicitly stated NUMERIC fields with provenance. PCB Z0 / length stay UNKNOWN where the PDF does not state them.
PE-AF-003 no longer certifies on recognition alone. HubAudio-style RMII (LAN8720A ETH_RXD/TXD/TXEN) reports `RMII not certified — because …` with the AN-133 cite when PCB impedance is UNKNOWN. SGMII uses the Cisco 100 Ω Rload / Rin 80120 Ω cite when geometry allows a pair Z. RJ45/MDI 100 Ω stays separate.
- [Changed] `protocol_catalog.py` / `catalog.json`: `rmii-pcb`, `mii-pcb`, `rgmii-pcb`, `sgmii-pcb`.
- [Changed] `af_trace_check.py` PE-AF-003 cert lines.
- [Changed] `standards/protocol-specs/INDEX.md` (+ on-disk PDFs, gitignored).
- [Changed] pytest `test_mac_phy_pack.py`, `test_rmii_cert_line.py`.
## 2.85.0 — 2026-09-23 — USB / RMII cert lines; not_reviewed count
USB 2.0 D+/D is one line: calculated pair Z versus the packed 90 Ω board cite. Certified names the measured ohm and the cite. Not certified says why — Z is outside that cite, or Z cannot be calculated from the PCB stackup/geometry. Missing length, stub, via, termination, rise/fall, and channel fields do not each warn. L3 is omitted when no channel fact is on file.
LAN8720A ETH_RXD / ETH_TXD / ETH_TXEN are RMII (single-ended PHYMAC), not RJ45/MDI 100 Ω. Recognition alone is not certification (fixed in 2.86.0). RJ45 pairs keep `Ethernet certified / not certified`.
`not_reviewed` count matches the list (one designator per row). Real PCB AI failures go to `review_errors` with the exception message — not a blanket `pcb_review error` on every skip. ICs already in coverage are not listed as not-reviewed.
- [Changed] `protocol_l1.py`, `protocol_l2.py`, `protocol_l3.py`, `protocol_report.py`, `af_trace_check.py`, `si_check.py`.
- [Changed] `pcb_validation.py`, `pcb_pipeline.py`, `pcb_checks.py` merge; report UI copy.
- [Changed] Protocol tree shows the certification sentence.
- [Changed] pytest `tests/pcb/test_j2_usb_cert_line.py`, `test_rmii_cert_line.py`, `test_not_reviewed_count.py`.
## 2.84.0 — 2026-09-22 — Exposed-pad names, ESD cites, CC-via-net, Ethernet pair Z
PE-BOM-011 treats EP, EPAD, THERMAL PAD, exposed pad, and the footprint's exposed land (including pin_count+1) as the same pad when both sides have one. A datasheet exposed pad with no land on the footprint stays ERROR.
PE-ESD-001 says "datasheet-specified" only with a part and section/page. A 3V3 jack rail and VBUS are not warned without that cite. Optical S/PDIF (TOSLINK/optocoupler) is not an ESD warning; an unverified S/PDIF path stays a visible warning.
PE-PRT-L0-001 follows Type-C CC pins (A5/B5) to the net and looks for the packed CabCon Rd 5.1 kΩ. The net does not have to be named CC1/CC2.
PE-AF-002 on an Ethernet MDI pair compares one calculated pair Z to the packed cite for the speed on the board (LAN8720A 10/100 → 100 Ω, IEEE 100BASE-TX 25.4.9; Clause 40 only if gigabit). Inside is PASS, outside is FAIL, and no calculation is ERROR. No tr/f request.
- [Changed] `bom_pcb_check.py`, `esd_return_check.py`, `protocol_l0.py`, `af_trace_check.py`.
- [Changed] pytest `tests/pcb/test_bom_esd_ep_cc_eth.py`.
- [Changed] Findings tree labels are English: domains, Certifications, Protocols, actions, and empty states. Error / Warning / Info stay.
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe.
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
- [Changed] Findings tree grouping + protocol section inside the tree.
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
## 2.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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "periscope-web",
"version": "2.60.0",
"version": "2.87.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "periscope-web",
"version": "2.60.0",
"version": "2.87.4",
"dependencies": {
"@base-ui/react": "^1.3.0",
"@types/dagre": "^0.7.54",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.61.2",
"version": "2.87.5",
"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 src/lib/report-export.test.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
LayoutGrid,
List,
Loader2,
X,
} from "lucide-react";
import { useCredits } from "@/components/billing/credits-context";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -23,13 +21,10 @@ import {
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/types";
type LayoutKind = "cards" | "table";
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
const LAYOUT_KEY = "periscopex:dashboard:view";
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
export default function DashboardPage() {
@@ -62,28 +57,9 @@ function DashboardBody() {
const [bannerVisible, setBannerVisible] = useState(false);
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [layout, setLayout] = useState<LayoutKind>("cards");
const ordered = useMemo(() => newestFirst(projects), [projects]);
useEffect(() => {
try {
const stored = localStorage.getItem(LAYOUT_KEY);
if (stored === "cards" || stored === "table") setLayout(stored);
} catch {
/* storage blocked */
}
}, []);
function persistLayout(next: LayoutKind) {
setLayout(next);
try {
localStorage.setItem(LAYOUT_KEY, next);
} catch {
/* ignore */
}
}
function stripCheckoutQuery() {
const url = new URL(window.location.href);
url.searchParams.delete("topup");
@@ -208,7 +184,7 @@ function DashboardBody() {
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
<OnboardingSurvey />
{bannerVisible && topupPhase !== "dismissed" && (
<TopupBanner
@@ -217,116 +193,44 @@ function DashboardBody() {
onDismiss={hideBanner}
/>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<CreditsLine credits={credits} />
</div>
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
cloneAsNewProject={cloneAsNewProject}
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
</div>
{!loading && projects.length > 0 && (
<div className="flex items-center justify-end gap-2 mb-4">
<div className="inline-flex rounded-lg border border-input p-0.5">
<button
type="button"
onClick={() => persistLayout("cards")}
aria-pressed={layout === "cards"}
title="Card view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => persistLayout("table")}
aria-pressed={layout === "table"}
title="Table view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
<PageHeader
title="Progetti"
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
actions={
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
cloneAsNewProject={cloneAsNewProject}
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
}
/>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-[11px]" />
))}
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No projects yet. Create one to get started.
</p>
) : layout === "table" ? (
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
<p className="mt-1 text-[13px] text-neutral-500">
Crea un progetto e carica netlist, BOM e PCB. Lesame parte da .
</p>
</div>
) : (
<ProjectsTable
projects={ordered}
onDeleted={dropProject}
onRerun={setRerunProject}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ordered.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDeleted={() => dropProject(p.id)}
onRerun={setRerunProject}
/>
))}
</div>
)}
</div>
);
}
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
if (!credits) {
return (
<p className="text-sm text-muted-foreground">
Schematic validation projects
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{credits.balance.toFixed(2)} credits
{" · "}
<Link
href="/billing"
className="underline underline-offset-2 hover:text-foreground"
>
buy more
</Link>
{" · "}
<Link
href="/credits"
className="underline underline-offset-2 hover:text-foreground"
>
ledger
</Link>
</p>
);
}
function TopupBanner({
phase,
detail,
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { CreditsProvider } from "@/components/billing/credits-context";
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
import { AuthGate } from "@/components/layout/auth-gate";
import { Sidebar } from "@/components/layout/sidebar";
import { TopBar } from "@/components/layout/top-bar";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function AppShellLayout({
@@ -12,9 +12,9 @@ export default function AppShellLayout({
<TooltipProvider>
<CreditsProvider>
<AuthGate>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
<div className="flex h-full flex-col">
<TopBar />
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
{children}
</main>
</div>
@@ -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">
<Badge variant="outline">pin table</Badge>
{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">
{p.mpn}
<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>
@@ -3,9 +3,7 @@
import { use, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
fetchProject,
fetchProjectLogs,
@@ -48,6 +46,7 @@ import { CollaboratorsSection } from "@/components/project/collaborators-section
import { SkippedComponentsSection } from "@/components/project/skipped-components-section";
import { ReportVersionSection } from "@/components/project/report-version-section";
import { PipelineErrorBanner } from "@/components/project/pipeline-error-banner";
import { AppearanceSettings } from "@/components/theme/appearance-settings";
import {
deratingOverridesKey,
deratingSettingsKey,
@@ -262,39 +261,35 @@ export default function ProjectDetailPage({
const hasFailedReviews = Boolean(hasSkipped);
return (
<div className="flex-1 p-6 max-w-4xl mx-auto w-full space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">{project.name}</h1>
<p className="text-sm text-muted-foreground">
<div className="mx-auto w-full max-w-4xl flex-1 space-y-5 px-6 py-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-[22px] font-semibold tracking-tight text-neutral-900 dark:text-white">
{project.name}
</h1>
<p className="mt-0.5 text-[13px] tabular-nums text-neutral-500">
{new Date(project.created).toLocaleDateString()}
{project.hasPcb ? (
<span className="ml-2">· PCB uploaded</span>
) : (
<span className="ml-2">· no PCB</span>
)}
{project.hasPcb ? " · PCB on file" : " · schematic only"}
{typeof project.totalCostUsd === "number" && project.totalCostUsd > 0 && (
<span className="ml-2 font-mono tabular-nums text-foreground">
<span className="ml-2 text-neutral-800 dark:text-neutral-200">
${project.totalCostUsd.toFixed(4)}
</span>
)}
{" · "}
<span className="capitalize">{project.status}</span>
</p>
</div>
<div className="flex items-center gap-2">
{project.status !== "running" && project.status !== "queued" && (
<Button
size="sm"
variant="outline"
onClick={() => setRerunProject(project)}
>
<Upload className="h-4 w-4 mr-1" />
Replace BOM & netlist
</Button>
)}
<Badge variant="outline" className="capitalize text-xs">
{project.status}
</Badge>
</div>
{project.status !== "running" && project.status !== "queued" && (
<Button
size="sm"
variant="outline"
className="shrink-0 rounded-[8px]"
onClick={() => setRerunProject(project)}
>
<Upload className="mr-1 h-4 w-4" />
Replace KiCad project
</Button>
)}
</div>
{isPaused && (
@@ -313,8 +308,7 @@ export default function ProjectDetailPage({
/>
)}
<Card>
<CardContent className="py-3">
<div className="rounded-[11px] border border-black/10 bg-white px-4 py-3 dark:border-white/10 dark:bg-[#2c2c2e]">
{project.status === "complete" ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="text-sm text-emerald-600 dark:text-emerald-400">
@@ -448,8 +442,8 @@ export default function ProjectDetailPage({
: "Run PCB review"}
</Button>
{!canRun && (
<span className="text-xs text-muted-foreground">
Upload BOM and netlist to enable
<span className="text-[12px] text-neutral-500">
Open a KiCad project to enable the exam
</span>
)}
</div>
@@ -467,8 +461,7 @@ export default function ProjectDetailPage({
)}
</div>
)}
</CardContent>
</Card>
</div>
{tab === "bom" && (
<BomSummaryTable
@@ -518,19 +511,20 @@ export default function ProjectDetailPage({
{tab === "settings" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-sm">KiCad PCB</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
{project.hasPcb
? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0."
: "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}
</p>
<div className="rounded-[11px] border border-black/10 bg-white px-5 py-4 dark:border-white/10 dark:bg-[#2c2c2e]">
<AppearanceSettings />
</div>
<div className="rounded-[11px] border border-black/10 bg-white px-5 py-4 dark:border-white/10 dark:bg-[#2c2c2e]">
<h2 className="text-[13px] font-semibold tracking-tight">KiCad PCB</h2>
<p className="mt-1 text-[13px] text-neutral-500">
{project.hasPcb
? "A .kicad_pcb is on this project. Replace it here, then re-run the pipeline for layout and net Z0."
: "No board yet. Schematic review does not need one. Layout checks and net Z0 do."}
</p>
<div className="mt-3">
<PcbUploadButton projectId={id} onUploaded={reload} />
</CardContent>
</Card>
</div>
</div>
<CollaboratorsSection projectId={id} />
<SkippedComponentsSection skipped={project.skippedComponents} />
<ReportVersionSection periscopeVersion={project.periscopeVersion} />
@@ -33,7 +33,6 @@ import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Coffee,
Coins,
Loader2,
OctagonX,
@@ -237,21 +236,21 @@ export default function ProgressPage({
)}
{isRunning && !isQueued && (
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-amber-500/30 bg-gradient-to-r from-amber-500/10 via-amber-500/[0.04] to-transparent overflow-hidden">
<div className="relative flex items-center gap-4 p-5 rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/10 via-blue-500/[0.04] to-transparent overflow-hidden">
<div className="relative shrink-0">
<span
aria-hidden
className="absolute inset-0 -m-2 rounded-full bg-amber-400/30 blur-xl animate-pulse"
className="absolute inset-0 -m-2 rounded-full bg-blue-400/30 blur-xl animate-pulse"
/>
<Coffee
className="relative h-8 w-8 text-amber-700 dark:text-amber-300 drop-shadow-[0_0_10px_rgba(251,191,36,0.75)]"
<Loader2
className="relative h-8 w-8 text-blue-700 dark:text-blue-300 animate-spin drop-shadow-[0_0_10px_rgba(96,165,250,0.75)]"
strokeWidth={2.25}
/>
</div>
<div className="flex-1">
<p className="text-base font-medium text-foreground">Feel free to grab a coffee</p>
<p className="text-base font-medium text-foreground">Pipeline running</p>
<p className="text-sm text-muted-foreground mt-0.5">
You can close this tab we&apos;ll email you when it&apos;s ready.
Stages update below as the run proceeds.
</p>
</div>
</div>
@@ -18,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";
@@ -30,7 +31,7 @@ import {
signReport,
downloadEcoCsv,
} from "@/lib/api";
import { exportReportToExcel } from "@/lib/report-export";
import { exportReportToMarkdown } from "@/lib/report-export";
import { cn, getFindingKey } from "@/lib/utils";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
@@ -239,21 +240,30 @@ 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 || "Report"}
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"
className="rounded-[8px]"
onClick={() => exportReportToMarkdown(report, graph, projectName)}
disabled={
report.findings.length === 0 &&
!(report.protocol_certification?.recognized_instances?.length)
}
>
<Download /> Save as Markdown
</Button>
<Button
variant="outline"
size="sm"
className="rounded-[8px]"
disabled={reprocessing}
onClick={handleReprocessFailed}
>
@@ -261,16 +271,9 @@ function ReportContent({ projectId }: { projectId: string }) {
{reprocessing ? "Starting..." : "Retry failed reviews"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportReportToExcel(report, graph, projectName)}
disabled={report.findings.length === 0}
>
<Download /> Export Excel
</Button>
<Button
variant="outline"
variant="ghost"
size="sm"
className="rounded-[8px] text-neutral-500"
onClick={async () => {
try {
await downloadEcoCsv(projectId);
@@ -279,11 +282,12 @@ function ReportContent({ projectId }: { projectId: string }) {
}
}}
>
<Download /> ECO CSV
ECO CSV
</Button>
<Button
variant="outline"
variant="ghost"
size="sm"
className="rounded-[8px] text-neutral-500"
onClick={async () => {
try {
const rel = await signReport(projectId);
@@ -295,8 +299,9 @@ function ReportContent({ projectId }: { projectId: string }) {
>
Sign release
</Button>
</div>
</div>
</div>
}
/>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
@@ -304,50 +309,53 @@ function ReportContent({ projectId }: { projectId: string }) {
totalCostUsd={totalCostUsd}
/>
{failedReviews > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
<div className="space-y-2 rounded-[11px] border border-amber-500/30 bg-[rgba(255,214,10,0.08)] px-4 py-3">
<p className="text-[13px] font-medium text-amber-800 dark:text-amber-200">
{failedReviews} IC review{failedReviews === 1 ? "" : "s"} failed
</p>
<p className="text-xs text-muted-foreground">
<p className="text-[12px] text-neutral-500">
These ICs could not be reviewed against their datasheets. Use Retry
failed reviews to run them again without repeating ICs that already succeeded.
</p>
<ul className="space-y-1 text-xs">
<ul className="space-y-1 text-[12px]">
{Object.entries(report.review_errors!).map(([ref, err]) => (
<li key={ref} className="flex gap-2">
<span className="font-mono font-medium text-amber-700 dark:text-amber-300 shrink-0">
<span className="shrink-0 font-mono font-medium text-amber-800 dark:text-amber-200">
{ref}
</span>
<span className="text-muted-foreground break-all">{err}</span>
<span className="break-all text-neutral-500">{err}</span>
</li>
))}
</ul>
</div>
)}
{report.not_reviewed && report.not_reviewed.length > 0 && (
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-2">
<p className="text-sm font-medium">
<div className="space-y-2 rounded-[11px] border border-black/10 bg-white/70 px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
<p className="text-[13px] font-medium tracking-tight">
{report.not_reviewed.length} component{report.not_reviewed.length === 1 ? "" : "s"} not reviewed
</p>
<p className="text-xs text-muted-foreground">
These components have no datasheet on file, so they were not checked against one. A reversed or mis-wired pin on an unreviewed part (e.g. a DNP footprint with no BOM entry) cannot be caught here verify these manually.
<p className="text-[12px] text-neutral-500">
Each line is one designator. The reason is per part missing
datasheet, missing library extraction, or SI re-extract needed.
Parts that were reviewed are not listed here. Review failures
(with an error message) appear under failed reviews above.
</p>
<ul className="space-y-1 text-xs">
<ul className="space-y-1 text-[12px]">
{report.not_reviewed.map((nr) => (
<li key={nr.designator} className="flex gap-2">
<span className="font-mono font-medium shrink-0">{nr.designator}</span>
<span className="text-muted-foreground break-all">{nr.reason}</span>
<span className="shrink-0 font-mono font-medium">{nr.designator}</span>
<span className="break-all text-neutral-500">{nr.reason}</span>
</li>
))}
</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 dark:border-white/10">
<p className="text-[15px] font-medium tracking-tight">No findings</p>
<p className="mt-1 text-[13px] text-neutral-500">
Open the KiCad project on Exam and run the exam again after datasheets are on file.
</p>
</div>
) : (
@@ -368,6 +376,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>
@@ -348,17 +348,11 @@ const WIZARD_STEP_DEFS: { key: WizardStep; label: string }[] = [
const BARE_CELL_KEY = "";
const subdesignMapKey = (id: string | null): string => id ?? BARE_CELL_KEY;
/** User-facing ingress is KiCad only. Spreadsheet / PADS / EDIF stay on the API. */
const NETLIST_EXTS = new Set([
".kicad_sch",
".kicad_net",
".kicad_pro",
".asc",
".net",
".edn",
".edif",
".edf",
".xml",
".zip",
]);
function extensionOf(name: string): string {
@@ -380,8 +374,9 @@ function sortIncomingFiles(incoming: File[]): {
).toLowerCase();
if (rel.includes("-backups/") || rel.includes(".pretty/")) continue;
const ext = extensionOf(f.name);
// Ignore csv/xlsx — BOM is derived from the KiCad schematic on the server.
if (ext === ".csv" || ext === ".xlsx") continue;
if (ext === ".kicad_pcb") pcb.push(f);
else if (ext === ".csv" || ext === ".xlsx") bom.push(f);
else if (NETLIST_EXTS.has(ext)) netlist.push(f);
}
return { netlist, pcb, bom };
@@ -844,6 +839,8 @@ export function CreateProjectDialog({
// lcsc-passives drains to zero before the auto-advance effect
// moves us off the step on the next tick).
if (s.key === step) return true;
// KiCad-only ingress: BOM columns come from the schematic fields — no spreadsheet step.
if (s.key === "columns") return false;
if (s.key === "subdesigns") return hasSubdesignChoice;
if (s.key === "lcsc-passives") return hasLcscPassives;
if (s.key === "datasheets") return hasIcs;
@@ -901,15 +898,11 @@ export function CreateProjectDialog({
setNetlistUploadedEarly(false);
setNetlistIsEdif(false);
if (!kept.some((f) => looksLikeKicadPro(f))) {
setNetlistError(
"In questa cartella non c'è un progetto KiCad (.kicad_pro).",
);
setNetlistError("This folder has no KiCad project (.kicad_pro).");
return;
}
if (!kept.some((f) => looksLikeKicadSch(f))) {
setNetlistError(
"Manca lo schematico nella stessa cartella del .kicad_pro.",
);
setNetlistError("Schematic (.kicad_sch) must sit beside the .kicad_pro.");
return;
}
setNetlistError(null);
@@ -1748,14 +1741,11 @@ export function CreateProjectDialog({
const advanceStep = async () => {
if (step === "details") {
if (!netlistFiles.some((f) => looksLikeKicadSch(f))) {
setNetlistError(
"Manca lo schematico nella stessa cartella del .kicad_pro.",
);
setNetlistError("Schematic (.kicad_sch) must sit beside the .kicad_pro.");
return;
}
// EDIF: eagerly upload so we can show the sub-design picker before
// the user reaches Run. Skip in rerun mode (netlist is already on
// the server) and when already done.
// KiCad: upload early so the server can derive the BOM from schematic fields.
// Skip in rerun mode (files already on the server) and when already done.
if (
!rerunProject &&
!netlistUploadedEarly &&
@@ -1766,7 +1756,7 @@ export function CreateProjectDialog({
if (!res.ok) return;
if (!res.bomSaved) {
setEarlyUploadError(
"Nello schematico non ho trovato i codici pezzo (MPN). Non ne invento nessuno.",
"No manufacturer part numbers found in the schematic. Periscope will not invent MPNs.",
);
return;
}
@@ -1774,7 +1764,9 @@ export function CreateProjectDialog({
setStep(next);
return;
}
setStep("columns");
// Rerun / already-uploaded: never show BOM Columns — go to the next real step.
const next = routeAfterColumns();
if (next) setStep(next);
}
else if (step === "columns") {
// LCSC-detected BOMs need an early upload so we have the LCSC → MPN
@@ -1852,25 +1844,25 @@ export function CreateProjectDialog({
const retreatStep = () => {
setError(null);
if (step === "columns") setStep("details");
else if (step === "subdesigns") setStep("columns");
else if (step === "subdesigns") setStep("details");
else if (step === "lcsc-passives") {
if (hasSubdesignChoice) setStep("subdesigns");
else setStep("columns");
else setStep("details");
} else if (step === "datasheets") {
if (hasLcscPassives) setStep("lcsc-passives");
else if (hasSubdesignChoice) setStep("subdesigns");
else setStep("columns");
else setStep("details");
} else if (step === "simple") {
if (hasIcs) setStep("datasheets");
else if (hasLcscPassives) setStep("lcsc-passives");
else if (hasSubdesignChoice) setStep("subdesigns");
else setStep("columns");
else setStep("details");
} else if (step === "passives") {
if (hasSimple) setStep("simple");
else if (hasIcs) setStep("datasheets");
else if (hasLcscPassives) setStep("lcsc-passives");
else if (hasSubdesignChoice) setStep("subdesigns");
else setStep("columns");
else setStep("details");
}
};
@@ -2127,7 +2119,7 @@ export function CreateProjectDialog({
</div>
<div className="space-y-1.5">
<FileUploadZone
label="Apri progetto KiCad"
label="Open KiCad project"
directory
files={netlistFiles}
onFilesChange={onKicadProjectFolder}
@@ -2142,10 +2134,10 @@ export function CreateProjectDialog({
{netlistError}
</p>
) : (
<p className="text-sm text-muted-foreground leading-tight">
Scegli la cartella del progetto (quella dove sta il file
.kicad_pro). Carichiamo schematico e circuito stampato da .
Non serve un netlist.
<p className="text-[13px] leading-tight text-neutral-500">
Choose the project folder that contains the .kicad_pro file.
Periscope loads the schematic and PCB from there. No separate
BOM or netlist upload.
</p>
)}
</div>
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function PageHeader({
title,
meta,
actions,
}: {
title: string;
meta?: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
<div className="min-w-0">
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
{title}
</h1>
{meta ? (
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
@@ -13,7 +13,7 @@ export function SidebarCredits() {
return null;
}
export function SidebarUserButton() {
export function SidebarUserButton({ compact = false }: { compact?: boolean }) {
const { user, isLoaded } = useOptionalUser();
const { signOut, isSignedIn } = useOptionalAuth();
@@ -26,6 +26,16 @@ export function SidebarUserButton() {
}
if (!isSignedIn || !user) {
if (compact) {
return (
<Link
href="/sign-in"
className={cn(buttonVariants({ size: "sm", variant: "outline" }), "h-7 px-2 text-[11px]")}
>
Sign in
</Link>
);
}
return (
<div className="flex flex-col gap-1 px-1">
<Link
@@ -44,6 +54,22 @@ export function SidebarUserButton() {
);
}
if (compact) {
return (
<button
type="button"
title={user.email || user.name || "Account"}
className="max-w-[120px] truncate rounded-[8px] px-2 py-1 text-[11px] text-neutral-600 hover:bg-black/[0.04] dark:text-neutral-300 dark:hover:bg-white/[0.06]"
onClick={() => {
signOut?.();
window.location.href = "/sign-in";
}}
>
{user.name || user.email}
</button>
);
}
return (
<div className="flex flex-col gap-1 px-2 py-1">
<div className="truncate text-xs font-medium">{user.name || user.email}</div>
@@ -1,294 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ArrowLeft,
Boxes,
CircuitBoard,
ClipboardList,
LayoutDashboard,
Library,
Loader2,
MessageSquareWarning,
Ruler,
ScrollText,
Settings,
Shield,
TableProperties,
Zap,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
import { ThemeToggle } from "@/components/theme/theme-toggle";
import { useAuthApi } from "@/hooks/use-auth-api";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { fetchProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import { APP_VERSION } from "@/lib/version";
function useProjectFromPath(pathname: string): {
projectId: string | null;
project: Project | null;
} {
const match = pathname.match(/^\/project\/([^/]+)/);
const projectId = match ? match[1] : null;
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (!projectId) {
setProject(null);
return;
}
fetchProject(projectId)
.then(setProject)
.catch(() => setProject(null));
}, [projectId]);
useEffect(() => {
if (!projectId || project?.status !== "running") return;
const interval = setInterval(() => {
fetchProject(projectId)
.then(setProject)
.catch(() => {
/* keep last known project */
});
}, 3000);
return () => clearInterval(interval);
}, [projectId, project?.status]);
return { projectId, project };
}
export function Sidebar() {
const pathname = usePathname();
const { user } = useOptionalUser();
useAuthApi();
const [feedbackOpen, setFeedbackOpen] = useState(false);
const { projectId, project } = useProjectFromPath(pathname);
const isAdmin = user?.isAdmin ?? false;
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
<div className="px-4 py-4 border-b border-border">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
{projectId ? (
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
<ProjectNav
pathname={pathname}
projectId={projectId}
project={project}
isAdmin={isAdmin}
/>
</Suspense>
) : (
<DefaultNav pathname={pathname} isAdmin={isAdmin} />
)}
</div>
<div className="border-t border-border">
<SidebarCredits />
<div className="px-2 py-1.5 border-b border-border/60">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<SidebarUserButton />
<Link
href="/changelog"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
v{APP_VERSION}
</Link>
<ThemeToggle className="ml-auto" />
</div>
</div>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</aside>
);
}
function navItemClass(active: boolean) {
return cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
);
}
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 px-2 py-3 space-y-0.5">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<LayoutDashboard className="h-4 w-4" />
Projects
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-4 w-4" />
Library
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-4 w-4" />
My Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-4 w-4" />
Admin
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
</Link>
)}
</nav>
);
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
const PROJECT_NAV_ITEMS: NavItem[] = [
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
function NavLink({
href,
active,
children,
forceDocument,
}: {
href: string;
active?: boolean;
children: ReactNode;
forceDocument?: boolean;
}) {
const className = navItemClass(Boolean(active));
if (forceDocument) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
function ProjectNav({
pathname,
projectId,
project,
isAdmin,
}: {
pathname: string;
projectId: string;
project: Project | null;
isAdmin: boolean;
}) {
const searchParams = useSearchParams();
const base = `/project/${projectId}`;
const currentTab = searchParams.get("tab");
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
function isActive(item: NavItem): boolean {
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
if (item.tab === "bom") {
return pathname === base && (currentTab === "bom" || currentTab === null);
}
return pathname === base && currentTab === item.tab;
}
function getHref(item: NavItem): string {
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
return (
<nav className="flex-1 px-2 py-3 space-y-1">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<ArrowLeft className="h-4 w-4" />
Dashboard
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-4 w-4" />
Library
</NavLink>
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-foreground truncate">
{project?.name ?? "Loading..."}
</p>
</div>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
</NavLink>
)}
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-4 w-4" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</NavLink>
);
})}
</div>
</nav>
);
}
@@ -0,0 +1,392 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ClipboardList,
Folder,
Library,
Loader2,
Menu,
MessageSquareWarning,
CircuitBoard,
ScanLine,
Settings,
Shield,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { SidebarCredits, SidebarUserButton } from "@/components/layout/sidebar-auth";
import { AppearanceSettings } from "@/components/theme/appearance-settings";
import { useAuthApi } from "@/hooks/use-auth-api";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { fetchProject } from "@/lib/api";
import type { Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import { APP_VERSION } from "@/lib/version";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
function useProjectFromPath(pathname: string): {
projectId: string | null;
project: Project | null;
} {
const match = pathname.match(/^\/project\/([^/]+)/);
const projectId = match ? match[1] : null;
const [project, setProject] = useState<Project | null>(null);
useEffect(() => {
if (!projectId) {
setProject(null);
return;
}
fetchProject(projectId)
.then(setProject)
.catch(() => setProject(null));
}, [projectId]);
useEffect(() => {
if (!projectId || project?.status !== "running") return;
const interval = setInterval(() => {
fetchProject(projectId)
.then(setProject)
.catch(() => {
/* keep last known project */
});
}, 3000);
return () => clearInterval(interval);
}, [projectId, project?.status]);
return { projectId, project };
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
const PRIMARY_NAV: NavItem[] = [
{ type: "tab", tab: "bom", label: "Exam", icon: ScanLine },
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
];
const SECONDARY_NAV: NavItem[] = [
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
function tabClass(active: boolean) {
return cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] transition-colors duration-150 ease-out",
active
? "bg-[#007aff]/12 text-[#007aff]"
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
);
}
function NavLink({
href,
active,
children,
forceDocument,
className,
}: {
href: string;
active?: boolean;
children: ReactNode;
forceDocument?: boolean;
className?: string;
}) {
const cls = className ?? tabClass(Boolean(active));
if (forceDocument) {
return (
<a href={href} className={cls}>
{children}
</a>
);
}
return (
<Link href={href} className={cls}>
{children}
</Link>
);
}
/** Thin iPad-style top toolbar. Replaces the left sidebar column. */
export function TopBar() {
const pathname = usePathname();
const { user } = useOptionalUser();
useAuthApi();
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { projectId, project } = useProjectFromPath(pathname);
const isAdmin = user?.isAdmin ?? false;
return (
<>
<header className="sticky top-0 z-40 flex h-11 shrink-0 items-center gap-2 border-b border-black/10 bg-[#f5f5f7]/90 px-2 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90 sm:px-3">
<Link
href="/dashboard"
className="flex shrink-0 items-center gap-1.5 pr-1"
aria-label="Periscope home"
>
<PeriscopeMark className="h-5 w-5" />
<span className="hidden text-[13px] font-semibold tracking-tight sm:inline">
Periscope
</span>
</Link>
<div className="min-w-0 flex-1 overflow-x-auto">
{projectId ? (
<Suspense fallback={<div className="h-7" aria-hidden />}>
<ProjectTabs
pathname={pathname}
projectId={projectId}
project={project}
isAdmin={isAdmin}
/>
</Suspense>
) : (
<DefaultTabs pathname={pathname} isAdmin={isAdmin} />
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
<SidebarCredits />
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="hidden items-center gap-1 rounded-[8px] px-2 py-1 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900 sm:inline-flex dark:hover:bg-white/[0.06]"
aria-label="Feedback"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
<span className="hidden md:inline">Feedback</span>
</button>
<Link
href="/changelog"
className="hidden text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900 sm:inline dark:hover:text-white"
>
v{APP_VERSION}
</Link>
<button
type="button"
onClick={() => setSettingsOpen(true)}
className={tabClass(false)}
aria-label="Open settings"
>
<Settings className="h-3.5 w-3.5" />
<span className="hidden lg:inline">Settings</span>
</button>
<button
type="button"
onClick={() => setMenuOpen(true)}
className={cn(tabClass(false), "sm:hidden")}
aria-label="Open menu"
>
<Menu className="h-3.5 w-3.5" />
</button>
<div className="hidden sm:block">
<SidebarUserButton compact />
</div>
</div>
</header>
<Sheet open={settingsOpen} onOpenChange={setSettingsOpen}>
<SheetContent side="right" className="w-full sm:max-w-sm">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Appearance and account.</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-4 pb-6">
<AppearanceSettings />
<div className="sm:hidden">
<SidebarUserButton />
</div>
<div className="flex items-center justify-between text-[12px] text-neutral-500">
<Link href="/changelog" className="hover:text-neutral-900 dark:hover:text-white">
Changelog
</Link>
<span className="tabular-nums">v{APP_VERSION}</span>
</div>
</div>
</SheetContent>
</Sheet>
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent side="top" className="max-h-[70vh]">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
<SheetDescription>Navigate Periscope.</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-1 px-4 pb-6" onClick={() => setMenuOpen(false)}>
<NavLink href="/dashboard" className={tabClass(pathname === "/dashboard")}>
<Folder className="h-3.5 w-3.5" />
Projects
</NavLink>
<NavLink href="/library" className={tabClass(pathname === "/library")}>
<Library className="h-3.5 w-3.5" />
Library
</NavLink>
<NavLink href="/feedback" className={tabClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</NavLink>
{isAdmin && (
<NavLink
href="/admin"
className={tabClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-3.5 w-3.5" />
Admin
</NavLink>
)}
{projectId ? (
<>
<p className="truncate px-2.5 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
{project?.name ?? "…"}
</p>
<NavLink href={`/project/${projectId}?tab=bom`}>Exam</NavLink>
<NavLink href={`/project/${projectId}/report`}>Report</NavLink>
<NavLink href={`/project/${projectId}?tab=settings`}>Project settings</NavLink>
</>
) : null}
</div>
</SheetContent>
</Sheet>
<FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</>
);
}
function DefaultTabs({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex items-center gap-0.5" aria-label="Main">
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
<Folder className="h-3.5 w-3.5" />
Projects
</NavLink>
<NavLink href="/library" active={pathname === "/library"}>
<Library className="h-3.5 w-3.5" />
Library
</NavLink>
<NavLink
href="/feedback"
active={pathname === "/feedback"}
className={cn(tabClass(pathname === "/feedback"), "hidden md:inline-flex")}
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</NavLink>
{isAdmin && (
<NavLink
href="/admin"
active={pathname === "/admin" || pathname.startsWith("/admin/")}
className={cn(
tabClass(pathname === "/admin" || pathname.startsWith("/admin/")),
"hidden lg:inline-flex",
)}
>
<Shield className="h-3.5 w-3.5" />
Admin
</NavLink>
)}
</nav>
);
}
function ProjectTabs({
pathname,
projectId,
project,
isAdmin,
}: {
pathname: string;
projectId: string;
project: Project | null;
isAdmin: boolean;
}) {
const searchParams = useSearchParams();
const base = `/project/${projectId}`;
const currentTab = searchParams.get("tab");
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
const onReport = pathname === `${base}/report`;
function isActive(item: NavItem): boolean {
if (item.label === "Report") return onReport;
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
if (item.tab === "bom") {
return pathname === base && (currentTab === "bom" || currentTab === null);
}
return pathname === base && currentTab === item.tab;
}
function getHref(item: NavItem): string {
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
function renderItem(item: NavItem) {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
if (isRunning) {
return (
<span
key={item.label}
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[12px] text-neutral-400"
>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-3.5 w-3.5" />
{item.label}
</NavLink>
);
}
return (
<nav className="flex items-center gap-0.5" aria-label="Project">
<NavLink href="/dashboard" forceDocument={onNestedRoute} className={cn(tabClass(false), "hidden sm:inline-flex")}>
<Folder className="h-3.5 w-3.5" />
Projects
</NavLink>
<span className="hidden max-w-[140px] truncate px-1 text-[11px] font-medium text-neutral-500 md:inline">
{project?.name ?? "…"}
</span>
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Running
</NavLink>
)}
{PRIMARY_NAV.map(renderItem)}
<span className="mx-0.5 hidden h-4 w-px bg-black/10 sm:inline dark:bg-white/10" aria-hidden />
{SECONDARY_NAV.map(renderItem)}
{isAdmin ? (
<NavLink
href={`${base}?tab=logs`}
forceDocument={onNestedRoute}
className={cn(tabClass(pathname === base && currentTab === "logs"), "hidden xl:inline-flex")}
>
<Shield className="h-3.5 w-3.5" />
Log
</NavLink>
) : null}
</nav>
);
}
@@ -1,114 +0,0 @@
"use client";
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
interface ComponentGroupProps {
designator: string;
findings: Finding[];
component?: Component;
onViewReference: (finding: Finding) => void;
findingKeys?: Map<Finding, string>;
isReviewed?: (key: string) => boolean;
onToggleReviewed?: (key: string) => void;
comments?: Record<string, FindingComment[]>;
projectId?: string;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function ComponentGroup({
designator,
findings,
component,
onViewReference,
findingKeys,
isReviewed,
onToggleReviewed,
comments,
projectId,
collaborators,
currentUserId,
currentUserName,
onCommentAdded,
onCommentDeleted,
onReportFinding,
reportedFindingIds,
reviews,
onReviewSaved,
}: ComponentGroupProps) {
const [open, setOpen] = useState(false);
const sorted = sortFindings(findings);
const errorCount = findings.filter((f) => f.status === "ERROR").length;
const warnCount = findings.filter((f) => f.status === "WARNING").length;
const infoCount = findings.filter((f) => f.status === "INFO").length;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-3 w-full py-3 group">
<ChevronRight
className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-90")}
/>
<Badge variant="secondary" className="font-mono text-sm">
{designator}
</Badge>
{component?.mpn && (
<span className="text-sm text-muted-foreground font-mono">{component.mpn}</span>
)}
{component?.component_subtype && (
<span className="text-xs text-muted-foreground">
{subtypeLabel(component.component_subtype)}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
{errorCount > 0 && <StatusBadge status="ERROR" />}
{warnCount > 0 && <StatusBadge status="WARNING" />}
{infoCount > 0 && <StatusBadge status="INFO" />}
<span className="text-xs text-muted-foreground ml-1">
{findings.length} {findings.length === 1 ? "finding" : "findings"}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 pl-7 pb-4">
{sorted.map((f, i) => {
const key = findingKeys?.get(f);
return (
<FindingCard
key={key ?? i}
finding={f}
onViewReference={onViewReference}
checked={key && isReviewed ? isReviewed(key) : undefined}
onCheckedChange={key && onToggleReviewed ? () => onToggleReviewed(key) : undefined}
comments={f.finding_id ? comments?.[f.finding_id] : undefined}
projectId={projectId}
collaborators={collaborators}
currentUserId={currentUserId}
currentUserName={currentUserName}
onCommentAdded={onCommentAdded}
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
isReported={!!(f.finding_id && reportedFindingIds?.has(f.finding_id))}
review={f.finding_id ? reviews?.[f.finding_id] : undefined}
onReviewSaved={onReviewSaved}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -4,19 +4,12 @@ import { useState } from "react";
import { ChevronDown, FileText, Check, MessageSquare, Star, Flag, Cpu } from "lucide-react";
import { Checkbox } from "@base-ui/react/checkbox";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "./status-badge";
import { FindingComments } from "./finding-comments";
import { FindingReviewControls } from "./finding-review-controls";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
const EDGE: Record<string, string> = {
ERROR: "border-l-rose-500",
WARNING: "border-l-amber-500",
INFO: "border-l-blue-500",
};
const DEFAULT_ACTION =
"Review this finding against the datasheet and the board, then change the design if it applies.";
@@ -39,6 +32,7 @@ interface FindingCardProps {
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
/** Tree-leaf detail — flat row under the findings tree, not a dashboard card. */
export function FindingCard({
finding,
onViewReference,
@@ -87,57 +81,55 @@ export function FindingCard({
: undefined
}
className={cn(
"rounded-lg border border-border bg-card p-4 border-l-4 transition-colors",
expandable && "cursor-pointer hover:bg-accent/30",
EDGE[finding.status],
"rounded-[8px] px-2.5 py-2 transition-colors duration-150 ease-out",
expandable && "cursor-pointer hover:bg-black/[0.03] dark:hover:bg-white/[0.04]",
)}
>
<div className="flex items-start gap-3">
<div className="flex items-start gap-2.5">
{onCheckedChange && (
<div onClick={(e) => e.stopPropagation()}>
<Checkbox.Root
checked={checked}
onCheckedChange={() => onCheckedChange()}
aria-label="Mark as reviewed"
className="mt-0.5 h-5 w-5 shrink-0 rounded border border-border hover:border-muted-foreground data-[checked]:bg-emerald-500 data-[checked]:border-emerald-500 flex items-center justify-center cursor-pointer transition-colors"
className="mt-0.5 flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-black/20 transition-colors hover:border-neutral-500 data-[checked]:border-[#007aff] data-[checked]:bg-[#007aff] dark:border-white/25"
>
<Checkbox.Indicator>
<Check className="h-3.5 w-3.5 text-white" />
<Check className="h-3 w-3 text-white" />
</Checkbox.Indicator>
</Checkbox.Root>
</div>
)}
<StatusBadge status={finding.status} />
<div className="flex-1 min-w-0 space-y-1.5">
<p className="text-sm font-semibold leading-snug flex items-center gap-1.5">
<div className="min-w-0 flex-1 space-y-1.5">
<p className="flex items-start gap-1.5 text-[13px] font-medium leading-snug tracking-tight text-neutral-900 dark:text-neutral-100">
{commentCount > 0 && (
<Star
className="h-3.5 w-3.5 shrink-0 text-amber-600 fill-amber-600 dark:text-amber-400 dark:fill-amber-400"
className="mt-0.5 h-3.5 w-3.5 shrink-0 fill-amber-500 text-amber-500"
aria-label="Has comments"
/>
)}
<span>{finding.finding}</span>
</p>
{finding.why && !finding.facts && !finding.requirement && (
<p className="text-sm text-muted-foreground leading-relaxed">{finding.why}</p>
<p className="text-[12px] leading-relaxed text-neutral-500">{finding.why}</p>
)}
<p className="text-sm text-muted-foreground mt-2 pl-1 border-l-2 border-muted leading-relaxed">
<span className="font-medium text-foreground">Action. </span>
<p className="text-[12px] leading-relaxed text-neutral-600 dark:text-neutral-400">
<span className="font-medium text-neutral-800 dark:text-neutral-200">Action. </span>
{actionText}
</p>
<div className="flex items-center gap-2 pt-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 pt-0.5 text-[11px] text-neutral-500">
{expandable && (
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground">
<span className="inline-flex items-center">
<ChevronDown
className={cn("h-3.5 w-3.5 mr-1 transition-transform", open && "rotate-180")}
className={cn("mr-0.5 h-3 w-3 transition-transform duration-150", open && "rotate-180")}
/>
Details
</span>
)}
{commentsEnabled && finding.finding_id && (
<span className="inline-flex items-center h-7 px-2 text-xs text-muted-foreground gap-1">
<MessageSquare className="h-3.5 w-3.5" />
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{commentCount > 0
? `${commentCount} comment${commentCount > 1 ? "s" : ""}`
: "Comment"}
@@ -147,8 +139,8 @@ export function FindingCard({
<button
type="button"
className={cn(
"inline-flex items-center h-7 px-2 text-xs transition-colors",
isReported ? "text-rose-500" : "text-muted-foreground hover:text-amber-500",
"inline-flex items-center transition-colors",
isReported ? "text-rose-500" : "hover:text-neutral-800 dark:hover:text-white",
)}
onClick={(e) => {
e.stopPropagation();
@@ -156,94 +148,73 @@ export function FindingCard({
}}
aria-label={isReported ? "Reported" : "Report this finding"}
>
<Flag className={cn("h-3.5 w-3.5", isReported && "fill-rose-500")} />
<Flag className={cn("h-3 w-3", isReported && "fill-rose-500")} />
</button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
className="h-6 px-1.5 text-[11px] text-[#007aff] hover:bg-[#007aff]/10 hover:text-[#007aff]"
onClick={(e) => {
e.stopPropagation();
onViewReference(finding);
}}
>
<FileText className="h-3.5 w-3.5 mr-1" />
<FileText className="mr-1 h-3 w-3" />
{finding.source_page ? `p.${finding.source_page}` : "ref"}
</Button>
{finding.finding_id && (
<span className="font-mono text-[11px] text-muted-foreground">{finding.finding_id}</span>
<span className="font-mono tabular-nums">{finding.finding_id}</span>
)}
{finding.source && finding.source !== "review" && (
<span
title="Found by a deterministic rule check, not the datasheet review"
className="inline-flex items-center gap-1 rounded border border-blue-500/30 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300"
>
<span className="inline-flex items-center gap-1 text-neutral-500">
<Cpu className="h-3 w-3" />
Automated check
</span>
)}
{isLayoutFinding(finding) && (
<span className="inline-flex items-center rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:text-emerald-300">
Layout
Automated
</span>
)}
{isLayoutFinding(finding) && <span>Layout</span>}
{finding.rule_id && (
<span className="font-mono text-[11px] text-muted-foreground">{finding.rule_id}</span>
)}
{finding.finding_class && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{finding.finding_class}
</span>
)}
{finding.provenance && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{finding.provenance}
</span>
<span className="font-mono tabular-nums">{finding.rule_id}</span>
)}
{finding.finding_class && <span>{finding.finding_class}</span>}
{finding.provenance && <span>{finding.provenance}</span>}
{finding.evidence_status === "INSUFFICIENT" && (
<span className="inline-flex items-center rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:text-amber-300">
Insufficient evidence
</span>
)}
{finding.suppressed && (
<span className="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Decision
</span>
<span className="text-amber-700 dark:text-amber-300">Insufficient evidence</span>
)}
{finding.suppressed && <span>Decision</span>}
</div>
{open && (
<div onClick={(e) => e.stopPropagation()}>
{(finding.facts || finding.requirement || finding.inference) && (
<dl className="mt-2 space-y-1.5 text-xs text-muted-foreground">
<dl className="mt-2 space-y-1.5 text-[12px] text-neutral-500">
{finding.facts ? (
<div>
<dt className="font-medium text-foreground">Fact</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-200">Fact</dt>
<dd>{finding.facts}</dd>
</div>
) : null}
{finding.requirement ? (
<div>
<dt className="font-medium text-foreground">Requirement</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-200">Requirement</dt>
<dd>{finding.requirement}</dd>
</div>
) : null}
{finding.inference ? (
<div>
<dt className="font-medium text-foreground">Inference</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-200">Inference</dt>
<dd>{finding.inference}</dd>
</div>
) : null}
{finding.calculation ? (
<div>
<dt className="font-medium text-foreground">Calculation</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-200">Calculation</dt>
<dd className="font-mono">{finding.calculation}</dd>
</div>
) : null}
{typeof finding.confidence === "number" ? (
<div>
<dt className="font-medium text-foreground">Confidence</dt>
<dt className="font-medium text-neutral-800 dark:text-neutral-200">Confidence</dt>
<dd>{Math.round(finding.confidence * 100)}%</dd>
</div>
) : null}
@@ -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,31 @@
"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,
protocolEmptyMessage,
protocolInstancesFromSection,
protocolInstancesForStatus,
worstStatus,
type DomainGroup,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
interface FindingsTreeProps {
findings: Finding[];
@@ -35,12 +45,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 +59,126 @@ function pinNetLabel(f: Finding): string {
return f.rule_id || "Finding";
}
function DisclosureMark({ open }: { open: boolean }) {
return (
<span
aria-hidden
className={cn(
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
"border border-black/20 text-[9px] leading-none text-neutral-600",
"dark:border-white/25 dark:text-neutral-300",
)}
>
{open ? "" : "+"}
</span>
);
}
function TreeBranch({
label,
extra,
findings,
children,
tint,
defaultOpen = false,
}: {
label: string;
extra?: string;
findings: Finding[];
findings: { status: FindingStatus }[];
children: ReactNode;
tint?: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const worst = worstStatus(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"flex w-full items-center gap-2 px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]",
tint,
)}
>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<span className="font-mono text-sm truncate">{label}</span>
<DisclosureMark open={open} />
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
{extra ? (
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
{extra}
</span>
) : null}
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<StatusBadge status={worst} />
<span className="text-xs text-muted-foreground">{findings.length}</span>
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
{findings.length}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
<span className="sr-only">{worst}</span>
</button>
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
</div>
);
}
export function FindingsTree(props: FindingsTreeProps) {
const sorted = sortFindings(props.findings);
const 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
{...props}
findings={root.items}
/>
);
if (!showRoots) return <div key={root.id}>{body}</div>;
<nav
aria-label="Findings tree"
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
>
{merged.map(({ status, folder, cert }, index) => {
const label =
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
const findings = [
...(folder?.findings ?? []),
...protocolFindings.filter((f) => f.status === status),
];
const protocolAtStatus = protocolFindings.filter((f) => f.status === status);
const inst = protocolInstancesForStatus(instances, status);
const count = Math.max(findings.length, inst.length);
return (
<TreeBranch key={root.id} label={root.label} findings={root.items}>
{body}
<TreeBranch
key={status}
label={label}
findings={count ? findings : [{ status }]}
tint={STATUS_TINT[status]}
defaultOpen={index === 0}
>
{(folder?.domains ?? []).map((domain) => (
<DomainBranch key={domain.id} domain={domain} {...props} />
))}
{cert ? (
<CertificationsBranch
{...props}
status={status}
instances={inst}
findings={protocolAtStatus}
/>
) : null}
</TreeBranch>
);
})}
@@ -122,89 +186,154 @@ 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];
const extra = [
component?.mpn,
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
]
.filter(Boolean)
.join(" · ");
const byLeaf = new Map<string, Finding[]>();
for (const f of group) {
const label = pinNetLabel(f);
const list = byLeaf.get(label) ?? [];
list.push(f);
byLeaf.set(label, list);
}
const leaves = [...byLeaf.entries()].sort((a, b) => a[0].localeCompare(b[0]));
return (
<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);
return (
<FindingCard
key={key}
finding={f}
onViewReference={cardProps.onViewReference}
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
onCheckedChange={
key && cardProps.onToggleReviewed
? () => cardProps.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}
/>
);
})}
</div>
<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) : "",
]
.filter(Boolean)
.join(" · ");
const byLeaf = new Map<string, Finding[]>();
for (const finding of group) {
const label = pinNetLabel(finding);
const list = byLeaf.get(label) ?? [];
list.push(finding);
byLeaf.set(label, list);
}
const leaves = [...byLeaf.entries()].sort((a, b) => a[0].localeCompare(b[0]));
return (
<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-2 py-1.5">
{sortFindings(items).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>
))}
</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="Certifications" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch
label="Protocols"
findings={all.length ? all : [{ status }]}
defaultOpen
>
{instances.map((inst) => (
<TreeBranch
key={inst.instanceId}
label={`${inst.logicalId || "protocol"}${inst.physicalId || "—"}`}
extra={inst.recognition}
findings={[{ status: inst.status }]}
>
<ProtocolInstanceBody instance={inst} />
</TreeBranch>
);
})}
</div>
))}
{findings.length > 0 ? (
<TreeBranch label="Findings" findings={findings}>
<div className="space-y-2 py-1.5">
{sortFindings(findings).map((item) => {
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</TreeBranch>
) : null}
{instances.length === 0 && findings.length === 0 ? (
<p className="px-2 py-2 text-[13px] text-neutral-500">
{protocolEmptyMessage(props.protocolSection?.message)}
</p>
) : null}
</TreeBranch>
</TreeBranch>
);
}
export function FindingsTreeEmpty() {
return (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
</p>
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
<p className="text-[15px] font-medium tracking-tight">No visible findings</p>
<p className="mt-1 text-[13px] text-neutral-500">
Filters are hiding the list. Clear a filter, or open the reviewed findings below.
</p>
</div>
);
}
@@ -1,72 +0,0 @@
"use client";
import { CircuitBoard } from "lucide-react";
import { FindingCard } from "@/components/report/finding-card";
import { isPcbExamFinding } from "@/lib/layout-finding";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
interface PcbExamSectionProps {
findings: Finding[];
onViewReference: (finding: Finding) => void;
projectId: string;
isReviewed: (key: string) => boolean;
toggleReviewed: (key: string) => void;
findingKey: (f: Finding) => string;
comments?: Record<string, FindingComment[]>;
collaborators?: Collaborator[];
currentUserId?: string;
currentUserName?: string;
onCommentAdded?: (comment: FindingComment) => void;
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function PcbExamSection(props: PcbExamSectionProps) {
const exam = props.findings.filter(isPcbExamFinding);
if (exam.length === 0) return null;
return (
<section className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4 space-y-3">
<div>
<h2 className="text-sm font-semibold flex items-center gap-2">
<CircuitBoard className="h-4 w-4" />
PCB exam
</h2>
<p className="text-xs text-muted-foreground mt-1">
Layout-only AI using the shared datasheet library (no second PDF pass),
plus power-trace / copper / thermal / via / Kelvin checks grounded in
board geometry and extracted specs.
</p>
</div>
<div className="space-y-2">
{exam.map((f) => {
const key = props.findingKey(f);
return (
<FindingCard
key={key}
finding={f}
onViewReference={props.onViewReference}
checked={props.isReviewed(key)}
onCheckedChange={() => props.toggleReviewed(key)}
comments={f.finding_id ? props.comments?.[f.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={
!!f.finding_id && props.reportedFindingIds?.has(f.finding_id)
}
review={f.finding_id ? props.reviews?.[f.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,145 @@
"use client";
import type { ProtocolCertificationSection } from "@/lib/types";
import {
formatLimit,
formatMargin,
formatMeasured,
formatProtocolChain,
sortProtocolChecks,
type ProtocolReportCheck,
} from "@/lib/protocol-report";
import {
protocolEmptyMessage,
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;
const certLine = checks.find((c) =>
/^(USB|Ethernet|RMII|MII|RGMII) (certified|not certified) —/.test(c.notes || ""),
);
return (
<div className="space-y-2 py-2 pr-2">
{certLine?.notes ? (
<p className="text-[13px] font-medium tracking-tight text-neutral-900 dark:text-neutral-100">
{certLine.notes}
</p>
) : null}
<p className="text-[11px] tabular-nums text-neutral-500">
{logical} {physical}
{instance.recognition ? ` · ${instance.recognition}` : ""}
{instance.worst ? ` · ${instance.worst}` : ""}
</p>
{fail ? (
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL is listed first not under 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">Chain: {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 = protocolEmptyMessage(section?.message);
const maxLevel = section?.max_level_reached || "—";
return (
<section className="space-y-2">
<p className="text-[11px] text-neutral-500">
Max level {maxLevel} (L0L3). HDMI 1.4 TMDS stays UNKNOWN no invented Zdiff.
</p>
{instances.length === 0 ? (
<p className="text-[13px] text-neutral-600">{message}</p>
) : (
<ul className="space-y-3">
{instances.map((inst) => (
<li key={inst.instanceId}>
<ProtocolInstanceBody instance={inst} />
</li>
))}
</ul>
)}
</section>
);
}
@@ -1,6 +1,5 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -27,22 +26,19 @@ interface ReportFiltersProps {
onDomainChange?: (value: string | null) => void;
}
const STATUS_CHIPS: { key: FindingStatus; label: string; activeClass: string }[] = [
{
key: "ERROR",
label: "Error",
activeClass: "bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/40",
},
{
key: "WARNING",
label: "Warning",
activeClass: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/40",
},
{
key: "INFO",
label: "Info",
activeClass: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/40",
},
function chipClass(active: boolean) {
return cn(
"inline-flex h-7 shrink-0 items-center rounded-[8px] px-2.5 text-[12px] transition-colors duration-150 ease-out",
active
? "bg-[#007aff]/12 text-[#007aff]"
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
);
}
const STATUS_CHIPS: { key: FindingStatus; label: string }[] = [
{ key: "ERROR", label: "Error" },
{ key: "WARNING", label: "Warning" },
{ key: "INFO", label: "Info" },
];
const DOMAINS = ["all", "schema", "layout"] as const;
@@ -61,54 +57,40 @@ export function ReportFilters({
onDomainChange,
}: ReportFiltersProps) {
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
{STATUS_CHIPS.map(({ key, label, activeClass }) => (
<Button
key={key}
variant="outline"
size="sm"
className={cn("h-8 text-xs", statusFilters.has(key) && activeClass)}
onClick={() => onToggleStatus(key)}
>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{STATUS_CHIPS.map(({ key, label }) => (
<button
key={key}
type="button"
className={chipClass(statusFilters.has(key))}
onClick={() => onToggleStatus(key)}
>
{label}
</button>
))}
{onToggleNeedsReview && (
<Button
variant="outline"
size="sm"
className={cn(
"h-8 text-xs",
needsReview && "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40",
)}
<button
type="button"
className={chipClass(Boolean(needsReview))}
onClick={onToggleNeedsReview}
>
Needs review
</Button>
)}
{onDomainChange && (
<div className="flex items-center gap-1.5">
{DOMAINS.map((d) => (
<Button
key={d}
variant="outline"
size="sm"
className={cn(
"h-8 text-xs capitalize",
(domain || "all") === d && "bg-emerald-500/15 border-emerald-500/40",
)}
onClick={() => onDomainChange(d === "all" ? null : d)}
>
{d === "all" ? "All" : d === "schema" ? "Schematic" : "Layout"}
</Button>
))}
</div>
</button>
)}
{onDomainChange &&
DOMAINS.map((d) => (
<button
key={d}
type="button"
className={chipClass((domain || "all") === d)}
onClick={() => onDomainChange(d === "all" ? null : d)}
>
{d === "all" ? "All" : d === "schema" ? "Schematic" : "Layout"}
</button>
))}
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectTrigger className="h-7 w-[140px] rounded-[8px] border-black/10 text-[12px] dark:border-white/10">
<SelectValue placeholder="All components" />
</SelectTrigger>
<SelectContent>
@@ -121,13 +103,13 @@ export function ReportFilters({
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<div className="relative min-w-[180px] flex-1">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-neutral-400" />
<Input
placeholder="Search findings..."
placeholder="Search findings"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 pl-8 text-xs"
className="h-7 rounded-[8px] border-black/10 pl-8 text-[12px] dark:border-white/10"
/>
</div>
</div>
@@ -1,6 +1,5 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ReportSummaryProps {
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
totalCostUsd?: number | null;
}
const CELLS = [
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
];
export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
const grid =
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total || errors + warnings + infos;
const outcome =
errors > 0 ? "Error" : warnings > 0 ? "Warning" : total > 0 ? "Complete" : "No findings";
const bits = [
`${errors} Error`,
`${warnings} Warning`,
`${infos} Info`,
`${reviewedCount} reviewed`,
];
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
bits.push(`$${totalCostUsd.toFixed(2)}`);
}
if (typeof creditsSpent === "number" && creditsSpent > 0) {
bits.push(`${creditsSpent.toFixed(2)} credits`);
}
return (
<div className="space-y-4">
<div className={cn("grid gap-4", grid)}>
{CELLS.map(({ key, label, color }) => (
<Card key={key}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
{summary[key] ?? 0}
</p>
</CardContent>
</Card>
))}
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Checked</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{reviewedCount}
</p>
</CardContent>
</Card>
{showCost && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">API cost</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
${totalCostUsd!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
{showCredits && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Credits</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
{creditsSpent!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
</div>
{total > 0 && (
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
({ key, barColor }) => (
<div
key={key}
className={cn("h-full", barColor)}
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
/>
),
)}
</div>
)}
</div>
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
{" · "}
{bits.join(" · ")}
</p>
);
}
@@ -1,11 +1,8 @@
"use client";
import { useState } from "react";
import { ChevronRight, CircleCheck, Check } from "lucide-react";
import { Check } from "lucide-react";
import { Checkbox } from "@base-ui/react/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "./status-badge";
import type { Finding } from "@/lib/types";
import { cn, sortFindings } from "@/lib/utils";
@@ -25,50 +22,57 @@ export function ReviewedSection({
const sorted = sortFindings(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 w-full py-3 group">
<ChevronRight
className={cn(
"h-4 w-4 text-emerald-600 dark:text-emerald-400 transition-transform",
open && "rotate-90",
)}
/>
<CircleCheck className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
Reviewed ({findings.length})
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"flex w-full items-center gap-2 rounded-[8px] px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
"text-neutral-600 hover:bg-black/[0.04] dark:text-neutral-400 dark:hover:bg-white/[0.06]",
)}
>
<span
aria-hidden
className="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>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2 pl-7 pb-4">
<span className="font-medium tracking-tight">Reviewed</span>
<span className="ml-auto font-mono text-[11px] tabular-nums text-neutral-500">
{findings.length}
</span>
</button>
{open ? (
<div className="ml-[13px] space-y-0.5 border-l border-black/10 py-1 pl-2 dark:border-white/10">
{sorted.map((f) => {
const key = findingKeys.get(f);
return (
<div
key={key}
className="flex items-center gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 border-l-4 border-l-emerald-500"
className="flex items-center gap-2.5 rounded-[8px] px-2 py-1.5"
>
<Checkbox.Root
checked={true}
onCheckedChange={() => key && onToggleReviewed(key)}
aria-label="Unmark as reviewed"
className="h-5 w-5 shrink-0 rounded border border-emerald-500/30 bg-emerald-500 flex items-center justify-center cursor-pointer transition-colors hover:bg-emerald-600"
className="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-[#007aff] bg-[#007aff]"
>
<Checkbox.Indicator>
<Check className="h-3.5 w-3.5 text-white" />
<Check className="h-3 w-3 text-white" />
</Checkbox.Indicator>
</Checkbox.Root>
<StatusBadge status={f.status} />
<Badge variant="secondary" className="font-mono text-xs shrink-0">
<span className="shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
{f.designator}
</Badge>
<p className="text-sm text-emerald-700/70 dark:text-emerald-300/70 truncate min-w-0 flex-1">
</span>
<p className="min-w-0 flex-1 truncate text-[12px] text-neutral-500">
{f.finding}
</p>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
@@ -1,17 +1,28 @@
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)] dark:text-[rgb(255,140,130)]",
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)] dark:text-[rgb(255,214,120)]",
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)] dark:text-neutral-300",
};
const LABEL: Record<FindingStatus, string> = {
ERROR: "Error",
WARNING: "Warning",
INFO: "Info",
};
export function StatusBadge({ status }: { status: FindingStatus }) {
return (
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
{status}
</Badge>
<span
className={cn(
"inline-flex items-center rounded-[6px] px-1.5 py-px text-[11px] font-medium tracking-tight",
TONE[status],
)}
>
{LABEL[status]}
</span>
);
}
@@ -0,0 +1,59 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/** Light/dark preference. Persisted by next-themes (localStorage). */
export function AppearanceSettings({ className }: { className?: string }) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const current = mounted ? (theme ?? resolvedTheme ?? "light") : "light";
return (
<section className={cn("space-y-3", className)}>
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Appearance</h2>
<p className="mt-0.5 text-[13px] text-neutral-500">
Light is the default. Dark mode is optional and remembered on this device.
</p>
</div>
<div
role="radiogroup"
aria-label="Color theme"
className="inline-flex rounded-[10px] border border-black/10 bg-black/[0.03] p-0.5 dark:border-white/10 dark:bg-white/[0.06]"
>
{(
[
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
] as const
).map((opt) => {
const active = current === opt.id;
return (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setTheme(opt.id)}
className={cn(
"rounded-[8px] px-3.5 py-1.5 text-[13px] transition-colors duration-150 ease-out",
active
? "bg-white text-neutral-900 shadow-sm dark:bg-[#2c2c2e] dark:text-neutral-50"
: "text-neutral-500 hover:text-neutral-900 dark:hover:text-white",
)}
>
{opt.label}
</button>
);
})}
</div>
</section>
);
}
@@ -3,14 +3,15 @@
import type { ReactNode } from "react";
import { ThemeProvider as NextThemes } from "next-themes";
/** Class-based color scheme. Default is dark; OS preference is ignored. */
/** Class-based color scheme. Light by default; preference persisted in localStorage. */
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemes
attribute="class"
defaultTheme="dark"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
storageKey="periscope-theme"
>
{children}
</NextThemes>
@@ -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,101 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
groupByWorstStatus,
groupDomains,
isProtocolFinding,
protocolEmptyMessage,
ruleDomain,
worstStatus,
} from "./findings-forest";
import { isPcbExamFinding } from "./layout-finding";
import type { Finding } from "./types";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
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("protocol folder label is Protocols, including a stored Italian empty message", () => {
const domain = ruleDomain(
f({ designator: "J2", status: "ERROR", rule_id: "PE-PRT-L0-001", source: "protocol_l0" }),
);
assert.equal(domain.label, "Protocols");
assert.equal(protocolEmptyMessage("nessun protocollo riconosciuto"), "No protocol recognized.");
assert.equal(protocolEmptyMessage(""), "No protocol recognized.");
assert.equal(protocolEmptyMessage("USB2 recognized"), "USB2 recognized");
});
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
assert.equal(
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
true,
);
assert.equal(
isPcbExamFinding({ source: "bom_pcb_check", rule_id: "PE-BOM-011" }),
true,
);
});
@@ -0,0 +1,279 @@
/** Group report findings for the tree: status folders, then domains/rules, then Certifications. */
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: "Protocols",
MUX: "Pin mux",
NC: "NC pins",
DEC: "Decoupling",
SCH: "Schematic",
PCB: "PCB exam",
};
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
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 const PROTOCOL_EMPTY_MESSAGE = "No protocol recognized.";
/** Tree copy. Stored 2.83 reports still carry the Italian empty phrase. */
export function protocolEmptyMessage(message?: string | null): string {
const text = (message || "").trim();
if (!text || text.toLowerCase() === "nessun protocollo riconosciuto") {
return PROTOCOL_EMPTY_MESSAGE;
}
return text;
}
export function isProtocolFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const src = f.source || "";
const rid = f.rule_id || "";
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
}
export function ruleDomain(f: Finding): { id: string; label: string } {
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocols" };
const rid = f.rule_id || "";
const m = rid.match(/^PE-([A-Z]+)/);
if (m) {
const id = m[1];
return { id, label: DOMAIN_LABEL[id] || id };
}
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
return { id: "PCB", label: DOMAIN_LABEL.PCB };
}
return { id: "SCH", label: DOMAIN_LABEL.SCH };
}
export function groupRules(findings: Finding[]): RuleGroup[] {
const byRule = new Map<string, Finding[]>();
for (const f of findings) {
const id = (f.rule_id || "").trim() || "(no rule)";
const list = byRule.get(id) ?? [];
list.push(f);
byRule.set(id, list);
}
return [...byRule.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ruleId, items]) => ({
ruleId,
findings: items,
designators: groupByDesignator(items),
}));
}
export function groupDomains(findings: Finding[]): DomainGroup[] {
const byId = new Map<string, Finding[]>();
for (const f of findings) {
const { id } = ruleDomain(f);
const list = byId.get(id) ?? [];
list.push(f);
byId.set(id, list);
}
return [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, items]) => ({
id,
label: DOMAIN_LABEL[id] || id,
findings: items,
rules: groupRules(items),
}));
}
/**
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
* U18 with PE-PLC-002 ERROR + five WARNING Error, badge ERROR, count 6.
* Protocol findings (PE-PRT) are excluded from domain folders they sit under Certifications.
*/
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
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: "USB not certified — because Z cannot be calculated from the PCB stackup/geometry. Cited 90 Ω.",
}),
]);
assert.equal(groups[0].label, "Error");
assert.equal(groups[0].status, "ERROR");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
});
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
const row = {
check: "differential_impedance",
level: "L2",
result: "UNKNOWN",
measured: 100,
limit: 100,
margin: 0,
unit: "ohm",
chain: {
logical_protocol_id: "hdmi-1.4",
physical_interface_id: "hdmi-1.4-tmds-type-a",
},
};
assert.equal(isHdmi14TmdsElectrical(row), true);
assert.equal(formatLimit(row), "UNKNOWN");
assert.equal(formatMeasured(row), "UNKNOWN");
assert.ok(!formatLimit(row).includes("100"));
assert.ok(!formatMeasured(row).includes("Ω"));
});
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
const row = {
check: "cec",
level: "L2",
result: "PASS",
limit: 27000,
unit: "ohm",
chain: { logical_protocol_id: "hdmi-1.4" },
};
assert.equal(isHdmi14TmdsElectrical(row), false);
assert.equal(formatLimit(row), "27000 ohm");
});
@@ -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}`;
}
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildReportMarkdown } from "./report-export";
import type { Component, DesignGraph, Finding, ValidationReport } from "./types";
function comp(reference: string, mpn: string): Component {
return {
reference,
value: "",
footprint: "",
component_type: "ic",
component_subtype: null,
mpn,
pins: {},
specs: null,
};
}
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status" | "finding">): Finding {
return {
finding_id: partial.finding_id ?? null,
mpn: partial.mpn ?? "",
aspect: null,
why: partial.why ?? "Because",
source_page: null,
reference: "",
rule_id: partial.rule_id ?? "PE-PLC-002",
...partial,
};
}
test("Markdown export has title, summary counts, and Error→Warning tree", () => {
const findings: Finding[] = [
f({
designator: "U1",
status: "ERROR",
finding: "Missing decoupling",
finding_id: "PCB-U1-001",
rule_id: "PE-DEC-001",
}),
f({
designator: "R2",
status: "WARNING",
finding: "Tolerance wide",
finding_id: "PCB-R2-001",
rule_id: "PE-BOM-011",
}),
];
const report: ValidationReport = {
project: "demo",
timestamp: "2026-09-23T00:00:00Z",
findings,
summary: { ERROR: 1, WARNING: 1, INFO: 0, total: 2 },
coverage: {},
};
const graph: DesignGraph = {
components: {
U1: comp("U1", "MSPM0"),
R2: comp("R2", "10k"),
},
nets: {},
};
const md = buildReportMarkdown(report, graph, "Demo Board");
assert.match(md, /^# Demo Board/m);
assert.match(md, /## Summary/);
assert.match(md, /- Error: 1/);
assert.match(md, /- Warning: 1/);
assert.match(md, /## Error/);
assert.match(md, /## Warning/);
assert.match(md, /##### U1/);
assert.match(md, /\*\*Missing decoupling\*\*/);
assert.ok(md.indexOf("## Error") < md.indexOf("## Warning"));
assert.doesNotMatch(md, /xlsx|\.xls\b/i);
});
+186 -47
View File
@@ -1,60 +1,199 @@
/** Client-side findings workbook (.xlsx) for a finished review. */
/** Client-side findings report as readable Markdown (.md). Not a spreadsheet. */
import * as XLSX from "xlsx";
import type { DesignGraph, Finding, ValidationReport } from "./types";
import { sortFindings } from "./utils";
const COLUMNS: string[] = [
"Designator",
"MPN",
"ID",
"Severity",
"Title",
"Description",
"Recommendation",
"Source",
"Net",
"Pins",
"Rule",
];
const WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24, 16, 16, 14];
function sourceCell(f: Finding): string {
if (f.source && f.source !== "review") return "Automated check";
const who = f.source_designator || f.designator;
return f.source_page ? `${who} datasheet p.${f.source_page}` : `${who} datasheet`;
}
import type {
DesignGraph,
Finding,
FindingStatus,
ProtocolCertificationSection,
ValidationReport,
} from "./types";
import {
STATUS_FOLDER,
groupByWorstStatus,
isProtocolFinding,
protocolEmptyMessage,
protocolInstancesFromSection,
protocolInstancesForStatus,
} from "./findings-forest";
function fileStem(name: string): string {
const slug = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return slug || "report";
}
export function exportReportToExcel(
function escapeMd(text: string): string {
return text.replace(/\r\n/g, "\n").trim();
}
function findingBullet(f: Finding, mpn: string): string {
const bits: string[] = [];
const title = escapeMd(f.finding || f.finding_id || "Finding");
bits.push(`- **${title}**`);
if (f.status) bits.push(` - Severity: ${f.status}`);
if (f.finding_id) bits.push(` - ID: \`${f.finding_id}\``);
if (f.rule_id) bits.push(` - Rule: \`${f.rule_id}\``);
if (mpn) bits.push(` - MPN: ${mpn}`);
if (f.net) bits.push(` - Net: \`${f.net}\``);
if (f.pins?.length) bits.push(` - Pins: ${(f.pins ?? []).join(", ")}`);
if (f.why) bits.push(` - ${escapeMd(f.why)}`);
if (f.recommendation) bits.push(` - Recommendation: ${escapeMd(f.recommendation)}`);
return bits.join("\n");
}
function renderStatusTree(
findings: Finding[],
graph: DesignGraph,
protocolSection?: ProtocolCertificationSection | null,
): string[] {
const lines: string[] = [];
const protocolFindings = findings.filter(isProtocolFinding);
const nonProtocol = findings.filter((f) => !isProtocolFinding(f));
const instances = protocolInstancesFromSection(protocolSection);
const groups = groupByWorstStatus(nonProtocol.length ? nonProtocol : findings);
// Always walk Error → Warning → Info even if empty folders omitted by groupByWorstStatus
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
const byStatus = new Map(groups.map((g) => [g.status, g]));
for (const status of order) {
const group = byStatus.get(status);
const protoHere = protocolInstancesForStatus(instances, status);
const protoFindingsHere = protocolFindings.filter((f) => f.status === status);
if (!group && protoHere.length === 0 && protoFindingsHere.length === 0) continue;
lines.push(`## ${STATUS_FOLDER[status]}`);
lines.push("");
if (group) {
for (const domain of group.domains) {
lines.push(`### ${domain.label}`);
lines.push("");
for (const rule of domain.rules) {
lines.push(`#### ${rule.ruleId}`);
lines.push("");
for (const des of rule.designators) {
lines.push(`##### ${des.designator}`);
lines.push("");
for (const f of des.findings) {
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
lines.push(findingBullet(f, mpn));
lines.push("");
}
}
}
}
}
if (protoHere.length > 0 || protoFindingsHere.length > 0) {
lines.push("### Certifications");
lines.push("");
lines.push("#### Protocols");
lines.push("");
if (protoHere.length === 0 && protoFindingsHere.length === 0) {
lines.push(`- ${protocolEmptyMessage(protocolSection?.message)}`);
lines.push("");
}
for (const inst of protoHere) {
lines.push(
`- **${inst.logicalId || inst.physicalId || inst.instanceId}** — ${inst.worst || inst.recognition}`,
);
if (inst.notes) lines.push(` - ${escapeMd(inst.notes)}`);
for (const c of inst.checks) {
lines.push(
` - [${c.level || "?"}] ${c.check || "check"}: ${c.result}${c.notes ? `${escapeMd(c.notes)}` : ""}`,
);
}
lines.push("");
}
for (const f of protoFindingsHere) {
const mpn = f.mpn || graph.components[f.designator]?.mpn || "";
lines.push(findingBullet(f, mpn));
lines.push("");
}
}
}
if (
instances.length === 0 &&
protocolFindings.length === 0 &&
protocolSection
) {
lines.push("## Certifications");
lines.push("");
lines.push("### Protocols");
lines.push("");
lines.push(`- ${protocolEmptyMessage(protocolSection.message)}`);
lines.push("");
}
return lines;
}
/** Build Markdown for the report tree (title, summary counts, hierarchy). */
export function buildReportMarkdown(
report: ValidationReport,
graph: DesignGraph,
projectName?: string,
): string {
const title = projectName || report.project || "Periscope report";
const summary = report.summary ?? {};
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total ?? report.findings.length;
const lines: string[] = [
`# ${title}`,
"",
`Generated ${report.timestamp || new Date().toISOString()}`,
"",
"## Summary",
"",
`- Total: ${total}`,
`- Error: ${errors}`,
`- Warning: ${warnings}`,
`- Info: ${infos}`,
"",
];
lines.push(
...renderStatusTree(report.findings, graph, report.protocol_certification),
);
if (report.not_reviewed?.length) {
lines.push("## Not reviewed");
lines.push("");
for (const nr of report.not_reviewed) {
lines.push(`- **${nr.designator}** — ${escapeMd(nr.reason)}`);
}
lines.push("");
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
}
function downloadTextFile(filename: string, contents: string): void {
const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/** Save the findings tree as a `.md` file (readable Markdown lists). */
export function exportReportToMarkdown(
report: ValidationReport,
graph: DesignGraph,
projectName?: string,
): void {
const body = sortFindings(report.findings).map((f) => [
f.designator,
f.mpn || graph.components[f.designator]?.mpn || "",
f.finding_id ?? "",
f.status,
f.finding,
f.why ?? "",
f.recommendation ?? "",
sourceCell(f),
f.net ?? "",
(f.pins ?? []).join(", "),
f.rule_id ?? "",
]);
const sheet = XLSX.utils.aoa_to_sheet([COLUMNS, ...body]);
sheet["!cols"] = WIDTHS.map((wch) => ({ wch }));
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, "Findings");
XLSX.writeFile(
book,
`${fileStem(projectName || report.project || "report")}-findings.xlsx`,
const md = buildReportMarkdown(report, graph, projectName);
downloadTextFile(
`${fileStem(projectName || report.project || "report")}-findings.md`,
md,
);
}
+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.87.5";
export const APP_VERSION_DATE = "2026-09-23";

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