Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29e1a3033d | ||
|
|
bb83f63114 | ||
|
|
3c0b3e14dc | ||
|
|
1455e974ac | ||
|
|
25aa608052 | ||
|
|
d32af5627a | ||
|
|
8a93ffc1c5 | ||
|
|
421f659054 | ||
|
|
c4cd046ea9 | ||
|
|
f3be485b75 | ||
|
|
949f5f1541 | ||
|
|
7baffadd6b | ||
|
|
866c2e9242 | ||
|
|
14272d7446 | ||
|
|
24c4beecc9 | ||
|
|
0848eb8416 | ||
|
|
10d21cdc80 | ||
|
|
3cc2e5469b | ||
|
|
fc6524a2a9 | ||
|
|
36ad979612 | ||
|
|
c87adf11a9 | ||
|
|
8b4f7710cf | ||
|
|
8f434ffd31 | ||
|
|
cd7a7ca3a6 | ||
|
|
6b751522b7 | ||
|
|
6d9d21a023 | ||
|
|
4df04df5d4 | ||
|
|
20733f0ec6 | ||
|
|
69d5dd9313 | ||
|
|
309644e654 |
@@ -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
|
||||
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
# Periscope (DeepSeek)
|
||||
# Periscope
|
||||
|
||||
Periscope reviews schematics the way a good senior engineer does: with the datasheets open.
|
||||
Periscope is full **PCB analysis** for a board you intend to send to fab with confidence. Schematic review is a stage, not the deliverable.
|
||||
|
||||
This tree is adapted from [manvalan/pinscope](https://github.com/manvalan/pinscope) so the pipeline talks to the **DeepSeek API** (`deepseek-flash`, with legacy aliases still accepted). Do not use Anthropic.
|
||||
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints once into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
|
||||
|
||||
Give it a netlist, a BOM, and your datasheet PDFs. It builds a graph of your design, reads each IC's datasheet, and checks the circuit around every part against what the manufacturer actually specifies — reference application, pin functions, absolute maximums, recommended operating conditions. Every finding points at the datasheet page that backs it up.
|
||||
Live instance: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
|
||||
|
||||
## What changed for DeepSeek
|
||||
This tree is the Periscope product (operator: Michele Bigi). It is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope); Faradworks does not operate this instance.
|
||||
|
||||
DeepSeek's Chat Completions API is OpenAI-compatible but **does not accept native PDF documents**. Periscope therefore:
|
||||
## What it checks — and what it will not invent
|
||||
|
||||
1. **Extracts datasheet text** with `pypdf` (page-marked) and sends it as chat content.
|
||||
2. **Renders pages to JPEG** with PyMuPDF when the stage uses a vision model, so pin diagrams and tables survive.
|
||||
3. **Runs extraction skills locally.** `skills/*/SKILL.md` is inlined as the system prompt; `validate.py` runs in-process. You do not need Anthropic Console Skills.
|
||||
4. **Round-trips `reasoning_content`** when DeepSeek thinking mode is on, so multi-turn review and tool calls do not 400.
|
||||
USB pair impedance, Ethernet class, and trace current versus width are **examples** of gated checks. They run when that bus, connector, or datasheet number exists on the board. They are not a catalog of every interface in electronics.
|
||||
|
||||
Default routing:
|
||||
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net, sitting inside a courtyard, or having nearby coordinates does not change the object type.
|
||||
|
||||
| Stage | Model |
|
||||
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips the check). It does not invent current, impedance, copper weight, geometry, IEC creepage, or “typical USB 500 mA”.
|
||||
|
||||
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the datasheet reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip — not a guessed ampacity table, not via-IPC folklore.
|
||||
|
||||
**Interface class certifiers are gated on parts that are present.** USB-C, RJ45/Ethernet, PoE, and DDR-style certifiers fire only if that connector or device is on the graph. No USB-C receptacle → no USB-C findings (silence, not N/A). A bare RJ45 is Ethernet, not PoE. No DDR device → no DDR findings.
|
||||
|
||||
Periscope does **not** claim FEM, thermal spreading, OpenEMS, or a field solver. Closed-form Z0 comes from vendored ImpedenceFinder (`vendor/impedancefinder/`; upstream license UNKNOWN).
|
||||
|
||||
Findings keep FACT, REQUIREMENT, and INFERENCE apart. Recommended datasheet notes are not errors. The same finding object is used for schematic (`MODE=run`) and PCB (`MODE=pcb`).
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| Pintable / pattern / specs extraction | `deepseek-flash` (native vision) |
|
||||
| Per-IC datasheet review | `deepseek-flash` |
|
||||
| Auto-resolve / normalize | `deepseek-flash` |
|
||||
| `periscope/src/` | Native Periscope (finding engine, PCB/placement, DeepSeek, Dockerfiles, KiCad plugin) |
|
||||
| `periscope/dependency/` | Inherited PinScope (in-tree AGPL dependency — do not delete) |
|
||||
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
|
||||
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
|
||||
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
|
||||
| `LICENSE` | GNU AGPL v3 |
|
||||
|
||||
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `.env`. See `periscope/src/backend/.env.example`.
|
||||
See `periscope/README.md` for the physical split.
|
||||
|
||||
## Layout
|
||||
## Run locally
|
||||
|
||||
- `periscope/src/` — native Periscope
|
||||
- `periscope/dependency/` — inherited PinScope (AGPL in-tree dependency; do not delete)
|
||||
- `LICENSE` — GNU AGPL v3 (visible at repo root)
|
||||
- `vendor/impedancefinder/` — third party (license UNKNOWN)
|
||||
|
||||
See `periscope/README.md` and `periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md`.
|
||||
|
||||
## How it works
|
||||
|
||||
<p align="center">
|
||||
<img src="periscope/dependency/docs/how-it-works.svg" width="920" alt="Pipeline: the netlist and BOM are parsed into a design graph; datasheet PDFs are extracted into pin tables and specs; a per-IC review reads both and files findings cited to datasheet pages; the derating table and BOM roll-up are computed straight from the graph, no model involved.">
|
||||
</p>
|
||||
|
||||
1. **Parse** the BOM (CSV/XLSX) and netlist (PADS-PCB `.asc` or EDIF 2.0.0 `.edn`) into a queryable bipartite graph of components and nets.
|
||||
2. **Extract** pin tables and specs from the PDFs. Large datasheets are trimmed to the relevant pages first, and every extraction is cached in a shared library.
|
||||
3. **Review** each IC in isolation. The model gets the datasheet plus that IC's circuit neighborhood, can query the graph, and files findings with severity, reasoning, and page citations. Extraction now also stores absolute-maximum ratings so the reviewer does not have to rediscover supply limits from a 300-page PDF.
|
||||
4. **Compute** the deterministic parts deterministically — BOM roll-up and a capacitor voltage-derating table come straight from the graph.
|
||||
|
||||
## Try it on the bundled design
|
||||
|
||||
`periscope/dependency/simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO.
|
||||
|
||||
You need Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/):
|
||||
Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/) for live extraction and review. Offline graph/smoke does not need the key.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
@@ -58,13 +48,27 @@ pip install -r periscope/src/backend/requirements.txt
|
||||
cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
|
||||
python3 -m uvicorn backend.main:app --reload --host 127.0.0.1 --port 18741
|
||||
```
|
||||
|
||||
# in another terminal
|
||||
cd periscope/src/frontend && npm install
|
||||
Frontend — overlay native UI onto the inherited shell, then start Next.js (this is what CI runs):
|
||||
|
||||
```bash
|
||||
./scripts/materialize-frontend.sh
|
||||
cd .merge/frontend && npm install
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostname 127.0.0.1
|
||||
```
|
||||
|
||||
Open the frontend URL, create a project, and feed it the netlist and BOM from `periscope/dependency/simple_project/`. Datasheets are fetched automatically (LCSC, TI, optional DigiKey); you can still drop in PDFs by hand. Fetched PDFs and extracted pin tables land in the **Library** (sidebar) and are reused on later projects. Everything runs locally against your own DeepSeek key; projects and the extraction library live in `data/`. Skills are local `periscope/src/skills/*/SKILL.md` — do not run `scripts/upload_skills.py`.
|
||||
Open the frontend URL, create a project, and upload a netlist plus BOM. A `.kicad_pcb` is required for the PCB job; without it the project can complete schematic review only. `periscope/dependency/simple_project/` is a small bundled schematic fixture (MSPM0G3507 + CH340E + SPX3819).
|
||||
|
||||
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`. Do not run an Anthropic Console uploader.
|
||||
|
||||
Tests from the repo root:
|
||||
|
||||
```bash
|
||||
pip install pytest pytest-asyncio
|
||||
pytest tests/ -q
|
||||
python3 scripts/smoke_simple_project.py # offline
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -73,27 +77,30 @@ cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Backend on port 8080, frontend on port 3000.
|
||||
Backend listens on port 8080, frontend on port 3000 (`docker-compose.yml`).
|
||||
|
||||
### Update a live instance (e.g. periscope.michelebigi.it)
|
||||
### Live host
|
||||
|
||||
One checkout on the VPS: **`/root/periscope`**. The GitHub clone URL may still be `manvalan/pinscope`; clone into that path so the folder is not `pinscope`:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:manvalan/pinscope.git /root/periscope
|
||||
```
|
||||
|
||||
Do not keep a second live tree under `/root/pinscope` (or `/opt/pinscope`). Stop compose there, then deploy only from the canonical root:
|
||||
The public site is rebuilt from **`/root/periscope`** with the script that actually ships:
|
||||
|
||||
```bash
|
||||
cd /root/periscope
|
||||
./scripts/update-periscope.sh
|
||||
```
|
||||
|
||||
The script cds via `dirname "$0"/..` (no `find`). It refuses to run if the resolved root is not `/root/periscope`. Compose project name is `periscope`. After `up`, the script connects `periscope-frontend` / `periscope-backend` to Docker network **`pinscope_pinscope`** (where `railway-caddy` reverse_proxies `periscope-frontend:3000`). It pulls the current branch, writes `NEXT_PUBLIC_API_URL` / `CORS_ORIGINS` for `https://periscope.michelebigi.it`, rebuilds both Docker images, and leaves `data/` alone. First run: put `DEEPSEEK_API_KEY` in `.env` at the repo root (compose reads that file). `--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL.
|
||||
`--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL. The script refuses to run unless the checkout is `/root/periscope` (override with `CANONICAL_ROOT` only if the host layout differs). It does not touch `data/`.
|
||||
|
||||
Do not set `ENVIRONMENT=production` unless Clerk auth is configured — that flag refuses to boot with auth disabled.
|
||||
## Source
|
||||
|
||||
| Remote | URL |
|
||||
| --- | --- |
|
||||
| GitHub | [https://github.com/manvalan/pinscope](https://github.com/manvalan/pinscope) (`git@github.com:manvalan/pinscope.git`) |
|
||||
| Gitea | [http://192.168.1.71:3000/michele/periscope.git](http://192.168.1.71:3000/michele/periscope.git) |
|
||||
|
||||
Push both: `git push github && git push gitea` (no force).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 (see `LICENSE`). This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). Faradworks does not operate this instance. For a commercial license of **upstream Pinscope**, write to Faradworks as they publish it (`dev@faradworks.com` historically). For this Periscope instance, contact the operator (Michele Bigi, mikbigi@gmail.com).
|
||||
[AGPL-3.0](LICENSE) — GNU Affero General Public License v3.0.
|
||||
|
||||
This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). For a commercial license of **upstream Pinscope**, contact Faradworks as they publish it. For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
|
||||
|
||||
+24
-4
@@ -10,9 +10,29 @@ from pathlib import Path
|
||||
from pkgutil import extend_path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
for _p in (_REPO / "periscope" / "src", _REPO / "periscope" / "dependency"):
|
||||
_SRC = _REPO / "periscope" / "src"
|
||||
_DEP = _REPO / "periscope" / "dependency"
|
||||
# Last insert is searched first: native src overlays inherited dependency.
|
||||
for _p in (_DEP, _SRC):
|
||||
_s = str(_p)
|
||||
if _p.is_dir() and _s not in sys.path:
|
||||
sys.path.insert(0, _s)
|
||||
if not _p.is_dir():
|
||||
continue
|
||||
if _s in sys.path:
|
||||
sys.path.remove(_s)
|
||||
sys.path.insert(0, _s)
|
||||
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
|
||||
def _prefer_native_backend(paths: list[str]) -> list[str]:
|
||||
src, other, dep = [], [], []
|
||||
for p in paths:
|
||||
norm = p.replace("\\", "/")
|
||||
if "/periscope/src/" in norm:
|
||||
src.append(p)
|
||||
elif "/periscope/dependency/" in norm:
|
||||
dep.append(p)
|
||||
else:
|
||||
other.append(p)
|
||||
return src + other + dep
|
||||
|
||||
|
||||
__path__ = _prefer_native_backend(list(extend_path(__path__, __name__)))
|
||||
|
||||
@@ -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 l’analisi, 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** TVS–connettore (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 l’IC.
|
||||
|
||||
- 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).
|
||||
@@ -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 dall’ordine 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.** L’autorità deterministica è nei check; l’LLM 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, l’implementazione 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 l’empty 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.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Libreria componenti — porta prodotto
|
||||
|
||||
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi *usa* la libreria; non è l’unico 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. L’LLM 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.
|
||||
@@ -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 = un’unità: `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.
|
||||
@@ -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 nell’orchestrazione, non nell’I/O HTTP, non nell’LLM.
|
||||
4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dall’LLM.
|
||||
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 §§33–36)
|
||||
|
||||
```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
|
||||
```
|
||||
@@ -1,4 +1,14 @@
|
||||
"""PinScope-inherited backend package (in-tree dependency)."""
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
_src, _other, _dep = [], [], []
|
||||
for _p in __path__:
|
||||
_n = str(_p).replace("\\", "/")
|
||||
if "/periscope/src/" in _n:
|
||||
_src.append(_p)
|
||||
elif "/periscope/dependency/" in _n:
|
||||
_dep.append(_p)
|
||||
else:
|
||||
_other.append(_p)
|
||||
__path__[:] = _src + _other + _dep
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
_src, _other, _dep = [], [], []
|
||||
for _p in __path__:
|
||||
_n = str(_p).replace("\\", "/")
|
||||
if "/periscope/src/" in _n:
|
||||
_src.append(_p)
|
||||
elif "/periscope/dependency/" in _n:
|
||||
_dep.append(_p)
|
||||
else:
|
||||
_other.append(_p)
|
||||
__path__[:] = _src + _other + _dep
|
||||
|
||||
@@ -380,6 +380,7 @@ class ValidationReport(BaseModel):
|
||||
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
|
||||
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
|
||||
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
|
||||
protocol_certification: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FindingComment(BaseModel):
|
||||
@@ -492,6 +493,7 @@ class LayoutVia(BaseModel):
|
||||
y: float
|
||||
net: str = ""
|
||||
drill: float | None = None
|
||||
layers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class LayoutDielectric(BaseModel):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../src/frontend/src/app/(app)/library/library-editor.tsx
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../src/frontend/src/app/(app)/library/library-import.tsx
|
||||
@@ -498,6 +498,56 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
export async function importLibraryDatasheet(mpn: string, file: File): Promise<{
|
||||
mpn: string;
|
||||
blob_key: string;
|
||||
has_extraction: boolean;
|
||||
has_model: boolean;
|
||||
}> {
|
||||
const body = new FormData();
|
||||
body.append("mpn", mpn);
|
||||
body.append("file", file);
|
||||
const res = await authFetch(`${BASE}/api/library/datasheets`, { method: "POST", body });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({ detail: "Import failed" }))) as {
|
||||
detail?: string;
|
||||
};
|
||||
throw new Error(err.detail || "Failed to add datasheet to the library");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
|
||||
|
||||
export async function fetchLibraryComponent(
|
||||
componentType: LibraryComponentType,
|
||||
name: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load component");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateLibraryComponent(
|
||||
componentType: LibraryComponentType,
|
||||
name: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await authFetch(
|
||||
`${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to update component");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Pipeline ---
|
||||
|
||||
export async function reprocessPipeline(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
"""Native Periscope core modules (finding engine, PCB, checks)."""
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
__path__ = list(extend_path(__path__, __name__))
|
||||
_src, _other, _dep = [], [], []
|
||||
for _p in __path__:
|
||||
_n = _p.replace("\\", "/")
|
||||
if "/periscope/src/" in _n:
|
||||
_src.append(_p)
|
||||
elif "/periscope/dependency/" in _n:
|
||||
_dep.append(_p)
|
||||
else:
|
||||
_other.append(_p)
|
||||
__path__[:] = _src + _other + _dep
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""AI flags probable HF issues; deterministic checks investigate.
|
||||
|
||||
Extra path — does not replace ``run_pcb_checks`` / SI / HF. Not DRC.
|
||||
Never invents Z, I, or millimetres. Missing net → silence (no N/A).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.periscopex.hf_line_check import check_hf_lines
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.si_check import check_si
|
||||
|
||||
SOURCE = "af_ai_hf"
|
||||
RULE_Z_NO_EVIDENCE = "PE-AF-001"
|
||||
|
||||
ALLOWED_ISSUE_CLASSES = frozenset({
|
||||
"stub",
|
||||
"split_reference",
|
||||
"unterminated",
|
||||
"z0",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HfHypothesis:
|
||||
"""Structured AI flag. ``why`` is INFERENCE, not FACT."""
|
||||
|
||||
net: str
|
||||
issue_class: str
|
||||
why: str
|
||||
|
||||
|
||||
def _net_on_board(graph: DesignGraph, layout: LayoutGraph | None, net: str) -> bool:
|
||||
if net in graph.nets:
|
||||
return True
|
||||
if layout is None:
|
||||
return False
|
||||
for seg in layout.segments:
|
||||
if seg.net and kicad_nets_match(seg.net, net):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_z0_facts(layout: LayoutGraph | None) -> bool:
|
||||
if layout is None or not layout.segments or layout.stackup is None:
|
||||
return False
|
||||
stack = layout.stackup
|
||||
layers = getattr(stack, "copper_layers", None) or []
|
||||
diel = getattr(stack, "dielectrics", None) or []
|
||||
return bool(layers) and bool(diel)
|
||||
|
||||
|
||||
def _z0_insufficient(net: str, why: str) -> Finding:
|
||||
rec = (
|
||||
"Provide stackup (copper + dielectric) and track geometry before Z0. "
|
||||
"Do not assume 50 Ω or 90 Ω."
|
||||
)
|
||||
return Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
aspect="si",
|
||||
finding=f"Unverified: AI flagged Z0 on {net} — no stackup+track FACT.",
|
||||
facts="stackup or HF track geometry missing.",
|
||||
requirement="Closed-form Z0 needs board stackup and width on this net.",
|
||||
inference=why,
|
||||
why="AI hypothesis is not a measured ohm.",
|
||||
status="INFO",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source=SOURCE,
|
||||
rule_id=RULE_Z_NO_EVIDENCE,
|
||||
finding_class="INFO",
|
||||
provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT",
|
||||
net=net,
|
||||
pins=[],
|
||||
)
|
||||
|
||||
|
||||
def investigate_hf_hypotheses(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
hypotheses: list[HfHypothesis],
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Run existing HF/SI checks only on nets the AI flagged."""
|
||||
out: list[Finding] = []
|
||||
flagged: set[str] = set()
|
||||
for hyp in hypotheses:
|
||||
kind = (hyp.issue_class or "").strip().lower()
|
||||
net = (hyp.net or "").strip()
|
||||
if not net or kind not in ALLOWED_ISSUE_CLASSES:
|
||||
continue
|
||||
if not _net_on_board(graph, layout, net):
|
||||
continue
|
||||
if kind == "z0" and not _has_z0_facts(layout):
|
||||
out.append(_z0_insufficient(net, hyp.why))
|
||||
continue
|
||||
flagged.add(net)
|
||||
|
||||
if not flagged or layout is None:
|
||||
return out
|
||||
|
||||
probed = check_hf_lines(graph, constraints_map, layout)
|
||||
probed.extend(check_si(graph, constraints_map, layout, impedance_nets))
|
||||
for finding in probed:
|
||||
net = finding.net or ""
|
||||
if any(kicad_nets_match(net, flag) for flag in flagged if net):
|
||||
out.append(finding)
|
||||
return out
|
||||
|
||||
|
||||
def hypotheses_from_rows(rows: list | None) -> list[HfHypothesis]:
|
||||
"""Parse structured AI flags. Unknown classes and empty nets are dropped."""
|
||||
out: list[HfHypothesis] = []
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
net = str(row.get("net") or "").strip()
|
||||
kind = str(row.get("issue_class") or "").strip().lower()
|
||||
why = str(row.get("why") or "").strip()
|
||||
if not net or kind not in ALLOWED_ISSUE_CLASSES:
|
||||
continue
|
||||
out.append(HfHypothesis(net=net, issue_class=kind, why=why))
|
||||
return out
|
||||
|
||||
|
||||
def append_investigated(
|
||||
pcb_findings: list[Finding],
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
hypotheses: list[HfHypothesis],
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Append AF+AI extras. Never drops ``run_pcb_checks`` rows.
|
||||
|
||||
Duplicate SI/HF clones (same rule_id+net already present) are skipped so
|
||||
a stub FAIL is not counted twice. ``PE-AF-001`` is always additive.
|
||||
"""
|
||||
extra = investigate_hf_hypotheses(
|
||||
graph, constraints_map, layout, hypotheses, impedance_nets,
|
||||
)
|
||||
seen = {(f.rule_id, f.net) for f in pcb_findings}
|
||||
added: list[Finding] = []
|
||||
for finding in extra:
|
||||
key = (finding.rule_id, finding.net)
|
||||
if finding.rule_id == RULE_Z_NO_EVIDENCE or key not in seen:
|
||||
pcb_findings.append(finding)
|
||||
added.append(finding)
|
||||
seen.add(key)
|
||||
return added
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Lossless RLGC sections + ABCD cascade. Not a field solver. Not OpenEMS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cmath
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.periscopex.af_trigger import C_MPS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CascadeResult:
|
||||
s11: complex
|
||||
s21: complex
|
||||
z_ref_ohm: float
|
||||
n_sections: int
|
||||
|
||||
|
||||
def _abcd_line(z0: float, tpd_s_per_m: float, length_m: float, omega: float) -> tuple[complex, complex, complex, complex]:
|
||||
beta = omega * tpd_s_per_m
|
||||
bl = beta * length_m
|
||||
j = 1j
|
||||
a = cmath.cos(bl)
|
||||
b = j * z0 * cmath.sin(bl)
|
||||
c = (j / z0) * cmath.sin(bl) if z0 else 0j
|
||||
d = a
|
||||
return a, b, c, d
|
||||
|
||||
|
||||
def _mul(
|
||||
p: tuple[complex, complex, complex, complex],
|
||||
q: tuple[complex, complex, complex, complex],
|
||||
) -> tuple[complex, complex, complex, complex]:
|
||||
a1, b1, c1, d1 = p
|
||||
a2, b2, c2, d2 = q
|
||||
return (
|
||||
a1 * a2 + b1 * c2,
|
||||
a1 * b2 + b1 * d2,
|
||||
c1 * a2 + d1 * c2,
|
||||
c1 * b2 + d1 * d2,
|
||||
)
|
||||
|
||||
|
||||
def cascade_sparam(
|
||||
z0_ohms: list[float],
|
||||
length_m: list[float],
|
||||
er_eff: float,
|
||||
f_hz: float,
|
||||
z_ref_ohm: float,
|
||||
) -> CascadeResult | None:
|
||||
"""Uniform-per-section lossless cascade. Z_ref must be a datasheet FACT."""
|
||||
if (
|
||||
not z0_ohms or len(z0_ohms) != len(length_m)
|
||||
or er_eff <= 0 or f_hz <= 0 or z_ref_ohm <= 0
|
||||
):
|
||||
return None
|
||||
tpd = math.sqrt(er_eff) / C_MPS
|
||||
omega = 2.0 * math.pi * f_hz
|
||||
abcd = (1 + 0j, 0j, 0j, 1 + 0j)
|
||||
n = 0
|
||||
for z0, ell in zip(z0_ohms, length_m, strict=True):
|
||||
if z0 <= 0 or ell <= 0:
|
||||
continue
|
||||
abcd = _mul(abcd, _abcd_line(z0, tpd, ell, omega))
|
||||
n += 1
|
||||
if n == 0:
|
||||
return None
|
||||
a, b, c, d = abcd
|
||||
zr = z_ref_ohm
|
||||
denom = a + b / zr + c * zr + d
|
||||
if denom == 0:
|
||||
return None
|
||||
s11 = (b - c * zr * zr) / denom
|
||||
s21 = 2.0 / denom
|
||||
return CascadeResult(s11=s11, s21=s21, z_ref_ohm=zr, n_sections=n)
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Triggered AF trace analysis. Additive. Not DRC. Not OpenEMS.
|
||||
|
||||
Skip without FACT is a visible PE-AF-002, never invented 50/90 Ω.
|
||||
Differential pairs are one unit. Trigger false → no finding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from backend.periscopex.af_rlgc import cascade_sparam
|
||||
from backend.periscopex.af_trigger import (
|
||||
AfUnit,
|
||||
TriggerResult,
|
||||
dielectric_height_mm,
|
||||
evaluate_trigger,
|
||||
iter_af_units,
|
||||
z_window_for_unit,
|
||||
)
|
||||
from backend.periscopex.finding_engine import complete_findings
|
||||
from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM
|
||||
from backend.periscopex.impedance import GeometryError
|
||||
from backend.periscopex.impedance_traces import analyze_specified_nets
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.si_check import partner_net
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
SOURCE = "af_trace_check"
|
||||
|
||||
_RIGHT_DOT = 0.087 # ~5° from 90°
|
||||
_WIDTH_STEP_MM = 0.05
|
||||
_PARALLEL_MIN_MM = 5.0
|
||||
|
||||
|
||||
def check_af_traces(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
existing: list[Finding] | None = None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or not layout.segments:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
seen_si_z = {
|
||||
f.net for f in (existing or [])
|
||||
if f.rule_id == "PE-SI-002" and f.net
|
||||
}
|
||||
zrows = _z_rows(impedance_nets)
|
||||
for unit in iter_af_units(layout, graph):
|
||||
trig = evaluate_trigger(graph, constraints_map, layout, unit)
|
||||
if trig.missing and not trig.af:
|
||||
out.append(_skip(unit, trig.missing))
|
||||
continue
|
||||
if not trig.af:
|
||||
continue
|
||||
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
|
||||
complete_findings(out)
|
||||
return out
|
||||
|
||||
|
||||
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
|
||||
raw = impedance_nets
|
||||
if isinstance(impedance_nets, dict):
|
||||
raw = list(impedance_nets.get("nets") or [])
|
||||
return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")]
|
||||
|
||||
|
||||
def _label(unit: AfUnit) -> str:
|
||||
return " / ".join(unit.nets)
|
||||
|
||||
|
||||
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
|
||||
miss = ", ".join(missing)
|
||||
text = f"Pista ad alta frequenza non controllata per mancanza di {miss}."
|
||||
if extra:
|
||||
text = f"{text} {extra}".strip()
|
||||
rec = (
|
||||
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
|
||||
"Non si assume 50 Ω o 90 Ω."
|
||||
)
|
||||
return Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
aspect="si",
|
||||
finding=text,
|
||||
facts=f"nets={_label(unit)}; missing={miss}; l={unit.length_mm:.3f} mm.",
|
||||
requirement="AF checks need FACT tr or f, stackup/εr, and datasheet Z for Z0.",
|
||||
inference="INSUFFICIENT — skip, not an invented ohm or ampere.",
|
||||
why="An HF candidate without evidence is reported, not guessed.",
|
||||
status="WARNING",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source=SOURCE,
|
||||
rule_id="PE-AF-002",
|
||||
finding_class="REVIEW",
|
||||
provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT",
|
||||
net=unit.nets[0],
|
||||
pins=[],
|
||||
)
|
||||
|
||||
|
||||
def _finding(
|
||||
*,
|
||||
rule_id: str,
|
||||
unit: AfUnit,
|
||||
finding: str,
|
||||
facts: str,
|
||||
requirement: str,
|
||||
rec: str,
|
||||
status: str = "WARNING",
|
||||
cls: str = "RISK",
|
||||
evidence: str = "SUFFICIENT",
|
||||
calculation: str = "",
|
||||
) -> Finding:
|
||||
return Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
aspect="si",
|
||||
finding=finding,
|
||||
facts=facts,
|
||||
requirement=requirement,
|
||||
inference="Triggered AF net: l ≥ λ/10 or l ≥ tr/(6 tpd).",
|
||||
why=requirement,
|
||||
status=status, # type: ignore[arg-type]
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source=SOURCE,
|
||||
rule_id=rule_id,
|
||||
finding_class=cls, # type: ignore[arg-type]
|
||||
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
|
||||
evidence_status=evidence, # type: ignore[arg-type]
|
||||
net=unit.nets[0],
|
||||
pins=[],
|
||||
calculation=calculation,
|
||||
)
|
||||
|
||||
|
||||
def _analyze_triggered(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph,
|
||||
trig: TriggerResult,
|
||||
zrows: list[dict],
|
||||
seen_si_z: set[str],
|
||||
) -> list[Finding]:
|
||||
unit = trig.unit
|
||||
out: list[Finding] = []
|
||||
out.extend(_corner_findings(layout, unit))
|
||||
out.extend(_width_findings(layout, unit))
|
||||
out.extend(_coupling_findings(layout, unit))
|
||||
samples = _samples(layout, unit)
|
||||
out.extend(_plane_findings(layout, unit, samples, trig))
|
||||
win = z_window_for_unit(graph, constraints_map, unit)
|
||||
if win is None:
|
||||
out.append(_skip(unit, ("Z target datasheet",)))
|
||||
else:
|
||||
already = any(n in seen_si_z or any(kicad_nets_match(n, s) for s in seen_si_z) for n in unit.nets)
|
||||
if not already:
|
||||
zf = _z_finding(unit, samples, win, trig)
|
||||
if zf is not None:
|
||||
out.append(zf)
|
||||
out.extend(_via_findings(layout, unit, trig))
|
||||
return out
|
||||
|
||||
|
||||
def _samples(layout: LayoutGraph, unit: AfUnit) -> list[dict]:
|
||||
if layout.stackup is None:
|
||||
return []
|
||||
try:
|
||||
rows = analyze_specified_nets(layout, list(unit.nets), 1.0)
|
||||
except GeometryError:
|
||||
return []
|
||||
except Exception:
|
||||
log.exception("ImpedenceFinder walk failed for %s", unit.nets)
|
||||
return []
|
||||
return [r for r in rows if isinstance(r, dict)]
|
||||
|
||||
|
||||
def _z_vals(samples: list[dict], diff: bool) -> list[float]:
|
||||
keys = ("zdiff_avg_ohms", "zdiff_ohms", "z0_avg_ohms", "z0_min_ohms") if diff else (
|
||||
"z0_avg_ohms", "z0_min_ohms", "z0_max_ohms",
|
||||
)
|
||||
out: list[float] = []
|
||||
for row in samples:
|
||||
flags = str(row.get("flags") or "")
|
||||
if "plane_broken" in flags or "topology_not_supported" in flags:
|
||||
continue
|
||||
for k in keys:
|
||||
v = row.get(k)
|
||||
if isinstance(v, (int, float)) and v > 0:
|
||||
out.append(float(v))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _z_finding(
|
||||
unit: AfUnit,
|
||||
samples: list[dict],
|
||||
window: tuple[float, float],
|
||||
trig: TriggerResult,
|
||||
) -> Finding | None:
|
||||
diff = len(unit.nets) == 2
|
||||
if any("topology_not_supported" in str(r.get("flags") or "") for r in samples) and not _z_vals(samples, diff):
|
||||
rec = "CPWG is not solved by ImpedenceFinder; no ohm invented."
|
||||
return _finding(
|
||||
rule_id="PE-AF-051", unit=unit,
|
||||
finding=f"Unverified: topology not supported on {_label(unit)} — no Z0 number.",
|
||||
facts=f"flags from ImpedenceFinder; nets={_label(unit)}.",
|
||||
requirement="Closed-form Z0 only for microstrip/stripline.",
|
||||
rec=rec, cls="REVIEW", evidence="INSUFFICIENT",
|
||||
)
|
||||
vals = _z_vals(samples, diff)
|
||||
if not vals:
|
||||
return None
|
||||
lo, hi = window
|
||||
zmin, zmax = min(vals), max(vals)
|
||||
zavg = sum(vals) / len(vals)
|
||||
calc = ""
|
||||
if trig.f_hz and trig.er_eff:
|
||||
z0s = [zavg]
|
||||
ell = [unit.length_mm / 1000.0]
|
||||
casc = cascade_sparam(z0s, ell, trig.er_eff, trig.f_hz, (lo + hi) / 2.0)
|
||||
if casc is not None:
|
||||
calc = (
|
||||
f"cascade lossless n={casc.n_sections} "
|
||||
f"S11={casc.s11.real:.4f}{casc.s11.imag:+.4f}j "
|
||||
f"S21={casc.s21.real:.4f}{casc.s21.imag:+.4f}j "
|
||||
f"Z_ref={casc.z_ref_ohm:g} Ω (datasheet)."
|
||||
)
|
||||
kind = "Zdiff" if diff else "Z0"
|
||||
facts = (
|
||||
f"{kind} avg={zavg:.3f} min={zmin:.3f} max={zmax:.3f} Ω "
|
||||
f"(ImpedenceFinder); window=[{lo:g}, {hi:g}] Ω; nets={_label(unit)}."
|
||||
)
|
||||
rec = f"Adjust geometry so {kind} stays in the datasheet window [{lo:g}, {hi:g}] Ω."
|
||||
if zmin < lo or zmax > hi:
|
||||
return _finding(
|
||||
rule_id="PE-AF-050", unit=unit,
|
||||
finding=f"FAIL: {kind} {zavg:.2f} Ω outside datasheet [{lo:g}, {hi:g}] Ω on {_label(unit)}.",
|
||||
facts=facts, requirement="ΔZ vs datasheet target ± window.", rec=rec,
|
||||
calculation=calc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _plane_findings(
|
||||
layout: LayoutGraph, unit: AfUnit, samples: list[dict], trig: TriggerResult,
|
||||
) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
broken = any("plane_broken" in str(r.get("flags") or "") for r in samples)
|
||||
split = any("plane_split_nearby" in str(r.get("flags") or "") for r in samples)
|
||||
if broken:
|
||||
rec = "Restore continuous reference copper under the pair/net."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-030", unit=unit,
|
||||
finding=f"Reference plane broken under {_label(unit)} (ImpedenceFinder).",
|
||||
facts=f"plane_broken on sampled walk; nets={_label(unit)}.",
|
||||
requirement="AF return path must exist under the track.", rec=rec,
|
||||
))
|
||||
if split:
|
||||
rec = "Move the track away from the plane split or close the void."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-032", unit=unit,
|
||||
finding=f"Reference-plane split nearby on {_label(unit)}.",
|
||||
facts=f"plane_split_nearby; nets={_label(unit)}.",
|
||||
requirement="3W proximity to a plane edge is REVIEW.", rec=rec, cls="REVIEW",
|
||||
))
|
||||
stack = layout.stackup
|
||||
if stack is not None:
|
||||
for net in unit.nets:
|
||||
layer, _w = _any_layer_width(layout, net)
|
||||
if not layer:
|
||||
continue
|
||||
h = dielectric_height_mm(stack, layer)
|
||||
if h is None:
|
||||
continue
|
||||
if not _copper_within_3h(layout, net, layer, h):
|
||||
rec = "Provide a reference pour within 3H of the track."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-031", unit=unit,
|
||||
finding=f"No reference copper within 3H under {net}.",
|
||||
facts=f"H={h:g} mm; 3H={3 * h:g} mm; net={net}.",
|
||||
requirement="Return path within 3H (not KiCad clearance).", rec=rec,
|
||||
))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _any_layer_width(layout: LayoutGraph, net: str) -> tuple[str, float]:
|
||||
for s in layout.segments:
|
||||
if s.net and kicad_nets_match(s.net, net) and s.layer and s.width > 0:
|
||||
return s.layer, s.width
|
||||
return "", 0.0
|
||||
|
||||
|
||||
def _copper_within_3h(layout: LayoutGraph, net: str, layer: str, h: float) -> bool:
|
||||
from backend.periscopex.placement_check import _in_poly
|
||||
|
||||
stack = layout.stackup
|
||||
if stack is None:
|
||||
return True
|
||||
try:
|
||||
idx = stack.copper_layers.index(layer)
|
||||
except ValueError:
|
||||
return True
|
||||
ref = None
|
||||
if idx + 1 < len(stack.copper_layers):
|
||||
ref = stack.copper_layers[idx + 1]
|
||||
elif idx > 0:
|
||||
ref = stack.copper_layers[idx - 1]
|
||||
if not ref:
|
||||
return True
|
||||
pts: list[tuple[float, float]] = []
|
||||
for s in layout.segments:
|
||||
if s.net and kicad_nets_match(s.net, net):
|
||||
pts.append(s.start)
|
||||
pts.append(s.end)
|
||||
if not pts:
|
||||
return True
|
||||
margin = 3.0 * h
|
||||
for z in layout.zones:
|
||||
if z.keepout or z.layer != ref:
|
||||
continue
|
||||
for ring in z.outlines:
|
||||
if len(ring) < 3:
|
||||
continue
|
||||
for x, y in pts:
|
||||
if _in_poly(x, y, ring):
|
||||
return True
|
||||
for px, py in ring:
|
||||
if math.hypot(px - x, py - y) <= margin:
|
||||
return True
|
||||
return not any(z.layer == ref and not z.keepout for z in layout.zones)
|
||||
|
||||
|
||||
def _qxy(x: float, y: float) -> tuple[float, float]:
|
||||
q = COORD_QUANT_MM
|
||||
return (round(x / q) * q, round(y / q) * q)
|
||||
|
||||
|
||||
def _track_edges(layout: LayoutGraph, net: str) -> list[tuple[tuple[float, float], tuple[float, float], float, str]]:
|
||||
edges = []
|
||||
for s in layout.segments:
|
||||
if not s.net or not kicad_nets_match(s.net, net):
|
||||
continue
|
||||
a, b = _qxy(*s.start), _qxy(*s.end)
|
||||
if a == b:
|
||||
continue
|
||||
edges.append((a, b, s.width, s.layer))
|
||||
return edges
|
||||
|
||||
|
||||
def _corner_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
for net in unit.nets:
|
||||
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
|
||||
for a, b, w, ly in _track_edges(layout, net):
|
||||
nbrs[a].append((b, w, ly))
|
||||
nbrs[b].append((a, w, ly))
|
||||
hit = False
|
||||
for node, friends in nbrs.items():
|
||||
if len(friends) != 2:
|
||||
continue
|
||||
(p1, w1, ly1), (p2, w2, ly2) = friends
|
||||
if ly1 != ly2:
|
||||
continue
|
||||
v1 = (p1[0] - node[0], p1[1] - node[1])
|
||||
v2 = (p2[0] - node[0], p2[1] - node[1])
|
||||
n1 = math.hypot(*v1)
|
||||
n2 = math.hypot(*v2)
|
||||
if n1 < ENDPOINT_SNAP_MM or n2 < ENDPOINT_SNAP_MM:
|
||||
continue
|
||||
dot = (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)
|
||||
if abs(dot) <= _RIGHT_DOT:
|
||||
hit = True
|
||||
break
|
||||
if hit:
|
||||
rec = "Replace the 90° corner with a 45° miter or an arc R ≥ 3W."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-020", unit=unit,
|
||||
finding=f"Right-angle track corner on {net}.",
|
||||
facts=f"two tracks meet at ~90°; net={net}; pair={_label(unit)}.",
|
||||
requirement="AF corners should be mitered or curved (not DRC clearance).",
|
||||
rec=rec,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _width_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
for net in unit.nets:
|
||||
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
|
||||
for a, b, w, ly in _track_edges(layout, net):
|
||||
nbrs[a].append((b, w, ly))
|
||||
nbrs[b].append((a, w, ly))
|
||||
for node, friends in nbrs.items():
|
||||
if len(friends) != 2:
|
||||
continue
|
||||
(_p1, w1, ly1), (_p2, w2, ly2) = friends
|
||||
if ly1 != ly2 or abs(w1 - w2) < _WIDTH_STEP_MM:
|
||||
continue
|
||||
rec = "Insert a taper L ≥ 2|W1−W2|; pads are not the track width."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-021", unit=unit,
|
||||
finding=f"Abrupt width step {w1:.3f}→{w2:.3f} mm on {net}.",
|
||||
facts=f"W1={w1:g} mm W2={w2:g} mm at {_fmt(node)}; net={net}.",
|
||||
requirement="Width steps on AF tracks need a taper (track ≠ pad).",
|
||||
rec=rec,
|
||||
))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _fmt(pt: tuple[float, float]) -> str:
|
||||
return f"({pt[0]:g},{pt[1]:g})"
|
||||
|
||||
|
||||
def _coupling_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
|
||||
mates = set(unit.nets)
|
||||
out: list[Finding] = []
|
||||
for net in unit.nets:
|
||||
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
|
||||
if not segs:
|
||||
continue
|
||||
w = segs[0].width or 0.0
|
||||
if w <= 0:
|
||||
continue
|
||||
for other in {s.net for s in layout.segments if s.net}:
|
||||
if other in mates or any(kicad_nets_match(other, m) for m in mates):
|
||||
continue
|
||||
if partner_net(net) and kicad_nets_match(other, partner_net(net) or ""):
|
||||
continue
|
||||
ov, gap = _parallel_overlap_gap(layout, net, other)
|
||||
if ov < _PARALLEL_MIN_MM or gap is None:
|
||||
continue
|
||||
# 3W is centre-to-centre; gap here is edge-to-edge.
|
||||
c2c = gap + w / 2.0 + _mean_width(layout, other) / 2.0
|
||||
if c2c < 3.0 * w:
|
||||
rec = "Increase spacing to S ≥ 3W versus the aggressor (not KiCad clearance)."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-040", unit=unit,
|
||||
finding=f"Spacing to {other} is {c2c:.3f} mm (< 3W={3 * w:.3f} mm) on {net}.",
|
||||
facts=f"overlap={ov:.2f} mm; edge_gap={gap:.3f} mm; W={w:g} mm.",
|
||||
requirement="3W coupling proxy on triggered AF nets; pair mate excluded.",
|
||||
rec=rec, cls="REVIEW",
|
||||
))
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def _mean_width(layout: LayoutGraph, net: str) -> float:
|
||||
ws = [s.width for s in layout.segments if s.net and kicad_nets_match(s.net, net) and s.width > 0]
|
||||
return sum(ws) / len(ws) if ws else 0.0
|
||||
|
||||
|
||||
def _parallel_overlap_gap(layout: LayoutGraph, a: str, b: str) -> tuple[float, float | None]:
|
||||
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)]
|
||||
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)]
|
||||
best_gap = None
|
||||
overlap = 0.0
|
||||
for x in sa:
|
||||
for y in sb:
|
||||
if x.layer != y.layer:
|
||||
continue
|
||||
ov = _axis_overlap(x.start, x.end, y.start, y.end)
|
||||
overlap = max(overlap, ov)
|
||||
g = _segment_gap(x.start, x.end, x.width, y.start, y.end, y.width)
|
||||
if g is not None and (best_gap is None or g < best_gap):
|
||||
best_gap = g
|
||||
return overlap, best_gap
|
||||
|
||||
|
||||
def _axis_overlap(
|
||||
a0: tuple[float, float], a1: tuple[float, float],
|
||||
b0: tuple[float, float], b1: tuple[float, float],
|
||||
) -> float:
|
||||
if abs(a0[1] - a1[1]) < COORD_QUANT_MM and abs(b0[1] - b1[1]) < COORD_QUANT_MM:
|
||||
return _interval_overlap(a0[0], a1[0], b0[0], b1[0])
|
||||
if abs(a0[0] - a1[0]) < COORD_QUANT_MM and abs(b0[0] - b1[0]) < COORD_QUANT_MM:
|
||||
return _interval_overlap(a0[1], a1[1], b0[1], b1[1])
|
||||
return 0.0
|
||||
|
||||
|
||||
def _interval_overlap(a: float, b: float, c: float, d: float) -> float:
|
||||
lo = max(min(a, b), min(c, d))
|
||||
hi = min(max(a, b), max(c, d))
|
||||
return max(0.0, hi - lo)
|
||||
|
||||
|
||||
def _segment_gap(
|
||||
a0: tuple[float, float], a1: tuple[float, float], wa: float,
|
||||
b0: tuple[float, float], b1: tuple[float, float], wb: float,
|
||||
) -> float | None:
|
||||
pts_a = (a0, a1)
|
||||
pts_b = (b0, b1)
|
||||
best = None
|
||||
for p in pts_a:
|
||||
for q in pts_b:
|
||||
d = math.hypot(p[0] - q[0], p[1] - q[1]) - wa / 2.0 - wb / 2.0
|
||||
if best is None or d < best:
|
||||
best = d
|
||||
return best
|
||||
|
||||
|
||||
def _via_span_h(layout: LayoutGraph, via: LayoutVia) -> float | None:
|
||||
stack = layout.stackup
|
||||
if stack is None or len(via.layers) < 2:
|
||||
return None
|
||||
names = list(stack.copper_layers)
|
||||
try:
|
||||
i0 = names.index(via.layers[0])
|
||||
i1 = names.index(via.layers[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
lo, hi = min(i0, i1), max(i0, i1)
|
||||
h = 0.0
|
||||
for i in range(lo, hi):
|
||||
if i < len(stack.dielectrics):
|
||||
h += stack.dielectrics[i].height_mm
|
||||
return h if h > 0 else None
|
||||
|
||||
|
||||
def _via_findings(layout: LayoutGraph, unit: AfUnit, trig: TriggerResult) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
vias = [
|
||||
v for v in layout.vias
|
||||
if v.net and any(kicad_nets_match(v.net, n) for n in unit.nets)
|
||||
]
|
||||
if not vias:
|
||||
return out
|
||||
if any(len(v.layers) < 2 for v in vias):
|
||||
out.append(_skip(unit, ("span via",)))
|
||||
return out
|
||||
for v in vias:
|
||||
if v.drill is None or v.drill <= 0:
|
||||
continue
|
||||
h = _via_span_h(layout, v)
|
||||
if h is None:
|
||||
continue
|
||||
ratio = 4.0 * h / v.drill
|
||||
if ratio <= 1.0:
|
||||
continue
|
||||
l_nh = 0.2 * h * (math.log(ratio) + 1.0)
|
||||
rec = "Treat the via as an RF discontinuity; add a return via or shorten the barrel."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-060", unit=unit,
|
||||
finding=f"Via L≈{l_nh:.3f} nH on {_label(unit)} (drill={v.drill:g} mm, h={h:g} mm).",
|
||||
facts=f"drill={v.drill:g} mm; h={h:g} mm; layers={v.layers}; L={l_nh:.4f} nH.",
|
||||
requirement="Via is not a pad or a track; L_via from drill and span.",
|
||||
rec=rec, cls="REVIEW",
|
||||
))
|
||||
tracks = {
|
||||
s.layer for s in layout.segments
|
||||
if s.net and any(kicad_nets_match(s.net, n) for n in unit.nets) and s.layer
|
||||
}
|
||||
stack = layout.stackup
|
||||
if stack is None or len(v.layers) < 2 or not tracks:
|
||||
continue
|
||||
names = list(stack.copper_layers)
|
||||
try:
|
||||
used = [names.index(ly) for ly in tracks if ly in names]
|
||||
span = [names.index(v.layers[0]), names.index(v.layers[-1])]
|
||||
except ValueError:
|
||||
continue
|
||||
if not used:
|
||||
continue
|
||||
stub_idx = max(span) - max(used)
|
||||
stub_lo = min(used) - min(span)
|
||||
layers_stub = max(stub_idx, stub_lo, 0)
|
||||
stub_h = 0.0
|
||||
lo, hi = min(span), max(span)
|
||||
unused = [i for i in range(lo, hi) if i < min(used) or i >= max(used)]
|
||||
for i in unused:
|
||||
if 0 <= i < len(stack.dielectrics):
|
||||
stub_h += stack.dielectrics[i].height_mm
|
||||
if stub_h <= 0 or trig.lambda_10_mm is None:
|
||||
continue
|
||||
lam = trig.lambda_10_mm * 10.0
|
||||
if stub_h > lam / 20.0:
|
||||
rec = "Back-drill or use a blind via; unused barrel is a λ/4 stub risk."
|
||||
out.append(_finding(
|
||||
rule_id="PE-AF-061", unit=unit,
|
||||
finding=f"Via stub {stub_h:.3f} mm > λ/20 on {_label(unit)}.",
|
||||
facts=f"L_stub={stub_h:g} mm; λ={lam:g} mm; unused_layers~{layers_stub}.",
|
||||
requirement="Via stub > λ/20 on a triggered AF net.",
|
||||
rec=rec,
|
||||
))
|
||||
return out
|
||||
@@ -0,0 +1,329 @@
|
||||
"""AF trigger: λ/10 and tr/(6 tpd) from FACT only. Pad/via/zone are not length."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
ensure_impedancefinder()
|
||||
from impedancefinder import zsolver
|
||||
|
||||
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, LayoutGraph, LayoutStackup, NetType
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.si_check import (
|
||||
_bus_in_targets,
|
||||
_expand_bus_token,
|
||||
bus_class,
|
||||
net_length_mm,
|
||||
partner_net,
|
||||
skip_si_net,
|
||||
)
|
||||
|
||||
C_MPS = 2.99792458e8
|
||||
# Geometry snap for width pick — not an SI millimetre limit.
|
||||
_MIN_W_MM = 1e-6
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AfUnit:
|
||||
"""One net or a differential pair analyzed together."""
|
||||
|
||||
nets: tuple[str, ...]
|
||||
length_mm: float
|
||||
bus: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriggerResult:
|
||||
unit: AfUnit
|
||||
af: bool
|
||||
missing: tuple[str, ...]
|
||||
er_eff: float | None
|
||||
tr_s: float | None
|
||||
f_hz: float | None
|
||||
lambda_10_mm: float | None
|
||||
l_crit_mm: float | None
|
||||
|
||||
|
||||
def pair_partner(layout: LayoutGraph, net: str) -> str | None:
|
||||
p = partner_net(net)
|
||||
if not p:
|
||||
return None
|
||||
names = {s.net for s in layout.segments if s.net}
|
||||
for n in names:
|
||||
if kicad_nets_match(p, n):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def is_candidate_net(net: str, graph: DesignGraph | None) -> bool:
|
||||
if not net or skip_si_net(net):
|
||||
return False
|
||||
if graph is not None:
|
||||
rec = graph.nets.get(net)
|
||||
if rec is not None and rec.net_type in (NetType.POWER, NetType.GROUND):
|
||||
return False
|
||||
return bus_class(net) is not None
|
||||
|
||||
|
||||
def iter_af_units(layout: LayoutGraph, graph: DesignGraph | None) -> list[AfUnit]:
|
||||
routed = sorted({s.net for s in layout.segments if s.net and s.width > 0})
|
||||
seen: set[str] = set()
|
||||
out: list[AfUnit] = []
|
||||
for net in routed:
|
||||
if net in seen or not is_candidate_net(net, graph):
|
||||
continue
|
||||
partner = pair_partner(layout, net)
|
||||
if partner and is_candidate_net(partner, graph):
|
||||
nets = tuple(sorted((net, partner)))
|
||||
for n in nets:
|
||||
seen.add(n)
|
||||
lp = net_length_mm(layout, nets[0])
|
||||
ln = net_length_mm(layout, nets[1])
|
||||
out.append(AfUnit(
|
||||
nets=nets, length_mm=max(lp, ln),
|
||||
bus=bus_class(nets[0]) or bus_class(nets[1]),
|
||||
))
|
||||
else:
|
||||
seen.add(net)
|
||||
out.append(AfUnit(
|
||||
nets=(net,), length_mm=net_length_mm(layout, net),
|
||||
bus=bus_class(net),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _num(v) -> float | None:
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
try:
|
||||
x = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return x if math.isfinite(x) else None
|
||||
|
||||
|
||||
def _tr_from_rule(rule: dict) -> float | None:
|
||||
s = _num(rule.get("tr_s"))
|
||||
if s is not None and s > 0:
|
||||
return s
|
||||
ns = _num(rule.get("tr_ns")) or _num(rule.get("rise_time_ns")) or _num(rule.get("t_r_ns"))
|
||||
if ns is not None and ns > 0:
|
||||
return ns * 1e-9
|
||||
ps = _num(rule.get("tr_ps"))
|
||||
if ps is not None and ps > 0:
|
||||
return ps * 1e-12
|
||||
kind = str(rule.get("kind") or "").lower()
|
||||
raw = _num(rule.get("tr")) or _num(rule.get("value"))
|
||||
if kind in {"rise_time", "tr", "t_r"} and raw is not None and raw > 0:
|
||||
if raw > 1e-3:
|
||||
return raw * 1e-9
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def _f_from_rule(rule: dict) -> float | None:
|
||||
hz = _num(rule.get("f_hz")) or _num(rule.get("f_max_hz")) or _num(rule.get("frequency_hz"))
|
||||
if hz is not None and hz > 0:
|
||||
return hz
|
||||
mhz = _num(rule.get("f_mhz"))
|
||||
if mhz is not None and mhz > 0:
|
||||
return mhz * 1e6
|
||||
ghz = _num(rule.get("f_ghz"))
|
||||
if ghz is not None and ghz > 0:
|
||||
return ghz * 1e9
|
||||
kind = str(rule.get("kind") or "").lower()
|
||||
raw = _num(rule.get("f")) or _num(rule.get("value"))
|
||||
if kind in {"frequency", "f_max", "f_clk"} and raw is not None and raw > 0:
|
||||
if raw < 1e4:
|
||||
return raw * 1e6
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def tr_f_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float | None, float | None]:
|
||||
tr_s: float | None = None
|
||||
f_hz: float | None = None
|
||||
for _ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if not cons:
|
||||
continue
|
||||
ic_nets = {n for n in (comp.pins.values()) if n}
|
||||
on_ic = any(
|
||||
any(kicad_nets_match(n, icn) for icn in ic_nets)
|
||||
for n in unit.nets
|
||||
)
|
||||
if not on_ic:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
targets = _expand_bus_token(str(rule.get("net_class") or ""))
|
||||
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
|
||||
continue
|
||||
t = _tr_from_rule(rule)
|
||||
f = _f_from_rule(rule)
|
||||
if t is not None:
|
||||
tr_s = t
|
||||
if f is not None:
|
||||
f_hz = f
|
||||
return tr_s, f_hz
|
||||
|
||||
|
||||
def z_window_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float, float] | None:
|
||||
from backend.periscopex.si_check import _z_window
|
||||
|
||||
for _ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if not cons:
|
||||
continue
|
||||
ic_nets = {n for n in (comp.pins.values()) if n}
|
||||
on_ic = any(
|
||||
any(kicad_nets_match(n, icn) for icn in ic_nets)
|
||||
for n in unit.nets
|
||||
)
|
||||
if not on_ic:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
kind = str(rule.get("kind") or "")
|
||||
if kind not in {"impedance", "zdiff", "z0", "si"}:
|
||||
continue
|
||||
if kind == "si" and str(rule.get("parameter") or "").lower() not in {"impedance", "zdiff", "z0"}:
|
||||
continue
|
||||
targets = _expand_bus_token(str(rule.get("net_class") or ""))
|
||||
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
|
||||
continue
|
||||
win = _z_window(rule)
|
||||
if win:
|
||||
return win
|
||||
return None
|
||||
|
||||
|
||||
def _dominant_trace(layout: LayoutGraph, net: str) -> tuple[str, float] | None:
|
||||
by_layer: dict[str, list[float]] = {}
|
||||
for s in layout.segments:
|
||||
if not s.net or not kicad_nets_match(s.net, net) or s.width <= _MIN_W_MM:
|
||||
continue
|
||||
layer = s.layer or ""
|
||||
by_layer.setdefault(layer, []).append(s.width)
|
||||
if not by_layer:
|
||||
return None
|
||||
layer = max(by_layer, key=lambda ly: len(by_layer[ly]))
|
||||
widths = sorted(by_layer[layer])
|
||||
w = widths[len(widths) // 2]
|
||||
return layer, w
|
||||
|
||||
|
||||
def _er_eff(layout: LayoutGraph, unit: AfUnit) -> tuple[float | None, list[str]]:
|
||||
missing: list[str] = []
|
||||
stack = layout.stackup
|
||||
if stack is None or not stack.copper_layers or not stack.dielectrics:
|
||||
return None, ["stackup", "εr"]
|
||||
t = stack.copper_thickness_mm
|
||||
if t is None or t <= 0:
|
||||
return None, ["stackup"]
|
||||
picked = None
|
||||
for net in unit.nets:
|
||||
picked = _dominant_trace(layout, net)
|
||||
if picked:
|
||||
break
|
||||
if picked is None:
|
||||
return None, ["εr"]
|
||||
layer, w = picked
|
||||
try:
|
||||
idx = stack.copper_layers.index(layer)
|
||||
except ValueError:
|
||||
return None, ["εr"]
|
||||
outer = idx == 0 or idx == len(stack.copper_layers) - 1
|
||||
if outer:
|
||||
diel_i = 0 if idx == 0 else len(stack.dielectrics) - 1
|
||||
if diel_i < 0 or diel_i >= len(stack.dielectrics):
|
||||
return None, ["εr"]
|
||||
d = stack.dielectrics[diel_i]
|
||||
if d.er <= 0 or d.height_mm <= 0:
|
||||
return None, ["εr"]
|
||||
z = zsolver.microstrip_z0(w, d.height_mm, d.er, t)
|
||||
zair = zsolver.microstrip_z0(w, d.height_mm, 1.0, t)
|
||||
if z <= 0 or zair <= 0:
|
||||
return None, ["εr"]
|
||||
return (zair / z) ** 2, missing
|
||||
if idx <= 0 or idx >= len(stack.dielectrics):
|
||||
return None, ["εr"]
|
||||
d = stack.dielectrics[idx]
|
||||
if d.er <= 0:
|
||||
return None, ["εr"]
|
||||
return d.er, missing
|
||||
|
||||
|
||||
def evaluate_trigger(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph,
|
||||
unit: AfUnit,
|
||||
) -> TriggerResult:
|
||||
missing: list[str] = []
|
||||
er_eff, er_miss = _er_eff(layout, unit)
|
||||
missing.extend(er_miss)
|
||||
tr_s, f_hz = tr_f_for_unit(graph, constraints_map, unit)
|
||||
if f_hz is None and tr_s is not None:
|
||||
f_hz = 0.35 / tr_s
|
||||
if tr_s is None:
|
||||
missing.append("tr")
|
||||
if f_hz is None and tr_s is None:
|
||||
missing.append("f")
|
||||
elif f_hz is None:
|
||||
missing.append("f")
|
||||
|
||||
l_m = unit.length_mm / 1000.0
|
||||
lam10 = None
|
||||
lcrit = None
|
||||
af = False
|
||||
if er_eff is not None and er_eff > 0:
|
||||
tpd = math.sqrt(er_eff) / C_MPS
|
||||
if f_hz is not None and f_hz > 0:
|
||||
lam = C_MPS / (f_hz * math.sqrt(er_eff))
|
||||
lam10 = lam / 10.0 * 1000.0
|
||||
if l_m >= lam / 10.0:
|
||||
af = True
|
||||
if tr_s is not None and tr_s > 0:
|
||||
lcrit_m = tr_s / (6.0 * tpd)
|
||||
lcrit = lcrit_m * 1000.0
|
||||
if l_m >= lcrit_m:
|
||||
af = True
|
||||
|
||||
trigger_ready = er_eff is not None and (tr_s is not None or f_hz is not None)
|
||||
if trigger_ready:
|
||||
miss_trig = tuple()
|
||||
else:
|
||||
miss_trig = tuple(dict.fromkeys(missing))
|
||||
return TriggerResult(
|
||||
unit=unit,
|
||||
af=bool(af and trigger_ready),
|
||||
missing=miss_trig,
|
||||
er_eff=er_eff,
|
||||
tr_s=tr_s,
|
||||
f_hz=f_hz,
|
||||
lambda_10_mm=lam10,
|
||||
l_crit_mm=lcrit,
|
||||
)
|
||||
|
||||
|
||||
def dielectric_height_mm(stack: LayoutStackup, layer: str) -> float | None:
|
||||
try:
|
||||
idx = stack.copper_layers.index(layer)
|
||||
except ValueError:
|
||||
return None
|
||||
if idx == 0:
|
||||
d = stack.dielectrics[0] if stack.dielectrics else None
|
||||
elif idx == len(stack.copper_layers) - 1:
|
||||
d = stack.dielectrics[-1] if stack.dielectrics else None
|
||||
else:
|
||||
d = stack.dielectrics[idx] if idx < len(stack.dielectrics) else None
|
||||
if d is None or d.height_mm <= 0:
|
||||
return None
|
||||
return d.height_mm
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Antenna keepout and matching — only if an antenna is on the graph.
|
||||
|
||||
No invented 50 Ω. No FEM. Skip when the antenna is absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
|
||||
|
||||
SOURCE = "antenna_layout_check"
|
||||
|
||||
_ANT_REF_RE = re.compile(r"^ANT", re.I)
|
||||
_FEED_RE = re.compile(r"ANT_FEED|RF_ANT|ANTENNA_FEED", re.I)
|
||||
_ZONE_RE = re.compile(r"antenna|ant_zone|rf_antenna", re.I)
|
||||
|
||||
|
||||
def check_antenna_layout(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
ants = [
|
||||
c for c in graph.components.values()
|
||||
if _ANT_REF_RE.match(c.reference or "")
|
||||
]
|
||||
feeds = [n for n in graph.nets if _FEED_RE.search(n)]
|
||||
if not ants and not feeds:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
keepout = []
|
||||
if layout is not None:
|
||||
keepout = [
|
||||
z for z in layout.zones
|
||||
if z.keepout and (z.net and _ZONE_RE.search(z.net) or _ZONE_RE.search(z.name or ""))
|
||||
]
|
||||
ref = ants[0].reference if ants else "ANT"
|
||||
if keepout:
|
||||
rec = "Keep copper out of the antenna keepout listed on the board."
|
||||
names = [z.net or z.name or "keepout" for z in keepout]
|
||||
out.append(Finding(
|
||||
designator=ref, mpn="", aspect="antenna",
|
||||
finding=f"Antenna keepout present: {', '.join(names)}.",
|
||||
facts=f"keepout_zones={names}; antenna_refs={[c.reference for c in ants]}; feeds={feeds}.",
|
||||
requirement="Report antenna keepout when that zone exists on the PCB.",
|
||||
inference="No keepout millimetre is invented.",
|
||||
why="Antenna checks run only when an antenna marker/net exists.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[],
|
||||
))
|
||||
else:
|
||||
rec = "Add a keepout zone around the antenna if the antenna datasheet requires one."
|
||||
out.append(Finding(
|
||||
designator=ref, mpn="", aspect="antenna",
|
||||
finding="Antenna is present; keepout zone is not evidenced.",
|
||||
facts=f"antenna_refs={[c.reference for c in ants]}; feeds={feeds}; keepout_zones=0.",
|
||||
requirement="Antenna keepout millimetres from the antenna datasheet (none on file).",
|
||||
inference="INSUFFICIENT — not inventing a keepout distance.",
|
||||
why="Missing keepout is not an invented millimetre FAIL.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT", net=feeds[0] if feeds else None, pins=[],
|
||||
))
|
||||
match_refs = []
|
||||
for name in feeds:
|
||||
net = graph.nets.get(name)
|
||||
if not net:
|
||||
continue
|
||||
for pin in net.pins:
|
||||
c = graph.components.get(pin.component_ref)
|
||||
if c and c.component_type in {ComponentType.INDUCTOR, ComponentType.CAPACITOR}:
|
||||
match_refs.append(c.reference)
|
||||
if match_refs:
|
||||
rec = "No matching-network change required; this is a BOM FACT."
|
||||
out.append(Finding(
|
||||
designator=ref, mpn="", aspect="antenna",
|
||||
finding=f"Antenna matching parts on feed: {', '.join(sorted(set(match_refs)))}.",
|
||||
facts=f"matching={sorted(set(match_refs))}; feeds={feeds}.",
|
||||
requirement="Report L/C matching that exists on the antenna feed.",
|
||||
inference="Missing match network is a skip unless a datasheet matching FACT exists.",
|
||||
why="Do not invent a 50 Ω match.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-ANT-002", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[],
|
||||
))
|
||||
return out
|
||||
@@ -1,12 +1,16 @@
|
||||
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
|
||||
|
||||
Skip when a side is missing. No invented JEDEC land patterns or IEC
|
||||
clearances. Thermal/EP pads may differ by one from pin_count.
|
||||
clearances. PE-BOM-011 joins pintable × lib × PCB by pin name — never
|
||||
by scalar pin_count. Pad ≠ via ≠ track ≠ zone. USB-C A5/B5 are pins,
|
||||
not EP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.models import (
|
||||
AbsMaxRating,
|
||||
@@ -27,6 +31,7 @@ from backend.periscopex.thermal_check import (
|
||||
_specs_values,
|
||||
)
|
||||
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_mod_pads
|
||||
|
||||
_FAMILIES = (
|
||||
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
|
||||
@@ -35,11 +40,23 @@ _FAMILIES = (
|
||||
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
|
||||
"TO-252", "TO252", "TO-263", "QFP",
|
||||
)
|
||||
# Same land family: VQFN/WQFN/HVQFN are QFN (24-QFN 4×4 ≠ a different package).
|
||||
_FAMILY_CANON = {
|
||||
"VQFN": "QFN",
|
||||
"WQFN": "QFN",
|
||||
"HVQFN": "QFN",
|
||||
}
|
||||
_QFN_PIN_N = re.compile(r"(?:HVQFN|VQFN|WQFN|QFN)[_-](\d+)\b", re.I)
|
||||
_MODULE_FP = re.compile(
|
||||
r"WROOM|WROVER|ESP-WROOM|RF[_-]?MODULE|CASTELLAT",
|
||||
re.I,
|
||||
)
|
||||
_EP_PAD = re.compile(
|
||||
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
|
||||
re.I,
|
||||
)
|
||||
_EP_SPLIT = re.compile(r"^\d+_\d+$")
|
||||
_SH_PAD = re.compile(r"^(?:SH|SHIELD)(?:[_-].*)?$", re.I)
|
||||
_V_PARAM = re.compile(
|
||||
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
|
||||
re.I,
|
||||
@@ -60,7 +77,7 @@ def _family(blob: str) -> str | None:
|
||||
return None
|
||||
for fam in _FAMILIES:
|
||||
if fam.replace("-", "") in u.replace("-", ""):
|
||||
return fam
|
||||
return _FAMILY_CANON.get(fam, fam)
|
||||
return None
|
||||
|
||||
|
||||
@@ -74,9 +91,16 @@ def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
|
||||
return str(row.get("footprint") or comp.footprint or "")
|
||||
|
||||
|
||||
def _norm_pin(s: str) -> str:
|
||||
return str(s).strip().upper()
|
||||
|
||||
|
||||
def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None) -> bool:
|
||||
"""Named EP / thermal / split / numeric pin_count+1. Not USB-C A5/B5."""
|
||||
s = str(number).strip()
|
||||
pf = str(pinfunction or "").strip()
|
||||
if _SH_PAD.match(s) or (pf and _SH_PAD.match(pf)):
|
||||
return False
|
||||
if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)):
|
||||
return True
|
||||
if s.lower().replace(" ", "") in {"thermalpad", "thermal"}:
|
||||
@@ -85,35 +109,187 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None
|
||||
return True
|
||||
if pin_count and s.isdigit() and int(s) == pin_count + 1:
|
||||
return True
|
||||
if pin_count and pin_count <= 48 and re.match(r"^[A-Z]\d+$", s):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _signal_pads(numbers: list[str], pin_count: int | None = None) -> set[str]:
|
||||
out: set[str] = set()
|
||||
for n in numbers:
|
||||
s = str(n).strip()
|
||||
if not s or _is_ep_land(s, pin_count=pin_count):
|
||||
continue
|
||||
out.add(s)
|
||||
return out
|
||||
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
|
||||
s = str(number).strip()
|
||||
pf = str(pinfunction or "").strip()
|
||||
return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf)))
|
||||
|
||||
|
||||
def _pcb_signal_pads(fp_layout, pin_count: int | None = None) -> set[str]:
|
||||
"""Component lands only. Vias are not in ``fp.pads``; EP/thermal names skip."""
|
||||
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_join_keys(
|
||||
footprint: str,
|
||||
*,
|
||||
pin_count: int | None,
|
||||
ds_keys: set[str],
|
||||
search_dirs: list[Path] | None,
|
||||
) -> tuple[set[str] | None, str]:
|
||||
"""Lib pad set, or None when the ``.kicad_mod`` is not on disk."""
|
||||
path = resolve_kicad_mod(footprint, search_dirs)
|
||||
if path is None:
|
||||
return None, ""
|
||||
ids = parse_kicad_mod_pads(path)
|
||||
if ids is None:
|
||||
return None, str(path)
|
||||
return _join_keys_from_ids(ids, pin_count=pin_count, ds_keys=ds_keys), str(path)
|
||||
|
||||
|
||||
def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None:
|
||||
if cons and cons.package_info:
|
||||
return cons.package_info
|
||||
@@ -160,8 +336,9 @@ def check_bom_pcb_datasheet(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
footprint_dirs: list[Path] | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Three-way mismatches when BOM, PCB, and library all have numbers."""
|
||||
"""Package family + named pin join. Skip a side when it is missing."""
|
||||
cmap = constraints_map or {}
|
||||
out: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
@@ -176,7 +353,9 @@ def check_bom_pcb_datasheet(
|
||||
out.extend(_package_findings(
|
||||
ref, comp, pkg, sch_name, bom_name, pcb_name,
|
||||
))
|
||||
out.extend(_pinout_findings(ref, comp, cons, fp_layout, pkg))
|
||||
out.extend(_pinout_findings(
|
||||
ref, comp, cons, fp_layout, pkg, footprint_dirs,
|
||||
))
|
||||
out.extend(_voltage_findings(graph, ref, comp, cons))
|
||||
out.extend(_temp_findings(ref, comp, cons))
|
||||
out.extend(_current_findings(graph, ref, comp, cons))
|
||||
@@ -188,6 +367,7 @@ def _mismatch(
|
||||
ref: str, mpn: str, rule_id: str, finding: str, facts: str,
|
||||
requirement: str, rec: str, status: str = "ERROR",
|
||||
inference: str = "", net: str | None = None,
|
||||
evidence_status: str = "SUFFICIENT",
|
||||
) -> Finding:
|
||||
return Finding(
|
||||
designator=ref,
|
||||
@@ -203,7 +383,7 @@ def _mismatch(
|
||||
action=rec,
|
||||
source="bom_pcb_check",
|
||||
rule_id=rule_id,
|
||||
evidence_status="SUFFICIENT",
|
||||
evidence_status=evidence_status,
|
||||
net=net,
|
||||
pins=[ref],
|
||||
)
|
||||
@@ -264,71 +444,173 @@ def _package_findings(
|
||||
return out
|
||||
|
||||
|
||||
def _pin_delta(
|
||||
*,
|
||||
ref: str,
|
||||
mpn: str,
|
||||
finding: str,
|
||||
facts: str,
|
||||
requirement: str,
|
||||
rec: str,
|
||||
status: str = "ERROR",
|
||||
evidence_status: str = "SUFFICIENT",
|
||||
) -> Finding:
|
||||
return _mismatch(
|
||||
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
|
||||
finding=finding, facts=facts, requirement=requirement, rec=rec,
|
||||
status=status, evidence_status=evidence_status,
|
||||
)
|
||||
|
||||
|
||||
def _pinout_findings(
|
||||
ref: str,
|
||||
comp: Component,
|
||||
cons: ComponentConstraints | None,
|
||||
fp_layout,
|
||||
pkg: PackageInfo | None,
|
||||
footprint_dirs: list[Path] | None = None,
|
||||
) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
"""Join pintable × lib × PCB by name. Never ERROR on pin_count alone."""
|
||||
ds = _ds_join_keys(cons)
|
||||
if not ds:
|
||||
return []
|
||||
mpn = comp.mpn or ""
|
||||
pin_count = pkg.pin_count if pkg else None
|
||||
sch_pins = _signal_pads(list(comp.pins.keys()), pin_count)
|
||||
ds_pins = _signal_pads(
|
||||
[str(p.number) for p in (cons.pintable if cons else [])],
|
||||
pin_count,
|
||||
pcb_ids = _pads_as_ids(fp_layout)
|
||||
fp_name = ""
|
||||
if fp_layout is not None:
|
||||
fp_name = (fp_layout.footprint or "").strip()
|
||||
if not fp_name:
|
||||
fp_name = (comp.footprint or "").strip()
|
||||
cad = _cad_blob(
|
||||
fp_name, comp.footprint or "", mpn, comp.value or "",
|
||||
)
|
||||
pcb_pins = _pcb_signal_pads(fp_layout, pin_count)
|
||||
ds_count = pkg.pin_count if pkg else (len(ds_pins) or None)
|
||||
pcb_count = len(pcb_pins) if pcb_pins else None
|
||||
if ds_count and pcb_count and abs(ds_count - pcb_count) > 1:
|
||||
rec = (
|
||||
f"Replace {ref}'s PCB footprint so pad count matches datasheet "
|
||||
f"pin_count={ds_count}."
|
||||
is_module = _is_module_land(cad)
|
||||
declared = _declared_io_pins(pkg, cad)
|
||||
ep_count = declared or pin_count
|
||||
pcb = _join_keys_from_ids(pcb_ids, pin_count=ep_count, ds_keys=ds)
|
||||
lib, lib_path = _lib_join_keys(
|
||||
fp_name, pin_count=ep_count, ds_keys=ds, search_dirs=footprint_dirs,
|
||||
)
|
||||
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 = sorted(ds - lib)
|
||||
extra_lib = sorted(lib - ds)
|
||||
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 = sorted(lib - pcb) if pcb else sorted(lib)
|
||||
extra_emb = sorted(pcb - lib) if pcb else []
|
||||
if pcb and (miss_emb or extra_emb):
|
||||
rec = (
|
||||
f"Replace {ref}'s embedded PCB footprint so it matches the "
|
||||
f"named library footprint {fp_name}."
|
||||
)
|
||||
fnd = _join_issue(
|
||||
miss=miss_emb, extra=extra_emb,
|
||||
finding=(
|
||||
f"{ref} library footprint vs PCB embedded pads differ "
|
||||
f"(missing on PCB {miss_emb}, extra on PCB {extra_emb})."
|
||||
),
|
||||
facts=f"lib={sorted(lib)}; PCB={sorted(pcb)}; fp={fp_name!r}.",
|
||||
requirement=(
|
||||
"Embedded PCB pads must match the named .kicad_mod, "
|
||||
"by pin name."
|
||||
),
|
||||
rec=rec,
|
||||
)
|
||||
if fnd:
|
||||
out.append(fnd)
|
||||
return out
|
||||
if pcb:
|
||||
miss = sorted(ds - pcb)
|
||||
extra = sorted(pcb - ds)
|
||||
if miss or extra:
|
||||
rec = (
|
||||
f"Fix {ref} pinout: PCB pad names vs datasheet pintable. "
|
||||
f"Missing on PCB: {miss or '—'}; extra on PCB: {extra or '—'}."
|
||||
)
|
||||
fnd = _join_issue(
|
||||
miss=miss, extra=extra,
|
||||
finding=(
|
||||
f"{ref} pintable vs PCB pads differ "
|
||||
f"(missing {missing_pcb}, extra {extra_pcb})."
|
||||
f"(missing {miss}, extra {extra})."
|
||||
),
|
||||
facts=(
|
||||
f"pintable={sorted(ds)}; PCB={sorted(pcb)}; "
|
||||
f"sch={sorted(sch)}; lib .kicad_mod not found."
|
||||
),
|
||||
requirement=(
|
||||
"Each datasheet pin name must exist as a PCB pad. "
|
||||
"Do not compare pin_count to pad cardinality."
|
||||
),
|
||||
facts=f"pintable={sorted(ds_pins)}; PCB={sorted(pcb_pins)}; sch={sorted(sch_pins)}.",
|
||||
requirement="Each datasheet pin number must exist as a PCB pad.",
|
||||
rec=rec,
|
||||
))
|
||||
elif ds_pins and sch_pins and ds_pins != sch_pins:
|
||||
missing = sorted(ds_pins - sch_pins)
|
||||
extra = sorted(sch_pins - ds_pins)
|
||||
if missing or extra:
|
||||
)
|
||||
if fnd:
|
||||
out.append(fnd)
|
||||
return out
|
||||
if sch and ds != sch:
|
||||
miss = sorted(ds - sch)
|
||||
extra = sorted(sch - ds)
|
||||
if miss or extra:
|
||||
rec = f"Align {ref} schematic pins with the datasheet pintable."
|
||||
out.append(_mismatch(
|
||||
ref=ref, mpn=mpn, rule_id="PE-BOM-011",
|
||||
out.append(_pin_delta(
|
||||
ref=ref, mpn=mpn,
|
||||
finding=(
|
||||
f"{ref} pintable vs schematic pins differ "
|
||||
f"(missing {missing}, extra {extra})."
|
||||
f"(missing {miss}, extra {extra})."
|
||||
),
|
||||
facts=f"pintable={sorted(ds_pins)}; sch={sorted(sch_pins)}.",
|
||||
facts=f"pintable={sorted(ds)}; sch={sorted(sch)}.",
|
||||
requirement="Schematic pin numbers must match the datasheet pintable.",
|
||||
rec=rec,
|
||||
status="ERROR",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
@@ -210,3 +211,138 @@ def check_return_path(
|
||||
pins=[],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _pad_xy(layout: LayoutGraph, ref: str) -> list[tuple[float, float, str]]:
|
||||
fp = layout.footprints.get(ref)
|
||||
if not fp:
|
||||
return []
|
||||
return [(p.x, p.y, p.net) for p in fp.pads]
|
||||
|
||||
|
||||
def _min_pad_distance_mm(
|
||||
a: list[tuple[float, float, str]],
|
||||
b: list[tuple[float, float, str]],
|
||||
net: str,
|
||||
) -> float | None:
|
||||
best = None
|
||||
for ax, ay, an in a:
|
||||
if an and not kicad_nets_match(an, net):
|
||||
continue
|
||||
for bx, by, bn in b:
|
||||
if bn and not kicad_nets_match(bn, net):
|
||||
continue
|
||||
d = math.hypot(ax - bx, ay - by)
|
||||
if best is None or d < best:
|
||||
best = d
|
||||
return best
|
||||
|
||||
|
||||
def check_esd_distance(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
"""TVS–connector pad distance. Vias are not pads. Skip without both lands."""
|
||||
if layout is None:
|
||||
return []
|
||||
from backend.periscopex.constraints_lookup import match_constraints as match_cons
|
||||
from backend.periscopex.si_check import _num, _quote
|
||||
|
||||
tvs = [
|
||||
(ref, comp) for ref, comp in sorted(graph.components.items())
|
||||
if _is_esd_part(comp)
|
||||
]
|
||||
connectors = [
|
||||
(ref, comp) for ref, comp in sorted(graph.components.items())
|
||||
if _is_j_connector(comp)
|
||||
]
|
||||
if not tvs or not connectors:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for j_ref, j_comp in connectors:
|
||||
j_pads = _pad_xy(layout, j_ref)
|
||||
if not j_pads:
|
||||
continue
|
||||
for d_ref, d_comp in tvs:
|
||||
d_pads = _pad_xy(layout, d_ref)
|
||||
if not d_pads:
|
||||
continue
|
||||
nets = sorted({
|
||||
n for n in list(j_comp.pins.values()) + list(d_comp.pins.values()) if n
|
||||
})
|
||||
for net in nets:
|
||||
if _skip_esd_net(net):
|
||||
continue
|
||||
key = (j_ref, d_ref, normalize_kicad_hierarchy_net(net))
|
||||
if key in seen:
|
||||
continue
|
||||
dist = _min_pad_distance_mm(j_pads, d_pads, net)
|
||||
if dist is None:
|
||||
continue
|
||||
seen.add(key)
|
||||
lim = None
|
||||
quote = ""
|
||||
cons = match_cons(d_comp.mpn or d_comp.value, constraints_map or {})
|
||||
if cons:
|
||||
for rule in cons.layout_rules or []:
|
||||
if str(rule.get("kind") or "") not in {"esd", "keepout"}:
|
||||
continue
|
||||
lim = _num(rule.get("max_distance_mm"))
|
||||
quote = _quote(rule)
|
||||
if lim is not None:
|
||||
break
|
||||
facts = (
|
||||
f"pad_distance_mm={dist:.3f}; connector={j_ref}; tvs={d_ref}; "
|
||||
f"net={net}; vias_not_used=1."
|
||||
)
|
||||
calc = f"min hypot between {j_ref} pads and {d_ref} pads = {dist:.3f} mm."
|
||||
if lim is None:
|
||||
rec = (
|
||||
"Re-extract the TVS layout millimetre. "
|
||||
"Do not assume a default ESD gap."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
|
||||
finding=(
|
||||
f"Unverified: {d_ref} is {dist:.2f} mm from {j_ref} — "
|
||||
"library has no ESD millimetre FACT."
|
||||
),
|
||||
facts=facts, calculation=calc,
|
||||
requirement=(
|
||||
"Datasheet ESD max_distance_mm from TVS to connector "
|
||||
"(none on file)."
|
||||
),
|
||||
inference="INSUFFICIENT — not inventing an ESD keep-in millimetre.",
|
||||
why="Distance is pad-to-pad; vias and tracks are not lands.",
|
||||
status="INFO", recommendation=rec, action=rec,
|
||||
source="esd_return_check", rule_id="PE-ESD-002",
|
||||
finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
continue
|
||||
fail = dist > lim + 1e-9
|
||||
rec = (
|
||||
f"Move {d_ref} within {lim:g} mm of {j_ref}."
|
||||
if fail
|
||||
else "TVS-to-connector distance meets the datasheet millimetre."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
|
||||
finding=(
|
||||
f"{'FAIL' if fail else 'PASS'}: {d_ref}–{j_ref} "
|
||||
f"{dist:.2f} mm (max {lim:g} mm)."
|
||||
),
|
||||
facts=facts, calculation=calc,
|
||||
requirement=f"ESD max_distance_mm={lim:g} ({quote or 'layout_rules'}).",
|
||||
inference="FAIL vs datasheet millimetre." if fail else "",
|
||||
why="Pad-to-pad only; a via on the net is not the TVS land.",
|
||||
status="WARNING" if fail else "INFO",
|
||||
recommendation=rec, action=rec,
|
||||
source="esd_return_check", rule_id="PE-ESD-002",
|
||||
finding_class="RISK" if fail else "INFO",
|
||||
provenance="RECOMMENDED" if fail else "TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
return out
|
||||
|
||||
@@ -152,6 +152,12 @@ def _seed() -> None:
|
||||
requirement="Series resistor on the HS net vs datasheet ohms.")
|
||||
_add("PE-SI-010", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.")
|
||||
_add("PE-SI-007", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Dangling track stub vs datasheet millimetres; pad/via/zone are not stubs.")
|
||||
_add("PE-SI-011", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="Continuous GND pour under an HF pair when that pour exists (not IEC).")
|
||||
_add("PE-SI-012", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="BOM termination on an HF net is reported; missing terminator is a skip.")
|
||||
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
|
||||
requirement="Keepout from layout_rules — not a DRC for pad copper.")
|
||||
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
|
||||
@@ -188,7 +194,7 @@ def _seed() -> None:
|
||||
_add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="BOM / PCB footprint family must match datasheet package.")
|
||||
_add("PE-BOM-011", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="Datasheet pin_count / pintable must match PCB pads.")
|
||||
requirement="Datasheet pintable names must join KiCad lib and PCB pads (not pin_count).")
|
||||
_add("PE-BOM-012", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="Operating voltage must not exceed the sourced rating / abs-max.")
|
||||
_add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb",
|
||||
@@ -224,6 +230,84 @@ def _seed() -> None:
|
||||
requirement="PoE class/type from 802.3af/at/bt or Class n — never invented.")
|
||||
_add("PE-POE-003", "MANDATORY", "RULE", domain="shared",
|
||||
requirement="PoE requires magnetics/isolation (MagJack or LAN transformer).")
|
||||
_add("PE-DDR-001", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="DDR class from a DRAM on this graph — never invented.")
|
||||
_add("PE-DDR-002", "MANDATORY", "RULE", domain="shared",
|
||||
requirement="DDR needs CK, DQS, and ≥8 DQ nets when the DRAM is fitted.")
|
||||
_add("PE-DDR-003", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="VTT/VREF reported only when those nets exist.")
|
||||
_add("PE-CPU-001", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="CPU data/address/control bus only if that parallel bus is on the graph.")
|
||||
_add("PE-CPU-002", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="Parallel data bus ≥8 D/DQ/AD nets.")
|
||||
_add("PE-CPU-003", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="Address or muxed AD nets when that CPU bus exists.")
|
||||
_add("PE-CPU-004", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="CS/WE/OE (or FMC equivalents) on the CPU parallel bus.")
|
||||
_add("PE-FPGA-001", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="FPGA class only when an FPGA is fitted.")
|
||||
_add("PE-FPGA-002", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="FPGA config flash reported when a flash IC is on the graph.")
|
||||
_add("PE-FPGA-003", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="FPGA core/IO rails reported only when VCCINT/VCCIO (named) exist.")
|
||||
_add("PE-STK-001", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="Board copper thickness vs a sourced fab stackup FACT (never 1 oz).")
|
||||
_add("PE-VIA-002", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="Via count/drill with datasheet I; IPC via chart is not applied.")
|
||||
_add("PE-ESD-002", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="TVS-to-connector pad distance vs datasheet millimetres.")
|
||||
_add("PE-PD-001", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="USB-PD contract only with a PD controller FACT (not USB2 Rd).")
|
||||
_add("PE-POE-004", "TYPICAL", "INFO", domain="shared",
|
||||
requirement="PoE isolation voltage only from a sourced number.")
|
||||
_add("PE-ANT-001", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="Antenna keepout reported when an antenna and that zone exist.")
|
||||
_add("PE-ANT-002", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="Antenna matching L/C reported when those parts exist on the feed.")
|
||||
_add("PE-PDN-001", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="PDN Z(f) only with frequency-domain FACT; SI Z0 is not PDN.")
|
||||
_add("PE-AF-001", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="AI Z0 flag needs stackup+track FACT — never 50/90 Ω.")
|
||||
_add("PE-AF-002", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="HF trace not checked: missing tr, f, εr, stackup, Z target, or via span.")
|
||||
_add("PE-AF-020", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Right-angle track corner on an electrically long net.")
|
||||
_add("PE-AF-021", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Abrupt track width step on an electrically long net.")
|
||||
_add("PE-AF-030", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Reference plane missing under a triggered AF sample.")
|
||||
_add("PE-AF-031", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Return copper farther than 3H under a triggered AF track.")
|
||||
_add("PE-AF-032", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="Reference-plane split nearby (ImpedenceFinder flag).")
|
||||
_add("PE-AF-040", "RECOMMENDED", "REVIEW", domain="pcb",
|
||||
requirement="Aggressor spacing < 3W on a triggered AF net (not the pair mate).")
|
||||
_add("PE-AF-050", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Z0/Zdiff vs datasheet window; cascade RLGC uses that same Z_ref.")
|
||||
_add("PE-AF-051", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="CPWG/unknown topology: no invented ohm.")
|
||||
_add("PE-AF-060", "RECOMMENDED", "REVIEW", domain="pcb",
|
||||
requirement="Via series inductance from drill, span, and dielectric height.")
|
||||
_add("PE-AF-061", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Via stub length vs λ/20 when span and f are FACT.")
|
||||
_add("PE-PRT-L0-001", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="Protocol nets required at L0 must exist and connect two components.")
|
||||
_add("PE-PRT-L0-002", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="Internal interconnect is NOT_APPLICABLE for PCB routing.")
|
||||
_add("PE-PRT-L1-001", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="L1 geometric skip: missing PCB geometry or NUMERIC/DESIGN millimetre limit.")
|
||||
_add("PE-PRT-L1-002", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="L1 grouping (DQ/DQS / byte-lane) cannot be reconstructed — not PASS.")
|
||||
_add("PE-PRT-L1-003", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="L1 geometric length/via vs NUMERIC or DESIGN millimetre limit.")
|
||||
_add("PE-PRT-L2-001", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="L2 electrical skip: Z/termination/levels need datasheet/stackup/fab/cited pack.")
|
||||
_add("PE-PRT-L2-002", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="L2 Z/termination vs cited pack or PHY datasheet window.")
|
||||
_add("PE-PRT-L3-001", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="L3 channel skip: missing existing IL/RL/crosstalk/budget FACT (no OpenEMS).")
|
||||
_add("PE-PRT-L3-002", "MANDATORY", "RULE", domain="pcb",
|
||||
requirement="L3 channel vs cited IL/RL/crosstalk/timing-budget FACT (no invented S-param).")
|
||||
|
||||
|
||||
_seed()
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""DDR / CPU parallel / FPGA class certifiers — only if that device is on the graph.
|
||||
|
||||
Not SI Z0. Not invented VTT/DDR/FPGA. Same FACT/REQUIREMENT/INFERENCE contract
|
||||
as USB-C / Ethernet / PoE (2.61.2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import Component, ComponentType, DesignGraph, Finding
|
||||
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||
|
||||
SOURCE = "hf_bus_class"
|
||||
|
||||
_MIN_PARALLEL_BITS = 8
|
||||
|
||||
_DDR_MPN_RE = re.compile(
|
||||
r"DDR[234]|MT41|MT47|MT48|K4B|K4A|IS43TR|IS42S|W971|W972|EDB\d|H5TQ|H5TC",
|
||||
re.I,
|
||||
)
|
||||
_SRAM_MPN_RE = re.compile(r"IS61|IS62|CY7C|AS6C|\bSRAM\b", re.I)
|
||||
_FPGA_MPN_RE = re.compile(
|
||||
r"XC[2367]|XCKU|XCZU|LFE5|ICE40|EP[1-4]C|10CL|10M\d|ECP5|"
|
||||
r"ARTIX|KINTEX|SPARTAN|CYCLONE|MAX.?10|POLARFIRE|\bLATTICE\b",
|
||||
re.I,
|
||||
)
|
||||
_FLASH_MPN_RE = re.compile(r"W25|S25|M25|MX25|GD25|AT25|SST25|N25Q", re.I)
|
||||
_DATA_RE = re.compile(r"(?:^|[_/.])(?:DQ|AD|DATA|FMC_D|D)(\d+)(?:$|[_/.])", re.I)
|
||||
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:ADDR|FMC_A|BA|A)(\d+)(?:$|[_/.])", re.I)
|
||||
_CTRL_RE = re.compile(
|
||||
r"(?:^|[_/.])(?:N?WE|N?OE|N?CS|N?LB|N?UB|RAS|CAS|CKE|ODT|"
|
||||
r"FMC_NWE|FMC_NOE|FMC_NE\d|NE\d)(?:$|[_/.])",
|
||||
re.I,
|
||||
)
|
||||
_CK_RE = re.compile(r"(?:DDR.*)?(?:CK|CLK)[_]?[PN](?:$|[_/.])|(?:^|[_/.])CK[_]?[PN]", re.I)
|
||||
_DQS_RE = re.compile(r"DQS", re.I)
|
||||
_VTT_RE = re.compile(r"VTT|VREF", re.I)
|
||||
_FPGA_IO_RE = re.compile(r"VCCINT|VCCIO|VCCAUX|VCORE|VCCBRAM", re.I)
|
||||
|
||||
|
||||
def check_memory_fpga_classes(graph: DesignGraph) -> list[Finding]:
|
||||
"""Certify DDR / CPU parallel / FPGA only when that part exists on this graph."""
|
||||
out: list[Finding] = []
|
||||
out.extend(_check_ddr(graph))
|
||||
out.extend(_check_cpu(graph))
|
||||
out.extend(_check_fpga(graph))
|
||||
return out
|
||||
|
||||
|
||||
def _leaf(net: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(net)
|
||||
return n.split("/")[-1] if n else ""
|
||||
|
||||
|
||||
def _blob(comp: Component) -> str:
|
||||
return " ".join(str(x or "") for x in (comp.mpn, comp.value, comp.component_subtype))
|
||||
|
||||
|
||||
def _nets_of(comp: Component) -> list[str]:
|
||||
return [n for n in comp.pins.values() if n]
|
||||
|
||||
|
||||
def _indices(nets: list[str], cre: re.Pattern[str]) -> set[int]:
|
||||
found: set[int] = set()
|
||||
for n in nets:
|
||||
m = cre.search(_leaf(n))
|
||||
if m:
|
||||
found.add(int(m.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def _has_ctrl(nets: list[str]) -> list[str]:
|
||||
return sorted({_leaf(n) for n in nets if _CTRL_RE.search(_leaf(n))})
|
||||
|
||||
|
||||
def _is_ddr_ic(comp: Component) -> bool:
|
||||
if comp.component_type != ComponentType.IC:
|
||||
return False
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
if sub == "ic.memory.ddr" or "ddr" in sub:
|
||||
return True
|
||||
return bool(_DDR_MPN_RE.search(_blob(comp)))
|
||||
|
||||
|
||||
def _is_fpga_ic(comp: Component) -> bool:
|
||||
if comp.component_type != ComponentType.IC:
|
||||
return False
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
if sub == "ic.fpga" or sub.startswith("ic.fpga."):
|
||||
return True
|
||||
return bool(_FPGA_MPN_RE.search(_blob(comp)))
|
||||
|
||||
|
||||
def _is_sram_ic(comp: Component) -> bool:
|
||||
if comp.component_type != ComponentType.IC:
|
||||
return False
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
if sub == "ic.memory.sram":
|
||||
return True
|
||||
return bool(_SRAM_MPN_RE.search(_blob(comp)))
|
||||
|
||||
|
||||
def _is_flash_ic(comp: Component) -> bool:
|
||||
if comp.component_type != ComponentType.IC:
|
||||
return False
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
if sub == "ic.memory.flash":
|
||||
return True
|
||||
return bool(_FLASH_MPN_RE.search(_blob(comp)))
|
||||
|
||||
|
||||
def _is_mcu(comp: Component) -> bool:
|
||||
if comp.component_type != ComponentType.IC:
|
||||
return False
|
||||
if _is_ddr_ic(comp) or _is_fpga_ic(comp) or _is_sram_ic(comp) or _is_flash_ic(comp):
|
||||
return False
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
return sub == "ic.mcu" or sub.startswith("ic.cpu") or "mcu" in (comp.value or "").lower()
|
||||
|
||||
|
||||
def _parallel_groups(nets: list[str]) -> tuple[set[int], set[int], list[str]]:
|
||||
data = _indices(nets, _DATA_RE)
|
||||
addr = _indices(nets, _ADDR_RE)
|
||||
return data, addr, _has_ctrl(nets)
|
||||
|
||||
|
||||
def _check_ddr(graph: DesignGraph) -> list[Finding]:
|
||||
chips = [c for c in graph.components.values() if _is_ddr_ic(c)]
|
||||
if not chips:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
for comp in sorted(chips, key=lambda c: c.reference):
|
||||
nets = _nets_of(comp)
|
||||
leaves = [_leaf(n) for n in nets]
|
||||
dq = _indices(nets, _DATA_RE)
|
||||
has_dqs = any(_DQS_RE.search(x) for x in leaves)
|
||||
has_ck = any(_CK_RE.search(x) for x in leaves)
|
||||
klass = "DDR"
|
||||
blob = _blob(comp).upper()
|
||||
if "DDR4" in blob:
|
||||
klass = "DDR4"
|
||||
elif "DDR3" in blob or "MT41" in blob or "K4B" in blob:
|
||||
klass = "DDR3"
|
||||
elif "DDR2" in blob:
|
||||
klass = "DDR2"
|
||||
out.append(_info(
|
||||
comp.reference, "PE-DDR-001",
|
||||
f"{comp.reference} class {klass} (device on this graph).",
|
||||
f"mpn={comp.mpn!r}; subtype={comp.component_subtype!r}; dq_bits={len(dq)}.",
|
||||
"DDR class from MPN/subtype on a DRAM that is actually fitted.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
if len(dq) >= _MIN_PARALLEL_BITS and has_dqs and has_ck:
|
||||
out.append(_info(
|
||||
comp.reference, "PE-DDR-002",
|
||||
f"{comp.reference} has CK, DQS, and {len(dq)} DQ nets.",
|
||||
f"ck={has_ck}; dqs={has_dqs}; dq_indices={sorted(dq)[:16]}.",
|
||||
"DDR needs CK, DQS, and a data bus on the netlist.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
else:
|
||||
out.append(_insufficient(
|
||||
comp.reference, "PE-DDR-002",
|
||||
f"{comp.reference} is DDR; CK/DQS/DQ bus is not fully evidenced.",
|
||||
f"ck={has_ck}; dqs={has_dqs}; dq_bits={len(dq)} (need ≥{_MIN_PARALLEL_BITS}).",
|
||||
"Do not invent DDR pinout; need CK, DQS, and ≥8 DQ nets.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
vtt = [n for n in nets if _VTT_RE.search(_leaf(n))]
|
||||
extra = [n for n in graph.nets if _VTT_RE.search(_leaf(n))]
|
||||
names = sorted({_leaf(n) for n in vtt + extra})
|
||||
if names:
|
||||
out.append(_info(
|
||||
comp.reference, "PE-DDR-003",
|
||||
f"{comp.reference} VTT/VREF nets present: {', '.join(names)}.",
|
||||
f"vtt_vref={names}.",
|
||||
"VTT/VREF is reported only when those nets exist — never invented.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _cpu_host(graph: DesignGraph) -> Component | None:
|
||||
for comp in sorted(graph.components.values(), key=lambda c: c.reference):
|
||||
if not (_is_mcu(comp) or _is_fpga_ic(comp)):
|
||||
continue
|
||||
data, addr, ctrl = _parallel_groups(_nets_of(comp))
|
||||
if len(data) >= _MIN_PARALLEL_BITS and ctrl and (addr or _muxed(data, addr)):
|
||||
if _is_fpga_ic(comp) and not _is_mcu(comp):
|
||||
continue
|
||||
return comp
|
||||
for comp in sorted(graph.components.values(), key=lambda c: c.reference):
|
||||
if not _is_sram_ic(comp):
|
||||
continue
|
||||
data, addr, ctrl = _parallel_groups(_nets_of(comp))
|
||||
if len(data) >= _MIN_PARALLEL_BITS and ctrl:
|
||||
return comp
|
||||
return None
|
||||
|
||||
|
||||
def _muxed(data: set[int], addr: set[int]) -> bool:
|
||||
return bool(data) and data == addr
|
||||
|
||||
|
||||
def _check_cpu(graph: DesignGraph) -> list[Finding]:
|
||||
host = _cpu_host(graph)
|
||||
if host is None:
|
||||
return []
|
||||
if _is_ddr_ic(host):
|
||||
return []
|
||||
nets = _nets_of(host)
|
||||
data, addr, ctrl = _parallel_groups(nets)
|
||||
if _is_fpga_ic(host) and not _is_mcu(host):
|
||||
return []
|
||||
kind = "mux AD" if _muxed(data, addr) else "parallel"
|
||||
if any("FMC" in _leaf(n).upper() for n in nets):
|
||||
kind = "FMC"
|
||||
elif _is_sram_ic(host) or any(_is_sram_ic(c) for c in graph.components.values()):
|
||||
kind = "SRAM"
|
||||
out = [_info(
|
||||
host.reference, "PE-CPU-001",
|
||||
f"{host.reference} class CPU {kind} bus (present on this graph).",
|
||||
f"data_bits={len(data)}; addr_bits={len(addr)}; ctrl={ctrl}.",
|
||||
"CPU data/address/control is certified only when that bus exists.",
|
||||
mpn=host.mpn or "",
|
||||
)]
|
||||
out.append(_info(
|
||||
host.reference, "PE-CPU-002",
|
||||
f"{host.reference} data bus {len(data)} bits.",
|
||||
f"dq_indices={sorted(data)[:16]}.",
|
||||
"Parallel data bus needs ≥8 D/DQ/AD nets.",
|
||||
mpn=host.mpn or "",
|
||||
))
|
||||
if addr and not _muxed(data, addr):
|
||||
out.append(_info(
|
||||
host.reference, "PE-CPU-003",
|
||||
f"{host.reference} address bus {len(addr)} bits.",
|
||||
f"a_indices={sorted(addr)[:16]}.",
|
||||
"Address bus reported when A/ADDR nets exist (mux AD is not a second bus).",
|
||||
mpn=host.mpn or "",
|
||||
))
|
||||
elif _muxed(data, addr):
|
||||
out.append(_info(
|
||||
host.reference, "PE-CPU-003",
|
||||
f"{host.reference} multiplexed AD bus ({len(data)} bits).",
|
||||
f"ad_indices={sorted(data)[:16]}.",
|
||||
"Muxed AD is one bus, not invented separate D and A.",
|
||||
mpn=host.mpn or "",
|
||||
))
|
||||
else:
|
||||
return out
|
||||
if ctrl:
|
||||
out.append(_info(
|
||||
host.reference, "PE-CPU-004",
|
||||
f"{host.reference} control: {', '.join(ctrl)}.",
|
||||
f"ctrl={ctrl}.",
|
||||
"CS/WE/OE (or FMC equivalents) from net names on this bus.",
|
||||
mpn=host.mpn or "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _check_fpga(graph: DesignGraph) -> list[Finding]:
|
||||
fpgas = [c for c in graph.components.values() if _is_fpga_ic(c)]
|
||||
if not fpgas:
|
||||
return []
|
||||
flashes = [c for c in graph.components.values() if _is_flash_ic(c)]
|
||||
out: list[Finding] = []
|
||||
for comp in sorted(fpgas, key=lambda c: c.reference):
|
||||
out.append(_info(
|
||||
comp.reference, "PE-FPGA-001",
|
||||
f"{comp.reference} class FPGA (device on this graph).",
|
||||
f"mpn={comp.mpn!r}; subtype={comp.component_subtype!r}.",
|
||||
"FPGA analysis runs only when an FPGA is fitted — never invented.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
if flashes:
|
||||
names = ", ".join(sorted(c.reference for c in flashes))
|
||||
out.append(_info(
|
||||
comp.reference, "PE-FPGA-002",
|
||||
f"{comp.reference} config flash present: {names}.",
|
||||
f"flash={[c.reference + '=' + (c.mpn or '') for c in flashes]}.",
|
||||
"Config flash is reported when a flash IC is on the graph.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
rails = sorted({
|
||||
_leaf(n) for n in _nets_of(comp) if _FPGA_IO_RE.search(_leaf(n))
|
||||
})
|
||||
if rails:
|
||||
out.append(_info(
|
||||
comp.reference, "PE-FPGA-003",
|
||||
f"{comp.reference} FPGA rails: {', '.join(rails)}.",
|
||||
f"rails={rails}.",
|
||||
"Core/IO supplies reported only when VCCINT/VCCIO (or named equivalent) exist.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _info(
|
||||
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
|
||||
*, mpn: str = "",
|
||||
) -> Finding:
|
||||
rec = "No connection-integrity change required for this check."
|
||||
return Finding(
|
||||
designator=designator, mpn=mpn, aspect="interface_class",
|
||||
finding=finding, why=requirement, facts=facts, requirement=requirement,
|
||||
inference="Class/integrity FACT from netlist — not SI Z0 and not invented I/Z/mm.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id=rule_id, finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", confidence=0.9, pins=[],
|
||||
)
|
||||
|
||||
|
||||
def _insufficient(
|
||||
designator: str, rule_id: str, finding: str, facts: str, requirement: str,
|
||||
*, mpn: str = "",
|
||||
) -> Finding:
|
||||
rec = "Add BOM/net evidence, then re-run. Do not invent DDR/CPU/FPGA buses."
|
||||
return Finding(
|
||||
designator=designator, mpn=mpn, aspect="interface_class",
|
||||
finding=finding, why=requirement, facts=facts, requirement=requirement,
|
||||
inference="Insufficient evidence — not inventing DDR pinout, VTT, Z, I, or mm.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id=rule_id, finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT", confidence=0.3, pins=[],
|
||||
)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""HF pair geometry: stubs, reference split, BOM termination.
|
||||
|
||||
Track ≠ via ≠ pad ≠ zone. Skip without evidence. No invented 50/90 Ω or mm.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
|
||||
from backend.periscopex.models import (
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
LayoutSegment,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match, refs_on_matched_net
|
||||
from backend.periscopex.pcb_power_thermal import _is_gnd_name
|
||||
from backend.periscopex.placement_check import _in_poly
|
||||
from backend.periscopex.si_check import (
|
||||
_bus_in_targets,
|
||||
_expand_bus_token,
|
||||
_num,
|
||||
_quote,
|
||||
bus_class,
|
||||
partner_net,
|
||||
skip_si_net,
|
||||
)
|
||||
|
||||
SOURCE = "hf_line_check"
|
||||
|
||||
# Join tolerance for collinear KiCad segment ends — not an SI length limit.
|
||||
ENDPOINT_SNAP_MM = 0.05
|
||||
COORD_QUANT_MM = 0.01
|
||||
|
||||
|
||||
def check_hf_lines(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or not layout.segments:
|
||||
return []
|
||||
hs = _hs_nets(layout)
|
||||
if not hs:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
out.extend(_stub_findings(graph, constraints_map, layout, hs))
|
||||
out.extend(_split_findings(layout, hs))
|
||||
out.extend(_termination_findings(graph, hs))
|
||||
return out
|
||||
|
||||
|
||||
def _hs_nets(layout: LayoutGraph) -> list[str]:
|
||||
names = sorted({s.net for s in layout.segments if s.net})
|
||||
return [n for n in names if bus_class(n) and not skip_si_net(n)]
|
||||
|
||||
|
||||
def _qxy(x: float, y: float) -> tuple[float, float]:
|
||||
q = COORD_QUANT_MM
|
||||
return (round(x / q) * q, round(y / q) * q)
|
||||
|
||||
|
||||
def _edge_key(a: tuple[float, float], b: tuple[float, float]) -> tuple:
|
||||
return tuple(sorted((a, b)))
|
||||
|
||||
|
||||
def _track_graph(layout: LayoutGraph, net: str) -> tuple[
|
||||
dict[tuple[float, float], set[tuple[float, float]]],
|
||||
dict[tuple, float],
|
||||
]:
|
||||
nbrs: dict[tuple[float, float], set[tuple[float, float]]] = defaultdict(set)
|
||||
elen: dict[tuple, float] = {}
|
||||
for s in layout.segments:
|
||||
if not s.net or not kicad_nets_match(s.net, net):
|
||||
continue
|
||||
a = _qxy(*s.start)
|
||||
b = _qxy(*s.end)
|
||||
if a == b:
|
||||
continue
|
||||
nbrs[a].add(b)
|
||||
nbrs[b].add(a)
|
||||
elen[_edge_key(a, b)] = math.hypot(s.end[0] - s.start[0], s.end[1] - s.start[1])
|
||||
return nbrs, elen
|
||||
|
||||
|
||||
def _snap(pt: tuple[float, float], nodes: list[tuple[float, float]]) -> tuple[float, float] | None:
|
||||
px, py = _qxy(*pt)
|
||||
best = None
|
||||
best_d = ENDPOINT_SNAP_MM
|
||||
for n in nodes:
|
||||
d = math.hypot(n[0] - px, n[1] - py)
|
||||
if d <= best_d:
|
||||
best_d = d
|
||||
best = n
|
||||
return best
|
||||
|
||||
|
||||
def _terminals(layout: LayoutGraph, net: str, nodes: list[tuple[float, float]]) -> set[tuple[float, float]]:
|
||||
found: set[tuple[float, float]] = set()
|
||||
for fp in layout.footprints.values():
|
||||
for pad in fp.pads:
|
||||
if pad.net and kicad_nets_match(pad.net, net):
|
||||
hit = _snap((pad.x, pad.y), nodes)
|
||||
if hit:
|
||||
found.add(hit)
|
||||
for v in layout.vias:
|
||||
if v.net and kicad_nets_match(v.net, net):
|
||||
hit = _snap((v.x, v.y), nodes)
|
||||
if hit:
|
||||
found.add(hit)
|
||||
for z in layout.zones:
|
||||
if not z.net or not kicad_nets_match(z.net, net):
|
||||
continue
|
||||
for outline in z.outlines:
|
||||
if len(outline) < 3:
|
||||
continue
|
||||
for node in nodes:
|
||||
if _in_poly(node[0], node[1], outline):
|
||||
found.add(node)
|
||||
return found
|
||||
|
||||
|
||||
def stub_lengths_mm(layout: LayoutGraph, net: str) -> list[float]:
|
||||
"""Dangling track branches on ``net``. Pads, vias, and same-net zones end a stub."""
|
||||
nbrs, elen = _track_graph(layout, net)
|
||||
if not nbrs:
|
||||
return []
|
||||
nodes = list(nbrs)
|
||||
terms = _terminals(layout, net, nodes)
|
||||
stubs: list[float] = []
|
||||
seen: set[tuple[float, float]] = set()
|
||||
for node, friends in nbrs.items():
|
||||
if len(friends) != 1 or node in terms or node in seen:
|
||||
continue
|
||||
prev = None
|
||||
cur = node
|
||||
total = 0.0
|
||||
for _ in range(len(nbrs) + 1):
|
||||
nxts = [n for n in nbrs[cur] if n != prev]
|
||||
if not nxts:
|
||||
break
|
||||
nxt = nxts[0]
|
||||
total += elen.get(_edge_key(cur, nxt), 0.0)
|
||||
prev, cur = cur, nxt
|
||||
if cur in terms or len(nbrs[cur]) != 2:
|
||||
break
|
||||
if total > COORD_QUANT_MM:
|
||||
stubs.append(total)
|
||||
seen.add(node)
|
||||
return stubs
|
||||
|
||||
|
||||
def _stub_limit(graph: DesignGraph, constraints_map: dict, bus: str) -> tuple[float | None, str]:
|
||||
for _ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if not cons:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
kind = str(rule.get("kind") or "")
|
||||
if kind not in {"stub", "max_stub"}:
|
||||
continue
|
||||
targets = _expand_bus_token(str(rule.get("net_class") or ""))
|
||||
if targets and not _bus_in_targets(bus, targets):
|
||||
continue
|
||||
lim = _num(rule.get("max_distance_mm"))
|
||||
if lim is None:
|
||||
continue
|
||||
return lim, _quote(rule)
|
||||
return None, ""
|
||||
|
||||
|
||||
def _pair_seen(net: str, seen: set[tuple[str, str]]) -> bool:
|
||||
p = partner_net(net)
|
||||
key = tuple(sorted((net, p or "")))
|
||||
if key in seen:
|
||||
return True
|
||||
seen.add(key) # type: ignore[arg-type]
|
||||
return False
|
||||
|
||||
|
||||
def _stub_findings(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph,
|
||||
hs: list[str],
|
||||
) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for net in hs:
|
||||
if _pair_seen(net, seen):
|
||||
continue
|
||||
lengths = stub_lengths_mm(layout, net)
|
||||
p = partner_net(net)
|
||||
if p:
|
||||
lengths.extend(stub_lengths_mm(layout, p))
|
||||
if not lengths:
|
||||
continue
|
||||
stub_mm = max(lengths)
|
||||
bc = bus_class(net) or "hs"
|
||||
lim, quote = _stub_limit(graph, constraints_map, bc)
|
||||
facts = f"stub_mm={stub_mm:.3f}; branches={ [round(x, 3) for x in lengths] }."
|
||||
if lim is None:
|
||||
rec = (
|
||||
"Re-extract layout_rules stub millimetres from the datasheet. "
|
||||
"Do not assume a default stub length."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator="layout", mpn="", aspect="si",
|
||||
finding=(
|
||||
f"Unverified: {bc} {net} stub {stub_mm:.2f} mm — "
|
||||
"library has no stub millimetre FACT."
|
||||
),
|
||||
facts=facts,
|
||||
requirement="Datasheet layout_rules stub max_distance_mm on this bus (none on file).",
|
||||
inference="Not an invented stub limit; pads/vias/zones are not stubs.",
|
||||
why="Insufficient library stub millimetres for this bus.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-SI-007", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
continue
|
||||
fail = stub_mm > lim + 1e-9
|
||||
rec = "Shorten the dangling copper on the pair." if fail else "Stub length is within the datasheet millimetre."
|
||||
out.append(Finding(
|
||||
designator="layout", mpn="", aspect="si",
|
||||
finding=(
|
||||
f"{'FAIL' if fail else 'PASS'}: {net} stub {stub_mm:.2f} mm "
|
||||
f"(max {lim:g} mm)."
|
||||
),
|
||||
facts=facts,
|
||||
requirement=f"stub max_distance_mm={lim:g} ({quote or 'layout_rules'})",
|
||||
inference="FAIL vs datasheet stub millimetre." if fail else "",
|
||||
why="Stub is dangling track, not a via or pad.",
|
||||
status="WARNING" if fail else "INFO",
|
||||
recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-SI-007",
|
||||
finding_class="RISK" if fail else "INFO",
|
||||
provenance="RECOMMENDED" if fail else "TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _ref_layer(layout: LayoutGraph, track_layer: str) -> str | None:
|
||||
stack = layout.stackup
|
||||
if stack is None or not stack.copper_layers:
|
||||
return None
|
||||
layers = stack.copper_layers
|
||||
want = track_layer
|
||||
idx = None
|
||||
for i, name in enumerate(layers):
|
||||
if name == want or name.lower() == want.lower():
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
return None
|
||||
if idx + 1 < len(layers):
|
||||
return layers[idx + 1]
|
||||
if idx - 1 >= 0:
|
||||
return layers[idx - 1]
|
||||
return None
|
||||
|
||||
|
||||
def _seg_samples(seg: LayoutSegment) -> list[tuple[float, float]]:
|
||||
return [
|
||||
seg.start,
|
||||
((seg.start[0] + seg.end[0]) / 2.0, (seg.start[1] + seg.end[1]) / 2.0),
|
||||
seg.end,
|
||||
]
|
||||
|
||||
|
||||
def _point_in_gnd_zone(layout: LayoutGraph, x: float, y: float, layer: str) -> bool:
|
||||
for z in layout.zones:
|
||||
if z.keepout or not z.net or not _is_gnd_name(z.net):
|
||||
continue
|
||||
if z.layer and z.layer.lower() != layer.lower():
|
||||
continue
|
||||
for outline in z.outlines:
|
||||
if len(outline) >= 3 and _in_poly(x, y, outline):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _split_findings(layout: LayoutGraph, hs: list[str]) -> list[Finding]:
|
||||
if layout.stackup is None:
|
||||
return []
|
||||
gnd_layers = {
|
||||
z.layer for z in layout.zones
|
||||
if z.net and _is_gnd_name(z.net) and z.layer and not z.keepout and z.outlines
|
||||
}
|
||||
if not gnd_layers:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for net in hs:
|
||||
if _pair_seen(net, seen):
|
||||
continue
|
||||
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
|
||||
exposed = 0
|
||||
checked = 0
|
||||
ref = None
|
||||
for s in segs:
|
||||
if not s.layer:
|
||||
continue
|
||||
ref = _ref_layer(layout, s.layer)
|
||||
if ref is None or ref not in gnd_layers:
|
||||
continue
|
||||
for x, y in _seg_samples(s):
|
||||
checked += 1
|
||||
if not _point_in_gnd_zone(layout, x, y, ref):
|
||||
exposed += 1
|
||||
if checked == 0 or exposed == 0:
|
||||
continue
|
||||
rec = f"Restore a continuous GND reference under {net}, then re-run PCB review."
|
||||
out.append(Finding(
|
||||
designator="layout", mpn="", aspect="si",
|
||||
finding=(
|
||||
f"{net} crosses a gap in the GND pour on reference layer "
|
||||
f"{ref} ({exposed}/{checked} samples off pour)."
|
||||
),
|
||||
facts=(
|
||||
f"ref_layer={ref}; samples={checked}; off_pour={exposed}; "
|
||||
f"gnd_zone_layers={sorted(gnd_layers)}."
|
||||
),
|
||||
requirement="Continuous reference pour under an HF pair (typical, not IEC).",
|
||||
inference="REVIEW — no creepage/IEC millimetre in context.",
|
||||
why="Split under the line is geometry FACT; via/pad/track stay distinct.",
|
||||
status="WARNING", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-SI-011", finding_class="REVIEW", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _termination_findings(graph: DesignGraph, hs: list[str]) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
hs_set = set(hs)
|
||||
for net in hs:
|
||||
if skip_si_net(net):
|
||||
continue
|
||||
for ref in refs_on_matched_net(graph, net):
|
||||
if ref in seen:
|
||||
continue
|
||||
c = graph.components.get(ref)
|
||||
if not c or c.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
pins = [n for n in c.pins.values() if n]
|
||||
if len(pins) < 2:
|
||||
continue
|
||||
others = [n for n in pins if not kicad_nets_match(n, net)]
|
||||
if not others:
|
||||
continue
|
||||
other = others[0]
|
||||
series = (not _is_gnd_name(other)) and (not skip_si_net(other) or other in hs_set)
|
||||
parallel = _is_gnd_name(other)
|
||||
if not series and not parallel:
|
||||
continue
|
||||
seen.add(ref)
|
||||
ohms = None
|
||||
if isinstance(c.specs, ResistorSpecs) and c.specs.value_ohms:
|
||||
ohms = float(c.specs.value_ohms)
|
||||
kind = "parallel-to-GND" if parallel else "series"
|
||||
rec = "No termination change required; this is a BOM FACT."
|
||||
facts = f"{ref} {kind} on {net}; other={other}; ohms={ohms}."
|
||||
out.append(Finding(
|
||||
designator=ref, mpn=c.mpn or "", aspect="si",
|
||||
finding=f"{net} has {kind} termination {ref}"
|
||||
+ (f" ({ohms:g} Ω)" if ohms is not None else "") + ".",
|
||||
facts=facts,
|
||||
requirement="Report matching parts that exist on the HF net; do not invent a terminator.",
|
||||
inference="BOM FACT — missing terminator is a skip, not a folklore FAIL.",
|
||||
why="Termination is certified only when the resistor is on the netlist.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-SI-012", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=net, pins=[ref],
|
||||
))
|
||||
return out
|
||||
@@ -17,6 +17,7 @@ from backend.periscopex.models import (
|
||||
Finding,
|
||||
NetType,
|
||||
ResistorSpecs,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import (
|
||||
is_no_connect_net,
|
||||
@@ -424,9 +425,35 @@ def _poe_one(graph: DesignGraph, comp: Component, evidence: str) -> list[Finding
|
||||
facts, req,
|
||||
f"Use PoE magnetics / MagJack isolation on {ref}.",
|
||||
mpn=comp.mpn or ""))
|
||||
iso_v = _isolation_v(comp)
|
||||
if iso_v is not None:
|
||||
out.append(_info(
|
||||
ref, "PE-POE-004",
|
||||
f"{ref} PoE isolation {iso_v:g} V (datasheet FACT).",
|
||||
f"isolation_v={iso_v:g}.",
|
||||
"PoE isolation voltage is reported only from a sourced number — never IEC-invented.",
|
||||
mpn=comp.mpn or "",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _isolation_v(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if not isinstance(specs, SimpleComponentSpecs):
|
||||
return None
|
||||
for key in ("isolation_v", "isolation_voltage_v", "hipot_v"):
|
||||
raw = specs.values.get(key)
|
||||
if isinstance(raw, bool) or raw is None:
|
||||
continue
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v > 0:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _blob(graph: DesignGraph | None, comp: Component) -> str:
|
||||
parts = [
|
||||
comp.value or "",
|
||||
|
||||
@@ -385,6 +385,8 @@ class ValidationReport(BaseModel):
|
||||
coverage: dict[str, list[str]] = {} # designator -> areas checked and found OK
|
||||
review_errors: dict[str, str] = {} # designator -> error message for ICs whose review raised
|
||||
not_reviewed: list[dict] = [] # [{"designator","reason"}] — ICs skipped (e.g. no datasheet PDF)
|
||||
# M0 protocol cert: empty instances / "nessun protocollo riconosciuto". No Z.
|
||||
protocol_certification: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FindingComment(BaseModel):
|
||||
@@ -500,6 +502,7 @@ class LayoutVia(BaseModel):
|
||||
y: float
|
||||
net: str = ""
|
||||
drill: float | None = None
|
||||
layers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class LayoutDielectric(BaseModel):
|
||||
|
||||
@@ -236,6 +236,38 @@ def _is_npth_pad(pad: object) -> bool:
|
||||
return _pad_kind(pad) in {"np_thru_hole", "np_through_hole", "npth"}
|
||||
|
||||
|
||||
def footprint_pad_ids(node: object) -> list[tuple[str, str]]:
|
||||
"""``(number, pinfunction)`` lands. Via and NPTH are not pads."""
|
||||
out: list[tuple[str, str]] = []
|
||||
for pad in _kids(node, "pad"):
|
||||
if _is_footprint_via(pad) or _is_npth_pad(pad):
|
||||
continue
|
||||
num = str(pad[1]) if len(pad) > 1 and not isinstance(pad[1], list) else ""
|
||||
if not num:
|
||||
continue
|
||||
out.append((num, _val(pad, "pinfunction")))
|
||||
return out
|
||||
|
||||
|
||||
def parse_kicad_mod_pads(path: str | Path) -> list[tuple[str, str]] | None:
|
||||
"""Pad ids from a ``.kicad_mod``. None if the file is missing or not a footprint."""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
tag = _tag(tree)
|
||||
if tag in {"footprint", "module"}:
|
||||
return footprint_pad_ids(tree)
|
||||
if tag == "kicad_pcb":
|
||||
for node in tree[1:]:
|
||||
if isinstance(node, list) and _tag(node) in {"footprint", "module"}:
|
||||
return footprint_pad_ids(node)
|
||||
return None
|
||||
|
||||
|
||||
def _is_footprint_via(pad: object) -> bool:
|
||||
"""Footprint child that is a via, never a component pin.
|
||||
|
||||
@@ -259,6 +291,15 @@ def _is_footprint_via(pad: object) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _via_copper_layers(node: object) -> tuple[str, ...]:
|
||||
"""KiCad ``(layers "F.Cu" "B.Cu")``. Empty if the file has no span."""
|
||||
el = _kid(node, "layers")
|
||||
if not el:
|
||||
return ()
|
||||
names = tuple(str(x) for x in el[1:] if not isinstance(x, list) and str(x).strip())
|
||||
return names
|
||||
|
||||
|
||||
def _via_from_node(
|
||||
node: object,
|
||||
nets: dict[str, int],
|
||||
@@ -275,7 +316,7 @@ def _via_from_node(
|
||||
rx, ry = _rotate(vx, vy, frot)
|
||||
vx, vy = fx + rx, fy + ry
|
||||
net = _net_name(node, nets)
|
||||
return LayoutVia(x=vx, y=vy, net=net, drill=drill)
|
||||
return LayoutVia(x=vx, y=vy, net=net, drill=drill, layers=_via_copper_layers(node))
|
||||
|
||||
|
||||
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
|
||||
@@ -376,7 +417,8 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
|
||||
drill_el = _kid(pad, "drill")
|
||||
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
|
||||
vias.append(LayoutVia(
|
||||
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad), drill=drill,
|
||||
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad),
|
||||
drill=drill, layers=_via_copper_layers(pad),
|
||||
))
|
||||
continue
|
||||
if _is_npth_pad(pad):
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections import Counter
|
||||
from backend.periscopex.derating import build_derating_table
|
||||
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
|
||||
from backend.periscopex.emi_check import check_emi
|
||||
from backend.periscopex.esd_return_check import check_esd, check_return_path
|
||||
from backend.periscopex.esd_return_check import check_esd, check_esd_distance, check_return_path
|
||||
from backend.periscopex.finding_engine import complete_findings
|
||||
from backend.periscopex.functional_groups import FunctionalGroupsReport
|
||||
from backend.periscopex.hierarchy import check_hierarchy
|
||||
@@ -29,6 +29,12 @@ from backend.periscopex.pcb_power_thermal import (
|
||||
from backend.periscopex.pi_check import check_power_integrity
|
||||
from backend.periscopex.placement_check import check_placement
|
||||
from backend.periscopex.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes
|
||||
from backend.periscopex.antenna_layout_check import check_antenna_layout
|
||||
from backend.periscopex.hf_bus_class import check_memory_fpga_classes
|
||||
from backend.periscopex.hf_line_check import check_hf_lines
|
||||
from backend.periscopex.pdn_check import check_pdn
|
||||
from backend.periscopex.stackup_check import check_stackup
|
||||
from backend.periscopex.usb_pd_check import check_usb_pd
|
||||
from backend.periscopex.si_check import check_si
|
||||
from backend.periscopex.spof_check import check_spof
|
||||
from backend.periscopex.timing_check import check_timing
|
||||
@@ -270,6 +276,7 @@ def run_pcb_checks(
|
||||
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
|
||||
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
||||
("si_check", lambda: check_si(graph, constraints_map, layout, impedance_nets)),
|
||||
("hf_line", lambda: check_hf_lines(graph, constraints_map, layout)),
|
||||
("pcb_derating", lambda: check_pcb_derating(graph)),
|
||||
("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)),
|
||||
("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)),
|
||||
@@ -280,12 +287,18 @@ def run_pcb_checks(
|
||||
("timing_check", lambda: check_timing(graph, constraints_map)),
|
||||
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
|
||||
("esd_check", lambda: check_esd(graph, constraints_map)),
|
||||
("esd_distance", lambda: check_esd_distance(graph, constraints_map, layout)),
|
||||
("return_path", lambda: check_return_path(graph, layout)),
|
||||
("stackup_check", lambda: check_stackup(graph, constraints_map, layout)),
|
||||
("usb_pd", lambda: check_usb_pd(graph)),
|
||||
("antenna_layout", lambda: check_antenna_layout(graph, layout)),
|
||||
("pdn_check", lambda: check_pdn(graph, layout, impedance_nets)),
|
||||
("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)),
|
||||
("spof_check", lambda: check_spof(graph, constraints_map)),
|
||||
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
|
||||
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
|
||||
("interface_class", lambda: check_interface_classes(graph)),
|
||||
("hf_bus_class", lambda: check_memory_fpga_classes(graph)),
|
||||
):
|
||||
try:
|
||||
out.extend(fn())
|
||||
|
||||
@@ -278,10 +278,37 @@ def check_pcb_via_current(
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
n, _drill = _via_stats(layout, net)
|
||||
n, drill = _via_stats(layout, net)
|
||||
if n > 0:
|
||||
# Geometry is present; no via-ampacity table in the library — do not
|
||||
# invent a rating or claim the vias were not parsed.
|
||||
rec = (
|
||||
"Supply a datasheet via current or plating thickness before judging "
|
||||
"via ampacity. IPC via charts are not applied."
|
||||
)
|
||||
drill_txt = f"{drill:g}" if drill is not None else "—"
|
||||
out.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="layout_power",
|
||||
finding=(
|
||||
f"{net} carries I_load={i_load:.3g} A on {n} via(s) "
|
||||
f"(min drill {drill_txt} mm); no via ampacity table in the library."
|
||||
),
|
||||
why="I from datasheet; via count and drill from the board. No invented IPC via k.",
|
||||
status="INFO",
|
||||
recommendation=rec,
|
||||
action=rec,
|
||||
source="pcb_power_thermal",
|
||||
rule_id="PE-VIA-002",
|
||||
evidence_status="INSUFFICIENT",
|
||||
facts=(
|
||||
f"I_load={i_load:.3g} A; via_count={n}; min_drill_mm={drill_txt}; "
|
||||
f"board_vias={len(layout.vias)}."
|
||||
),
|
||||
requirement="Via ampacity table is not in the library (IPC via chart not applied).",
|
||||
inference="INSUFFICIENT — not inventing via ampacity from an IPC chart.",
|
||||
net=net,
|
||||
pins=[],
|
||||
))
|
||||
continue
|
||||
out.append(Finding(
|
||||
designator=ref,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""PDN Z(f). Skip without a frequency-domain FACT. SI Z0 is not PDN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||
|
||||
SOURCE = "pdn_check"
|
||||
|
||||
|
||||
def check_pdn(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph | None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Require explicit PDN Z(f) rows. ImpedenceFinder SI Z0 is not Z(f)."""
|
||||
_ = graph
|
||||
_ = layout
|
||||
raw = impedance_nets
|
||||
if isinstance(impedance_nets, dict):
|
||||
raw = list(impedance_nets.get("pdn") or impedance_nets.get("pdn_nets") or [])
|
||||
rows = [r for r in (raw or []) if isinstance(r, dict)]
|
||||
pdn = [
|
||||
r for r in rows
|
||||
if str(r.get("kind") or "").lower() == "pdn"
|
||||
or r.get("frequency_hz") is not None
|
||||
or r.get("z_ohm_at_hz") is not None
|
||||
]
|
||||
if not pdn:
|
||||
return []
|
||||
rec = "Use measured PDN Z(f); do not invent a target impedance."
|
||||
facts = f"pdn_rows={len(pdn)}."
|
||||
return [Finding(
|
||||
designator="PDN", mpn="", aspect="pdn",
|
||||
finding=f"PDN Z(f) FACT present ({len(pdn)} row(s)).",
|
||||
facts=facts,
|
||||
requirement="PDN Z(f) is reported only with frequency-domain measurements.",
|
||||
inference="No target Z(f) is invented.",
|
||||
why="PDN is last; SI differential Z0 is a different check.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-PDN-001", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", pins=[],
|
||||
)]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
{
|
||||
"$defs": {
|
||||
"ConstraintSource": {
|
||||
"properties": {
|
||||
"document": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Document"
|
||||
},
|
||||
"organization": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Organization"
|
||||
},
|
||||
"revision": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Revision"
|
||||
},
|
||||
"section": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Section"
|
||||
},
|
||||
"table": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Table"
|
||||
},
|
||||
"page": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Page"
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Url"
|
||||
}
|
||||
},
|
||||
"title": "ConstraintSource",
|
||||
"type": "object"
|
||||
},
|
||||
"LogicalProtocol": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"bus_type": {
|
||||
"enum": [
|
||||
"MEMORY",
|
||||
"PROCESSOR_INTERCONNECT",
|
||||
"PERIPHERAL",
|
||||
"CHIP_TO_CHIP",
|
||||
"STORAGE",
|
||||
"GRAPHICS",
|
||||
"SERIAL",
|
||||
"PARALLEL"
|
||||
],
|
||||
"title": "Bus Type",
|
||||
"type": "string"
|
||||
},
|
||||
"family": {
|
||||
"default": "",
|
||||
"title": "Family",
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"default": "",
|
||||
"title": "Notes",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"bus_type"
|
||||
],
|
||||
"title": "LogicalProtocol",
|
||||
"type": "object"
|
||||
},
|
||||
"PhysicalInterface": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"logical_protocol_id": {
|
||||
"title": "Logical Protocol Id",
|
||||
"type": "string"
|
||||
},
|
||||
"bus_type": {
|
||||
"enum": [
|
||||
"MEMORY",
|
||||
"PROCESSOR_INTERCONNECT",
|
||||
"PERIPHERAL",
|
||||
"CHIP_TO_CHIP",
|
||||
"STORAGE",
|
||||
"GRAPHICS",
|
||||
"SERIAL",
|
||||
"PARALLEL"
|
||||
],
|
||||
"title": "Bus Type",
|
||||
"type": "string"
|
||||
},
|
||||
"physical_layer": {
|
||||
"enum": [
|
||||
"INTERNAL",
|
||||
"PARALLEL_SINGLE_ENDED",
|
||||
"PARALLEL_DIFFERENTIAL",
|
||||
"SERIAL_SINGLE_ENDED",
|
||||
"SERIAL_DIFFERENTIAL",
|
||||
"MIXED"
|
||||
],
|
||||
"title": "Physical Layer",
|
||||
"type": "string"
|
||||
},
|
||||
"pcb_relevant": {
|
||||
"enum": [
|
||||
"YES",
|
||||
"NO",
|
||||
"CONDITIONAL"
|
||||
],
|
||||
"title": "Pcb Relevant",
|
||||
"type": "string"
|
||||
},
|
||||
"connector": {
|
||||
"default": "none",
|
||||
"title": "Connector",
|
||||
"type": "string"
|
||||
},
|
||||
"recognition_hints": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Recognition Hints",
|
||||
"type": "array"
|
||||
},
|
||||
"required_checks": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Required Checks",
|
||||
"type": "array"
|
||||
},
|
||||
"constraints": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/ProtocolConstraint"
|
||||
},
|
||||
"title": "Constraints",
|
||||
"type": "array"
|
||||
},
|
||||
"notes": {
|
||||
"default": "",
|
||||
"title": "Notes",
|
||||
"type": "string"
|
||||
},
|
||||
"incomplete": {
|
||||
"default": false,
|
||||
"title": "Incomplete",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"logical_protocol_id",
|
||||
"bus_type",
|
||||
"physical_layer",
|
||||
"pcb_relevant"
|
||||
],
|
||||
"title": "PhysicalInterface",
|
||||
"type": "object"
|
||||
},
|
||||
"ProtocolConstraint": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"parameter": {
|
||||
"title": "Parameter",
|
||||
"type": "string"
|
||||
},
|
||||
"value_kind": {
|
||||
"enum": [
|
||||
"NUMERIC",
|
||||
"UNKNOWN",
|
||||
"CONTROLLER_DEPENDENT",
|
||||
"PHY_DEPENDENT",
|
||||
"VENDOR_DEPENDENT",
|
||||
"NOT_APPLICABLE",
|
||||
"MISSING_SOURCE"
|
||||
],
|
||||
"title": "Value Kind",
|
||||
"type": "string"
|
||||
},
|
||||
"mandatory": {
|
||||
"enum": [
|
||||
"MANDATORY",
|
||||
"RECOMMENDED",
|
||||
"OPTIONAL",
|
||||
"INFORMATIONAL"
|
||||
],
|
||||
"title": "Mandatory",
|
||||
"type": "string"
|
||||
},
|
||||
"source_type": {
|
||||
"enum": [
|
||||
"STANDARD",
|
||||
"CONNECTOR",
|
||||
"PHY",
|
||||
"VENDOR",
|
||||
"CONTROLLER",
|
||||
"MEMORY",
|
||||
"COMPONENT",
|
||||
"CABLE",
|
||||
"PCB",
|
||||
"DERIVED"
|
||||
],
|
||||
"title": "Source Type",
|
||||
"type": "string"
|
||||
},
|
||||
"source_class": {
|
||||
"enum": [
|
||||
"NORMATIVE",
|
||||
"VENDOR",
|
||||
"IMPLEMENTATION",
|
||||
"DERIVED"
|
||||
],
|
||||
"title": "Source Class",
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Value"
|
||||
},
|
||||
"unit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Unit"
|
||||
},
|
||||
"source": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ConstraintSource"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"conditions": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Conditions",
|
||||
"type": "array"
|
||||
},
|
||||
"measurement_method": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"DATASHEET",
|
||||
"TDR",
|
||||
"FIELD_SOLVER",
|
||||
"PCB_STACKUP",
|
||||
"FABRICATOR",
|
||||
"SIMULATION",
|
||||
"CALCULATION",
|
||||
"EXTRACTION",
|
||||
"MEASUREMENT"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Measurement Method"
|
||||
},
|
||||
"limit_kind": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"STANDARD",
|
||||
"DESIGN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Limit Kind"
|
||||
},
|
||||
"value_origin": {
|
||||
"default": "SPEC",
|
||||
"enum": [
|
||||
"SPEC",
|
||||
"TYPICAL",
|
||||
"EXAMPLE",
|
||||
"DERIVED"
|
||||
],
|
||||
"title": "Value Origin",
|
||||
"type": "string"
|
||||
},
|
||||
"typical": {
|
||||
"default": false,
|
||||
"title": "Typical",
|
||||
"type": "boolean"
|
||||
},
|
||||
"needed_document": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Needed Document"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"parameter",
|
||||
"value_kind",
|
||||
"mandatory",
|
||||
"source_type",
|
||||
"source_class"
|
||||
],
|
||||
"title": "ProtocolConstraint",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"title": "Schema Version",
|
||||
"type": "string"
|
||||
},
|
||||
"logical_protocols": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/LogicalProtocol"
|
||||
},
|
||||
"title": "Logical Protocols",
|
||||
"type": "array"
|
||||
},
|
||||
"physical_interfaces": {
|
||||
"items": {
|
||||
"$ref": "#/$defs/PhysicalInterface"
|
||||
},
|
||||
"title": "Physical Interfaces",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schema_version",
|
||||
"logical_protocols",
|
||||
"physical_interfaces"
|
||||
],
|
||||
"title": "ProtocolCatalog",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"""M8: DDR physical instance grouping. Controller-aware. No universal mm/ps/ohm.
|
||||
|
||||
DQ/DQS byte lanes are separate from ADDRESS / COMMAND / CONTROL / CLOCK.
|
||||
HBM is package/interposer — not classic DDR PCB DQ rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import Component, ComponentType, DesignGraph
|
||||
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||
from backend.periscopex.protocol_recognize import (
|
||||
EvidenceKind,
|
||||
PhysicalBusInstance,
|
||||
SignalGroup,
|
||||
_blob,
|
||||
_EVIDENCE_SCORE,
|
||||
)
|
||||
|
||||
PACK_MACROPHASE = "M8"
|
||||
|
||||
_DQ_RE = re.compile(r"(?:^|[_/.])DQ(\d+)(?:$|[_/.])", re.I)
|
||||
_DQS_RE = re.compile(r"(?:^|[_/.])DQS(\d*)(?:[_#]?(P|N|PINS|PLUS|MINUS))?(?:$|[_/.])", re.I)
|
||||
_DM_RE = re.compile(r"(?:^|[_/.])(?:DM|DBI)(\d+)(?:$|[_/.])", re.I)
|
||||
_CK_RE = re.compile(r"(?:^|[_/.])(?:CK)(?:[_#]?(P|N))?(?:$|[_/.])", re.I)
|
||||
_WCK_RE = re.compile(r"(?:^|[_/.])WCK", re.I)
|
||||
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I)
|
||||
_CA_RE = re.compile(r"(?:^|[_/.])CA(\d+)(?:$|[_/.])", re.I)
|
||||
_CMD_RE = re.compile(r"(?:^|[_/.])(?:RAS|CAS|WE|ACT)(?:[#_N]*)(?:$|[_/.])", re.I)
|
||||
_CTRL_RE = re.compile(
|
||||
r"(?:^|[_/.])(?:CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])", re.I,
|
||||
)
|
||||
_HBM_RE = re.compile(
|
||||
r"\bHBM(?:2E|3E|[23])?\b|H26M|MT54A|interposer",
|
||||
re.I,
|
||||
)
|
||||
_DDR_GEN = [
|
||||
("ddr5", re.compile(r"\bDDR5\b", re.I)),
|
||||
("ddr4", re.compile(r"\bDDR4\b|MT41|K4A|IS43TR", re.I)),
|
||||
("ddr3l", re.compile(r"\bDDR3L\b", re.I)),
|
||||
("ddr3", re.compile(r"\bDDR3\b|K4B", re.I)),
|
||||
("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)),
|
||||
("ddr1", re.compile(r"\bDDR1\b", re.I)),
|
||||
]
|
||||
_LPDDR_GEN = [
|
||||
("lpddr5x", re.compile(r"LPDDR5X", re.I)),
|
||||
("lpddr5", re.compile(r"LPDDR5", re.I)),
|
||||
("lpddr4x", re.compile(r"LPDDR4X", re.I)),
|
||||
("lpddr4", re.compile(r"LPDDR4", re.I)),
|
||||
("lpddr3", re.compile(r"LPDDR3", re.I)),
|
||||
("lpddr2", re.compile(r"LPDDR2", re.I)),
|
||||
("lpddr-family", re.compile(r"\bLPDDR\b", re.I)),
|
||||
]
|
||||
_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I)
|
||||
_MEMORY_PART = re.compile(r"MT41|MT47|K4A|K4B|IS43TR|W631|EDY4016", re.I)
|
||||
_CONTROLLER_HINT = re.compile(
|
||||
r"FPGA|XC7|XCKU|Artix|Kintex|Zynq|STM32|i\.?MX|RK33|controller|MIG|\bSoC\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _leaf(name: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(name)
|
||||
return n.split("/")[-1] if n else ""
|
||||
|
||||
|
||||
def is_hbm_blob(text: str) -> bool:
|
||||
return bool(_HBM_RE.search(text or ""))
|
||||
|
||||
|
||||
def ddr_gen_from_text(text: str) -> str | None:
|
||||
if is_hbm_blob(text):
|
||||
return None
|
||||
for gen, cre in _LPDDR_GEN:
|
||||
if cre.search(text):
|
||||
return gen
|
||||
if _DDR4_PART.search(text):
|
||||
return "ddr4"
|
||||
for gen, cre in _DDR_GEN:
|
||||
if cre.search(text):
|
||||
return gen
|
||||
return None
|
||||
|
||||
|
||||
def hbm_gen_from_text(text: str) -> str | None:
|
||||
if not is_hbm_blob(text):
|
||||
return None
|
||||
t = re.sub(r"[\s_\-]", "", text.upper())
|
||||
for gen in ("HBM3E", "HBM2E", "HBM3", "HBM2", "HBM"):
|
||||
if gen in t:
|
||||
return gen.lower()
|
||||
return "hbm-family"
|
||||
|
||||
|
||||
def byte_lanes(net_names: list[str]) -> list[SignalGroup]:
|
||||
dq: dict[int, str] = {}
|
||||
dqs: dict[int, dict[str, str]] = {}
|
||||
dm: dict[int, str] = {}
|
||||
for n in net_names:
|
||||
leaf = _leaf(n)
|
||||
m = _DQ_RE.search(leaf)
|
||||
if m and "DQS" not in leaf.upper():
|
||||
dq[int(m.group(1))] = n
|
||||
continue
|
||||
m = _DQS_RE.search(leaf)
|
||||
if m:
|
||||
idx = int(m.group(1) or "0")
|
||||
pol = (m.group(2) or "P").upper()[:1]
|
||||
dqs.setdefault(idx, {})[pol] = n
|
||||
continue
|
||||
m = _DM_RE.search(leaf)
|
||||
if m:
|
||||
dm[int(m.group(1))] = n
|
||||
lanes: list[SignalGroup] = []
|
||||
lane_ids = set(i // 8 for i in dq) | set(dqs) | set(dm)
|
||||
for lane in sorted(lane_ids):
|
||||
roles: dict[str, str] = {}
|
||||
nets: list[str] = []
|
||||
for bit in range(lane * 8, lane * 8 + 8):
|
||||
if bit in dq:
|
||||
roles[f"DQ{bit}"] = dq[bit]
|
||||
nets.append(dq[bit])
|
||||
if lane in dqs:
|
||||
for pol, net in dqs[lane].items():
|
||||
roles[f"DQS{lane}_{pol}"] = net
|
||||
nets.append(net)
|
||||
if lane in dm:
|
||||
roles[f"DM{lane}"] = dm[lane]
|
||||
nets.append(dm[lane])
|
||||
if nets:
|
||||
lanes.append(SignalGroup(
|
||||
kind="BYTE_LANE", id=f"BYTE_LANE_{lane}", nets=nets, roles=roles,
|
||||
))
|
||||
return lanes
|
||||
|
||||
|
||||
def memory_groups(net_names: list[str]) -> list[SignalGroup]:
|
||||
"""DQ/DQS lanes vs ADDRESS/COMMAND/CONTROL vs CLOCK. No invented numbers."""
|
||||
groups: list[SignalGroup] = list(byte_lanes(net_names))
|
||||
data_nets = {n for g in groups for n in g.nets}
|
||||
|
||||
def _take(kind: str, gid: str, pred) -> None:
|
||||
nets = [n for n in net_names if pred(_leaf(n)) and n not in data_nets]
|
||||
if nets:
|
||||
groups.append(SignalGroup(kind=kind, id=gid, nets=nets, roles={})) # type: ignore[arg-type]
|
||||
|
||||
_take("CLOCK_GROUP", "CK", lambda lf: _CK_RE.search(lf) and not _WCK_RE.search(lf))
|
||||
_take("CLOCK_GROUP", "WCK", lambda lf: bool(_WCK_RE.search(lf)))
|
||||
_take("ADDRESS_GROUP", "ADDRESS", lambda lf: bool(_ADDR_RE.search(lf)) and not _CA_RE.search(lf))
|
||||
_take("ADDRESS_GROUP", "CA", lambda lf: bool(_CA_RE.search(lf)))
|
||||
_take("COMMAND_GROUP", "COMMAND", lambda lf: bool(_CMD_RE.search(lf)))
|
||||
_take("CONTROL_GROUP", "CONTROL", lambda lf: bool(_CTRL_RE.search(lf)))
|
||||
return groups
|
||||
|
||||
|
||||
def data_vs_addr_overlap(groups: list[SignalGroup]) -> list[str]:
|
||||
data = {n for g in groups if g.kind == "BYTE_LANE" for n in g.nets}
|
||||
other = {
|
||||
n for g in groups
|
||||
if g.kind in {"ADDRESS_GROUP", "COMMAND_GROUP", "CONTROL_GROUP", "CLOCK_GROUP"}
|
||||
for n in g.nets
|
||||
}
|
||||
return sorted(data & other)
|
||||
|
||||
|
||||
def find_controller(graph: DesignGraph, memory: Component, nets: list[str]) -> Component | None:
|
||||
netset = set(nets)
|
||||
for comp in graph.components.values():
|
||||
if comp.reference == memory.reference:
|
||||
continue
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
blob = _blob(comp, graph)
|
||||
if is_hbm_blob(blob):
|
||||
continue
|
||||
if ddr_gen_from_text(blob) and _MEMORY_PART.search(blob):
|
||||
continue
|
||||
pins = set(comp.pins.values())
|
||||
if pins & netset:
|
||||
return comp
|
||||
return None
|
||||
|
||||
|
||||
def phys_id_for_gen(gen: str, ifaces: dict) -> str:
|
||||
if gen.startswith("lpddr"):
|
||||
pid = f"{gen}-pcb"
|
||||
return pid if pid in ifaces else "lpddr-family-pcb"
|
||||
if gen == "ddr-family":
|
||||
return "ddr-family-sdram-pcb"
|
||||
pid = f"{gen}-sdram-pcb"
|
||||
return pid if pid in ifaces else "ddr-family-sdram-pcb"
|
||||
|
||||
|
||||
def build_ddr_instance(
|
||||
graph: DesignGraph,
|
||||
memory: Component,
|
||||
gen: str,
|
||||
evidence: EvidenceKind,
|
||||
ifaces: dict,
|
||||
) -> PhysicalBusInstance:
|
||||
iface_id = phys_id_for_gen(gen, ifaces)
|
||||
iface = ifaces[iface_id]
|
||||
nets = [n for n in memory.pins.values() if n]
|
||||
groups = memory_groups(nets)
|
||||
overlap = data_vs_addr_overlap(groups)
|
||||
controller = find_controller(graph, memory, nets)
|
||||
lanes = [g for g in groups if g.kind == "BYTE_LANE"]
|
||||
incomplete = not lanes
|
||||
mixed = bool(overlap)
|
||||
status = "REVIEW" if incomplete or mixed or gen in {"ddr-family", "lpddr-family"} else "RECOGNIZED"
|
||||
notes = (
|
||||
f"Controller-aware DDR instance. memory={memory.reference}"
|
||||
f"{(' controller=' + controller.reference) if controller else ' (no controller IC on nets)'}."
|
||||
" No universal mm/ps or Z."
|
||||
)
|
||||
if incomplete:
|
||||
notes += " Byte-lane grouping incomplete — REVIEW, not PASS."
|
||||
if mixed:
|
||||
notes += f" Address/command mixed into DQ: {overlap}. Not certified as DDR data."
|
||||
status = "REVIEW"
|
||||
host = controller.reference if controller else memory.reference
|
||||
peers = [memory.reference]
|
||||
if controller:
|
||||
peers = [memory.reference]
|
||||
group_nets = sorted({n for g in groups for n in g.nets})
|
||||
return PhysicalBusInstance(
|
||||
instance_id=f"{iface_id}:{memory.reference}",
|
||||
logical_protocol_id=gen,
|
||||
physical_interface_id=iface_id,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE[evidence],
|
||||
evidence_kind=evidence,
|
||||
recognition_status=status,
|
||||
nets=group_nets,
|
||||
groups=groups,
|
||||
host_ref=host,
|
||||
peer_refs=peers,
|
||||
ambiguous_logical_ids=["ddr-family"] if gen == "ddr-family" else [],
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def recognize_hbm(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
out: list[PhysicalBusInstance] = []
|
||||
for comp in graph.components.values():
|
||||
blob = _blob(comp, graph)
|
||||
gen = hbm_gen_from_text(blob)
|
||||
if not gen:
|
||||
continue
|
||||
pid = f"{gen}-package" if f"{gen}-package" in ifaces else "hbm-family-package"
|
||||
iface = ifaces[pid]
|
||||
out.append(PhysicalBusInstance(
|
||||
instance_id=f"{pid}:{comp.reference}",
|
||||
logical_protocol_id=gen if gen in {
|
||||
"hbm", "hbm2", "hbm2e", "hbm3", "hbm3e", "hbm-family",
|
||||
} else "hbm-family",
|
||||
physical_interface_id=pid,
|
||||
pcb_relevant="NO",
|
||||
confidence=_EVIDENCE_SCORE["part" if comp.mpn else "silicon"],
|
||||
evidence_kind="part" if comp.mpn else "silicon",
|
||||
recognition_status="NOT_APPLICABLE",
|
||||
nets=[],
|
||||
groups=[],
|
||||
host_ref=comp.reference,
|
||||
notes=(
|
||||
"HBM is package/interposer. Classic DDR PCB DQ rules do not apply. "
|
||||
"pcb_relevant=NO."
|
||||
),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def recognize_ddr_instances(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
memories: list[tuple[Component, str, EvidenceKind]] = []
|
||||
for comp in graph.components.values():
|
||||
blob = _blob(comp, graph)
|
||||
if is_hbm_blob(blob):
|
||||
continue
|
||||
gen = ddr_gen_from_text(blob)
|
||||
if not gen:
|
||||
continue
|
||||
if _CONTROLLER_HINT.search(blob) and not _MEMORY_PART.search(blob):
|
||||
continue
|
||||
kind: EvidenceKind = "part" if (comp.mpn or _DDR4_PART.search(blob)) else "silicon"
|
||||
memories.append((comp, gen, kind))
|
||||
out = [build_ddr_instance(graph, m, gen, kind, ifaces) for m, gen, kind in memories]
|
||||
if out:
|
||||
return out
|
||||
if any(is_hbm_blob(_blob(c, graph)) for c in graph.components.values()):
|
||||
return []
|
||||
net_gen = None
|
||||
for n in graph.nets:
|
||||
g = ddr_gen_from_text(_leaf(n))
|
||||
if g:
|
||||
net_gen = g
|
||||
break
|
||||
if not net_gen:
|
||||
dq = [n for n in graph.nets if _DQ_RE.search(_leaf(n)) and "DQS" not in _leaf(n).upper()]
|
||||
dqs = [n for n in graph.nets if "DQS" in _leaf(n).upper()]
|
||||
if len(dq) >= 8 and dqs:
|
||||
net_gen = "ddr-family"
|
||||
else:
|
||||
return []
|
||||
groups = memory_groups(list(graph.nets))
|
||||
pid = phys_id_for_gen(net_gen, ifaces)
|
||||
iface = ifaces[pid]
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{pid}:nets",
|
||||
logical_protocol_id=net_gen,
|
||||
physical_interface_id=pid,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["net_name"],
|
||||
evidence_kind="net_name",
|
||||
recognition_status="REVIEW",
|
||||
nets=sorted({n for g in groups for n in g.nets}),
|
||||
groups=groups,
|
||||
notes="DDR nets without a memory MPN. Controller-aware grouping only. No universal mm/ps.",
|
||||
)]
|
||||
@@ -0,0 +1,361 @@
|
||||
"""M2: L0 STRUCTURAL protocol certifier. Presence and connection only.
|
||||
|
||||
FAIL only when a MANDATORY structural net/group is missing or unconnected.
|
||||
Internal interconnects (AXI4, AMBA, HBM DQ) are NOT_APPLICABLE, never FAIL.
|
||||
RECOMMENDED never FAIL. No L1–L3. No invented ohms/mm/ps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
SCHEMA_VERSION,
|
||||
PhysicalInterface,
|
||||
ProtocolCatalog,
|
||||
ProtocolCertificationSection,
|
||||
ProtocolConstraint,
|
||||
empty_protocol_section,
|
||||
load_catalog,
|
||||
map_protocol_outcome,
|
||||
)
|
||||
from backend.periscopex.protocol_recognize import (
|
||||
PhysicalBusInstance,
|
||||
recognize_physical_buses,
|
||||
)
|
||||
|
||||
PACK_MACROPHASE = "M2"
|
||||
SOURCE = "protocol_l0"
|
||||
LEVEL = "L0"
|
||||
|
||||
L0_CHECKS = frozenset({
|
||||
"topology",
|
||||
"byte_lane_mapping",
|
||||
"cmd_dat_grouping",
|
||||
"clk_reference",
|
||||
"cc_rd_rp",
|
||||
"vbus_gnd",
|
||||
"pcb_routing",
|
||||
"pcb_individual_dq_routing",
|
||||
"dq_group",
|
||||
"superspeed_pairs",
|
||||
})
|
||||
|
||||
_CC_RE = re.compile(r"(?:^|[_/.])CC[12]$", re.I)
|
||||
_VBUS_RE = re.compile(r"VBUS|\+?VUSB|USB_VBUS", re.I)
|
||||
_GND_RE = re.compile(r"(?:^|[_/.])(?:GND|VSS|DGND)(?:$|[_/.])", re.I)
|
||||
_SS_RE = re.compile(r"SSTX|SSRX|SS_T[XR]|USB3|USB_SS", re.I)
|
||||
|
||||
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
|
||||
|
||||
|
||||
class L0CheckResult(BaseModel):
|
||||
check: str
|
||||
level: Literal["L0"] = "L0"
|
||||
result: Literal[
|
||||
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE", "VENDOR_DEPENDENT",
|
||||
]
|
||||
mandatory: MandatoryClass
|
||||
nets: list[str] = Field(default_factory=list)
|
||||
notes: str = ""
|
||||
finding_class: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
def _leaf(name: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(name)
|
||||
return n.split("/")[-1] if n else ""
|
||||
|
||||
|
||||
def _net_present(graph: DesignGraph, name: str) -> bool:
|
||||
return name in graph.nets
|
||||
|
||||
|
||||
def _net_connected(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
if not net:
|
||||
return False
|
||||
refs = {p.component_ref for p in net.pins if p.component_ref}
|
||||
return len(refs) >= 2
|
||||
|
||||
|
||||
def _constraint_for(iface: PhysicalInterface, check: str) -> ProtocolConstraint | None:
|
||||
aliases = {
|
||||
"cc_rd_rp": "cc_termination",
|
||||
"pcb_routing": "pcb_routing",
|
||||
"pcb_individual_dq_routing": "pcb_individual_dq_routing",
|
||||
"superspeed_pairs": "superspeed_pairs",
|
||||
"topology": "topology",
|
||||
"byte_lane_mapping": "topology",
|
||||
"byte_lane_skew": "byte_to_byte_skew",
|
||||
"ck_to_command_skew": "ck_to_command_skew",
|
||||
"ck_to_address_skew": "ck_to_address_skew",
|
||||
"length": "max_length",
|
||||
"clock_length": "max_length",
|
||||
"data_length": "max_length",
|
||||
"via_count": "vias",
|
||||
"stub_length": "stub_length",
|
||||
}
|
||||
want = aliases.get(check, check)
|
||||
for c in iface.constraints:
|
||||
if c.parameter == want or c.parameter == check:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _mandatory(iface: PhysicalInterface, check: str) -> MandatoryClass:
|
||||
c = _constraint_for(iface, check)
|
||||
if c:
|
||||
return c.mandatory
|
||||
if check in {"superspeed_pairs", "pcb_routing", "pcb_individual_dq_routing"}:
|
||||
return "INFORMATIONAL"
|
||||
return "MANDATORY"
|
||||
|
||||
|
||||
def _pack(check: str, result: str, mandatory: MandatoryClass, *,
|
||||
nets: list[str] | None = None, notes: str = "") -> L0CheckResult:
|
||||
if result == "FAIL" and mandatory != "MANDATORY":
|
||||
result = "WARNING"
|
||||
cls, status, _ev = map_protocol_outcome(result, mandatory) # type: ignore[arg-type]
|
||||
return L0CheckResult(
|
||||
check=check, result=result, mandatory=mandatory, # type: ignore[arg-type]
|
||||
nets=nets or [], notes=notes,
|
||||
finding_class=cls, status=status,
|
||||
)
|
||||
|
||||
|
||||
def _structural_nets_ok(graph: DesignGraph, names: list[str]) -> tuple[bool, str]:
|
||||
missing = [n for n in names if not _net_present(graph, n)]
|
||||
if missing:
|
||||
return False, f"missing net(s): {', '.join(missing)}"
|
||||
dangling = [n for n in names if not _net_connected(graph, n)]
|
||||
if dangling:
|
||||
return False, f"unconnected net(s): {', '.join(dangling)}"
|
||||
return True, "present and connected"
|
||||
|
||||
|
||||
def certify_instance_l0(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
) -> list[L0CheckResult]:
|
||||
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
|
||||
return [_pack(
|
||||
"pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
|
||||
notes="Internal interconnect — not PCB routing. Not FAIL.",
|
||||
)]
|
||||
out: list[L0CheckResult] = []
|
||||
checks = [c for c in iface.required_checks if c in L0_CHECKS]
|
||||
if not checks:
|
||||
checks = ["topology"]
|
||||
for check in checks:
|
||||
mand = _mandatory(iface, check)
|
||||
out.append(_run_l0_check(graph, inst, check, mand))
|
||||
return out
|
||||
|
||||
|
||||
def _run_l0_check(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
) -> L0CheckResult:
|
||||
if check in {"pcb_routing", "pcb_individual_dq_routing"}:
|
||||
return _pack(check, "NOT_APPLICABLE", mand,
|
||||
notes="Not a PCB-routed individual channel at L0.")
|
||||
if check == "superspeed_pairs":
|
||||
ss = [n for n in graph.nets if _SS_RE.search(_leaf(n))]
|
||||
return _pack(
|
||||
check, "NOT_APPLICABLE", mand, nets=ss,
|
||||
notes="USB2-only physical interface: SuperSpeed pairs not required.",
|
||||
)
|
||||
if check == "topology":
|
||||
names: list[str] = []
|
||||
for g in inst.groups:
|
||||
if g.kind == "DIFFERENTIAL_PAIR":
|
||||
names.extend(g.nets)
|
||||
if not names:
|
||||
names = list(inst.nets)
|
||||
if len(names) < 2:
|
||||
return _pack(
|
||||
check, "FAIL", mand, nets=names,
|
||||
notes="MANDATORY differential pair or bus nets not present.",
|
||||
)
|
||||
ok, msg = _structural_nets_ok(graph, names)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
|
||||
if check == "byte_lane_mapping" or check == "dq_group":
|
||||
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
|
||||
if not lanes:
|
||||
return _pack(
|
||||
check, "FAIL", mand,
|
||||
notes="MANDATORY byte-lane / DQ group not reconstructed.",
|
||||
)
|
||||
names = [n for g in lanes for n in g.nets]
|
||||
ok, msg = _structural_nets_ok(graph, names)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
|
||||
if check == "cmd_dat_grouping":
|
||||
names = [n for g in inst.groups for n in g.nets]
|
||||
if not names:
|
||||
return _pack(check, "FAIL", mand, notes="No CMD/DAT group nets.")
|
||||
ok, msg = _structural_nets_ok(graph, names)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
|
||||
if check == "clk_reference":
|
||||
clocks = [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets]
|
||||
if not clocks:
|
||||
return _pack(check, "FAIL", mand, notes="Clock reference net missing.")
|
||||
ok, msg = _structural_nets_ok(graph, clocks)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=clocks, notes=msg)
|
||||
if check == "cc_rd_rp":
|
||||
ccs = [n for n in graph.nets if _CC_RE.search(_leaf(n))]
|
||||
if len(ccs) < 1:
|
||||
return _pack(check, "FAIL", mand, notes="CC1/CC2 net not present.")
|
||||
ok, msg = _structural_nets_ok(graph, ccs)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=ccs, notes=msg)
|
||||
if check == "vbus_gnd":
|
||||
vbus = [n for n in graph.nets if _VBUS_RE.search(_leaf(n))]
|
||||
gnd = [n for n in graph.nets if _GND_RE.search(_leaf(n))]
|
||||
names = vbus + gnd
|
||||
if not vbus or not gnd:
|
||||
return _pack(
|
||||
check, "FAIL", mand, nets=names,
|
||||
notes="VBUS and/or GND net not present.",
|
||||
)
|
||||
ok, msg = _structural_nets_ok(graph, names)
|
||||
return _pack(check, "PASS" if ok else "FAIL", mand, nets=names, notes=msg)
|
||||
return _pack(check, "NOT_APPLICABLE", mand, notes="Not an L0 structural check.")
|
||||
|
||||
|
||||
def l0_findings(
|
||||
inst: PhysicalBusInstance,
|
||||
results: list[L0CheckResult],
|
||||
) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
for r in results:
|
||||
if r.result == "PASS":
|
||||
continue
|
||||
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
||||
continue
|
||||
status = r.status if r.status in {"ERROR", "WARNING", "INFO"} else "INFO"
|
||||
if r.result == "FAIL":
|
||||
rule_id = "PE-PRT-L0-001"
|
||||
finding = (
|
||||
f"L0 structural: {r.check} {r.notes or 'missing MANDATORY net/connection'}."
|
||||
)
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
finding = f"L0 {r.result}: {r.check} — {r.notes}".strip()
|
||||
f = Finding(
|
||||
designator=designator,
|
||||
mpn="",
|
||||
aspect="protocol_l0",
|
||||
finding=finding,
|
||||
why=r.notes,
|
||||
status=status,
|
||||
source=SOURCE,
|
||||
net=r.nets[0] if r.nets else None,
|
||||
rule_id=rule_id,
|
||||
facts=finding,
|
||||
requirement=(
|
||||
"MANDATORY protocol nets must exist and connect at least two components."
|
||||
if r.result == "FAIL"
|
||||
else "Internal protocol is not certified as PCB routing."
|
||||
),
|
||||
inference=f"{LEVEL} {r.result} (not electrical; no invented Z).",
|
||||
provenance="MANDATORY" if r.mandatory == "MANDATORY" else "TYPICAL",
|
||||
finding_class=r.finding_class if r.finding_class in {"RULE", "RISK", "REVIEW", "INFO"} else "INFO",
|
||||
)
|
||||
out.append(complete_finding(f))
|
||||
return out
|
||||
|
||||
|
||||
def certify_l0(
|
||||
graph: DesignGraph,
|
||||
instances: list[PhysicalBusInstance],
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
) -> tuple[dict[str, list[L0CheckResult]], list[Finding]]:
|
||||
cat = catalog or load_catalog()
|
||||
ifaces = {p.id: p for p in cat.physical_interfaces}
|
||||
by_id: dict[str, list[L0CheckResult]] = {}
|
||||
findings: list[Finding] = []
|
||||
for inst in instances:
|
||||
iface = ifaces.get(inst.physical_interface_id)
|
||||
if iface is None:
|
||||
continue
|
||||
rows = certify_instance_l0(graph, inst, iface)
|
||||
by_id[inst.instance_id] = rows
|
||||
findings.extend(l0_findings(inst, rows))
|
||||
return by_id, findings
|
||||
|
||||
|
||||
def protocol_exam(
|
||||
graph: DesignGraph | None,
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
layout: LayoutGraph | None = None,
|
||||
constraints_map: dict | None = None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
channel_data: dict | None = None,
|
||||
) -> tuple[ProtocolCertificationSection, list[Finding]]:
|
||||
from backend.periscopex.protocol_l1 import certify_l1
|
||||
from backend.periscopex.protocol_l2 import certify_l2
|
||||
from backend.periscopex.protocol_l3 import LEVEL as L3_LEVEL, certify_l3
|
||||
from backend.periscopex.protocol_report import (
|
||||
PACK_MACROPHASE as REPORT_MACRO,
|
||||
attach_instance_report,
|
||||
result_rank,
|
||||
)
|
||||
|
||||
if graph is None:
|
||||
return empty_protocol_section(), []
|
||||
cat = catalog or load_catalog()
|
||||
instances = recognize_physical_buses(graph, cat)
|
||||
if not instances:
|
||||
sec = empty_protocol_section()
|
||||
sec.macrophase = REPORT_MACRO
|
||||
return sec, []
|
||||
l0, findings = certify_l0(graph, instances, cat)
|
||||
l1, l1_findings_out = certify_l1(graph, instances, layout, cat)
|
||||
findings.extend(l1_findings_out)
|
||||
l2, l2_findings_out = certify_l2(
|
||||
graph, instances, cat,
|
||||
layout=layout,
|
||||
constraints_map=constraints_map,
|
||||
impedance_nets=impedance_nets,
|
||||
)
|
||||
findings.extend(l2_findings_out)
|
||||
l3, l3_findings_out = certify_l3(
|
||||
instances, cat, channel_data=channel_data,
|
||||
)
|
||||
findings.extend(l3_findings_out)
|
||||
ifaces = {p.id: p for p in cat.physical_interfaces}
|
||||
dumps: list[dict[str, Any]] = []
|
||||
for inst in instances:
|
||||
row = inst.model_dump()
|
||||
row["l0_checks"] = [c.model_dump() for c in l0.get(inst.instance_id, [])]
|
||||
row["l1_checks"] = [c.model_dump() for c in l1.get(inst.instance_id, [])]
|
||||
row["l2_checks"] = [c.model_dump() for c in l2.get(inst.instance_id, [])]
|
||||
row["l3_checks"] = [c.model_dump() for c in l3.get(inst.instance_id, [])]
|
||||
row.update(attach_instance_report(
|
||||
inst,
|
||||
ifaces.get(inst.physical_interface_id),
|
||||
l0.get(inst.instance_id, []),
|
||||
l1.get(inst.instance_id, []),
|
||||
l2.get(inst.instance_id, []),
|
||||
l3.get(inst.instance_id, []),
|
||||
))
|
||||
dumps.append(row)
|
||||
dumps.sort(key=lambda r: result_rank(str(r.get("worst_result") or "UNKNOWN")))
|
||||
sec = ProtocolCertificationSection(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
macrophase=REPORT_MACRO,
|
||||
recognized_instances=dumps,
|
||||
message="",
|
||||
max_level_reached=L3_LEVEL,
|
||||
)
|
||||
return sec, findings
|
||||
@@ -0,0 +1,548 @@
|
||||
"""M3: L1 GEOMETRIC protocol certifier. Length, vias, layer, grouping, skew.
|
||||
|
||||
Skew is compared only when the constraint is NUMERIC (length unit) or an
|
||||
explicit DESIGN LIMIT. Otherwise UNKNOWN / CONTROLLER_DEPENDENT /
|
||||
PHY_DEPENDENT / VENDOR_DEPENDENT. Length is not delay. Not electrical.
|
||||
RECOMMENDED never FAIL. Internal interfaces are NOT_APPLICABLE.
|
||||
Missing DQ/DQS grouping or missing PCB geometry is a visible skip, not PASS.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
PhysicalInterface,
|
||||
ProtocolCatalog,
|
||||
ProtocolConstraint,
|
||||
load_catalog,
|
||||
map_protocol_outcome,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance, SignalGroup
|
||||
from backend.periscopex.si_check import net_length_mm
|
||||
|
||||
PACK_MACROPHASE = "M3"
|
||||
SOURCE = "protocol_l1"
|
||||
LEVEL = "L1"
|
||||
|
||||
L1_CHECKS = frozenset({
|
||||
"length",
|
||||
"clock_length",
|
||||
"data_length",
|
||||
"intra_pair_skew",
|
||||
"pair_skew",
|
||||
"dq_to_dqs_skew",
|
||||
"byte_lane_skew",
|
||||
"ck_to_command_skew",
|
||||
"ck_to_address_skew",
|
||||
"clock_to_data_skew",
|
||||
"vias",
|
||||
"via_count",
|
||||
"stubs",
|
||||
"stub_length",
|
||||
"topology",
|
||||
"byte_lane_mapping",
|
||||
"dq_group",
|
||||
"dqs_relationship_if_present",
|
||||
"cmd_dat_grouping",
|
||||
"lane_relationships",
|
||||
})
|
||||
|
||||
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
|
||||
|
||||
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
|
||||
L1Result = Literal[
|
||||
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
|
||||
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
]
|
||||
|
||||
|
||||
class L1CheckResult(BaseModel):
|
||||
check: str
|
||||
level: Literal["L1"] = "L1"
|
||||
result: L1Result
|
||||
mandatory: MandatoryClass
|
||||
nets: list[str] = Field(default_factory=list)
|
||||
measured_mm: float | None = None
|
||||
limit_mm: float | None = None
|
||||
margin_mm: float | None = None
|
||||
value_kind: str = ""
|
||||
notes: str = ""
|
||||
finding_class: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
def _pack(
|
||||
check: str,
|
||||
result: str,
|
||||
mandatory: MandatoryClass,
|
||||
*,
|
||||
nets: list[str] | None = None,
|
||||
measured_mm: float | None = None,
|
||||
limit_mm: float | None = None,
|
||||
value_kind: str = "",
|
||||
notes: str = "",
|
||||
) -> L1CheckResult:
|
||||
if result == "FAIL" and mandatory != "MANDATORY":
|
||||
result = "WARNING"
|
||||
mapped = result
|
||||
if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
|
||||
mapped = "VENDOR_DEPENDENT"
|
||||
cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
|
||||
margin = None
|
||||
if measured_mm is not None and limit_mm is not None:
|
||||
margin = limit_mm - measured_mm
|
||||
return L1CheckResult(
|
||||
check=check,
|
||||
result=result, # type: ignore[arg-type]
|
||||
mandatory=mandatory,
|
||||
nets=nets or [],
|
||||
measured_mm=measured_mm,
|
||||
limit_mm=limit_mm,
|
||||
margin_mm=margin,
|
||||
value_kind=value_kind,
|
||||
notes=notes,
|
||||
finding_class=cls,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def geometric_verdict(constraint: ProtocolConstraint | None, measured_mm: float | None) -> str:
|
||||
"""PASS/FAIL only with NUMERIC length limit or DESIGN LIMIT. Never invent ps."""
|
||||
if constraint is None:
|
||||
return "UNKNOWN"
|
||||
kind = constraint.value_kind
|
||||
unit = (constraint.unit or "").strip().lower()
|
||||
design = constraint.limit_kind == "DESIGN" and constraint.value is not None
|
||||
numeric = kind == "NUMERIC" and constraint.value is not None
|
||||
if numeric or design:
|
||||
if unit in _TIME_UNITS:
|
||||
return "UNKNOWN"
|
||||
if measured_mm is None:
|
||||
return "UNKNOWN"
|
||||
if measured_mm <= constraint.value:
|
||||
return "PASS"
|
||||
return "FAIL"
|
||||
if kind in {
|
||||
"UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE",
|
||||
}:
|
||||
return kind
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _len(layout: LayoutGraph, net: str) -> float:
|
||||
return net_length_mm(layout, net)
|
||||
|
||||
|
||||
def _via_count(layout: LayoutGraph, net: str) -> int:
|
||||
return sum(1 for v in layout.vias if v.net == net)
|
||||
|
||||
|
||||
def _layers(layout: LayoutGraph, net: str) -> list[str]:
|
||||
return sorted({s.layer for s in layout.segments if s.net == net and s.layer})
|
||||
|
||||
|
||||
def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
|
||||
names: list[str] = []
|
||||
for g in inst.groups:
|
||||
names.extend(g.nets)
|
||||
names.extend(inst.nets)
|
||||
# preserve order, unique
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for n in names:
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def _skip_no_geometry(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
|
||||
f"per mancanza di geometria PCB (piste). L1 is geometric, not electrical."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _skip_grouping(check: str, mand: MandatoryClass, inst: PhysicalBusInstance) -> L1CheckResult:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
|
||||
f"per mancanza di grouping (DQ/DQS). Not PASS."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _grouping_recon(
|
||||
inst: PhysicalBusInstance,
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
kind: str,
|
||||
) -> L1CheckResult:
|
||||
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
|
||||
addr_cmd = [
|
||||
g for g in inst.groups
|
||||
if g.kind in {"ADDRESS_GROUP", "COMMAND_GROUP", "CONTROL_GROUP", "CLOCK_GROUP"}
|
||||
]
|
||||
data_nets = {n for g in lanes for n in g.nets}
|
||||
other_nets = {n for g in addr_cmd for n in g.nets}
|
||||
mixed = sorted(data_nets & other_nets)
|
||||
if mixed:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, nets=mixed, value_kind=kind,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
|
||||
f"per grouping misto DQ e ADDRESS/COMMAND ({mixed}). Not PASS. "
|
||||
"No universal mm/ps."
|
||||
),
|
||||
)
|
||||
if check == "cmd_dat_grouping":
|
||||
if not lanes or not addr_cmd:
|
||||
return _skip_grouping(check, mand, inst)
|
||||
return _pack(
|
||||
check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
|
||||
notes=(
|
||||
"DQ/DQS vs ADDRESS/COMMAND/CONTROL/CLOCK reconstructed separately. "
|
||||
"Controller-aware. L1 is not electrical. No universal mm/ps."
|
||||
),
|
||||
)
|
||||
if not lanes:
|
||||
return _skip_grouping(check, mand, inst)
|
||||
has_dqs = any(
|
||||
k.upper().startswith("DQS") for g in lanes for k in g.roles
|
||||
)
|
||||
if check == "dqs_relationship_if_present" and not has_dqs:
|
||||
return _skip_grouping(check, mand, inst)
|
||||
if check == "byte_lane_mapping" and not has_dqs:
|
||||
return _skip_grouping(check, mand, inst)
|
||||
return _pack(
|
||||
check, "PASS", mand, nets=_instance_nets(inst), value_kind=kind,
|
||||
notes=(
|
||||
"Byte-lane DQ/DQS grouping reconstructed. Not a global length-match PASS. "
|
||||
"L1 is not electrical certification. No universal mm/ps."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def certify_instance_l1(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[L1CheckResult]:
|
||||
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
|
||||
return [_pack(
|
||||
"pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
|
||||
notes="Internal interconnect — L1 geometric N/A, not FAIL, not electrical.",
|
||||
)]
|
||||
checks = [c for c in iface.required_checks if c in L1_CHECKS]
|
||||
if not checks:
|
||||
checks = ["length", "topology"]
|
||||
return [_run_l1_check(graph, inst, iface, layout, c) for c in checks]
|
||||
|
||||
|
||||
def _run_l1_check(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
layout: LayoutGraph | None,
|
||||
check: str,
|
||||
) -> L1CheckResult:
|
||||
mand = _mandatory(iface, check)
|
||||
cons = _constraint_for(iface, check)
|
||||
kind = cons.value_kind if cons else "UNKNOWN"
|
||||
grouping_recon = {
|
||||
"byte_lane_mapping", "dq_group", "dqs_relationship_if_present",
|
||||
"cmd_dat_grouping",
|
||||
}
|
||||
skew_checks = {
|
||||
"dq_to_dqs_skew", "byte_lane_skew", "ck_to_command_skew",
|
||||
"ck_to_address_skew", "clock_to_data_skew", "lane_relationships",
|
||||
"intra_pair_skew", "pair_skew",
|
||||
}
|
||||
lanes = [g for g in inst.groups if g.kind == "BYTE_LANE"]
|
||||
|
||||
if check in grouping_recon:
|
||||
return _grouping_recon(inst, check, mand, kind)
|
||||
|
||||
if check in skew_checks:
|
||||
if check in {
|
||||
"dq_to_dqs_skew", "byte_lane_skew",
|
||||
} and not lanes:
|
||||
return _skip_grouping(check, mand, inst)
|
||||
if layout is None:
|
||||
dep = kind if kind in {
|
||||
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "VENDOR_DEPENDENT",
|
||||
"UNKNOWN", "MISSING_SOURCE",
|
||||
} else "CONTROLLER_DEPENDENT"
|
||||
return _pack(
|
||||
check, dep, mand, nets=_instance_nets(inst), value_kind=kind,
|
||||
notes=(
|
||||
"Controller/PHY dependent skew — no universal mm/ps. "
|
||||
"L1 grouping is not a length-match PASS. Not electrical."
|
||||
),
|
||||
)
|
||||
|
||||
if layout is None:
|
||||
return _skip_no_geometry(check, mand, inst)
|
||||
|
||||
if check == "topology":
|
||||
nets = _instance_nets(inst)
|
||||
routed = [n for n in nets if _len(layout, n) > 0]
|
||||
if not routed:
|
||||
return _skip_no_geometry(check, mand, inst)
|
||||
layers = sorted({ly for n in routed for ly in _layers(layout, n)})
|
||||
return _pack(
|
||||
check, "PASS", mand, nets=routed, value_kind=kind,
|
||||
notes=f"Routed on layers {layers}. Geometric topology only, not electrical.",
|
||||
)
|
||||
|
||||
if check in {"length", "clock_length", "data_length"}:
|
||||
nets = _clock_or_data_nets(inst, check)
|
||||
if not nets:
|
||||
nets = _instance_nets(inst)
|
||||
if not nets:
|
||||
return _skip_no_geometry(check, mand, inst)
|
||||
measured = max(_len(layout, n) for n in nets)
|
||||
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
|
||||
verdict = geometric_verdict(cons, measured)
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, measured_mm=measured,
|
||||
limit_mm=limit, value_kind=kind,
|
||||
notes="Geometric length (mm). Not delay; not electrical Z.",
|
||||
)
|
||||
|
||||
if check in {"vias", "via_count"}:
|
||||
nets = _instance_nets(inst)
|
||||
count = sum(_via_count(layout, n) for n in nets)
|
||||
measured = float(count)
|
||||
verdict = geometric_verdict(cons, measured)
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, measured_mm=measured,
|
||||
limit_mm=cons.value if cons and cons.value_kind == "NUMERIC" else None,
|
||||
value_kind=kind,
|
||||
notes=f"Via count {count} (geometric). No invented via ampacity.",
|
||||
)
|
||||
|
||||
if check in {"stubs", "stub_length"}:
|
||||
verdict = geometric_verdict(cons, None)
|
||||
if verdict == "FAIL":
|
||||
verdict = "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict if verdict != "PASS" else "UNKNOWN", mand,
|
||||
nets=_instance_nets(inst), value_kind=kind,
|
||||
notes="Stub length not certified without a NUMERIC/DESIGN millimetre limit. Not invented.",
|
||||
)
|
||||
|
||||
if check in {"intra_pair_skew", "pair_skew"}:
|
||||
return _pair_skew(inst, layout, cons, mand, check, kind)
|
||||
|
||||
if check == "dq_to_dqs_skew":
|
||||
return _dq_dqs_skew(inst, lanes, layout, cons, mand, kind)
|
||||
|
||||
if check == "byte_lane_skew":
|
||||
return _lane_spread(lanes, layout, cons, mand, kind, "byte_lane_skew")
|
||||
|
||||
if check in {"ck_to_command_skew", "ck_to_address_skew", "clock_to_data_skew", "lane_relationships"}:
|
||||
measured = _group_spread(inst, layout)
|
||||
verdict = geometric_verdict(cons, measured)
|
||||
return _pack(
|
||||
check, verdict, mand, nets=_instance_nets(inst),
|
||||
measured_mm=measured, value_kind=kind,
|
||||
notes="Geometric length spread. No invented ps. Not electrical.",
|
||||
)
|
||||
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L1 geometric check.")
|
||||
|
||||
|
||||
def _clock_or_data_nets(inst: PhysicalBusInstance, check: str) -> list[str]:
|
||||
if check == "clock_length":
|
||||
return [n for g in inst.groups if g.kind == "CLOCK_GROUP" for n in g.nets]
|
||||
if check == "data_length":
|
||||
return [
|
||||
n for g in inst.groups
|
||||
if g.kind in {"BYTE_LANE", "DATA_LANE", "DIFFERENTIAL_PAIR"}
|
||||
for n in g.nets
|
||||
]
|
||||
return _instance_nets(inst)
|
||||
|
||||
|
||||
def _pair_skew(
|
||||
inst: PhysicalBusInstance,
|
||||
layout: LayoutGraph,
|
||||
cons: ProtocolConstraint | None,
|
||||
mand: MandatoryClass,
|
||||
check: str,
|
||||
kind: str,
|
||||
) -> L1CheckResult:
|
||||
pairs = [g for g in inst.groups if g.kind == "DIFFERENTIAL_PAIR"]
|
||||
if not pairs:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, value_kind=kind,
|
||||
notes="No differential pair group for geometric skew.",
|
||||
)
|
||||
deltas: list[float] = []
|
||||
nets: list[str] = []
|
||||
for g in pairs:
|
||||
if len(g.nets) < 2:
|
||||
continue
|
||||
a, b = g.nets[0], g.nets[1]
|
||||
deltas.append(abs(_len(layout, a) - _len(layout, b)))
|
||||
nets.extend([a, b])
|
||||
measured = max(deltas) if deltas else None
|
||||
verdict = geometric_verdict(cons, measured)
|
||||
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, measured_mm=measured,
|
||||
limit_mm=limit, value_kind=kind,
|
||||
notes="Intra-pair geometric length delta (mm). Not ps, not electrical Z.",
|
||||
)
|
||||
|
||||
|
||||
def _dq_dqs_skew(
|
||||
inst: PhysicalBusInstance,
|
||||
lanes: list[SignalGroup],
|
||||
layout: LayoutGraph,
|
||||
cons: ProtocolConstraint | None,
|
||||
mand: MandatoryClass,
|
||||
kind: str,
|
||||
) -> L1CheckResult:
|
||||
deltas: list[float] = []
|
||||
nets: list[str] = []
|
||||
for lane in lanes:
|
||||
dqs = [n for k, n in lane.roles.items() if k.upper().startswith("DQS")]
|
||||
dqs_len = [_len(layout, n) for n in dqs]
|
||||
ref = sum(dqs_len) / len(dqs_len) if dqs_len else None
|
||||
if ref is None:
|
||||
continue
|
||||
for key, net in lane.roles.items():
|
||||
if key.upper().startswith("DQ") and not key.upper().startswith("DQS"):
|
||||
deltas.append(abs(_len(layout, net) - ref))
|
||||
nets.append(net)
|
||||
nets.extend(dqs)
|
||||
measured = max(deltas) if deltas else None
|
||||
if measured is None:
|
||||
return _skip_grouping("dq_to_dqs_skew", mand, inst)
|
||||
verdict = geometric_verdict(cons, measured)
|
||||
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
|
||||
return _pack(
|
||||
"dq_to_dqs_skew", verdict, mand, nets=nets, measured_mm=measured,
|
||||
limit_mm=limit, value_kind=kind,
|
||||
notes="DQ-to-DQS geometric length delta (mm). No invented ps.",
|
||||
)
|
||||
|
||||
|
||||
def _lane_spread(
|
||||
lanes: list[SignalGroup],
|
||||
layout: LayoutGraph,
|
||||
cons: ProtocolConstraint | None,
|
||||
mand: MandatoryClass,
|
||||
kind: str,
|
||||
check: str,
|
||||
) -> L1CheckResult:
|
||||
spreads: list[float] = []
|
||||
nets: list[str] = []
|
||||
for lane in lanes:
|
||||
lens = [_len(layout, n) for n in lane.nets]
|
||||
if not lens:
|
||||
continue
|
||||
spreads.append(max(lens) - min(lens))
|
||||
nets.extend(lane.nets)
|
||||
measured = max(spreads) if spreads else None
|
||||
verdict = geometric_verdict(cons, measured)
|
||||
limit = cons.value if cons and cons.value_kind == "NUMERIC" and (cons.unit or "mm").lower() not in _TIME_UNITS else None
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, measured_mm=measured,
|
||||
limit_mm=limit, value_kind=kind,
|
||||
notes="Byte-lane geometric length spread (mm). Not a global length-match PASS.",
|
||||
)
|
||||
|
||||
|
||||
def _group_spread(inst: PhysicalBusInstance, layout: LayoutGraph) -> float | None:
|
||||
nets = _instance_nets(inst)
|
||||
lens = [_len(layout, n) for n in nets if _len(layout, n) > 0]
|
||||
if len(lens) < 2:
|
||||
return None
|
||||
return max(lens) - min(lens)
|
||||
|
||||
|
||||
def l1_findings(inst: PhysicalBusInstance, results: list[L1CheckResult]) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
for r in results:
|
||||
if r.result == "PASS":
|
||||
continue
|
||||
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
||||
continue
|
||||
skip_family = r.result in {
|
||||
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
}
|
||||
fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
|
||||
na = r.result == "NOT_APPLICABLE"
|
||||
if not skip_family and not fail and not na:
|
||||
continue
|
||||
if fail:
|
||||
rule_id = "PE-PRT-L1-003"
|
||||
status = "ERROR"
|
||||
finding = f"L1 geometric {r.check}: {r.notes}"
|
||||
elif skip_family:
|
||||
grouping = "grouping" in (r.notes or "").lower() or "dq/dqs" in (r.notes or "").lower()
|
||||
rule_id = "PE-PRT-L1-002" if grouping else "PE-PRT-L1-001"
|
||||
status = "WARNING"
|
||||
finding = r.notes or (
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L1 "
|
||||
f"per {r.check} ({r.result}). Not electrical."
|
||||
)
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
status = "INFO"
|
||||
finding = r.notes
|
||||
f = Finding(
|
||||
designator=designator,
|
||||
mpn="",
|
||||
aspect="protocol_l1",
|
||||
finding=finding,
|
||||
why=r.notes,
|
||||
status=status,
|
||||
source=SOURCE,
|
||||
net=r.nets[0] if r.nets else None,
|
||||
rule_id=rule_id,
|
||||
facts=finding,
|
||||
requirement="L1 geometric vs NUMERIC/DESIGN length only; never invent mm/ps.",
|
||||
inference="L1 is geometric, not electrical certification.",
|
||||
provenance="MANDATORY" if fail else "TYPICAL",
|
||||
finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
|
||||
evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
|
||||
)
|
||||
out.append(complete_finding(f))
|
||||
return out
|
||||
|
||||
|
||||
def certify_l1(
|
||||
graph: DesignGraph,
|
||||
instances: list[PhysicalBusInstance],
|
||||
layout: LayoutGraph | None,
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
) -> tuple[dict[str, list[L1CheckResult]], list[Finding]]:
|
||||
cat = catalog or load_catalog()
|
||||
ifaces = {p.id: p for p in cat.physical_interfaces}
|
||||
by_id: dict[str, list[L1CheckResult]] = {}
|
||||
findings: list[Finding] = []
|
||||
for inst in instances:
|
||||
iface = ifaces.get(inst.physical_interface_id)
|
||||
if iface is None:
|
||||
continue
|
||||
rows = certify_instance_l1(graph, inst, iface, layout)
|
||||
by_id[inst.instance_id] = rows
|
||||
findings.extend(l1_findings(inst, rows))
|
||||
return by_id, findings
|
||||
@@ -0,0 +1,624 @@
|
||||
"""M4: L2 ELECTRICAL protocol certifier. Z, termination, levels, rise/fall.
|
||||
|
||||
Z only from datasheet / stackup / fabricator / cited pack — never invented
|
||||
90 Ω USB. USB-IF/IEEE numbers only if the pack has a cite. Silicon cross is
|
||||
max PCB × PHY subset when datasheet windows exist. Length is not delay.
|
||||
Not L3 / OpenEMS. L0/L1 are not electrical certification.
|
||||
RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent.
|
||||
Visible skip (PE-AF-002 style) when Z cannot be obtained.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.constraints_lookup import match_constraints
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
)
|
||||
from backend.periscopex.pcb_net_match import kicad_nets_match
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
PhysicalInterface,
|
||||
ProtocolCatalog,
|
||||
ProtocolConstraint,
|
||||
load_catalog,
|
||||
map_protocol_outcome,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||
|
||||
PACK_MACROPHASE = "M4"
|
||||
SOURCE = "protocol_l2"
|
||||
LEVEL = "L2"
|
||||
|
||||
L2_CHECKS = frozenset({
|
||||
"differential_impedance",
|
||||
"impedance",
|
||||
"impedance_if_required",
|
||||
"termination",
|
||||
"cc_termination",
|
||||
"voltage",
|
||||
"levels",
|
||||
"rise_time",
|
||||
"fall_time",
|
||||
"return_path",
|
||||
"phy_requirements",
|
||||
"magnetics",
|
||||
"timing",
|
||||
"ac_coupling",
|
||||
})
|
||||
|
||||
_OHM_UNITS = frozenset({"ohm", "ohms", "ω", "Ω", ""})
|
||||
_VOLT_UNITS = frozenset({"v", "volt", "volts"})
|
||||
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
|
||||
|
||||
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
|
||||
L2Result = Literal[
|
||||
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
|
||||
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
]
|
||||
|
||||
|
||||
class L2CheckResult(BaseModel):
|
||||
check: str
|
||||
level: Literal["L2"] = "L2"
|
||||
result: L2Result
|
||||
mandatory: MandatoryClass
|
||||
nets: list[str] = Field(default_factory=list)
|
||||
measured_ohm: float | None = None
|
||||
limit_ohm: float | None = None
|
||||
limit_ohm_min: float | None = None
|
||||
limit_ohm_max: float | None = None
|
||||
margin_ohm: float | None = None
|
||||
value_kind: str = ""
|
||||
notes: str = ""
|
||||
finding_class: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
def _pack(
|
||||
check: str,
|
||||
result: str,
|
||||
mandatory: MandatoryClass,
|
||||
*,
|
||||
nets: list[str] | None = None,
|
||||
measured_ohm: float | None = None,
|
||||
limit_ohm: float | None = None,
|
||||
limit_ohm_min: float | None = None,
|
||||
limit_ohm_max: float | None = None,
|
||||
value_kind: str = "",
|
||||
notes: str = "",
|
||||
) -> L2CheckResult:
|
||||
if result == "FAIL" and mandatory != "MANDATORY":
|
||||
result = "WARNING"
|
||||
mapped = result
|
||||
if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
|
||||
mapped = "VENDOR_DEPENDENT"
|
||||
cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
|
||||
margin = None
|
||||
hi = limit_ohm_max if limit_ohm_max is not None else limit_ohm
|
||||
if measured_ohm is not None and hi is not None:
|
||||
margin = hi - measured_ohm
|
||||
return L2CheckResult(
|
||||
check=check,
|
||||
result=result, # type: ignore[arg-type]
|
||||
mandatory=mandatory,
|
||||
nets=nets or [],
|
||||
measured_ohm=measured_ohm,
|
||||
limit_ohm=limit_ohm,
|
||||
limit_ohm_min=limit_ohm_min,
|
||||
limit_ohm_max=limit_ohm_max,
|
||||
margin_ohm=margin,
|
||||
value_kind=value_kind,
|
||||
notes=notes,
|
||||
finding_class=cls,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def _num(v: Any) -> float | None:
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _z_window_from_rule(rule: dict) -> tuple[float, float] | None:
|
||||
lo = _num(rule.get("z_min_ohm"))
|
||||
hi = _num(rule.get("z_max_ohm"))
|
||||
if lo is not None and hi is not None and hi >= lo:
|
||||
return (lo, hi)
|
||||
nom = _num(rule.get("zdiff_ohm")) or _num(rule.get("z0_ohm"))
|
||||
tol = _num(rule.get("tolerance_pct"))
|
||||
if nom is not None and tol is not None and tol > 0:
|
||||
return (nom * (1 - tol / 100.0), nom * (1 + tol / 100.0))
|
||||
if nom is not None:
|
||||
return (nom, nom)
|
||||
return None
|
||||
|
||||
|
||||
def intersect_windows(windows: list[tuple[float, float]]) -> tuple[float, float] | None:
|
||||
"""Silicon cross: max of mins × min of maxes (PHY subset)."""
|
||||
if not windows:
|
||||
return None
|
||||
lo = max(w[0] for w in windows)
|
||||
hi = min(w[1] for w in windows)
|
||||
if lo > hi:
|
||||
return None
|
||||
return (lo, hi)
|
||||
|
||||
|
||||
def electrical_verdict(
|
||||
constraint: ProtocolConstraint | None,
|
||||
measured: float | None,
|
||||
window: tuple[float, float] | None,
|
||||
) -> str:
|
||||
"""PASS/FAIL only with a cited NUMERIC ohm/volt or a datasheet window."""
|
||||
if window is not None:
|
||||
if measured is None:
|
||||
return "UNKNOWN"
|
||||
if window[0] <= measured <= window[1]:
|
||||
return "PASS"
|
||||
return "FAIL"
|
||||
if constraint is None:
|
||||
return "UNKNOWN"
|
||||
kind = constraint.value_kind
|
||||
if kind == "NUMERIC" and constraint.value is not None:
|
||||
unit = (constraint.unit or "").strip().lower()
|
||||
if unit in _TIME_UNITS:
|
||||
return "UNKNOWN"
|
||||
if measured is None:
|
||||
return "UNKNOWN"
|
||||
if unit in _OHM_UNITS:
|
||||
return "PASS" if abs(measured - constraint.value) < 1e-6 else "FAIL"
|
||||
return "PASS" if measured <= constraint.value else "FAIL"
|
||||
if kind in {
|
||||
"UNKNOWN", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "MISSING_SOURCE", "NOT_APPLICABLE",
|
||||
}:
|
||||
return kind
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
|
||||
names: list[str] = []
|
||||
for g in inst.groups:
|
||||
names.extend(g.nets)
|
||||
names.extend(inst.nets)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for n in names:
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
|
||||
raw: Any = impedance_nets
|
||||
if isinstance(impedance_nets, dict):
|
||||
raw = list(impedance_nets.get("nets") or [])
|
||||
return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")]
|
||||
|
||||
|
||||
def _row_for(rows: list[dict], net: str) -> dict | None:
|
||||
for row in rows:
|
||||
name = str(row.get("net_name") or row.get("name") or "")
|
||||
if name and kicad_nets_match(name, net):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _z_from_row(row: dict | None) -> float | None:
|
||||
if not row:
|
||||
return None
|
||||
for k in ("zdiff_ohm", "zdiff_avg_ohms", "z0_avg_ohms", "z0_ohm", "mean_z0", "z0"):
|
||||
v = _num(row.get(k))
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def measured_z_ohm(nets: list[str], impedance_nets: list[dict] | dict | None) -> float | None:
|
||||
rows = _z_rows(impedance_nets)
|
||||
vals = [_z_from_row(_row_for(rows, n)) for n in nets]
|
||||
have = [v for v in vals if v is not None]
|
||||
if not have:
|
||||
return None
|
||||
return sum(have) / len(have)
|
||||
|
||||
|
||||
def _phy_z_windows(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
) -> list[tuple[float, float]]:
|
||||
if not constraints_map:
|
||||
return []
|
||||
refs = [r for r in [inst.host_ref, *inst.peer_refs] if r]
|
||||
inst_nets = set(_instance_nets(inst))
|
||||
for ref, comp in graph.components.items():
|
||||
if ref in refs:
|
||||
continue
|
||||
pins = set(comp.pins.values())
|
||||
if pins & inst_nets:
|
||||
refs.append(ref)
|
||||
windows: list[tuple[float, float]] = []
|
||||
seen: set[tuple[float, float]] = set()
|
||||
for ref in refs:
|
||||
comp = graph.components.get(ref)
|
||||
if comp is None or comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if cons is None:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
kind = str(rule.get("kind") or "").lower()
|
||||
param = str(rule.get("parameter") or "").lower()
|
||||
if kind not in {"impedance", "zdiff", "z0", "si"} and param not in {
|
||||
"impedance", "zdiff", "z0",
|
||||
}:
|
||||
continue
|
||||
w = _z_window_from_rule(rule)
|
||||
if w and w not in seen:
|
||||
seen.add(w)
|
||||
windows.append(w)
|
||||
return windows
|
||||
|
||||
|
||||
def _pack_ohm_window(cons: ProtocolConstraint | None) -> tuple[float, float] | None:
|
||||
if cons is None or cons.value_kind != "NUMERIC" or cons.value is None:
|
||||
return None
|
||||
unit = (cons.unit or "").strip().lower()
|
||||
if unit in _TIME_UNITS or unit in _VOLT_UNITS:
|
||||
return None
|
||||
if unit in _OHM_UNITS or unit in {"", "ohm"}:
|
||||
return (cons.value, cons.value)
|
||||
return None
|
||||
|
||||
|
||||
def _skip_z(check: str, mand: MandatoryClass, inst: PhysicalBusInstance, kind: str) -> L2CheckResult:
|
||||
return _pack(
|
||||
check, "UNKNOWN" if kind not in {
|
||||
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "NOT_APPLICABLE",
|
||||
} else kind,
|
||||
mand,
|
||||
value_kind=kind,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per mancanza di Z (datasheet/stackup/fab/cited pack). "
|
||||
f"Not USB/IEC folklore. L0/L1 are not electrical certification."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def certify_instance_l2(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
*,
|
||||
layout: LayoutGraph | None = None,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> list[L2CheckResult]:
|
||||
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
|
||||
return [_pack(
|
||||
"pcb_routing", "NOT_APPLICABLE", "INFORMATIONAL",
|
||||
notes="Internal interconnect — L2 electrical N/A, not FAIL. L0/L1 are not electrical.",
|
||||
)]
|
||||
checks = [c for c in iface.required_checks if c in L2_CHECKS]
|
||||
if not checks:
|
||||
return []
|
||||
return [
|
||||
_run_l2_check(
|
||||
graph, inst, iface, c,
|
||||
layout=layout,
|
||||
constraints_map=constraints_map,
|
||||
impedance_nets=impedance_nets,
|
||||
)
|
||||
for c in checks
|
||||
]
|
||||
|
||||
|
||||
def _run_l2_check(
|
||||
graph: DesignGraph,
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
check: str,
|
||||
*,
|
||||
layout: LayoutGraph | None,
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
impedance_nets: list[dict] | dict | None,
|
||||
) -> L2CheckResult:
|
||||
mand = _mandatory(iface, check)
|
||||
cons = _constraint_for(iface, check)
|
||||
kind = cons.value_kind if cons else "UNKNOWN"
|
||||
nets = _instance_nets(inst)
|
||||
|
||||
if check in {
|
||||
"differential_impedance", "impedance", "impedance_if_required",
|
||||
}:
|
||||
return _z_check(
|
||||
check, mand, cons, kind, inst, graph, nets,
|
||||
constraints_map, impedance_nets,
|
||||
)
|
||||
|
||||
if check in {"termination", "cc_termination"}:
|
||||
return _termination_check(check, mand, cons, kind, inst, graph, nets, constraints_map)
|
||||
|
||||
if check in {"voltage", "levels"}:
|
||||
return _levels_check(check, mand, cons, kind, inst, constraints_map, graph)
|
||||
|
||||
if check in {"rise_time", "fall_time", "timing"}:
|
||||
return _pack(
|
||||
check, "UNKNOWN" if kind in {"NUMERIC", "UNKNOWN", ""} else kind, mand,
|
||||
nets=nets, value_kind=kind,
|
||||
notes=(
|
||||
"Length is not delay. Rise/fall/timing UNKNOWN without a cited "
|
||||
"time source (no tpd invented from mm). Not L3."
|
||||
),
|
||||
)
|
||||
|
||||
if check in {"return_path", "magnetics", "phy_requirements", "ac_coupling"}:
|
||||
verdict = kind if kind not in {"NUMERIC"} else "UNKNOWN"
|
||||
if verdict == "NUMERIC":
|
||||
verdict = "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, value_kind=kind,
|
||||
notes=(
|
||||
f"{check} not certified without a cited electrical source. "
|
||||
f"Not invented. L0/L1 are not electrical certification."
|
||||
),
|
||||
)
|
||||
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L2 electrical check.")
|
||||
|
||||
|
||||
def _z_check(
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
cons: ProtocolConstraint | None,
|
||||
kind: str,
|
||||
inst: PhysicalBusInstance,
|
||||
graph: DesignGraph,
|
||||
nets: list[str],
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
impedance_nets: list[dict] | dict | None,
|
||||
) -> L2CheckResult:
|
||||
phy = _phy_z_windows(graph, inst, constraints_map)
|
||||
pack_w = _pack_ohm_window(cons)
|
||||
windows: list[tuple[float, float]] = []
|
||||
if pack_w:
|
||||
windows.append(pack_w)
|
||||
windows.extend(phy)
|
||||
window = intersect_windows(windows) if windows else None
|
||||
measured = measured_z_ohm(nets, impedance_nets)
|
||||
if window is None:
|
||||
# no cited pack number and no datasheet PHY window → never invent 90 Ω
|
||||
if measured is None:
|
||||
return _skip_z(check, mand, inst, kind)
|
||||
return _pack(
|
||||
check,
|
||||
kind if kind in {
|
||||
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "UNKNOWN",
|
||||
} else "UNKNOWN",
|
||||
mand,
|
||||
nets=nets, measured_ohm=measured, value_kind=kind,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per mancanza di Z target (cited pack or PHY datasheet). "
|
||||
f"Measured stackup/fab Z is not compared to invented USB/IEC ohms. "
|
||||
f"L0/L1 are not electrical certification."
|
||||
),
|
||||
)
|
||||
if measured is None:
|
||||
return _skip_z(check, mand, inst, kind)
|
||||
verdict = electrical_verdict(cons, measured, window)
|
||||
source_note = (
|
||||
"PHY datasheet subset × cited pack"
|
||||
if pack_w and phy
|
||||
else ("cited pack" if pack_w else "PHY datasheet subset")
|
||||
)
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, measured_ohm=measured,
|
||||
limit_ohm=window[1],
|
||||
limit_ohm_min=window[0],
|
||||
limit_ohm_max=window[1],
|
||||
value_kind=kind if not pack_w else "NUMERIC",
|
||||
notes=(
|
||||
f"L2 Z vs {source_note} {window[0]:g}–{window[1]:g} Ω. "
|
||||
f"Not folklore. Length is not delay. Not L3."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _termination_check(
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
cons: ProtocolConstraint | None,
|
||||
kind: str,
|
||||
inst: PhysicalBusInstance,
|
||||
graph: DesignGraph,
|
||||
nets: list[str],
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
) -> L2CheckResult:
|
||||
spec_ohm: float | None = None
|
||||
if cons and cons.value_kind == "NUMERIC" and cons.value is not None:
|
||||
unit = (cons.unit or "").strip().lower()
|
||||
if unit in _OHM_UNITS:
|
||||
spec_ohm = cons.value
|
||||
if spec_ohm is None and constraints_map:
|
||||
for ref in [inst.host_ref, *inst.peer_refs]:
|
||||
comp = graph.components.get(ref)
|
||||
if not comp:
|
||||
continue
|
||||
ds = match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if not ds:
|
||||
continue
|
||||
for rule in ds.layout_rules or []:
|
||||
if str(rule.get("kind") or "") in {"series_resistor", "termination"}:
|
||||
spec_ohm = _num(rule.get("value_ohms")) or spec_ohm
|
||||
if spec_ohm is None:
|
||||
verdict = kind if kind in {
|
||||
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||
} else "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, value_kind=kind,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per mancanza di termination cite. No invented Rd/ohm."
|
||||
),
|
||||
)
|
||||
measured = None
|
||||
netset = set(nets)
|
||||
for comp in graph.components.values():
|
||||
if comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
if not (set(comp.pins.values()) & netset):
|
||||
continue
|
||||
specs = getattr(comp, "specs", None)
|
||||
ohms = getattr(specs, "value_ohms", None) if specs is not None else None
|
||||
if isinstance(ohms, (int, float)):
|
||||
measured = float(ohms)
|
||||
break
|
||||
if measured is None:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, nets=nets, limit_ohm=spec_ohm, value_kind=kind,
|
||||
notes="Termination spec present but resistor value not FACT on the graph.",
|
||||
)
|
||||
verdict = "PASS" if abs(measured - spec_ohm) < 1e-3 else "FAIL"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets, measured_ohm=measured, limit_ohm=spec_ohm,
|
||||
value_kind=kind, notes="Termination vs cited/datasheet ohms. Not invented.",
|
||||
)
|
||||
|
||||
|
||||
def _levels_check(
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
cons: ProtocolConstraint | None,
|
||||
kind: str,
|
||||
inst: PhysicalBusInstance,
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
graph: DesignGraph,
|
||||
) -> L2CheckResult:
|
||||
if cons and cons.value_kind == "NUMERIC" and cons.value is not None:
|
||||
unit = (cons.unit or "").strip().lower()
|
||||
if unit in _VOLT_UNITS:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, value_kind="NUMERIC",
|
||||
notes="Voltage NUMERIC in pack but PCB rail FACT not supplied — not invented 3.3 V.",
|
||||
)
|
||||
if constraints_map:
|
||||
for ref in [inst.host_ref, *inst.peer_refs]:
|
||||
comp = graph.components.get(ref)
|
||||
if not comp:
|
||||
continue
|
||||
ds = match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if ds and ds.absolute_maximum_ratings:
|
||||
return _pack(
|
||||
check, "UNKNOWN", mand, value_kind=kind,
|
||||
notes=(
|
||||
"Silicon abs-max present; L2 levels need a cited operating "
|
||||
"window vs measured rail — not assumed. Not L3."
|
||||
),
|
||||
)
|
||||
verdict = kind if kind in {
|
||||
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||
} else "UNKNOWN"
|
||||
return _pack(
|
||||
check, verdict, mand, value_kind=kind,
|
||||
notes="Levels/voltage UNKNOWN without a cited electrical source. Not invented.",
|
||||
)
|
||||
|
||||
|
||||
def l2_findings(inst: PhysicalBusInstance, results: list[L2CheckResult]) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
for r in results:
|
||||
if r.result == "PASS":
|
||||
continue
|
||||
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
||||
continue
|
||||
skip_family = r.result in {
|
||||
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
}
|
||||
fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
|
||||
na = r.result == "NOT_APPLICABLE"
|
||||
if not skip_family and not fail and not na:
|
||||
continue
|
||||
if fail:
|
||||
rule_id = "PE-PRT-L2-002"
|
||||
status = "ERROR"
|
||||
finding = f"L2 electrical {r.check}: {r.notes}"
|
||||
elif skip_family:
|
||||
rule_id = "PE-PRT-L2-001"
|
||||
status = "WARNING"
|
||||
finding = r.notes or (
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L2 "
|
||||
f"per {r.check} ({r.result})."
|
||||
)
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
status = "INFO"
|
||||
finding = r.notes
|
||||
f = Finding(
|
||||
designator=designator,
|
||||
mpn="",
|
||||
aspect="protocol_l2",
|
||||
finding=finding,
|
||||
why=r.notes,
|
||||
status=status,
|
||||
source=SOURCE,
|
||||
net=r.nets[0] if r.nets else None,
|
||||
rule_id=rule_id,
|
||||
facts=finding,
|
||||
requirement="L2 Z/termination/levels only from datasheet/stackup/fab/cited pack.",
|
||||
inference="L2 is electrical. L0/L1 are not. Length is not delay. Not L3.",
|
||||
provenance="MANDATORY" if fail else "TYPICAL",
|
||||
finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
|
||||
evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
|
||||
)
|
||||
out.append(complete_finding(f))
|
||||
return out
|
||||
|
||||
|
||||
def certify_l2(
|
||||
graph: DesignGraph,
|
||||
instances: list[PhysicalBusInstance],
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
*,
|
||||
layout: LayoutGraph | None = None,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
impedance_nets: list[dict] | dict | None = None,
|
||||
) -> tuple[dict[str, list[L2CheckResult]], list[Finding]]:
|
||||
cat = catalog or load_catalog()
|
||||
ifaces = {p.id: p for p in cat.physical_interfaces}
|
||||
by_id: dict[str, list[L2CheckResult]] = {}
|
||||
findings: list[Finding] = []
|
||||
for inst in instances:
|
||||
iface = ifaces.get(inst.physical_interface_id)
|
||||
if iface is None:
|
||||
continue
|
||||
rows = certify_instance_l2(
|
||||
graph, inst, iface,
|
||||
layout=layout,
|
||||
constraints_map=constraints_map,
|
||||
impedance_nets=impedance_nets,
|
||||
)
|
||||
by_id[inst.instance_id] = rows
|
||||
findings.extend(l2_findings(inst, rows))
|
||||
return by_id, findings
|
||||
@@ -0,0 +1,601 @@
|
||||
"""M9: L3 CHANNEL protocol certifier. Timing budget / IL / RL / crosstalk.
|
||||
|
||||
Numerical execution only when channel FACT already exists (extracted dB/ps).
|
||||
Never invent S-parameters. Never OpenEMS/FEM. Never M6 pack numbers.
|
||||
L0/L1 are not electrical; this module does not call L0/L1 electrical.
|
||||
RECOMMENDED never FAIL. Internals (pcb_relevant=NO) are NOT_APPLICABLE.
|
||||
Visible skip: «Interfaccia X non certificata a L3 per mancanza di …».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.finding_engine import complete_finding
|
||||
from backend.periscopex.models import DesignGraph, Finding
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
PhysicalInterface,
|
||||
ProtocolCatalog,
|
||||
ProtocolConstraint,
|
||||
load_catalog,
|
||||
map_protocol_outcome,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import _constraint_for, _mandatory
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||
|
||||
PACK_MACROPHASE = "M9"
|
||||
SOURCE = "protocol_l3"
|
||||
LEVEL = "L3"
|
||||
|
||||
L3_CHECKS = frozenset({
|
||||
"timing_budget",
|
||||
"insertion_loss",
|
||||
"return_loss",
|
||||
"crosstalk",
|
||||
"channel",
|
||||
"connector",
|
||||
"via_transitions",
|
||||
"via_transition",
|
||||
"package",
|
||||
})
|
||||
|
||||
# Always structured on PCB-relevant instances even if the catalog omits them.
|
||||
L3_CORE = (
|
||||
"timing_budget",
|
||||
"insertion_loss",
|
||||
"return_loss",
|
||||
"crosstalk",
|
||||
"channel",
|
||||
)
|
||||
|
||||
_DB_UNITS = frozenset({"db", "dbm", "dbi"})
|
||||
_TIME_UNITS = frozenset({"ps", "ns", "us", "µs", "ms", "s"})
|
||||
_FEM_MARKERS = ("openems", "fem", "field_solver", "field-solver", "full-wave")
|
||||
|
||||
MandatoryClass = Literal["MANDATORY", "RECOMMENDED", "OPTIONAL", "INFORMATIONAL"]
|
||||
L3Result = Literal[
|
||||
"PASS", "FAIL", "WARNING", "UNKNOWN", "NOT_APPLICABLE",
|
||||
"VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT", "PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
]
|
||||
|
||||
|
||||
class L3CheckResult(BaseModel):
|
||||
check: str
|
||||
level: Literal["L3"] = "L3"
|
||||
result: L3Result
|
||||
mandatory: MandatoryClass
|
||||
nets: list[str] = Field(default_factory=list)
|
||||
measured_db: float | None = None
|
||||
limit_db: float | None = None
|
||||
margin_db: float | None = None
|
||||
measured_ps: float | None = None
|
||||
limit_ps: float | None = None
|
||||
margin_ps: float | None = None
|
||||
total_budget_ps: float | None = None
|
||||
used_budget_ps: float | None = None
|
||||
remaining_margin_ps: float | None = None
|
||||
value_kind: str = ""
|
||||
notes: str = ""
|
||||
finding_class: str = ""
|
||||
status: str = ""
|
||||
|
||||
|
||||
def _num(v: Any) -> float | None:
|
||||
if v is None or isinstance(v, bool):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _pack(
|
||||
check: str,
|
||||
result: str,
|
||||
mandatory: MandatoryClass,
|
||||
*,
|
||||
nets: list[str] | None = None,
|
||||
measured_db: float | None = None,
|
||||
limit_db: float | None = None,
|
||||
measured_ps: float | None = None,
|
||||
limit_ps: float | None = None,
|
||||
total_budget_ps: float | None = None,
|
||||
used_budget_ps: float | None = None,
|
||||
remaining_margin_ps: float | None = None,
|
||||
value_kind: str = "",
|
||||
notes: str = "",
|
||||
) -> L3CheckResult:
|
||||
if result == "FAIL" and mandatory != "MANDATORY":
|
||||
result = "WARNING"
|
||||
mapped = result
|
||||
if result in {"CONTROLLER_DEPENDENT", "PHY_DEPENDENT"}:
|
||||
mapped = "VENDOR_DEPENDENT"
|
||||
cls, status, _ev = map_protocol_outcome(mapped, mandatory) # type: ignore[arg-type]
|
||||
margin_db = None
|
||||
if measured_db is not None and limit_db is not None:
|
||||
margin_db = _db_margin(check, measured_db, limit_db)
|
||||
margin_ps = remaining_margin_ps
|
||||
if margin_ps is None and measured_ps is not None and limit_ps is not None:
|
||||
margin_ps = measured_ps - limit_ps if limit_ps < 0 else limit_ps - measured_ps
|
||||
return L3CheckResult(
|
||||
check=check,
|
||||
result=result, # type: ignore[arg-type]
|
||||
mandatory=mandatory,
|
||||
nets=nets or [],
|
||||
measured_db=measured_db,
|
||||
limit_db=limit_db,
|
||||
margin_db=margin_db,
|
||||
measured_ps=measured_ps,
|
||||
limit_ps=limit_ps,
|
||||
margin_ps=margin_ps,
|
||||
total_budget_ps=total_budget_ps,
|
||||
used_budget_ps=used_budget_ps,
|
||||
remaining_margin_ps=remaining_margin_ps,
|
||||
value_kind=value_kind,
|
||||
notes=notes,
|
||||
finding_class=cls,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def _db_margin(check: str, measured: float, limit: float) -> float:
|
||||
if check == "return_loss":
|
||||
if limit < 0:
|
||||
return limit - measured
|
||||
return measured - limit
|
||||
if limit < 0:
|
||||
return measured - limit
|
||||
return limit - measured
|
||||
|
||||
|
||||
def _db_pass(check: str, measured: float, limit: float) -> bool:
|
||||
"""IL/crosstalk: max magnitude (pos) or min S-param (neg). RL: min (pos) or max S11 (neg)."""
|
||||
if check == "return_loss":
|
||||
if limit < 0:
|
||||
return measured <= limit
|
||||
return measured >= limit
|
||||
if limit < 0:
|
||||
return measured >= limit
|
||||
return measured <= limit
|
||||
|
||||
|
||||
def _instance_nets(inst: PhysicalBusInstance) -> list[str]:
|
||||
names: list[str] = []
|
||||
for g in inst.groups:
|
||||
names.extend(g.nets)
|
||||
names.extend(inst.nets)
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for n in names:
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def _kind_skip(kind: str) -> str:
|
||||
if kind in {
|
||||
"MISSING_SOURCE", "PHY_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"VENDOR_DEPENDENT", "UNKNOWN", "NOT_APPLICABLE",
|
||||
}:
|
||||
return kind
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def _blob_mentions_fem(obj: Any) -> bool:
|
||||
text = str(obj).lower()
|
||||
return any(m in text for m in _FEM_MARKERS)
|
||||
|
||||
|
||||
def _looks_like_raw_sparam(facts: dict[str, Any]) -> bool:
|
||||
"""Raw Touchstone / unevaluated S-matrix — we do not reduce it here."""
|
||||
if facts.get("sparam_invented") is True:
|
||||
return True
|
||||
if facts.get("invent_sparam") is True:
|
||||
return True
|
||||
for key in ("touchstone", "sparam_file", "s2p", "s4p", "snp_path"):
|
||||
if facts.get(key):
|
||||
has_num = any(
|
||||
_num(facts.get(k)) is not None
|
||||
for k in (
|
||||
"insertion_loss_db", "il_db", "s21_db",
|
||||
"return_loss_db", "rl_db", "s11_db",
|
||||
)
|
||||
)
|
||||
if not has_num:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _as_fact_map(channel_data: Any) -> dict[str, Any]:
|
||||
if channel_data is None:
|
||||
return {}
|
||||
if isinstance(channel_data, dict):
|
||||
return channel_data
|
||||
return {}
|
||||
|
||||
|
||||
def lookup_channel_facts(
|
||||
inst: PhysicalBusInstance,
|
||||
channel_data: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Return FACT already on disk/in-memory. Never synthesize S-parameters."""
|
||||
root = _as_fact_map(channel_data)
|
||||
if not root:
|
||||
return {}
|
||||
candidates: list[Any] = []
|
||||
for bag_key in ("instances", "physical_interfaces", "logical_protocols"):
|
||||
bag = root.get(bag_key)
|
||||
if isinstance(bag, dict):
|
||||
if bag_key == "instances":
|
||||
candidates.append(bag.get(inst.instance_id))
|
||||
elif bag_key == "physical_interfaces":
|
||||
candidates.append(bag.get(inst.physical_interface_id))
|
||||
else:
|
||||
candidates.append(bag.get(inst.logical_protocol_id))
|
||||
candidates.append(root.get(inst.instance_id))
|
||||
candidates.append(root.get(inst.physical_interface_id))
|
||||
candidates.append(root.get(inst.logical_protocol_id))
|
||||
default = root.get("default")
|
||||
if isinstance(default, dict):
|
||||
candidates.append(default)
|
||||
records = root.get("records")
|
||||
if isinstance(records, list):
|
||||
for rec in records:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
if rec.get("instance_id") == inst.instance_id:
|
||||
candidates.append(rec)
|
||||
elif rec.get("physical_interface_id") == inst.physical_interface_id:
|
||||
candidates.append(rec)
|
||||
elif rec.get("logical_protocol_id") == inst.logical_protocol_id:
|
||||
candidates.append(rec)
|
||||
merged: dict[str, Any] = {}
|
||||
for c in candidates:
|
||||
if isinstance(c, dict):
|
||||
merged.update(c)
|
||||
return merged
|
||||
|
||||
|
||||
def remaining_margin_ps(
|
||||
total: float | None,
|
||||
used: float | None,
|
||||
remaining: float | None = None,
|
||||
) -> float | None:
|
||||
"""TOTAL − USED = remaining margin. Prefer explicit remaining if given."""
|
||||
if remaining is not None:
|
||||
return remaining
|
||||
if total is None or used is None:
|
||||
return None
|
||||
return total - used
|
||||
|
||||
|
||||
def _used_from_parts(facts: dict[str, Any]) -> float | None:
|
||||
keys = (
|
||||
"package_delay_ps",
|
||||
"pcb_trace_delay_ps",
|
||||
"via_delay_ps",
|
||||
"connector_delay_ps",
|
||||
"cable_delay_ps",
|
||||
"component_delay_ps",
|
||||
)
|
||||
parts = [_num(facts.get(k)) for k in keys]
|
||||
present = [p for p in parts if p is not None]
|
||||
if not present:
|
||||
return _num(facts.get("used_budget_ps"))
|
||||
extra = _num(facts.get("used_budget_ps"))
|
||||
if extra is not None and extra >= sum(present):
|
||||
return extra
|
||||
return sum(present)
|
||||
|
||||
|
||||
def _il_measured(facts: dict[str, Any]) -> float | None:
|
||||
for k in ("insertion_loss_db", "il_db", "s21_db"):
|
||||
n = _num(facts.get(k))
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _rl_measured(facts: dict[str, Any]) -> float | None:
|
||||
for k in ("return_loss_db", "rl_db", "s11_db"):
|
||||
n = _num(facts.get(k))
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _xt_measured(facts: dict[str, Any]) -> float | None:
|
||||
for k in ("crosstalk_db", "xtalk_db", "next_db", "fext_db"):
|
||||
n = _num(facts.get(k))
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _limit_from_constraint(cons: ProtocolConstraint | None, units: set[str]) -> float | None:
|
||||
if cons is None or cons.value_kind != "NUMERIC" or cons.value is None:
|
||||
return None
|
||||
unit = (cons.unit or "").strip().lower()
|
||||
if unit in units or unit == "":
|
||||
return cons.value
|
||||
return None
|
||||
|
||||
|
||||
def _skip(
|
||||
check: str,
|
||||
mand: MandatoryClass,
|
||||
inst: PhysicalBusInstance,
|
||||
kind: str,
|
||||
missing: str,
|
||||
*,
|
||||
nets: list[str] | None = None,
|
||||
) -> L3CheckResult:
|
||||
return _pack(
|
||||
check,
|
||||
_kind_skip(kind),
|
||||
mand,
|
||||
nets=nets,
|
||||
value_kind=kind,
|
||||
notes=(
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
|
||||
f"per mancanza di {missing}. OpenEMS/FEM out of product; S-parameters "
|
||||
f"are not invented. L0/L1 are not electrical certification. Not PASS."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _run_l3_check(
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
check: str,
|
||||
facts: dict[str, Any],
|
||||
) -> L3CheckResult:
|
||||
mand = _mandatory(iface, check)
|
||||
cons = _constraint_for(iface, check)
|
||||
kind = cons.value_kind if cons else "UNKNOWN"
|
||||
nets = _instance_nets(inst)
|
||||
|
||||
if facts and (_looks_like_raw_sparam(facts) or _blob_mentions_fem(facts.get("method") or facts.get("solver") or "")):
|
||||
return _skip(
|
||||
check, mand, inst, kind,
|
||||
"dati canale ridotti a FACT (Touchstone/OpenEMS/FEM non eseguiti nel prodotto)",
|
||||
nets=nets,
|
||||
)
|
||||
|
||||
if check == "timing_budget":
|
||||
total = _num(facts.get("total_budget_ps"))
|
||||
used = _used_from_parts(facts)
|
||||
remaining = remaining_margin_ps(total, used, _num(facts.get("remaining_margin_ps")))
|
||||
limit = _limit_from_constraint(cons, _TIME_UNITS)
|
||||
if remaining is None or total is None:
|
||||
return _skip(
|
||||
check, mand, inst, kind,
|
||||
"timing budget FACT (total_budget_ps e used/remaining)",
|
||||
nets=nets,
|
||||
)
|
||||
if cons is None or cons.value_kind != "NUMERIC":
|
||||
# Measured budget exists but no cited min-margin — report numbers, skip cert.
|
||||
return _pack(
|
||||
check, _kind_skip(kind), mand, nets=nets,
|
||||
total_budget_ps=total, used_budget_ps=used,
|
||||
remaining_margin_ps=remaining, measured_ps=remaining,
|
||||
value_kind=kind,
|
||||
notes=(
|
||||
f"Timing budget FACT total={total:g} ps used={used if used is not None else '—'} "
|
||||
f"remaining={remaining:g} ps. Interfaccia {inst.physical_interface_id} "
|
||||
f"non certificata a L3 per mancanza di limite citato. Not invented. Not OpenEMS."
|
||||
),
|
||||
)
|
||||
min_margin = limit if limit is not None else 0.0
|
||||
verdict = "PASS" if remaining >= min_margin else "FAIL"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets,
|
||||
total_budget_ps=total, used_budget_ps=used,
|
||||
remaining_margin_ps=remaining, measured_ps=remaining,
|
||||
limit_ps=min_margin, value_kind="NUMERIC",
|
||||
notes=(
|
||||
f"L3 timing budget TOTAL−USED=MARGIN ({total:g}−{used if used is not None else 0:g}={remaining:g} ps). "
|
||||
f"Cited min margin {min_margin:g} ps. Not L0/L1 electrical. Not OpenEMS."
|
||||
),
|
||||
)
|
||||
|
||||
if check in {"insertion_loss", "return_loss", "crosstalk"}:
|
||||
if check == "insertion_loss":
|
||||
measured = _il_measured(facts)
|
||||
missing = "insertion loss FACT (dB) — S-parameters non inventati"
|
||||
elif check == "return_loss":
|
||||
measured = _rl_measured(facts)
|
||||
missing = "return loss FACT (dB) — S-parameters non inventati"
|
||||
else:
|
||||
measured = _xt_measured(facts)
|
||||
missing = "crosstalk FACT (dB) — S-parameters non inventati"
|
||||
limit = _limit_from_constraint(cons, _DB_UNITS)
|
||||
if measured is None:
|
||||
return _skip(check, mand, inst, kind, missing, nets=nets)
|
||||
if cons is None or cons.value_kind != "NUMERIC" or limit is None:
|
||||
return _pack(
|
||||
check, _kind_skip(kind), mand, nets=nets,
|
||||
measured_db=measured, value_kind=kind,
|
||||
notes=(
|
||||
f"Channel FACT {check}={measured:g} dB. Interfaccia "
|
||||
f"{inst.physical_interface_id} non certificata a L3 per mancanza di "
|
||||
f"limite citato (pack NUMERIC). Not invented S-param. Not OpenEMS."
|
||||
),
|
||||
)
|
||||
verdict = "PASS" if _db_pass(check, measured, limit) else "FAIL"
|
||||
return _pack(
|
||||
check, verdict, mand, nets=nets,
|
||||
measured_db=measured, limit_db=limit, value_kind="NUMERIC",
|
||||
notes=(
|
||||
f"L3 {check} FACT {measured:g} dB vs cited {limit:g} dB. "
|
||||
f"Not invented. Not OpenEMS. L0/L1 are not electrical."
|
||||
),
|
||||
)
|
||||
|
||||
if check == "channel":
|
||||
if not facts:
|
||||
return _skip(
|
||||
check, mand, inst, kind,
|
||||
"modello di canale completo (IL/RL/budget FACT già presenti)",
|
||||
nets=nets,
|
||||
)
|
||||
if facts.get("channel_complete") is True:
|
||||
il = _il_measured(facts)
|
||||
rl = _rl_measured(facts)
|
||||
total = _num(facts.get("total_budget_ps"))
|
||||
used = _used_from_parts(facts)
|
||||
remaining = remaining_margin_ps(total, used, _num(facts.get("remaining_margin_ps")))
|
||||
if il is not None and rl is not None and remaining is not None:
|
||||
il_c = _constraint_for(iface, "insertion_loss")
|
||||
rl_c = _constraint_for(iface, "return_loss")
|
||||
tb_c = _constraint_for(iface, "timing_budget")
|
||||
il_lim = _limit_from_constraint(il_c, _DB_UNITS)
|
||||
rl_lim = _limit_from_constraint(rl_c, _DB_UNITS)
|
||||
tb_lim = _limit_from_constraint(tb_c, _TIME_UNITS)
|
||||
if il_lim is None or rl_lim is None:
|
||||
return _skip(
|
||||
check, mand, inst, kind,
|
||||
"limiti citati per IL/RL sul canale completo",
|
||||
nets=nets,
|
||||
)
|
||||
ok = _db_pass("insertion_loss", il, il_lim) and _db_pass("return_loss", rl, rl_lim)
|
||||
min_m = tb_lim if tb_lim is not None else 0.0
|
||||
ok = ok and remaining >= min_m
|
||||
return _pack(
|
||||
check, "PASS" if ok else "FAIL", mand, nets=nets,
|
||||
measured_db=il, remaining_margin_ps=remaining, value_kind="NUMERIC",
|
||||
notes=(
|
||||
"L3 complete channel from existing FACT (not OpenEMS). "
|
||||
f"IL={il:g} dB RL={rl:g} dB margin={remaining:g} ps."
|
||||
),
|
||||
)
|
||||
return _skip(
|
||||
check, mand, inst, kind,
|
||||
"canale completo (channel_complete + IL/RL/budget FACT)",
|
||||
nets=nets,
|
||||
)
|
||||
|
||||
# connector / via / package at L3 — FACT only, never FEM
|
||||
if check in {"connector", "via_transitions", "via_transition", "package"}:
|
||||
return _skip(
|
||||
check, mand, inst, kind,
|
||||
f"{check} channel FACT (no OpenEMS/FEM)",
|
||||
nets=nets,
|
||||
)
|
||||
return _pack(check, "UNKNOWN", mand, value_kind=kind, notes="Not an L3 channel check.")
|
||||
|
||||
|
||||
def certify_instance_l3(
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface,
|
||||
channel_data: Any = None,
|
||||
) -> list[L3CheckResult]:
|
||||
if inst.pcb_relevant == "NO" or iface.pcb_relevant == "NO":
|
||||
return [_pack(
|
||||
"channel", "NOT_APPLICABLE", "INFORMATIONAL",
|
||||
notes=(
|
||||
"Internal interconnect — L3 channel N/A, not FAIL. "
|
||||
"L0/L1 are not electrical. Not OpenEMS."
|
||||
),
|
||||
)]
|
||||
checks: list[str] = []
|
||||
for c in L3_CORE:
|
||||
if c not in checks:
|
||||
checks.append(c)
|
||||
for c in iface.required_checks:
|
||||
if c in L3_CHECKS and c not in checks:
|
||||
checks.append(c)
|
||||
facts = lookup_channel_facts(inst, channel_data)
|
||||
return [_run_l3_check(inst, iface, c, facts) for c in checks]
|
||||
|
||||
|
||||
def l3_findings(inst: PhysicalBusInstance, results: list[L3CheckResult]) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
designator = inst.host_ref or inst.physical_interface_id
|
||||
for r in results:
|
||||
if r.result == "PASS":
|
||||
continue
|
||||
if r.result == "FAIL" and r.mandatory != "MANDATORY":
|
||||
continue
|
||||
skip_family = r.result in {
|
||||
"UNKNOWN", "VENDOR_DEPENDENT", "CONTROLLER_DEPENDENT",
|
||||
"PHY_DEPENDENT", "MISSING_SOURCE",
|
||||
}
|
||||
fail = r.result == "FAIL" and r.mandatory == "MANDATORY"
|
||||
na = r.result == "NOT_APPLICABLE"
|
||||
if not skip_family and not fail and not na:
|
||||
continue
|
||||
if fail:
|
||||
rule_id = "PE-PRT-L3-002"
|
||||
status = "ERROR"
|
||||
finding = f"L3 channel {r.check}: {r.notes}"
|
||||
elif skip_family:
|
||||
rule_id = "PE-PRT-L3-001"
|
||||
status = "WARNING"
|
||||
finding = r.notes or (
|
||||
f"Interfaccia {inst.physical_interface_id} non certificata a L3 "
|
||||
f"per {r.check} ({r.result})."
|
||||
)
|
||||
else:
|
||||
rule_id = "PE-PRT-L0-002"
|
||||
status = "INFO"
|
||||
finding = r.notes
|
||||
f = Finding(
|
||||
designator=designator,
|
||||
mpn="",
|
||||
aspect="protocol_l3",
|
||||
finding=finding,
|
||||
why=r.notes,
|
||||
status=status,
|
||||
source=SOURCE,
|
||||
net=r.nets[0] if r.nets else None,
|
||||
rule_id=rule_id,
|
||||
facts=finding,
|
||||
requirement="L3 channel only from existing FACT (dB/ps). No OpenEMS. No invented S-param.",
|
||||
inference="L3 is channel. L0/L1 are not electrical. Timing budget = total−used=margin.",
|
||||
provenance="MANDATORY" if fail else "TYPICAL",
|
||||
finding_class="RULE" if fail else ("REVIEW" if skip_family else "INFO"),
|
||||
evidence_status="INSUFFICIENT" if skip_family else "SUFFICIENT",
|
||||
)
|
||||
out.append(complete_finding(f))
|
||||
return out
|
||||
|
||||
|
||||
def certify_l3(
|
||||
instances: list[PhysicalBusInstance],
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
*,
|
||||
channel_data: Any = None,
|
||||
) -> tuple[dict[str, list[L3CheckResult]], list[Finding]]:
|
||||
cat = catalog or load_catalog()
|
||||
ifaces = {p.id: p for p in cat.physical_interfaces}
|
||||
by_id: dict[str, list[L3CheckResult]] = {}
|
||||
findings: list[Finding] = []
|
||||
for inst in instances:
|
||||
iface = ifaces.get(inst.physical_interface_id)
|
||||
if iface is None:
|
||||
continue
|
||||
rows = certify_instance_l3(inst, iface, channel_data=channel_data)
|
||||
by_id[inst.instance_id] = rows
|
||||
findings.extend(l3_findings(inst, rows))
|
||||
return by_id, findings
|
||||
|
||||
|
||||
def load_channel_data_file(path: Any) -> dict[str, Any] | None:
|
||||
"""Load existing channel_data.json. Does not parse Touchstone or run FEM."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
p = Path(path) if path is not None else None
|
||||
if p is None or not p.is_file():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
return raw
|
||||
@@ -0,0 +1,814 @@
|
||||
"""M1: physical_bus_instance recognition. No L0–L3 certifier. No invented Z.
|
||||
|
||||
Priority: declared > silicon/BOM > pinout > net/pin name > pair geometry.
|
||||
Ambiguity is REVIEW, never FAIL. AXI4 internal does not attach PCB nets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.periscopex.models import Component, ComponentType, DesignGraph
|
||||
from backend.periscopex.pcb_net_match import normalize_kicad_hierarchy_net
|
||||
from backend.periscopex.protocol_catalog import (
|
||||
ProtocolCatalog,
|
||||
ProtocolCertificationSection,
|
||||
load_catalog,
|
||||
)
|
||||
|
||||
PACK_MACROPHASE = "M1"
|
||||
|
||||
EvidenceKind = Literal[
|
||||
"declared",
|
||||
"silicon",
|
||||
"part",
|
||||
"pinout",
|
||||
"net_name",
|
||||
"pair_geometry",
|
||||
]
|
||||
GroupKind = Literal[
|
||||
"DIFFERENTIAL_PAIR",
|
||||
"BYTE_LANE",
|
||||
"DATA_LANE",
|
||||
"CLOCK_GROUP",
|
||||
"ADDRESS_GROUP",
|
||||
"COMMAND_GROUP",
|
||||
"CONTROL_GROUP",
|
||||
"STROBE_GROUP",
|
||||
"CHANNEL",
|
||||
"SUBCHANNEL",
|
||||
]
|
||||
RecognitionStatus = Literal["RECOGNIZED", "REVIEW", "NOT_APPLICABLE"]
|
||||
|
||||
_EVIDENCE_SCORE: dict[str, float] = {
|
||||
"declared": 1.0,
|
||||
"silicon": 0.9,
|
||||
"part": 0.85,
|
||||
"pinout": 0.7,
|
||||
"net_name": 0.55,
|
||||
"pair_geometry": 0.4,
|
||||
}
|
||||
|
||||
_USB_C_RE = re.compile(r"USB[\s_\-]*C(?:[\s_\-]|\b|$)|TYPE[\s_\-]*C|USB4085", re.I)
|
||||
_USB2_RE = re.compile(r"USB[\s_\-]*2|16P", re.I)
|
||||
_USB3_RE = re.compile(r"USB[\s_\-]*3|SUPER\s*SPEED|SSTX|SSRX|SS_T[XR]", re.I)
|
||||
_USB4_RE = re.compile(r"USB[\s_\-]*4|\bUSB4\b|TBT4|THUNDERBOLT\s*4", re.I)
|
||||
_USB_HS_RE = re.compile(r"HIGH\s*SPEED|\b480\b", re.I)
|
||||
_USB_LSFS_RE = re.compile(r"LOW\s*SPEED|FULL\s*SPEED|\b12\s*MB|\b1\.5\s*MB", re.I)
|
||||
_USB_SILICON_RE = re.compile(
|
||||
r"CH340|CP210|FT232|USB\s*PHY|USB\s*CTRL|USB3300|TUSB|ISP150|MAX3421", re.I,
|
||||
)
|
||||
_RJ45_RE = re.compile(r"RJ45|8P8C|MAGJACK", re.I)
|
||||
_ETH_PHY_RE = re.compile(
|
||||
r"88E\d|LAN87|DP838|RTL821|KSZ90|BCM54|ETHERNET\s*PHY|\bPHY\b.*ETH|ETH.*PHY", re.I,
|
||||
)
|
||||
_ETH_MAG_RE = re.compile(r"MAGNETIC|PULSE\s*JACK|ETHERNET\s*XFR|LAN_XFR", re.I)
|
||||
_HDMI_MINI_RE = re.compile(r"MINI\s*HDMI|HDMI[\s_\-]*C\b|HDMI[\s_\-]*MINI", re.I)
|
||||
_HDMI_RE = re.compile(r"\bHDMI\b", re.I)
|
||||
_HDMI_FRL_RE = re.compile(r"\bFRL\b|HDMI\s*2\.1", re.I)
|
||||
_HDMI_TMDS_NET = re.compile(r"HDMI[_]?TX|HDMI[_]?CLK|HDMI[_]?D\d|TMDS", re.I)
|
||||
_DVI_RE = re.compile(r"\bDVI\b|DVI[\s_\-]*[DAI]", re.I)
|
||||
_DDR_GEN = [
|
||||
("ddr5", re.compile(r"\bDDR5\b", re.I)),
|
||||
("ddr4", re.compile(r"\bDDR4\b|MT41|K4A|IS43TR", re.I)),
|
||||
("ddr3l", re.compile(r"\bDDR3L\b", re.I)),
|
||||
("ddr3", re.compile(r"\bDDR3\b|MT41|K4B|IS43TR", re.I)),
|
||||
("ddr2", re.compile(r"\bDDR2\b|MT47", re.I)),
|
||||
("ddr1", re.compile(r"\bDDR1\b|\bDDR\b", re.I)),
|
||||
]
|
||||
_DDR4_PART = re.compile(r"MT41K|K4A|IS43TR|W631|EDY4016", re.I)
|
||||
_AXI_RE = re.compile(r"\bAXI4(?:-LITE|-STREAM)?\b|\bAXI\b", re.I)
|
||||
_AXI_C2C_RE = re.compile(r"CHIP[\s\-]*TO[\s\-]*CHIP|C2C|AXI.*PHY", re.I)
|
||||
_DECLARE_RE = re.compile(
|
||||
r"^(?:protocol|logical_protocol|physical_interface|bus)$", re.I,
|
||||
)
|
||||
_DQ_RE = re.compile(r"(?:^|[_/.])DQ(\d+)(?:$|[_/.])", re.I)
|
||||
_DQS_RE = re.compile(r"(?:^|[_/.])DQS(\d*)(?:[_#]?(P|N|PINS|PLUS|MINUS))?(?:$|[_/.])", re.I)
|
||||
_DM_RE = re.compile(r"(?:^|[_/.])(?:DM|DBI)(\d+)(?:$|[_/.])", re.I)
|
||||
_CK_RE = re.compile(r"(?:^|[_/.])(?:CK|CLK)(?:[_#]?(P|N))?(?:$|[_/.])", re.I)
|
||||
_ADDR_RE = re.compile(r"(?:^|[_/.])(?:A|ADDR|BA|BG)(\d+)(?:$|[_/.])", re.I)
|
||||
_CMD_RE = re.compile(
|
||||
r"(?:^|[_/.])(?:RAS|CAS|WE|ACT|CS|CKE|ODT|RESET|ZQ)(?:[#_N]*)(?:$|[_/.])",
|
||||
re.I,
|
||||
)
|
||||
_USB_DP_RE = re.compile(
|
||||
r"(?:^|[_/.])(?:USB[_]?D\+|USB[_]?DP|D\+)$", re.I,
|
||||
)
|
||||
_USB_DN_RE = re.compile(
|
||||
r"(?:^|[_/.])(?:USB[_]?D\-|USB[_]?DM|D\-)$", re.I,
|
||||
)
|
||||
|
||||
|
||||
class SignalGroup(BaseModel):
|
||||
kind: GroupKind
|
||||
id: str
|
||||
nets: list[str] = Field(default_factory=list)
|
||||
roles: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PhysicalBusInstance(BaseModel):
|
||||
instance_id: str
|
||||
logical_protocol_id: str
|
||||
physical_interface_id: str
|
||||
pcb_relevant: Literal["YES", "NO", "CONDITIONAL"]
|
||||
confidence: float
|
||||
evidence_kind: EvidenceKind
|
||||
recognition_status: RecognitionStatus
|
||||
nets: list[str] = Field(default_factory=list)
|
||||
groups: list[SignalGroup] = Field(default_factory=list)
|
||||
host_ref: str = ""
|
||||
peer_refs: list[str] = Field(default_factory=list)
|
||||
ambiguous_logical_ids: list[str] = Field(default_factory=list)
|
||||
notes: str = ""
|
||||
connector: str = ""
|
||||
physical_implementation: str = ""
|
||||
|
||||
|
||||
def _leaf(name: str) -> str:
|
||||
n = normalize_kicad_hierarchy_net(name)
|
||||
return n.split("/")[-1] if n else ""
|
||||
|
||||
|
||||
def _blob(comp: Component, graph: DesignGraph) -> str:
|
||||
extra = []
|
||||
for bag in (
|
||||
(graph.bom_fields or {}).get(comp.reference) or {},
|
||||
(graph.schematic_fields or {}).get(comp.reference) or {},
|
||||
):
|
||||
extra.extend(str(v) for v in bag.values() if v)
|
||||
specs = ""
|
||||
if comp.specs is not None and getattr(comp.specs, "values", None):
|
||||
specs = " ".join(str(v) for v in comp.specs.values.values() if v)
|
||||
return " ".join(
|
||||
str(x or "")
|
||||
for x in (
|
||||
comp.mpn, comp.value, comp.footprint, comp.component_subtype, specs, *extra,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _declared_ids(graph: DesignGraph) -> list[tuple[str, str, str]]:
|
||||
"""(ref, logical_or_physical_id, raw)."""
|
||||
hits: list[tuple[str, str, str]] = []
|
||||
bags: list[tuple[str, dict]] = []
|
||||
for ref, row in (graph.bom_fields or {}).items():
|
||||
bags.append((ref, row or {}))
|
||||
for ref, row in (graph.schematic_fields or {}).items():
|
||||
bags.append((ref, row or {}))
|
||||
for ref, comp in graph.components.items():
|
||||
if comp.specs is not None and getattr(comp.specs, "values", None):
|
||||
bags.append((ref, dict(comp.specs.values)))
|
||||
for ref, row in bags:
|
||||
for key, val in row.items():
|
||||
if val is None:
|
||||
continue
|
||||
if not _DECLARE_RE.match(str(key)):
|
||||
continue
|
||||
hits.append((ref, str(val).strip().lower(), str(val)))
|
||||
return hits
|
||||
|
||||
|
||||
def _pn_pairs(net_names: list[str]) -> list[tuple[str, str]]:
|
||||
by_leaf = {_leaf(n): n for n in net_names if n}
|
||||
pairs: list[tuple[str, str]] = []
|
||||
used: set[str] = set()
|
||||
for leaf, full in by_leaf.items():
|
||||
if full in used:
|
||||
continue
|
||||
mate_leaf = None
|
||||
if leaf.endswith("+"):
|
||||
mate_leaf = leaf[:-1] + "-"
|
||||
elif leaf.endswith("_P") or leaf.endswith("_p"):
|
||||
mate_leaf = leaf[:-2] + "_N"
|
||||
elif leaf.upper().endswith("DP"):
|
||||
stem = leaf[:-2]
|
||||
mate_leaf = stem + ("DM" if leaf[-2].isupper() else "dm")
|
||||
if mate_leaf and mate_leaf in by_leaf:
|
||||
other = by_leaf[mate_leaf]
|
||||
pairs.append((full, other))
|
||||
used.add(full)
|
||||
used.add(other)
|
||||
return pairs
|
||||
|
||||
|
||||
def _usb_pairs(net_names: list[str]) -> list[tuple[str, str]]:
|
||||
plus = [n for n in net_names if _USB_DP_RE.search(_leaf(n))]
|
||||
minus = [n for n in net_names if _USB_DN_RE.search(_leaf(n))]
|
||||
found: list[tuple[str, str]] = []
|
||||
used: set[str] = set()
|
||||
generic = _pn_pairs(net_names)
|
||||
for a, b in generic:
|
||||
la, lb = _leaf(a).upper(), _leaf(b).upper()
|
||||
if "USB" in la or "USB" in lb or la.startswith("D") or lb.startswith("D"):
|
||||
found.append((a, b))
|
||||
used.add(a)
|
||||
used.add(b)
|
||||
for p in plus:
|
||||
if p in used:
|
||||
continue
|
||||
pl = _leaf(p)
|
||||
for m in minus:
|
||||
if m in used:
|
||||
continue
|
||||
if pl.replace("+", "-").replace("DP", "DM").replace("_P", "_N") == _leaf(m):
|
||||
found.append((p, m))
|
||||
used.add(p)
|
||||
used.add(m)
|
||||
break
|
||||
if not found and len(plus) == 1 and len(minus) == 1:
|
||||
found.append((plus[0], minus[0]))
|
||||
return found
|
||||
|
||||
|
||||
def _byte_lanes(net_names: list[str]) -> list[SignalGroup]:
|
||||
dq: dict[int, str] = {}
|
||||
dqs: dict[int, dict[str, str]] = {}
|
||||
dm: dict[int, str] = {}
|
||||
for n in net_names:
|
||||
leaf = _leaf(n)
|
||||
m = _DQ_RE.search(leaf)
|
||||
if m and "DQS" not in leaf.upper():
|
||||
dq[int(m.group(1))] = n
|
||||
continue
|
||||
m = _DQS_RE.search(leaf)
|
||||
if m:
|
||||
idx = int(m.group(1) or "0")
|
||||
pol = (m.group(2) or "P").upper()[:1]
|
||||
dqs.setdefault(idx, {})[pol] = n
|
||||
continue
|
||||
m = _DM_RE.search(leaf)
|
||||
if m:
|
||||
dm[int(m.group(1))] = n
|
||||
lanes: list[SignalGroup] = []
|
||||
lane_ids = set(i // 8 for i in dq) | set(dqs) | set(dm)
|
||||
for lane in sorted(lane_ids):
|
||||
roles: dict[str, str] = {}
|
||||
nets: list[str] = []
|
||||
for bit in range(lane * 8, lane * 8 + 8):
|
||||
if bit in dq:
|
||||
roles[f"DQ{bit}"] = dq[bit]
|
||||
nets.append(dq[bit])
|
||||
if lane in dqs:
|
||||
for pol, net in dqs[lane].items():
|
||||
roles[f"DQS{lane}_{pol}"] = net
|
||||
nets.append(net)
|
||||
if lane in dm:
|
||||
roles[f"DM{lane}"] = dm[lane]
|
||||
nets.append(dm[lane])
|
||||
if nets:
|
||||
lanes.append(SignalGroup(
|
||||
kind="BYTE_LANE", id=f"BYTE_LANE_{lane}", nets=nets, roles=roles,
|
||||
))
|
||||
return lanes
|
||||
|
||||
|
||||
def _iface_map(catalog: ProtocolCatalog) -> dict[str, Any]:
|
||||
return {p.id: p for p in catalog.physical_interfaces}
|
||||
|
||||
|
||||
def _logical_ids(catalog: ProtocolCatalog) -> set[str]:
|
||||
return {p.id for p in catalog.logical_protocols}
|
||||
|
||||
|
||||
def recognize_physical_buses(
|
||||
graph: DesignGraph,
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
) -> list[PhysicalBusInstance]:
|
||||
cat = catalog or load_catalog()
|
||||
ifaces = _iface_map(cat)
|
||||
logical = _logical_ids(cat)
|
||||
out: list[PhysicalBusInstance] = []
|
||||
out.extend(_recognize_declared(graph, ifaces, logical))
|
||||
out.extend(_recognize_usb(graph, ifaces))
|
||||
out.extend(_recognize_ethernet(graph, ifaces))
|
||||
out.extend(_recognize_dvi(graph, ifaces))
|
||||
out.extend(_recognize_hdmi(graph, ifaces))
|
||||
out.extend(_recognize_hbm(graph, ifaces))
|
||||
out.extend(_recognize_ddr(graph, ifaces))
|
||||
out.extend(_recognize_axi(graph, ifaces))
|
||||
return _dedupe_instances(out)
|
||||
|
||||
|
||||
def _recognize_declared(
|
||||
graph: DesignGraph,
|
||||
ifaces: dict,
|
||||
logical: set[str],
|
||||
) -> list[PhysicalBusInstance]:
|
||||
out: list[PhysicalBusInstance] = []
|
||||
for ref, ident, raw in _declared_ids(graph):
|
||||
ident = ident.replace("_", "-")
|
||||
phys_id = None
|
||||
log_id = None
|
||||
if ident in ifaces:
|
||||
phys_id = ident
|
||||
log_id = ifaces[ident].logical_protocol_id
|
||||
elif ident in logical:
|
||||
log_id = ident
|
||||
for pid, iface in ifaces.items():
|
||||
if iface.logical_protocol_id == ident and iface.pcb_relevant == "NO":
|
||||
phys_id = pid
|
||||
break
|
||||
if phys_id is None:
|
||||
for pid, iface in ifaces.items():
|
||||
if iface.logical_protocol_id == ident:
|
||||
phys_id = pid
|
||||
break
|
||||
if not phys_id or not log_id:
|
||||
continue
|
||||
iface = ifaces[phys_id]
|
||||
nets: list[str] = []
|
||||
if iface.pcb_relevant != "NO":
|
||||
comp = graph.components.get(ref)
|
||||
if comp:
|
||||
nets = sorted({n for n in comp.pins.values() if n})
|
||||
out.append(PhysicalBusInstance(
|
||||
instance_id=f"{phys_id}:{ref}",
|
||||
logical_protocol_id=log_id,
|
||||
physical_interface_id=phys_id,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["declared"],
|
||||
evidence_kind="declared",
|
||||
recognition_status=(
|
||||
"NOT_APPLICABLE" if iface.pcb_relevant == "NO" else "RECOGNIZED"
|
||||
),
|
||||
nets=nets,
|
||||
host_ref=ref,
|
||||
notes=f"declared {raw}",
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _recognize_usb(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
instances: list[PhysicalBusInstance] = []
|
||||
type_c = []
|
||||
other_usb_conn = []
|
||||
for comp in graph.components.values():
|
||||
if comp.component_type != ComponentType.CONNECTOR:
|
||||
continue
|
||||
blob = _blob(comp, graph)
|
||||
if _USB_C_RE.search(blob) or _USB_C_RE.search(comp.footprint or ""):
|
||||
type_c.append(comp)
|
||||
elif re.search(r"USB", blob, re.I) or re.search(r"USB", comp.footprint or "", re.I):
|
||||
other_usb_conn.append(comp)
|
||||
pairs = _usb_pairs(list(graph.nets))
|
||||
silicon = [
|
||||
c for c in graph.components.values()
|
||||
if c.component_type == ComponentType.IC and (
|
||||
_USB_SILICON_RE.search(_blob(c, graph))
|
||||
or (pairs and any(
|
||||
n in (c.pins or {}).values() for n in [p for pair in pairs for p in pair]
|
||||
))
|
||||
)
|
||||
]
|
||||
ss_nets = [n for n in graph.nets if _USB3_RE.search(_leaf(n)) or _USB3_RE.search(n)]
|
||||
usb4_ev = any(_USB4_RE.search(_blob(c, graph)) for c in graph.components.values())
|
||||
usb3_ev = bool(ss_nets) or any(
|
||||
_USB3_RE.search(_blob(c, graph)) for c in [*type_c, *silicon]
|
||||
)
|
||||
if not type_c and not other_usb_conn and not pairs:
|
||||
return []
|
||||
|
||||
hs = any(_USB_HS_RE.search(_blob(c, graph)) for c in [*type_c, *other_usb_conn, *silicon])
|
||||
lsfs = any(_USB_LSFS_RE.search(_blob(c, graph)) for c in [*type_c, *other_usb_conn, *silicon])
|
||||
if hs and not lsfs:
|
||||
usb2_log, usb2_phys = "usb2-hs", "usb2-hs-dpair"
|
||||
elif lsfs and not hs:
|
||||
usb2_log, usb2_phys = "usb2-ls-fs", "usb2-ls-fs-dpair"
|
||||
else:
|
||||
usb2_log, usb2_phys = "usb2-hs", "usb2-hs-dpair"
|
||||
|
||||
groups = [
|
||||
SignalGroup(
|
||||
kind="DIFFERENTIAL_PAIR",
|
||||
id=f"USB2_D_{i}",
|
||||
nets=[a, b],
|
||||
roles={"D+": a, "D-": b},
|
||||
)
|
||||
for i, (a, b) in enumerate(pairs)
|
||||
]
|
||||
nets = sorted({n for g in groups for n in g.nets})
|
||||
|
||||
if type_c:
|
||||
host = type_c[0].reference
|
||||
pin_notes = _usb_c_orientation_note(type_c[0], graph)
|
||||
combo = bool(pairs) and bool(silicon)
|
||||
# Connector alone does not declare USB 2.0 / 3.x / USB4.
|
||||
if not combo:
|
||||
iface = ifaces["usb-c-receptacle"]
|
||||
instances.append(PhysicalBusInstance(
|
||||
instance_id=f"usb-c-receptacle:{host}",
|
||||
logical_protocol_id="usb-c",
|
||||
physical_interface_id="usb-c-receptacle",
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["part"],
|
||||
evidence_kind="part",
|
||||
recognition_status="REVIEW",
|
||||
nets=nets,
|
||||
groups=groups,
|
||||
host_ref=host,
|
||||
peer_refs=[c.reference for c in type_c],
|
||||
notes=(
|
||||
"USB Type-C connector only; protocol not inferred "
|
||||
"(need nets + silicon + topology). Not USB3. Not USB4. "
|
||||
+ pin_notes
|
||||
),
|
||||
connector="Type-C",
|
||||
physical_implementation="",
|
||||
))
|
||||
return instances
|
||||
iface = ifaces["usb-c-usb2-receptacle"]
|
||||
amb = [] if hs ^ lsfs else ["usb2-ls", "usb2-fs", "usb2-hs"]
|
||||
status: RecognitionStatus = "REVIEW" if amb or usb3_ev else "RECOGNIZED"
|
||||
notes = (
|
||||
"USB 2.0 over USB Type-C (connector+nets+silicon). "
|
||||
"Type-C is the connector, USB 2.0 is the protocol. "
|
||||
+ pin_notes
|
||||
)
|
||||
if usb3_ev:
|
||||
notes += " SuperSpeed evidence present — USB3 is a separate implementation, not implied by Type-C."
|
||||
amb = list(dict.fromkeys(amb + ["usb3-x"]))
|
||||
instances.append(PhysicalBusInstance(
|
||||
instance_id=f"usb-c-usb2-receptacle:{host}",
|
||||
logical_protocol_id=usb2_log,
|
||||
physical_interface_id="usb-c-usb2-receptacle",
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["silicon"],
|
||||
evidence_kind="silicon",
|
||||
recognition_status=status,
|
||||
nets=nets,
|
||||
groups=groups,
|
||||
host_ref=host,
|
||||
peer_refs=[c.reference for c in type_c] + [s.reference for s in silicon],
|
||||
ambiguous_logical_ids=amb,
|
||||
notes=notes,
|
||||
connector="Type-C",
|
||||
physical_implementation="USB 2.0 over USB Type-C",
|
||||
))
|
||||
if usb3_ev and "usb3-over-usb-c" in ifaces:
|
||||
instances.append(PhysicalBusInstance(
|
||||
instance_id=f"usb3-over-usb-c:{host}",
|
||||
logical_protocol_id="usb3-x",
|
||||
physical_interface_id="usb3-over-usb-c",
|
||||
pcb_relevant="YES",
|
||||
confidence=_EVIDENCE_SCORE["net_name"],
|
||||
evidence_kind="net_name",
|
||||
recognition_status="REVIEW",
|
||||
nets=sorted(ss_nets),
|
||||
host_ref=host,
|
||||
notes="USB 3.x over Type-C from SS evidence; not USB4; not implied by connector alone.",
|
||||
connector="Type-C",
|
||||
physical_implementation="USB 3.x over USB Type-C",
|
||||
))
|
||||
if usb4_ev and "usb4-over-usb-c" in ifaces:
|
||||
instances.append(PhysicalBusInstance(
|
||||
instance_id=f"usb4-over-usb-c:{host}",
|
||||
logical_protocol_id="usb4",
|
||||
physical_interface_id="usb4-over-usb-c",
|
||||
pcb_relevant="YES",
|
||||
confidence=_EVIDENCE_SCORE["part"],
|
||||
evidence_kind="part",
|
||||
recognition_status="REVIEW",
|
||||
host_ref=host,
|
||||
notes="USB4 evidence distinct from Type-C and USB3.",
|
||||
connector="Type-C",
|
||||
physical_implementation="USB4 over USB Type-C",
|
||||
))
|
||||
return instances
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
host = other_usb_conn[0].reference if other_usb_conn else (
|
||||
silicon[0].reference if silicon else (nets[0] if nets else "usb")
|
||||
)
|
||||
if not other_usb_conn and not silicon:
|
||||
# Pairs without connector or silicon: net-name only, REVIEW.
|
||||
evidence: EvidenceKind = "net_name"
|
||||
conf = _EVIDENCE_SCORE["net_name"]
|
||||
status = "REVIEW"
|
||||
notes = "USB 2.0 inferred from D+/D− nets only; connector+silicon missing."
|
||||
else:
|
||||
evidence = "silicon" if silicon else "part"
|
||||
conf = _EVIDENCE_SCORE[evidence]
|
||||
status = "REVIEW" if not (hs ^ lsfs) else "RECOGNIZED"
|
||||
notes = "USB 2.0 from connector+nets+silicon/topology. No invented Z."
|
||||
iface = ifaces[usb2_phys]
|
||||
instances.append(PhysicalBusInstance(
|
||||
instance_id=f"{usb2_phys}:{host}",
|
||||
logical_protocol_id=usb2_log,
|
||||
physical_interface_id=usb2_phys,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=conf,
|
||||
evidence_kind=evidence,
|
||||
recognition_status=status,
|
||||
nets=nets,
|
||||
groups=groups,
|
||||
host_ref=host,
|
||||
peer_refs=[c.reference for c in other_usb_conn] + [s.reference for s in silicon],
|
||||
ambiguous_logical_ids=["usb2-ls-fs", "usb2-hs"] if status == "REVIEW" else [],
|
||||
notes=notes,
|
||||
connector="",
|
||||
physical_implementation="USB 2.0",
|
||||
))
|
||||
return instances
|
||||
|
||||
|
||||
def _usb_c_orientation_note(comp: Component, graph: DesignGraph) -> str:
|
||||
"""D+/D− from schematic pins; do not assume a mux mapping."""
|
||||
a_side = []
|
||||
b_side = []
|
||||
for pin, net in (comp.pins or {}).items():
|
||||
pu = pin.upper()
|
||||
if pu in {"A6", "A7"} and net:
|
||||
a_side.append(f"{pin}={net}")
|
||||
if pu in {"B6", "B7"} and net:
|
||||
b_side.append(f"{pin}={net}")
|
||||
if a_side and b_side:
|
||||
return (
|
||||
f" D+/D− both orientations on schematic ({', '.join(a_side + b_side)}); "
|
||||
f"mux/switch not assumed."
|
||||
)
|
||||
if a_side or b_side:
|
||||
return (
|
||||
f" D+/D− orientation from schematic pins {', '.join(a_side or b_side)}; "
|
||||
f"opposite-side mux not assumed."
|
||||
)
|
||||
return " D+/D− orientation taken from schematic; mux not assumed."
|
||||
|
||||
|
||||
|
||||
|
||||
def _recognize_ethernet(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
rj45 = [
|
||||
c for c in graph.components.values()
|
||||
if c.component_type == ComponentType.CONNECTOR
|
||||
and (_RJ45_RE.search(_blob(c, graph)) or _RJ45_RE.search(c.footprint or ""))
|
||||
]
|
||||
phys = [
|
||||
c for c in graph.components.values()
|
||||
if c.component_type == ComponentType.IC and _ETH_PHY_RE.search(_blob(c, graph))
|
||||
]
|
||||
mags = [c for c in graph.components.values() if _ETH_MAG_RE.search(_blob(c, graph))]
|
||||
named = " ".join(_blob(c, graph) for c in graph.components.values()) + " " + " ".join(graph.nets)
|
||||
if not rj45 and not phys and not re.search(r"RGMII|SGMII|1000BASE|10BASE|100BASE", named, re.I):
|
||||
return []
|
||||
if rj45 and not phys and not mags and not re.search(
|
||||
r"1000BASE-T|100BASE-TX|10BASE-T|SGMII|RGMII", named, re.I,
|
||||
):
|
||||
iface = ifaces.get("ethernet-rj45-unspecified")
|
||||
if iface is None:
|
||||
return []
|
||||
host = rj45[0].reference
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"ethernet-rj45-unspecified:{host}",
|
||||
logical_protocol_id="ethernet-unspecified",
|
||||
physical_interface_id="ethernet-rj45-unspecified",
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["part"],
|
||||
evidence_kind="part",
|
||||
recognition_status="REVIEW",
|
||||
host_ref=host,
|
||||
peer_refs=[c.reference for c in rj45],
|
||||
notes="RJ45 connector alone is not 100BASE-TX or 1000BASE-T.",
|
||||
connector="RJ45",
|
||||
physical_implementation="",
|
||||
)]
|
||||
lid, pid = "100base-tx", "100base-tx-mdi"
|
||||
if re.search(r"1000BASE-T|\bGBE\b|GIGABIT", named, re.I):
|
||||
lid, pid = "1000base-t", "1000base-t-mdi"
|
||||
elif re.search(r"10GBASE-T", named, re.I):
|
||||
lid, pid = "10gbase-t", "10gbase-t-mdi"
|
||||
elif re.search(r"2\.5GBASE", named, re.I):
|
||||
lid, pid = "2.5gbase-t", "2.5gbase-t-mdi"
|
||||
elif re.search(r"5GBASE-T", named, re.I):
|
||||
lid, pid = "5gbase-t", "5gbase-t-mdi"
|
||||
elif re.search(r"SGMII", named, re.I):
|
||||
lid, pid = "sgmii", "sgmii-pcb"
|
||||
elif re.search(r"1000BASE-X", named, re.I):
|
||||
lid, pid = "1000base-x", "1000base-x-pcb"
|
||||
elif re.search(r"100BASE-FX", named, re.I):
|
||||
lid, pid = "100base-fx", "100base-fx-pcb"
|
||||
elif re.search(r"10GBASE-R", named, re.I):
|
||||
lid, pid = "10gbase-r", "10gbase-r-pcb"
|
||||
elif re.search(r"10BASE-T", named, re.I):
|
||||
lid, pid = "10base-t", "10base-t-mdi"
|
||||
elif re.search(r"100BASE-TX", named, re.I):
|
||||
lid, pid = "100base-tx", "100base-tx-mdi"
|
||||
else:
|
||||
return []
|
||||
iface = ifaces.get(pid)
|
||||
if iface is None:
|
||||
return []
|
||||
host = (phys[0].reference if phys else (rj45[0].reference if rj45 else lid))
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{pid}:{host}",
|
||||
logical_protocol_id=lid,
|
||||
physical_interface_id=pid,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["silicon"] if phys else _EVIDENCE_SCORE["part"],
|
||||
evidence_kind="silicon" if phys else "part",
|
||||
recognition_status="REVIEW",
|
||||
host_ref=host,
|
||||
notes="Ethernet variant from PHY/magnetics/naming; IEEE vs PHY vs PCB vs magnetics stay distinct. Incomplete without IEEE 802.3 on file.",
|
||||
connector=iface.connector,
|
||||
physical_implementation=lid,
|
||||
)]
|
||||
|
||||
|
||||
def _recognize_dvi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
conns = [
|
||||
c for c in graph.components.values()
|
||||
if c.component_type == ComponentType.CONNECTOR and (
|
||||
_DVI_RE.search(_blob(c, graph)) or _DVI_RE.search(c.footprint or "")
|
||||
)
|
||||
]
|
||||
if not conns:
|
||||
return []
|
||||
pid = "dvi-1.0-tmds"
|
||||
iface = ifaces.get(pid)
|
||||
if iface is None:
|
||||
return []
|
||||
host = conns[0].reference
|
||||
tmds = [
|
||||
n for n in graph.nets
|
||||
if _HDMI_TMDS_NET.search(n) or _HDMI_TMDS_NET.search(_leaf(n)) or _DVI_RE.search(n)
|
||||
]
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{pid}:{host}",
|
||||
logical_protocol_id="dvi",
|
||||
physical_interface_id=pid,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["part"],
|
||||
evidence_kind="part",
|
||||
recognition_status="REVIEW" if not tmds else "RECOGNIZED",
|
||||
nets=tmds,
|
||||
host_ref=host,
|
||||
notes="DVI 1.0 TMDS. Not HDMI. Connector is not HDMI Type A.",
|
||||
connector="DVI",
|
||||
physical_implementation="DVI 1.0 TMDS",
|
||||
)]
|
||||
|
||||
|
||||
def _recognize_hdmi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
conns = [
|
||||
c for c in graph.components.values()
|
||||
if c.component_type == ComponentType.CONNECTOR and (
|
||||
_HDMI_RE.search(_blob(c, graph)) or _HDMI_RE.search(c.footprint or "")
|
||||
or _HDMI_MINI_RE.search(_blob(c, graph))
|
||||
)
|
||||
]
|
||||
tmds = [n for n in graph.nets if _HDMI_TMDS_NET.search(n) or _HDMI_TMDS_NET.search(_leaf(n))]
|
||||
blob = " ".join(_blob(c, graph) for c in graph.components.values())
|
||||
dvi_present = any(
|
||||
_DVI_RE.search(_blob(c, graph)) or _DVI_RE.search(c.footprint or "")
|
||||
for c in graph.components.values()
|
||||
)
|
||||
if dvi_present and not re.search(r"\bHDMI\b", blob, re.I):
|
||||
return []
|
||||
frl = bool(_HDMI_FRL_RE.search(blob) or any(_HDMI_FRL_RE.search(n) for n in graph.nets))
|
||||
mini = any(
|
||||
_HDMI_MINI_RE.search(_blob(c, graph)) or _HDMI_MINI_RE.search(c.footprint or "")
|
||||
for c in conns
|
||||
)
|
||||
if not conns and not tmds and not frl:
|
||||
return []
|
||||
if conns and not tmds and not frl:
|
||||
host = conns[0].reference
|
||||
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
|
||||
pid = "hdmi-1.4-tmds-type-c-mini" if mini else "hdmi-1.4-tmds-type-a"
|
||||
iface = ifaces.get(pid)
|
||||
if iface is None:
|
||||
return []
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{pid}:{host}",
|
||||
logical_protocol_id="hdmi-1.4",
|
||||
physical_interface_id=pid,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["part"],
|
||||
evidence_kind="part",
|
||||
recognition_status="REVIEW",
|
||||
host_ref=host,
|
||||
notes=(
|
||||
"HDMI connector only; Mini HDMI is CONNECTOR not a protocol. "
|
||||
"Version/mode not assumed from connector. TMDS vs FRL not inferred."
|
||||
),
|
||||
connector=conn_name,
|
||||
physical_implementation="",
|
||||
)]
|
||||
if frl:
|
||||
lid, pid = "hdmi-frl", "hdmi-frl-pcb"
|
||||
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
|
||||
else:
|
||||
lid = "hdmi-1.4"
|
||||
pid = "hdmi-1.4-tmds-type-c-mini" if mini else "hdmi-1.4-tmds-type-a"
|
||||
conn_name = "HDMI-C-mini" if mini else "HDMI-A"
|
||||
if re.search(r"HDMI\s*2\.0|HDMI2\.0", blob, re.I):
|
||||
lid = "hdmi-2.0"
|
||||
pid = "hdmi-2.0-tmds-type-c-mini" if mini else "hdmi-2.0-tmds-type-a"
|
||||
iface = ifaces.get(pid)
|
||||
if iface is None:
|
||||
return []
|
||||
host = conns[0].reference if conns else (tmds[0] if tmds else lid)
|
||||
mode = "FRL" if frl else "TMDS"
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{pid}:{host}",
|
||||
logical_protocol_id=lid,
|
||||
physical_interface_id=pid,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["net_name"],
|
||||
evidence_kind="net_name",
|
||||
recognition_status="REVIEW",
|
||||
nets=tmds,
|
||||
host_ref=host,
|
||||
notes=(
|
||||
f"HDMI {mode} electrical; connector={conn_name}. "
|
||||
f"Mini HDMI is not a protocol id. TMDS rules are not applied to FRL."
|
||||
),
|
||||
connector=conn_name,
|
||||
physical_implementation=f"HDMI {mode}",
|
||||
)]
|
||||
|
||||
|
||||
def _ddr_gen_from_text(text: str) -> str | None:
|
||||
if _DDR4_PART.search(text):
|
||||
return "ddr4"
|
||||
for gen, cre in _DDR_GEN:
|
||||
if cre.search(text):
|
||||
return gen
|
||||
return None
|
||||
|
||||
|
||||
def _recognize_ddr(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
from backend.periscopex.protocol_ddr import recognize_ddr_instances
|
||||
return recognize_ddr_instances(graph, ifaces)
|
||||
|
||||
|
||||
def _recognize_hbm(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
from backend.periscopex.protocol_ddr import recognize_hbm
|
||||
return recognize_hbm(graph, ifaces)
|
||||
|
||||
|
||||
def _recognize_axi(graph: DesignGraph, ifaces: dict) -> list[PhysicalBusInstance]:
|
||||
hits: list[Component] = []
|
||||
chip_to_chip = False
|
||||
for comp in graph.components.values():
|
||||
blob = _blob(comp, graph)
|
||||
if _AXI_RE.search(blob):
|
||||
hits.append(comp)
|
||||
if _AXI_C2C_RE.search(blob):
|
||||
chip_to_chip = True
|
||||
declared = any(i == "axi4" or i.startswith("axi4") for _, i, _ in _declared_ids(graph))
|
||||
if not hits and not declared:
|
||||
# Net names alone (AXI_AWVALID) do not invent a PCB AXI bus.
|
||||
return []
|
||||
if chip_to_chip:
|
||||
iface = ifaces["axi4-chip-to-chip-phy"]
|
||||
host = hits[0].reference if hits else "axi4"
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{iface.id}:{host}",
|
||||
logical_protocol_id="axi4",
|
||||
physical_interface_id=iface.id,
|
||||
pcb_relevant=iface.pcb_relevant,
|
||||
confidence=_EVIDENCE_SCORE["silicon"],
|
||||
evidence_kind="silicon",
|
||||
recognition_status="REVIEW",
|
||||
nets=[],
|
||||
host_ref=host,
|
||||
notes="AXI chip-to-chip is CONDITIONAL; PHY docs required. No AXI nets assumed.",
|
||||
)]
|
||||
iface = ifaces["axi4-internal"]
|
||||
host = hits[0].reference if hits else "axi4"
|
||||
return [PhysicalBusInstance(
|
||||
instance_id=f"{iface.id}:{host}",
|
||||
logical_protocol_id="axi4",
|
||||
physical_interface_id=iface.id,
|
||||
pcb_relevant="NO",
|
||||
confidence=_EVIDENCE_SCORE["silicon"],
|
||||
evidence_kind="silicon",
|
||||
recognition_status="NOT_APPLICABLE",
|
||||
nets=[],
|
||||
groups=[],
|
||||
host_ref=host,
|
||||
peer_refs=[c.reference for c in hits],
|
||||
notes="AXI4 is an on-chip interconnect. No PCB nets.",
|
||||
)]
|
||||
|
||||
|
||||
def _dedupe_instances(items: list[PhysicalBusInstance]) -> list[PhysicalBusInstance]:
|
||||
by_id: dict[str, PhysicalBusInstance] = {}
|
||||
for inst in items:
|
||||
prev = by_id.get(inst.instance_id)
|
||||
if prev is None or inst.confidence > prev.confidence:
|
||||
by_id[inst.instance_id] = inst
|
||||
return sorted(by_id.values(), key=lambda i: (-i.confidence, i.instance_id))
|
||||
|
||||
|
||||
def protocol_section_for_graph(
|
||||
graph: DesignGraph | None,
|
||||
catalog: ProtocolCatalog | None = None,
|
||||
) -> ProtocolCertificationSection:
|
||||
from backend.periscopex.protocol_l0 import protocol_exam
|
||||
|
||||
return protocol_exam(graph, catalog)[0]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""M5/M9: protocol report view — checks, chain, FAIL before WARNING, visible skips.
|
||||
|
||||
Does not invent Z or S-parameters. L3 uses existing channel FACT only.
|
||||
OpenEMS/FEM out of product. Does not add pack numbers.
|
||||
L0/L1 are not electrical certification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.periscopex.protocol_catalog import PhysicalInterface, ProtocolConstraint
|
||||
from backend.periscopex.protocol_l0 import _constraint_for
|
||||
from backend.periscopex.protocol_recognize import PhysicalBusInstance
|
||||
|
||||
PACK_MACROPHASE = "M9"
|
||||
|
||||
_SKIP = frozenset({
|
||||
"UNKNOWN", "MISSING_SOURCE", "VENDOR_DEPENDENT",
|
||||
"CONTROLLER_DEPENDENT", "PHY_DEPENDENT",
|
||||
})
|
||||
L3_SKIP_NOTE = (
|
||||
"L3 channel not run (no channel/S-param data; OpenEMS out of scope). Not PASS."
|
||||
)
|
||||
|
||||
|
||||
def result_rank(result: str) -> int:
|
||||
"""FAIL is always first. Never bury FAIL under WARNING (HubAudio)."""
|
||||
if result == "FAIL":
|
||||
return 0
|
||||
if result == "WARNING" or result in _SKIP:
|
||||
return 1
|
||||
if result == "NOT_APPLICABLE":
|
||||
return 2
|
||||
return 3
|
||||
|
||||
|
||||
def instance_worst_result(results: list[str]) -> str:
|
||||
if not results:
|
||||
return "UNKNOWN"
|
||||
return min(results, key=result_rank)
|
||||
|
||||
|
||||
def _unit(dump: dict[str, Any]) -> str:
|
||||
if dump.get("measured_mm") is not None or dump.get("limit_mm") is not None:
|
||||
return "mm"
|
||||
if dump.get("measured_ohm") is not None or dump.get("limit_ohm") is not None:
|
||||
return "ohm"
|
||||
if dump.get("measured_db") is not None or dump.get("limit_db") is not None:
|
||||
return "dB"
|
||||
if (
|
||||
dump.get("measured_ps") is not None
|
||||
or dump.get("limit_ps") is not None
|
||||
or dump.get("remaining_margin_ps") is not None
|
||||
):
|
||||
return "ps"
|
||||
return ""
|
||||
|
||||
|
||||
def _measured(dump: dict[str, Any]) -> float | None:
|
||||
for k in ("measured_ohm", "measured_mm", "measured_db", "measured_ps", "remaining_margin_ps"):
|
||||
v = dump.get(k)
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
return None
|
||||
|
||||
|
||||
def _limit(dump: dict[str, Any]) -> float | None:
|
||||
for k in ("limit_ohm", "limit_mm", "limit_ohm_max", "limit_db", "limit_ps"):
|
||||
v = dump.get(k)
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
return None
|
||||
|
||||
|
||||
def _margin(dump: dict[str, Any]) -> float | None:
|
||||
for k in ("margin_ohm", "margin_mm", "margin_db", "margin_ps", "remaining_margin_ps"):
|
||||
v = dump.get(k)
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
return None
|
||||
|
||||
|
||||
def _group_for_net(inst: PhysicalBusInstance, net: str | None) -> str:
|
||||
if not net:
|
||||
if inst.groups:
|
||||
return inst.groups[0].id or inst.groups[0].kind
|
||||
return ""
|
||||
for g in inst.groups:
|
||||
if net in g.nets:
|
||||
return g.id or g.kind
|
||||
return ""
|
||||
|
||||
|
||||
def _cite(cons: ProtocolConstraint | None) -> tuple[str, str, str]:
|
||||
if cons is None:
|
||||
return "", "", ""
|
||||
src = cons.source
|
||||
doc = ""
|
||||
section = ""
|
||||
if src is not None:
|
||||
doc = src.document or src.organization or ""
|
||||
section = src.section or src.table or (src.page or "")
|
||||
method = cons.measurement_method or ""
|
||||
return doc, section, method
|
||||
|
||||
|
||||
def check_to_report_row(
|
||||
dump: dict[str, Any],
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface | None,
|
||||
) -> dict[str, Any]:
|
||||
check = str(dump.get("check") or "")
|
||||
result = str(dump.get("result") or "")
|
||||
cons = _constraint_for(iface, check) if iface is not None else None
|
||||
doc, section, method = _cite(cons)
|
||||
nets = list(dump.get("nets") or [])
|
||||
net = nets[0] if nets else (inst.nets[0] if inst.nets else "")
|
||||
group = _group_for_net(inst, net or None)
|
||||
skip = result in _SKIP or "non certificata" in str(dump.get("notes") or "")
|
||||
row = {
|
||||
"check": check,
|
||||
"level": dump.get("level") or "",
|
||||
"result": result,
|
||||
"mandatory": dump.get("mandatory") or "",
|
||||
"measured": _measured(dump),
|
||||
"limit": _limit(dump),
|
||||
"margin": _margin(dump),
|
||||
"unit": _unit(dump),
|
||||
"source": doc,
|
||||
"method": method,
|
||||
"notes": dump.get("notes") or "",
|
||||
"skip_visible": skip,
|
||||
"rank": result_rank(result),
|
||||
"chain": {
|
||||
"net": net,
|
||||
"group": group,
|
||||
"physical_interface_id": inst.physical_interface_id,
|
||||
"logical_protocol_id": inst.logical_protocol_id,
|
||||
"constraint_id": cons.id if cons else "",
|
||||
"document": doc,
|
||||
"section": section,
|
||||
},
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
def l3_not_run_row(inst: PhysicalBusInstance) -> dict[str, Any]:
|
||||
return {
|
||||
"check": "channel",
|
||||
"level": "L3",
|
||||
"result": "UNKNOWN",
|
||||
"mandatory": "INFORMATIONAL",
|
||||
"measured": None,
|
||||
"limit": None,
|
||||
"margin": None,
|
||||
"unit": "",
|
||||
"source": "",
|
||||
"method": "",
|
||||
"notes": L3_SKIP_NOTE,
|
||||
"skip_visible": True,
|
||||
"rank": result_rank("UNKNOWN"),
|
||||
"chain": {
|
||||
"net": inst.nets[0] if inst.nets else "",
|
||||
"group": inst.groups[0].id if inst.groups else "",
|
||||
"physical_interface_id": inst.physical_interface_id,
|
||||
"logical_protocol_id": inst.logical_protocol_id,
|
||||
"constraint_id": "",
|
||||
"document": "",
|
||||
"section": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def format_chain(chain: dict[str, Any]) -> str:
|
||||
keys = (
|
||||
"net", "group", "physical_interface_id", "logical_protocol_id",
|
||||
"constraint_id", "document", "section",
|
||||
)
|
||||
parts = [str(chain.get(k) or "—") for k in keys]
|
||||
return " → ".join(parts)
|
||||
|
||||
|
||||
def attach_instance_report(
|
||||
inst: PhysicalBusInstance,
|
||||
iface: PhysicalInterface | None,
|
||||
l0: list[Any],
|
||||
l1: list[Any],
|
||||
l2: list[Any],
|
||||
l3: list[Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
dumps = (
|
||||
[c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l0]
|
||||
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l1]
|
||||
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in l2]
|
||||
+ [c.model_dump() if hasattr(c, "model_dump") else dict(c) for c in (l3 or [])]
|
||||
)
|
||||
rows = [check_to_report_row(d, inst, iface) for d in dumps]
|
||||
if not any(r["level"] == "L3" for r in rows):
|
||||
rows.append(l3_not_run_row(inst))
|
||||
rows.sort(key=lambda r: (r["rank"], str(r["level"]), str(r["check"])))
|
||||
worst = instance_worst_result([str(r["result"]) for r in rows if r["level"] != "L3"])
|
||||
# L3 skip/UNKNOWN must not upgrade instance to PASS or hide FAIL
|
||||
return {
|
||||
"report_checks": rows,
|
||||
"worst_result": worst,
|
||||
"fail_count": sum(1 for r in rows if r["result"] == "FAIL"),
|
||||
"skip_count": sum(1 for r in rows if r.get("skip_visible")),
|
||||
"chain_example": format_chain(rows[0]["chain"]) if rows else "",
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder.
|
||||
|
||||
USB2 / USB3 SuperSpeed / Ethernet MDI / RGMII / SGMII / DDR3 / HDMI /
|
||||
PCIe / LVDS are checked only against a rule whose quote or ``net_class``
|
||||
names that bus. I2C, GPIO, EN, analog REGN, USB CC, and strap/EN RC are
|
||||
not HS pairs. No invented USB/IEC 90 Ω.
|
||||
PCIe / LVDS / MIPI / HF clock are checked only against a rule whose quote
|
||||
or ``net_class`` names that bus. I2C, GPIO, EN, analog REGN, USB CC,
|
||||
strap/EN RC, and crystal XTAL nets are not HS pairs. No invented USB/IEC 90 Ω.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,6 +30,11 @@ _SKIP_RE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
|
||||
_XTAL_RE = re.compile(
|
||||
r"XTAL|OSCIN|OSCOUT|HFXIN|LFXIN|\bXIN\b|\bXOUT\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_HS_CLASS_RE = (
|
||||
("usb3", re.compile(
|
||||
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
|
||||
@@ -43,6 +48,7 @@ _HS_CLASS_RE = (
|
||||
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
|
||||
re.I,
|
||||
)),
|
||||
("mipi", re.compile(r"MIPI|\bDSI\b|\bCSI\b|CSI[_-]|DSI[_-]", re.I)),
|
||||
("lvds", re.compile(r"LVDS", re.I)),
|
||||
("ddr3_clk", re.compile(r"DDR3?.*CK|(?:^|[_/])CK[_]?[PN](?:$|[_/])", re.I)),
|
||||
("ddr3_dqs", re.compile(r"DQS", re.I)),
|
||||
@@ -51,6 +57,10 @@ _HS_CLASS_RE = (
|
||||
r"DDR3?.*(?:A\d+|ADDR|BA\d+|RAS|CAS|WE|ODT|CKE|(?:^|[_/])CS)",
|
||||
re.I,
|
||||
)),
|
||||
("hf_clk", re.compile(
|
||||
r"GTX_CLK|(?:^|[_/])CLK[_]?[PN](?:$|[_/])|(?:^|[_/])MCLK[_]?[PN]",
|
||||
re.I,
|
||||
)),
|
||||
)
|
||||
|
||||
_STRAP_RULE_RE = re.compile(
|
||||
@@ -94,6 +104,11 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
|
||||
"hdmi": frozenset({"hdmi"}),
|
||||
"pcie": frozenset({"pcie"}),
|
||||
"lvds": frozenset({"lvds"}),
|
||||
"mipi": frozenset({"mipi"}),
|
||||
"dsi": frozenset({"mipi"}),
|
||||
"csi": frozenset({"mipi"}),
|
||||
"hf_clk": frozenset({"hf_clk"}),
|
||||
"clock": frozenset({"hf_clk"}),
|
||||
}
|
||||
|
||||
_SI_KINDS = frozenset({
|
||||
@@ -151,6 +166,8 @@ def bus_class(net: str) -> str | None:
|
||||
if skip_si_net(net):
|
||||
return None
|
||||
leaf = _leaf(net)
|
||||
if _XTAL_RE.search(leaf):
|
||||
return None
|
||||
u = leaf.upper()
|
||||
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
|
||||
return "usb3"
|
||||
@@ -217,6 +234,8 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
|
||||
(r"hdmi", "hdmi"),
|
||||
(r"pcie|pci[\s-]*express", "pcie"),
|
||||
(r"lvds", "lvds"),
|
||||
(r"mipi|\bdsi\b|\bcsi\b", "mipi"),
|
||||
(r"gtx_clk|hf\s*clock|ref\s*clk", "hf_clk"),
|
||||
(r"usb\s*2|d\s*\+|d\s*−|d\s*-|dp\s*/\s*dm|dp/dm", "usb2"),
|
||||
(r"\busb\b", "usb2"),
|
||||
(r"\bethernet\b|\beth\b", "eth_mdi"),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Stackup vs fab spec. Skip without a sourced fab FACT. Never default 1 oz."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
|
||||
from backend.periscopex.si_check import _num, _quote
|
||||
|
||||
SOURCE = "stackup_check"
|
||||
|
||||
|
||||
def check_stackup(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or layout.stackup is None:
|
||||
return []
|
||||
board_t = layout.stackup.copper_thickness_mm
|
||||
fab_t = None
|
||||
quote = ""
|
||||
for _ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||||
if not cons:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
if str(rule.get("kind") or "") != "stackup":
|
||||
continue
|
||||
fab_t = _num(rule.get("copper_thickness_mm"))
|
||||
quote = _quote(rule)
|
||||
if fab_t is not None:
|
||||
break
|
||||
if fab_t is not None:
|
||||
break
|
||||
if fab_t is None or board_t is None or board_t <= 0:
|
||||
return []
|
||||
same = abs(board_t - fab_t) <= 1e-9
|
||||
rec = (
|
||||
"Match the board stackup copper thickness to the fab spec, then re-run."
|
||||
if not same else "Board copper thickness matches the fab spec."
|
||||
)
|
||||
return [Finding(
|
||||
designator="layout", mpn="", aspect="stackup",
|
||||
finding=(
|
||||
f"{'PASS' if same else 'REVIEW'}: board copper_thickness_mm={board_t:g} "
|
||||
f"vs fab {fab_t:g} mm."
|
||||
),
|
||||
facts=(
|
||||
f"board_copper_thickness_mm={board_t:g}; fab_copper_thickness_mm={fab_t:g}; "
|
||||
f"copper_layers={layout.stackup.copper_layers}."
|
||||
),
|
||||
requirement=f"Fab stackup copper thickness {fab_t:g} mm ({quote or 'layout_rules'}).",
|
||||
inference="" if same else "REVIEW — no invented 1 oz default or IPC copper weight.",
|
||||
why="Compare parsed KiCad stackup to a sourced fab thickness only.",
|
||||
status="INFO" if same else "WARNING",
|
||||
recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-STK-001",
|
||||
finding_class="INFO" if same else "REVIEW",
|
||||
provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", pins=[],
|
||||
)]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""USB-PD power contract. Skip without a PD controller. Not SI. Not USB2 Rd."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding
|
||||
|
||||
SOURCE = "usb_pd_check"
|
||||
|
||||
_PD_RE = re.compile(
|
||||
r"FUSB302|STUSB|IP2721|TPS257|CYPD|CH224|HUSB238|"
|
||||
r"USB[\s\-]?PD|POWER\s*DELIVERY",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def check_usb_pd(graph: DesignGraph) -> list[Finding]:
|
||||
chips = [
|
||||
c for c in graph.components.values()
|
||||
if c.component_type == ComponentType.IC and _PD_RE.search(
|
||||
" ".join(str(x or "") for x in (c.mpn, c.value, c.component_subtype))
|
||||
)
|
||||
]
|
||||
if not chips:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
rec = "No USB-PD contract change from this class FACT."
|
||||
for comp in sorted(chips, key=lambda c: c.reference):
|
||||
out.append(Finding(
|
||||
designator=comp.reference, mpn=comp.mpn or "", aspect="usb_pd",
|
||||
finding=f"{comp.reference} USB-PD controller is on this graph.",
|
||||
facts=f"mpn={comp.mpn!r}; value={comp.value!r}.",
|
||||
requirement="USB-PD contract runs only with a PD IC FACT — not USB2 Rd/CC.",
|
||||
inference="PDO voltages are not invented; missing PDO is a skip.",
|
||||
why="USB-PD is a power contract, not SuperSpeed SI.",
|
||||
status="INFO", recommendation=rec, action=rec, source=SOURCE,
|
||||
rule_id="PE-PD-001", finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="SUFFICIENT", pins=[],
|
||||
))
|
||||
return out
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -1,8 +1,9 @@
|
||||
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
|
||||
|
||||
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
|
||||
ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
|
||||
af_trace → af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||
untouched. ``af_trace`` and ``af_ai`` are additive and do not replace
|
||||
``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +28,9 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
|
||||
)
|
||||
from backend.periscopex.protocol_l0 import protocol_exam
|
||||
from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows
|
||||
from backend.periscopex.af_trace_check import check_af_traces
|
||||
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||
from backend.services import projects as proj_svc
|
||||
@@ -107,6 +111,17 @@ def _load_constraints_map(
|
||||
return result
|
||||
|
||||
|
||||
def _load_channel_data(ws: PipelineWorkspace) -> dict | None:
|
||||
"""Use existing channel_data.json only. Do not parse Touchstone or run OpenEMS."""
|
||||
from backend.periscopex.protocol_l3 import load_channel_data_file
|
||||
|
||||
for rel in ("channel_data.json", "uploads/channel_data.json"):
|
||||
loaded = load_channel_data_file(ws.local_path(rel))
|
||||
if loaded is not None:
|
||||
return loaded
|
||||
return None
|
||||
|
||||
|
||||
def si_extract_needed_skips(
|
||||
graph: DesignGraph,
|
||||
cmap: dict,
|
||||
@@ -162,6 +177,29 @@ def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
|
||||
_publish(project_id, "pcb_step_update", payload)
|
||||
|
||||
|
||||
def _run_af_ai_section(
|
||||
ws: PipelineWorkspace,
|
||||
graph: DesignGraph,
|
||||
cmap: dict,
|
||||
layout: LayoutGraph | None,
|
||||
zrep,
|
||||
findings: list,
|
||||
) -> int:
|
||||
"""AI-flag-then-investigate. Does not replace ``run_pcb_checks``."""
|
||||
raw: list = []
|
||||
hyp_path = ws.local_path("hf_hypotheses.json")
|
||||
if hyp_path.is_file():
|
||||
try:
|
||||
loaded = json.loads(hyp_path.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, list):
|
||||
raw = loaded
|
||||
except Exception:
|
||||
logger.exception("hf_hypotheses.json unreadable — AF+AI flags empty")
|
||||
hyps = hypotheses_from_rows(raw)
|
||||
added = append_investigated(findings, graph, cmap, layout, hyps, zrep)
|
||||
return len(added)
|
||||
|
||||
|
||||
async def run_pcb_pipeline(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
@@ -280,6 +318,34 @@ async def run_pcb_pipeline(
|
||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "af_trace", "running", "AF trigger + closed-form/cascade (not OpenEMS)")
|
||||
try:
|
||||
extra_af = check_af_traces(graph, cmap, layout, zrep, findings)
|
||||
findings.extend(extra_af)
|
||||
n_trace = len(extra_af)
|
||||
except Exception:
|
||||
logger.exception("AF trace analysis failed — keeping PCB checks")
|
||||
n_trace = 0
|
||||
_step(
|
||||
project_id, "af_trace", "complete",
|
||||
f"{n_trace} AF trace findings (SI/HF checks kept)",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
|
||||
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
|
||||
_step(
|
||||
project_id, "af_ai", "complete",
|
||||
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
@@ -321,6 +387,12 @@ async def run_pcb_pipeline(
|
||||
return
|
||||
|
||||
_step(project_id, "write_report", "running")
|
||||
proto_section, proto_findings = protocol_exam(
|
||||
graph, layout=layout,
|
||||
constraints_map=cmap, impedance_nets=zrep,
|
||||
channel_data=_load_channel_data(ws),
|
||||
)
|
||||
findings.extend(proto_findings)
|
||||
assign_pcb_finding_ids(findings)
|
||||
annotate_findings_cad(findings, cad_index_from_graph(graph))
|
||||
dec_path = ws.local_path("decisions.json")
|
||||
@@ -339,6 +411,7 @@ async def run_pcb_pipeline(
|
||||
summary=summary,
|
||||
coverage=coverage,
|
||||
not_reviewed=skipped + si_skip,
|
||||
protocol_certification=proto_section.model_dump(),
|
||||
)
|
||||
report_path = ws.local_path("pcb_report.json")
|
||||
report_path.write_text(report.model_dump_json(indent=2) + "\n")
|
||||
|
||||
@@ -15,6 +15,17 @@ from typing import Any
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from backend.periscopex.utils import natural_sort_key, safe_mpn
|
||||
from backend.services.library import (
|
||||
library_has_datasheet,
|
||||
library_has_extraction,
|
||||
library_has_model,
|
||||
library_has_passive_model,
|
||||
list_library_catalog,
|
||||
list_library_patterns,
|
||||
load_library_patterns,
|
||||
remember_datasheet,
|
||||
save_to_library,
|
||||
)
|
||||
from backend.services.storage import StaleGeneration, StorageBackend
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -692,17 +703,6 @@ def save_pcb(
|
||||
return key
|
||||
|
||||
|
||||
def remember_datasheet(
|
||||
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
from backend.services.datasheet_store import store_datasheet_bytes
|
||||
|
||||
store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns)
|
||||
except Exception:
|
||||
log.exception("library datasheet store failed for %s", mpn)
|
||||
|
||||
|
||||
def save_datasheet(
|
||||
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes,
|
||||
) -> str:
|
||||
@@ -738,213 +738,3 @@ def get_datasheet_key(
|
||||
) -> str | None:
|
||||
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe_mpn(mpn)}.pdf"
|
||||
return key if storage.exists(key) else None
|
||||
|
||||
|
||||
def library_has_extraction(
|
||||
storage: StorageBackend, mpn: str, min_version: str | None = None,
|
||||
) -> str | None:
|
||||
key = f"library/extracted/{safe_mpn(mpn)}.json"
|
||||
if not storage.exists(key):
|
||||
return None
|
||||
data = storage.read_json(key)
|
||||
if not data.get("pintable"):
|
||||
return None
|
||||
if min_version:
|
||||
from backend.services.admin_settings import version_is_stale
|
||||
|
||||
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
|
||||
return None
|
||||
return key
|
||||
|
||||
|
||||
def library_has_datasheet(
|
||||
storage: StorageBackend, mpn: str, patterns: list | None = None,
|
||||
) -> str | None:
|
||||
from backend.services.datasheet_store import resolve_datasheet
|
||||
|
||||
hit = resolve_datasheet(storage, mpn)
|
||||
if hit:
|
||||
return hit
|
||||
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
|
||||
if storage.exists(legacy):
|
||||
return legacy
|
||||
if patterns:
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
|
||||
match = resolve_mpn(mpn, patterns)
|
||||
if match is not None:
|
||||
ds = match[0].datasheet_key
|
||||
if ds and storage.exists(ds):
|
||||
return ds
|
||||
return None
|
||||
|
||||
|
||||
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
|
||||
key = f"library/models/{safe_mpn(mpn)}.json"
|
||||
return key if storage.exists(key) else None
|
||||
|
||||
|
||||
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
|
||||
safe = safe_mpn(mpn)
|
||||
key = f"library/passives/{safe}.json"
|
||||
if storage.exists(key):
|
||||
return key
|
||||
legacy = f"library/models/{safe}.json"
|
||||
return legacy if storage.exists(legacy) else None
|
||||
|
||||
|
||||
def save_to_library(
|
||||
storage: StorageBackend, src_key: str, category: str, filename: str,
|
||||
) -> str:
|
||||
dst = f"library/{category}/{filename}"
|
||||
storage.copy_object(src_key, dst)
|
||||
return dst
|
||||
|
||||
|
||||
def _specs_param_count(specs: dict) -> int:
|
||||
if not isinstance(specs, dict):
|
||||
return 0
|
||||
values = specs.get("values")
|
||||
if isinstance(values, dict):
|
||||
return sum(1 for v in values.values() if v not in (None, "", []))
|
||||
skip = {"specs_type", "component_subtype"}
|
||||
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
|
||||
|
||||
|
||||
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
|
||||
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
specs = data.get("specs", {}) or {}
|
||||
return {
|
||||
"mpn": mpn,
|
||||
"type": row_type,
|
||||
"specs_type": specs.get("specs_type", ""),
|
||||
"subtype": specs.get("component_subtype", ""),
|
||||
"param_count": _specs_param_count(specs),
|
||||
}
|
||||
|
||||
|
||||
def list_library_catalog(storage: StorageBackend) -> dict:
|
||||
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
|
||||
|
||||
ics: list[dict] = []
|
||||
seen_ic: set[str] = set()
|
||||
for key in storage.list_prefix("library/extracted/"):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ic:
|
||||
continue
|
||||
seen_ic.add(mpn)
|
||||
ics.append({
|
||||
"mpn": mpn,
|
||||
"type": "ic",
|
||||
"subtype": data.get("component_subtype", ""),
|
||||
"pin_count": len(data.get("pintable", [])),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
passives: list[dict] = []
|
||||
seen_p: set[str] = set()
|
||||
for key in storage.list_prefix("library/patterns/"):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if name in seen_p:
|
||||
continue
|
||||
seen_p.add(name)
|
||||
passives.append({
|
||||
"mpn": name,
|
||||
"type": "passive",
|
||||
"subtype": data.get("component_type", ""),
|
||||
"description": data.get("description", ""),
|
||||
"regex": data.get("regex", ""),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
simple_models: list[dict] = []
|
||||
passive_parts: list[dict] = []
|
||||
seen_m: set[str] = set()
|
||||
for prefix, row_type, dest in (
|
||||
("library/passives/", "passive_part", passive_parts),
|
||||
("library/models/", "simple", simple_models),
|
||||
):
|
||||
for key in storage.list_prefix(prefix):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
row = _catalog_model_row(data, key, row_type=row_type)
|
||||
if row["mpn"] in seen_m:
|
||||
continue
|
||||
seen_m.add(row["mpn"])
|
||||
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
|
||||
dest.append(row)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
datasheets: list[dict] = []
|
||||
seen_d: set[str] = set()
|
||||
for key in storage.list_prefix(REF_PREFIX):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
ref = storage.read_json(key)
|
||||
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_d:
|
||||
continue
|
||||
seen_d.add(mpn)
|
||||
datasheets.append({
|
||||
"mpn": mpn,
|
||||
"hash": ref.get("hash"),
|
||||
"has_extraction": mpn in seen_ic,
|
||||
"has_model": mpn in seen_m,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ics.sort(key=lambda r: r["mpn"].lower())
|
||||
passives.sort(key=lambda r: r["mpn"].lower())
|
||||
passive_parts.sort(key=lambda r: r["mpn"].lower())
|
||||
simple_models.sort(key=lambda r: r["mpn"].lower())
|
||||
datasheets.sort(key=lambda r: r["mpn"].lower())
|
||||
return {
|
||||
"ics": ics,
|
||||
"passives": passives,
|
||||
"passive_parts": passive_parts,
|
||||
"simple": simple_models,
|
||||
"datasheets": datasheets,
|
||||
}
|
||||
|
||||
|
||||
def list_library_patterns(storage: StorageBackend) -> list[str]:
|
||||
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
|
||||
|
||||
|
||||
def load_library_patterns(storage: StorageBackend):
|
||||
from backend.periscopex.resolve_passives import load_patterns
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
if isinstance(storage, LocalStorageBackend):
|
||||
d = storage._path("library/patterns")
|
||||
if not d.is_dir():
|
||||
return []
|
||||
return load_patterns(str(d))
|
||||
import tempfile
|
||||
|
||||
keys = list_library_patterns(storage)
|
||||
if not keys:
|
||||
return []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dest = Path(tmp) / "patterns"
|
||||
dest.mkdir()
|
||||
for key in keys:
|
||||
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
|
||||
return load_patterns(str(dest))
|
||||
|
||||
@@ -28,6 +28,7 @@ from backend.periscopex.interface_class_check import check_interface_classes
|
||||
from backend.periscopex.led_current_check import check_led_current
|
||||
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, ValidationReport
|
||||
from backend.periscopex.protocol_l0 import protocol_exam
|
||||
from backend.periscopex.nc_pin_check import check_nc_pins
|
||||
from backend.periscopex.parsers import ic_mpn_skip_reason
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
@@ -208,6 +209,8 @@ async def validate_design_async(
|
||||
return clean
|
||||
|
||||
def _write_report(paused: bool = False) -> ValidationReport:
|
||||
proto_section, proto_findings = protocol_exam(graph)
|
||||
all_findings.extend(proto_findings)
|
||||
annotate_findings_cad(all_findings, graph.cad_index)
|
||||
assign_finding_ids(all_findings)
|
||||
dec_path = existing_path.with_name("decisions.json")
|
||||
@@ -228,6 +231,7 @@ async def validate_design_async(
|
||||
coverage=_sanitize_coverage(all_coverage),
|
||||
review_errors=dict(review_errors),
|
||||
not_reviewed=not_reviewed,
|
||||
protocol_certification=proto_section.model_dump(),
|
||||
)
|
||||
except Exception:
|
||||
report = ValidationReport(
|
||||
@@ -238,6 +242,7 @@ async def validate_design_async(
|
||||
coverage={},
|
||||
review_errors=dict(review_errors),
|
||||
not_reviewed=not_reviewed,
|
||||
protocol_certification=proto_section.model_dump(),
|
||||
)
|
||||
report_dict = json.loads(report.model_dump_json(indent=2))
|
||||
if preserved_comments is not None:
|
||||
|
||||
@@ -17,7 +17,10 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S
|
||||
| PE-USBC-001…005 | c’è un receptacle USB-C | classe USB2/USB3, CC1/CC2, Rd/Rp 5.1 kΩ, VBUS/GND, SuperSpeed solo se USB3 |
|
||||
| PE-ETH-001…004 | c’è un RJ45 | 10/100 vs GbE, coppie TX/RX o 4 MDI, magnetics se GbE, Bob Smith REVIEW |
|
||||
| PE-POE-001…003 | evidenza PoE sul jack | classe/tipo se citati; isolamento/magnetics; **skip** se RJ45 nudo |
|
||||
| PE-POE-004 | PoE + `isolation_v` | tensione di isolamento solo se sourced; niente IEC inventato |
|
||||
| PE-DDR-* | solo se c’è un dispositivo DDR | non inventato su HubAudio (nessun DDR in BOM) |
|
||||
| PE-CPU-* | solo se c’è un bus parallelo D/A/controllo | SRAM/NOR/FMC/mux AD; niente bus inventato |
|
||||
| PE-FPGA-* | solo se c’è un FPGA | bank/IO rails e flash di config se presenti; niente FPGA inventata |
|
||||
|
||||
Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. L’IA spiega i FACT, non certifica. Nessun I inventato: PE-PWR **skip** se il datasheet non riporta I_load/Imax/I_abs.
|
||||
|
||||
|
||||
@@ -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% (81–99), 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.
|
||||
|
||||
@@ -2,6 +2,224 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.83.0 — 2026-09-22 — USB-C vSafe still UNKNOWN (no USB-IF PD on file)
|
||||
|
||||
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.75–5.5 V stays not-vSafe. HDMI 1.4 TMDS stays UNKNOWN.
|
||||
|
||||
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
|
||||
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
|
||||
|
||||
## 2.82.0 — 2026-09-22 — HDMI 2.0+ TI fields use source_type VENDOR
|
||||
|
||||
Michele exception: HDMI 2.0 and FRL electrical/PCB numbers from TMDS1204 / SLLA633 use `source_type=VENDOR` (not HDMI Forum STANDARD). TMDS1204-specific limits (skew, vias, FRL data_rate, SIGDET) are PHY_DEPENDENT with a document/rev/section cite and no number. HDMI 1.4 TMDS stays UNKNOWN. pcbsync.com not packed.
|
||||
|
||||
- [Changed] `SourceType` includes `VENDOR`; `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
|
||||
|
||||
## 2.81.0 — 2026-09-22 — Protocol PDF archive + HDMI 2.0/FRL TI VENDOR overlay
|
||||
|
||||
On-disk archive `standards/protocol-specs/` (PDFs gitignored). INDEX.md lists cites. HDMI 2.0 and FRL take TMDS1204 Table 8-6 layout as VENDOR/PHY RECOMMENDED — not HDMI Forum STANDARD. HDMI 1.4 TMDS Zdiff stays UNKNOWN. pcbsync.com not packed.
|
||||
|
||||
- [New] `standards/protocol-specs/INDEX.md`; `.gitignore` `standards/protocol-specs/*.pdf`.
|
||||
- [Changed] HDMI 2.0 / FRL constraints; `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest pdf-pack / m6.
|
||||
|
||||
## 2.80.0 — 2026-09-22 — TI TMDS1204 HDMI layout as VENDOR/PHY only
|
||||
|
||||
TMDS1204 datasheet SLLSF57A fills sink/source PCB layout (ZPCB 90 Ω / 75 Ω ranges, skew, length, vias, XTALK −24 dB, 5W spacing, RINT, AC cap) on physical ids `tmds1204-hdmi-sink` / `tmds1204-hdmi-source`. source_type PHY, source_class VENDOR, RECOMMENDED. Generic `hdmi-*` TMDS/FRL Zdiff stays UNKNOWN. SLLA633 is SIGDET wakeup (PHY_DEPENDENT), no layout ohms. pcbsync.com blog not packed.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_catalog_m0.py`.
|
||||
|
||||
## 2.79.0 — 2026-09-22 — HDMI CEC electrical from HDMI Specification 1.3
|
||||
|
||||
`CEC_HDMI_Specification.pdf` is High-Definition Multimedia Interface Specification Version 1.3 (22 June 2006), not a CEC-only pamphlet. CEC pull-up 27 kΩ, VOL 0.6 V, VOH 2.5 V, device 200 pF / cable 700 pF, interconnect 5 Ω are packed. TMDS/FRL Zdiff/skew stay UNKNOWN (Adopter spec still required). DDC/HPD not packed.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
|
||||
|
||||
## 2.78.0 — 2026-09-22 — 1000BASE-T from IEEE 802.3-2012 Section Three
|
||||
|
||||
IEEE Std 802.3-2012 Section Three Clause 40 fills 1000BASE-T: 1000 Mb/s, 100 m Class D link, cable 100 Ω, MDI return loss 16 dB (1–40 MHz), pair skew 50 ns, isolation 2 MΩ / 1500 V rms, MDI peak 0.67 V, link delay 570 ns. Cable 100 Ω is **CABLE**; L2 PCB Zdiff stays UNKNOWN. Clause 36 fills 1000BASE-X 1000 Mb/s (optical Zdiff N/A). Insertion-loss formula not packed as a single dB.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
|
||||
|
||||
## 2.77.0 — 2026-09-22 — 10BASE-T from IEEE 802.3-2015 Section One
|
||||
|
||||
IEEE Std 802.3-2015 Section One Clause 14 fills 10BASE-T: 10 Mb/s, 100 m design-objective link, cable 100 Ω ± 15 Ω, 11.5 dB link insertion loss, MAU TD return loss 15 dB, isolation 2 MΩ / 1500 V rms, TD peak 2.2 V. Cable 100 Ω is **CABLE**, not L2 PCB Zdiff. 1000BASE-T (Clause 40) is not in this volume.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`, `test_protocol_m6_m7.py`.
|
||||
|
||||
## 2.76.0 — 2026-09-22 — Type-C CabCon R2.0 hole-fill (CC shall-detect; VBUS from R2.5)
|
||||
|
||||
USB Type-C Cable and Connector Specification Release 2.0 (August 2019) fills Source CC shall-detect voltages that R2.5 only illustrates as variance examples (Table 4-32 p.240: vRd 1.50 V, vOPEN 1.65 V, vRa 0.15 V). Type-C Current charger VBUS 4.75–5.5 V no-load / 4.0–5.5 V at 3 A, zOPEN 126 kΩ, tCCDebounce 100–200 ms, and tVBUSON 275 ms stay on newer CabCon R2.5 (Figure 4-39 / Tables 4-30, 4-32, 4-34). Rp/Rd/Ra unchanged. vSafe5V volt range still needs USB PD.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
|
||||
|
||||
## 2.75.0 — 2026-09-22 — Protocol packs from USB 2.0 Rev 2.0 and Type-C CabCon R2.5
|
||||
|
||||
USB 2.0 Base Specification (April 27, 2000) fills LS 1.50 Mb/s, FS 12 Mb/s, HS 480 Mb/s, pull-up/down, VBUS 4.75–5.25 V, LS/FS edges. Cable Z0 90 Ω is `source_type=CABLE`; HS PCB traces *should* 90 Ω is RECOMMENDED PCB — L2 `differential_impedance` stays UNKNOWN so 45/50 Ω fixtures are not treated as Zdiff. Type-C Cable and Connector Specification Release 2.5 fills Rp/Rd/Ra from Tables 4-27/4-28/4-29. VBUS volts still need USB PD vSafe5V. IEEE 802.3 Section One/Three and HDMI Adopter remain missing.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`.
|
||||
- [Changed] pytest `tests/pcb/test_protocol_pdf_pack.py`.
|
||||
|
||||
## 2.74.0 — 2026-09-22 — Protocol packs from recovered PDFs (USB CTS, Type-C FTS, DVI 1.0, IEEE 802.3-2012 §2)
|
||||
|
||||
NUMERIC constraints now carry title, revision, section/table, and page from the recovered PDFs. USB 2.0 HS fills data rate / rise-fall / 1.5 kΩ pull-up / DCR from Electrical CTS v1.08; Zdiff stays UNKNOWN (CTS does not state 90 Ω). Type-C Rp/Rd/VBUS volts stay UNKNOWN (Functional Test Spec points at Cable and Connector tables). DVI 1.0 is a separate protocol from HDMI. IEEE 802.3-2012 Section Two fills 100BASE-TX cabling 100 Ω / 100 m / isolation and 100 Mb/s; 10BASE-T Clause 14 and 1000BASE-T Clause 40 are not in Section Two.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`; DVI recognition.
|
||||
- [New] pytest `tests/pcb/test_protocol_pdf_pack.py`.
|
||||
|
||||
## 2.73.0 — 2026-09-22 — Protocol certification M6/M7 (USB-C / Ethernet / HDMI packs)
|
||||
|
||||
USB 2.0 (LS/FS/HS) is the protocol; USB Type-C is a connector/interface system; USB 2.0 over Type-C is the physical implementation. Type-C does not imply USB3 or USB4. Mini HDMI is a connector, not a protocol. Ethernet variants (10/100/1000/2.5G/5G/10G, SGMII, 1000BASE-X, 100BASE-FX, 10GBASE-R) are incomplete skeletons. All listed numeric fields stay **UNKNOWN** with `needed_document` (USB-IF / IEEE 802.3 / HDMI Adopter). No invented ohms, no typical-as-standard, no OpenEMS.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + `catalog.json`; `needed_document` on UNKNOWN/MISSING_SOURCE.
|
||||
- [Changed] USB/Ethernet/HDMI recognition: connector alone is not the protocol.
|
||||
- [New] pytest `tests/pcb/test_protocol_m6_m7.py`.
|
||||
|
||||
## 2.72.0 — 2026-09-22 — Protocol certification M10 (catalog skeletons)
|
||||
|
||||
Catalog skeletons and `required_checks` for **PCIe / CXL** (logical vs physical), **I2C**, **CAN**, **RGMII**, plus missing high-speed processor ids (PCI, PCI-X, CCIX, OpenCAPI, UPI, DMI, Infinity Fabric, HyperTransport, UCIe). Numbers stay **UNKNOWN / MISSING_SOURCE / PHY_DEPENDENT** until an official cite is in the pack. `pcb_relevant` is YES or CONDITIONAL as appropriate. No invented ohms, no USB-IF fill, no OpenEMS.
|
||||
|
||||
- [Changed] `protocol_catalog.py` + committed `catalog.json`.
|
||||
- [New] pytest `tests/pcb/test_protocol_catalog_m10.py`.
|
||||
|
||||
## 2.71.0 — 2026-09-22 — Protocol certification M9 (L3 channel + visible skip)
|
||||
|
||||
L3 **CHANNEL** certifier: structure for timing budget (TOTAL−USED=MARGIN), insertion loss, return loss, crosstalk, and complete channel. Numerical PASS/FAIL only when **existing channel FACT** (dB/ps) is already present — never invented S-parameters, never OpenEMS/FEM. Otherwise a visible skip: «Interfaccia X non certificata a L3 per mancanza di …». Internals (AXI/HBM) stay NOT_APPLICABLE. L0/L1 are not electrical. No M6 numeric packs.
|
||||
|
||||
- [New] `protocol_l3.py`; `PE-PRT-L3-001` / `PE-PRT-L3-002`.
|
||||
- [New] pytest `tests/pcb/test_protocol_l3_m9.py`.
|
||||
|
||||
## 2.70.0 — 2026-09-22 — Protocol certification M8 (DDR instance + L1 grouping)
|
||||
|
||||
DDR **physical instances** are controller-aware: one instance per memory device, DQ/DQS **byte lanes** separate from ADDRESS / COMMAND / CONTROL / CLOCK. Skew/length stay **CONTROLLER_DEPENDENT / PHY_DEPENDENT / UNKNOWN** — no universal mm/ps/ohm. HBM is package/interposer (`pcb_relevant: NO`); classic DDR PCB DQ rules are not applied. M6 numeric packs and L3 solver are not in this release.
|
||||
|
||||
- [New] `protocol_ddr.py`; L1 grouping reconstruction without PCB geometry.
|
||||
- [New] pytest `tests/pcb/test_protocol_ddr_m8.py`.
|
||||
|
||||
## 2.69.0 — 2026-09-22 — Protocol certification M5 (Protocolli report)
|
||||
|
||||
Report section **Protocolli**: recognised instances, max level L0–L3 (L3 is a visible not-run skip, no solver), each check with measured / limit / margin / source / method, traceability chain net → group → physical interface → protocol → constraint → document → section. FAIL is listed first and styled as Error — not hidden under Warning (HubAudio). Visible skips stay on the page. No pack numbers, no invented Z, no OpenEMS.
|
||||
|
||||
- [New] `protocol_report.py`; Protocolli table UI.
|
||||
- [New] pytest `tests/pcb/test_protocol_report_m5.py`; `protocol-report.test.ts`.
|
||||
|
||||
## 2.68.0 — 2026-09-22 — Protocol certification M4 (L2 electrical)
|
||||
|
||||
L2 ELECTRICAL certifier: impedance, termination, voltage/levels, rise/fall when sources exist. Z only from datasheet / stackup / fabricator / **cited pack** — never invented 90 Ω USB. USB-IF/IEEE only if the pack has a cite. Silicon cross is max PCB × PHY subset when datasheet windows exist. Length is not delay. No L3/OpenEMS. L0/L1 are **not** electrical. RECOMMENDED never FAIL. MISSING_SOURCE / UNKNOWN when cite is absent. Visible skip when Z cannot be obtained.
|
||||
|
||||
- [New] `protocol_l2.py`; `PE-PRT-L2-001` / `PE-PRT-L2-002`.
|
||||
- [New] pytest `tests/pcb/test_protocol_l2_m4.py`.
|
||||
|
||||
## 2.67.0 — 2026-09-22 — Protocol certification M3 (L1 geometric)
|
||||
|
||||
L1 GEOMETRIC certifier: length, topology, vias, stubs, layer, grouping. Geometric skew only vs **NUMERIC** millimetre or an explicit **DESIGN LIMIT**. Otherwise UNKNOWN / VENDOR_DEPENDENT / CONTROLLER_DEPENDENT / PHY_DEPENDENT — never invent mm/ps. L1 is **not** electrical. No L2/L3. RECOMMENDED never FAIL. Internal interfaces stay NOT_APPLICABLE. Missing DQ/DQS grouping is a visible skip, not PASS.
|
||||
|
||||
- [New] `protocol_l1.py`; `PE-PRT-L1-001` / `PE-PRT-L1-002` / `PE-PRT-L1-003`.
|
||||
- [New] pytest `tests/pcb/test_protocol_l1_m3.py`.
|
||||
|
||||
## 2.66.0 — 2026-09-22 — Protocol certification M2 (L0 structural)
|
||||
|
||||
L0 STRUCTURAL certifier: nets must exist and connect at least two components. FAIL only for missing **MANDATORY** structure. AXI4 / internal interconnects are **NOT_APPLICABLE** (not FAIL). RECOMMENDED never FAIL. No L1–L3, no invented ohms/mm/ps.
|
||||
|
||||
- [New] `protocol_l0.py`; `PE-PRT-L0-001` / `PE-PRT-L0-002`.
|
||||
- [New] pytest `tests/pcb/test_protocol_l0_m2.py`.
|
||||
|
||||
## 2.65.0 — 2026-09-22 — Protocol certification M1 (instance recognition)
|
||||
|
||||
`physical_bus_instance` from declaration / silicon / part / pin / net / pairs. Output: instances, signal groups (DIFF pair, byte lane, clock/address), confidence. Ambiguity is **REVIEW**, never FAIL. AXI4 internal is recognised with `pcb_relevant: NO` and **no PCB nets**. No L0–L3 certifier. No invented ohms.
|
||||
|
||||
- [New] `protocol_recognize.py`; report Protocolli lists recognised instances or stays empty.
|
||||
- [New] pytest `tests/pcb/test_protocol_recognize_m1.py` (USB/DDR fixtures; AXI has no nets).
|
||||
|
||||
## 2.64.0 — 2026-09-22 — Protocol certification M0 (catalog, no numbers)
|
||||
|
||||
Versioned JSON protocol pack + Python loader/validator. Catalog skeletons for USB2 / USB-C-USB2 / 100BASE-TX / HDMI (connector distinct) / DDR / LPDDR / HBM / HyperBus / QSPI-octal / eMMC / UFS / AMBA / Wishbone / Avalon / TileLink / PCIe-CXL. Constraints without an official cite are UNKNOWN / CONTROLLER_DEPENDENT / PHY_DEPENDENT / MISSING_SOURCE — **no invented 90/100/50 Ω**. `logical_protocol_id` ≠ `physical_interface_id`. `pcb_relevant` is YES|NO|CONDITIONAL. RECOMMENDED is not FAIL-mandatory. Typical-as-standard is rejected by the parser. Report section **Protocolli** is empty (`nessun protocollo riconosciuto`). Recognition and L0–L3 certifiers are not in this release.
|
||||
|
||||
- [New] `protocol_catalog.py`, `protocol_data/catalog.json`, schema 1.0.0.
|
||||
- [New] `protocol_certification` on `ValidationReport`; UI section Protocolli.
|
||||
- [New] pytest `tests/pcb/test_protocol_catalog_m0.py`.
|
||||
- [Fixed] Native `periscope/src` wins over inherited `periscopex` on the package path.
|
||||
|
||||
## 2.63.5 — 2026-09-22 — AF trace analysis (trigger, cascade, visible skips)
|
||||
|
||||
PCB exam adds **AF trace analysis** after unchanged `run_pcb_checks` (SI/HF stay). Trigger is λ/10 or tr/(6 tpd) from FACT only. Differential pairs are one unit. Z0/Zdiff only vs a **datasheet** window. Missing tr/f/εr/stackup/Z target/via span is a visible INSUFFICIENT finding («Pista ad alta frequenza non controllata per mancanza di …»), not invented 50/90 Ω. Trigger false → no AF finding. Numerical cascade is lossless RLGC sections (ImpedenceFinder Z0); not OpenEMS/FEM. Python. PE-AF-001 AI path unchanged.
|
||||
|
||||
- [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 1–24; 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 25–48).
|
||||
- [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 TVS–connector, 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` TVS–connector **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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "periscope-web",
|
||||
"version": "2.61.2",
|
||||
"version": "2.83.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
@@ -9,7 +9,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "node --experimental-strip-types --test src/lib/findings-forest.test.ts src/lib/protocol-report.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { ProtocolSection } from "@/components/report/protocol-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Toast, useToast } from "@/components/ui/toast";
|
||||
@@ -303,6 +304,7 @@ function ReportContent({ projectId }: { projectId: string }) {
|
||||
creditsSpent={creditsSpent}
|
||||
totalCostUsd={totalCostUsd}
|
||||
/>
|
||||
<ProtocolSection section={report.protocol_certification} />
|
||||
{failedReviews > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
|
||||
@@ -9,13 +9,13 @@ import type {
|
||||
Finding,
|
||||
FindingComment,
|
||||
FindingReview,
|
||||
FindingStatus,
|
||||
Collaborator,
|
||||
Component,
|
||||
DesignGraph,
|
||||
} from "@/lib/types";
|
||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||
import { isPcbExamFinding } from "@/lib/layout-finding";
|
||||
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
||||
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
|
||||
|
||||
interface FindingsTreeProps {
|
||||
findings: Finding[];
|
||||
@@ -37,12 +37,6 @@ interface FindingsTreeProps {
|
||||
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";
|
||||
}
|
||||
|
||||
function pinNetLabel(f: Finding): string {
|
||||
const pin = (f.pins && f.pins[0]) || "";
|
||||
const net = f.net || "";
|
||||
@@ -94,11 +88,15 @@ function TreeBranch({
|
||||
|
||||
export function FindingsTree(props: FindingsTreeProps) {
|
||||
const sorted = sortFindings(props.findings);
|
||||
const schematic = sorted.filter((f) => !isPcbExamFinding(f));
|
||||
const pcb = sorted.filter(isPcbExamFinding);
|
||||
const af = sorted.filter(isAfAiFinding);
|
||||
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
|
||||
const schematic = sorted.filter(
|
||||
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
|
||||
);
|
||||
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
|
||||
|
||||
const showRoots = roots.length > 1;
|
||||
|
||||
@@ -127,76 +125,74 @@ function DesignatorForest({
|
||||
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);
|
||||
});
|
||||
const folders = groupByWorstStatus(findings);
|
||||
|
||||
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>
|
||||
{folders.map((folder) => (
|
||||
<TreeBranch
|
||||
key={folder.status}
|
||||
label={folder.label}
|
||||
findings={folder.findings}
|
||||
>
|
||||
{folder.designators.map((row) => {
|
||||
const d = row.designator;
|
||||
const group = row.findings;
|
||||
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 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-3 py-2">
|
||||
{sortFindings(items).map((item) => {
|
||||
const key = cardProps.findingKeys.get(item);
|
||||
return (
|
||||
<FindingCard
|
||||
key={key}
|
||||
finding={item}
|
||||
onViewReference={cardProps.onViewReference}
|
||||
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
|
||||
onCheckedChange={
|
||||
key && cardProps.onToggleReviewed
|
||||
? () => cardProps.onToggleReviewed!(key)
|
||||
: undefined
|
||||
}
|
||||
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
|
||||
projectId={cardProps.projectId}
|
||||
collaborators={cardProps.collaborators}
|
||||
currentUserId={cardProps.currentUserId}
|
||||
currentUserName={cardProps.currentUserName}
|
||||
onCommentAdded={cardProps.onCommentAdded}
|
||||
onCommentDeleted={cardProps.onCommentDeleted}
|
||||
onReportFinding={cardProps.onReportFinding}
|
||||
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
|
||||
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
|
||||
onReviewSaved={cardProps.onReviewSaved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TreeBranch>
|
||||
))}
|
||||
</TreeBranch>
|
||||
))}
|
||||
</TreeBranch>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</TreeBranch>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import type { ProtocolCertificationSection } from "@/lib/types";
|
||||
import { StatusBadge } from "@/components/report/status-badge";
|
||||
import {
|
||||
formatLimit,
|
||||
formatMargin,
|
||||
formatMeasured,
|
||||
formatProtocolChain,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
type ProtocolReportCheck,
|
||||
} from "@/lib/protocol-report";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
|
||||
const raw = row.report_checks;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return sortProtocolChecks(raw as ProtocolReportCheck[]);
|
||||
}
|
||||
const fallback: ProtocolReportCheck[] = [];
|
||||
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
|
||||
const list = row[key];
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const c of list) {
|
||||
const rec = c as Record<string, unknown>;
|
||||
fallback.push({
|
||||
check: String(rec.check ?? ""),
|
||||
level: String(rec.level ?? ""),
|
||||
result: String(rec.result ?? ""),
|
||||
notes: String(rec.notes ?? ""),
|
||||
skip_visible:
|
||||
String(rec.result ?? "") !== "PASS" &&
|
||||
String(rec.result ?? "") !== "FAIL",
|
||||
});
|
||||
}
|
||||
}
|
||||
return sortProtocolChecks(fallback);
|
||||
}
|
||||
|
||||
export function ProtocolSection({
|
||||
section,
|
||||
}: {
|
||||
section?: ProtocolCertificationSection | null;
|
||||
}) {
|
||||
const instances = section?.recognized_instances ?? [];
|
||||
const message = section?.message || "nessun protocollo riconosciuto";
|
||||
const maxLevel = section?.max_level_reached || "—";
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">Protocolli</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Livello massimo: <span className="font-mono text-foreground">{maxLevel}</span>
|
||||
{" "}
|
||||
(L0–L3; L3 skip unless channel FACT exists — no OpenEMS)
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Logical protocol vs physical interface. L0 structural, L1 geometric,
|
||||
L2 electrical (Z only from datasheet/stackup/fab or a cited pack).
|
||||
L3 channel (timing budget / IL / RL / crosstalk) only from existing FACT.
|
||||
L0/L1 are not electrical. Length is not delay. FAIL is listed first —
|
||||
it is not filed under Warning.
|
||||
</p>
|
||||
{instances.length === 0 ? (
|
||||
<p className="text-sm">{message}</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{instances.map((row, i) => {
|
||||
const rec = row as Record<string, unknown>;
|
||||
const phys = String(rec.physical_interface_id ?? "");
|
||||
const log = String(rec.logical_protocol_id ?? "");
|
||||
const status = String(rec.recognition_status ?? "");
|
||||
const worst = String(rec.worst_result ?? "");
|
||||
const checks = asChecks(rec);
|
||||
const fail = Number(rec.fail_count ?? 0) > 0 || worst === "FAIL";
|
||||
return (
|
||||
<li
|
||||
key={String(rec.instance_id ?? i)}
|
||||
className={cn(
|
||||
"rounded-md border p-3 space-y-2",
|
||||
fail
|
||||
? "border-rose-500/50 bg-rose-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-mono text-xs">
|
||||
{log} → {phys}
|
||||
{status ? ` · ${status}` : ""}
|
||||
</p>
|
||||
{worst ? (
|
||||
<StatusBadge status={protocolResultToFindingStatus(worst)} />
|
||||
) : null}
|
||||
{fail ? (
|
||||
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
|
||||
FAIL visible — not under Warning
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground">
|
||||
<th className="pr-2 py-1 font-medium">Lv</th>
|
||||
<th className="pr-2 py-1 font-medium">Check</th>
|
||||
<th className="pr-2 py-1 font-medium">Result</th>
|
||||
<th className="pr-2 py-1 font-medium">Measured</th>
|
||||
<th className="pr-2 py-1 font-medium">Limit</th>
|
||||
<th className="pr-2 py-1 font-medium">Margin</th>
|
||||
<th className="pr-2 py-1 font-medium">Source</th>
|
||||
<th className="py-1 font-medium">Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c, j) => {
|
||||
const isFail = c.result === "FAIL";
|
||||
const skip = Boolean(c.skip_visible);
|
||||
return (
|
||||
<tr
|
||||
key={`${c.level}-${c.check}-${j}`}
|
||||
className={cn(
|
||||
isFail && "text-rose-700 dark:text-rose-400 font-medium",
|
||||
skip && !isFail && "text-amber-800 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
<td className="pr-2 py-1 font-mono">{c.level}</td>
|
||||
<td className="pr-2 py-1">{c.check}</td>
|
||||
<td className="pr-2 py-1">
|
||||
{c.result}
|
||||
{skip ? " · skip" : ""}
|
||||
</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatMeasured(c)}</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatLimit(c)}</td>
|
||||
<td className="pr-2 py-1 font-mono">{formatMargin(c)}</td>
|
||||
<td className="pr-2 py-1">{c.source || "—"}</td>
|
||||
<td className="py-1">{c.method || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{String(rec.chain_example || "") ? (
|
||||
<p className="text-[11px] text-muted-foreground break-all">
|
||||
Catena: {String(rec.chain_example)}
|
||||
</p>
|
||||
) : null}
|
||||
{checks
|
||||
.filter((c) => c.result === "FAIL" || c.skip_visible)
|
||||
.map((c, j) =>
|
||||
c.chain ? (
|
||||
<p
|
||||
key={`chain-${j}`}
|
||||
className="text-[11px] text-muted-foreground break-all"
|
||||
>
|
||||
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
|
||||
</p>
|
||||
) : null,
|
||||
)}
|
||||
{checks.some((c) => c.skip_visible && c.notes) ? (
|
||||
<ul className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
|
||||
{checks
|
||||
.filter((c) => c.skip_visible && c.notes)
|
||||
.map((c, j) => (
|
||||
<li key={`skip-${j}`}>{c.notes}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
|
||||
import { isPcbExamFinding } from "./layout-finding.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
|
||||
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("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,70 @@
|
||||
/** Group report findings for the tree: status folders, then every ref (J2 included). */
|
||||
|
||||
import type { Finding, FindingStatus } from "./types";
|
||||
|
||||
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
|
||||
|
||||
export const STATUS_FOLDER: Record<FindingStatus, string> = {
|
||||
ERROR: "Error",
|
||||
WARNING: "Warning",
|
||||
INFO: "Info",
|
||||
};
|
||||
|
||||
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 StatusGroup = {
|
||||
status: FindingStatus;
|
||||
label: string;
|
||||
findings: Finding[];
|
||||
designators: DesignatorGroup[];
|
||||
};
|
||||
|
||||
/** 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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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];
|
||||
return {
|
||||
status,
|
||||
label: STATUS_FOLDER[status],
|
||||
findings: designators.flatMap((d) => d.findings),
|
||||
designators,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
formatProtocolChain,
|
||||
instanceWorstResult,
|
||||
protocolResultRank,
|
||||
protocolResultToFindingStatus,
|
||||
sortProtocolChecks,
|
||||
} from "./protocol-report.ts";
|
||||
import { groupByWorstStatus } from "./findings-forest.ts";
|
||||
import type { Finding } from "./types.ts";
|
||||
|
||||
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
|
||||
return {
|
||||
finding_id: null,
|
||||
mpn: "",
|
||||
aspect: null,
|
||||
finding: partial.finding ?? "",
|
||||
why: "",
|
||||
source_page: null,
|
||||
reference: "",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
test("FAIL ranks before WARNING and is not buried", () => {
|
||||
assert.ok(protocolResultRank("FAIL") < protocolResultRank("WARNING"));
|
||||
assert.ok(protocolResultRank("FAIL") < protocolResultRank("UNKNOWN"));
|
||||
const sorted = sortProtocolChecks([
|
||||
{ check: "z", result: "UNKNOWN", level: "L2" },
|
||||
{ check: "topology", result: "FAIL", level: "L0" },
|
||||
{ check: "length", result: "PASS", level: "L1" },
|
||||
]);
|
||||
assert.equal(sorted[0].result, "FAIL");
|
||||
assert.equal(sorted[0].check, "topology");
|
||||
assert.equal(instanceWorstResult(["UNKNOWN", "FAIL", "PASS"]), "FAIL");
|
||||
assert.equal(protocolResultToFindingStatus("FAIL"), "ERROR");
|
||||
});
|
||||
|
||||
test("chain is net → group → physical → protocol → constraint → document → section", () => {
|
||||
const text = formatProtocolChain({
|
||||
net: "USB_D+",
|
||||
group: "DIFF_USB",
|
||||
physical_interface_id: "usb2-hs-dpair",
|
||||
logical_protocol_id: "usb2-hs",
|
||||
constraint_id: "usb2-hs-dpair-differential_impedance",
|
||||
document: "",
|
||||
section: "",
|
||||
});
|
||||
assert.equal(
|
||||
text,
|
||||
"USB_D+ → DIFF_USB → usb2-hs-dpair → usb2-hs → usb2-hs-dpair-differential_impedance → — → —",
|
||||
);
|
||||
assert.ok(!text.includes("90"));
|
||||
});
|
||||
|
||||
test("protocol FAIL finding stays in Error folder with WARNING skips on same ref", () => {
|
||||
const groups = groupByWorstStatus([
|
||||
f({
|
||||
designator: "U1",
|
||||
status: "ERROR",
|
||||
rule_id: "PE-PRT-L0-001",
|
||||
source: "protocol_l0",
|
||||
finding: "L0 FAIL topology",
|
||||
}),
|
||||
f({
|
||||
designator: "U1",
|
||||
status: "WARNING",
|
||||
rule_id: "PE-PRT-L2-001",
|
||||
source: "protocol_l2",
|
||||
finding: "non certificata a L2 per mancanza di Z",
|
||||
}),
|
||||
]);
|
||||
assert.equal(groups[0].label, "Error");
|
||||
assert.equal(groups[0].status, "ERROR");
|
||||
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/** 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(" → ");
|
||||
}
|
||||
|
||||
export function formatMeasured(row: ProtocolReportCheck): string {
|
||||
if (row.measured == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.measured}${unit}`;
|
||||
}
|
||||
|
||||
export function formatLimit(row: ProtocolReportCheck): string {
|
||||
if (row.limit == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.limit}${unit}`;
|
||||
}
|
||||
|
||||
export function formatMargin(row: ProtocolReportCheck): string {
|
||||
if (row.margin == null) return "—";
|
||||
const unit = row.unit ? ` ${row.unit}` : "";
|
||||
return `${row.margin}${unit}`;
|
||||
}
|
||||
@@ -75,6 +75,15 @@ export interface ValidationReport {
|
||||
comments?: Record<string, FindingComment[]>;
|
||||
review_states?: Record<string, FindingReview>;
|
||||
release?: ReportRelease;
|
||||
protocol_certification?: ProtocolCertificationSection | null;
|
||||
}
|
||||
|
||||
export interface ProtocolCertificationSection {
|
||||
schema_version: string;
|
||||
macrophase: string;
|
||||
recognized_instances: Record<string, unknown>[];
|
||||
message: string;
|
||||
max_level_reached: string | null;
|
||||
}
|
||||
|
||||
export type NetType = "power" | "ground" | "signal" | "unknown";
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||
export const APP_VERSION = "2.61.0";
|
||||
export const APP_VERSION_DATE = "2026-09-21";
|
||||
export const APP_VERSION = "2.66.0";
|
||||
export const APP_VERSION_DATE = "2026-09-22";
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"ic.memory.eeprom": {
|
||||
"description": "EEPROM"
|
||||
},
|
||||
"ic.memory.ddr": {
|
||||
"description": "DDR SDRAM",
|
||||
"example_mpn": "MT41K256M16TW"
|
||||
},
|
||||
"ic.memory.sram": {
|
||||
"description": "Parallel SRAM",
|
||||
"example_mpn": "IS61WV51216"
|
||||
},
|
||||
"ic.fpga": {
|
||||
"description": "Field-programmable gate array",
|
||||
"example_mpn": "XC7A35T"
|
||||
},
|
||||
"ic.logic.buffer": {
|
||||
"description": "Buffer or line driver"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Protocol specification archive
|
||||
|
||||
PDFs live **on disk** in this folder. They are **not** committed to GitHub/Gitea (license). See `.gitignore` `standards/protocol-specs/*.pdf`.
|
||||
|
||||
Mirror: agent-store `internal/protocol-specs/`.
|
||||
|
||||
TI files are **source_type VENDOR**, never HDMI Forum STANDARD. `https://pcbsync.com/hdmi-pcb-layout/` is a blog — **not packed**.
|
||||
|
||||
| Filename | Title | Revision | Cited by (physical / notes) | Class |
|
||||
|---|---|---|---|---|
|
||||
| `usb_20.pdf` | Universal Serial Bus Specification | Revision 2.0, April 27, 2000 | `usb2-ls-dpair`, `usb2-fs-dpair`, `usb2-hs-dpair` (data_rate, pull-up/down, VBUS, cable Z0, HS PCB *should*) | STANDARD |
|
||||
| `USB_2.0_Electrical_Compliance_Test_Spec.pdf` | USB 2.0 Electrical Compliance Test Specification | Version 1.08, April 2026 | `usb2-hs-dpair` DCR 17/23/13 Ω, HS edges | STANDARD |
|
||||
| `USB Type-C Spec R2.5 - March 2026.pdf` | USB Type-C Cable and Connector Specification | Release 2.5, March 2026 | `usb-c-receptacle` Rp/Rd/Ra, charger VBUS Fig 4-39, zOPEN, tCCDebounce, tVBUSON | STANDARD |
|
||||
| `USB Type-C Spec R2.0 - August 2019.pdf` | USB Type-C Cable and Connector Specification | Release 2.0, August 2019 | `usb-c-receptacle` Source CC shall-detect Table 4-32 (vRd/vOPEN/vRa) | STANDARD |
|
||||
| `USB_Type-C_Functional_Test_Spec.pdf` | USB Type-C Functional Test Specification Chapters 4 and 5 | Revision 0.91, March 3, 2024 | `usb-c-receptacle` cable IR drop 250/500 mV OPTIONAL CABLE | STANDARD (OPTIONAL CABLE) |
|
||||
| `DVI_Specification_Revision_1.0.pdf` | Digital Visual Interface (DVI) | Revision 1.0, 02 April 1999 | `dvi-1.0-tmds` termination / Zdiff | STANDARD |
|
||||
| `802.3-2012_section2.pdf` | IEEE Std 802.3-2012 IEEE Standard for Ethernet (Section Two) | 2012 | `100base-tx-mdi`, `100base-fx-pcb` | STANDARD |
|
||||
| `802.3-2015_SECTION1.pdf` | IEEE Std 802.3-2015 IEEE Standard for Ethernet (Section One) | 2015 | `10base-t-mdi` Clause 14 | STANDARD |
|
||||
| `802.3-2012_section3.pdf` | IEEE Std 802.3-2012 IEEE Standard for Ethernet (Section Three) | 2012 | `1000base-t-mdi` Clause 40; `1000base-x-pcb` Clause 36 | STANDARD |
|
||||
| `CEC_HDMI_Specification.pdf` | High-Definition Multimedia Interface Specification | Version 1.3, June 22, 2006 | HDMI `cec*` fields (Supplement 1 + Table 4-27). **Not** used for TMDS/FRL Zdiff | STANDARD (CEC only) |
|
||||
| `tmds1204.pdf` | TMDS1204 12Gbps HDMI Hybrid Redriver | SLLSF57A – August 2022 / revised April 2024 | `tmds1204-hdmi-sink`, `tmds1204-hdmi-source`; HDMI **2.0 / FRL** common layout `source_type=VENDOR` | **VENDOR** |
|
||||
| `slla633.pdf` | TMDS_CLOCK/FRL_Data Detection Design in HDMI Sink Applications TMDS1204 | SLLA633 – May 2024 | HDMI 2.0+/`tmds1204-*` `sigdet_wakeup` PHY_DEPENDENT (no layout ohms) | **VENDOR** |
|
||||
| `HDMI-FRL-Software-Datasheet-61W6169600.pdf` | HDMI 2.1 FRL Compliance Test Solution Datasheet (Tektronix) | (on file; 4 pages) | **not packed** — test-equipment datasheet, not HDMI Forum Adopter | archived only |
|
||||
| `snla027b.pdf` | AN-807 Reflections: Computations and Waveforms | SNLA027B – May 2004 / revised May 2004 | **not packed** for USB-C vSafe — TI SI, no vSafe5V/vSafe0V | archived only |
|
||||
| `sdaa499.pdf` | Simple Methods to Reduce EMI from a PCB Trace | SDAA499 – September 2026 | **not packed** for USB-C vSafe — TI EMI, no vSafe | archived only |
|
||||
|
||||
Duplicates on Desktop (same MD5, not stored twice here): `USB2_Electrical_Compliance.pdf`, `DVI_Spec_v1.0.pdf`, `USB_TypeC_Functional_Test.pdf`.
|
||||
@@ -0,0 +1,208 @@
|
||||
"""AF board + AI: hypotheses of probable HF issues, then deterministic checks.
|
||||
|
||||
Does not replace run_pcb_checks. Not DRC. Never invents Z.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.af_ai_hf import (
|
||||
HfHypothesis,
|
||||
append_investigated,
|
||||
hypotheses_from_rows,
|
||||
investigate_hf_hypotheses,
|
||||
)
|
||||
from backend.periscopex.hf_line_check import check_hf_lines
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
LayoutStackup,
|
||||
LayoutDielectric,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||
|
||||
|
||||
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _usb_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
|
||||
"J2": Component(
|
||||
reference="J2", value="USB_C", footprint="",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A6": "USB_D+", "A7": "USB_D-"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
|
||||
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _fp(ref: str, pads: list[LayoutPad], x: float = 0.0) -> LayoutFootprint:
|
||||
return LayoutFootprint(reference=ref, footprint="P", x=x, y=0.0, pads=pads)
|
||||
|
||||
|
||||
def _stub_layout() -> LayoutGraph:
|
||||
return LayoutGraph(
|
||||
footprints={
|
||||
"U1": _fp("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
|
||||
"J2": _fp("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
|
||||
},
|
||||
segments=[
|
||||
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||
LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _stub_constraints() -> dict:
|
||||
return {
|
||||
"PHY": ComponentConstraints(
|
||||
mpn="PHY", pintable=[Pin(number="1", name="D+")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
layout_rules=[{
|
||||
"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0,
|
||||
"note": "USB stub < 1 mm", "source_page": 12,
|
||||
}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_ai_stub_flag_then_deterministic_pe_si_007():
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
cons = _stub_constraints()
|
||||
flags = [HfHypothesis(net="USB_D+", issue_class="stub", why="looks long")]
|
||||
out = investigate_hf_hypotheses(graph, cons, layout, flags)
|
||||
codes = [f.rule_id for f in out]
|
||||
assert "PE-SI-007" in codes
|
||||
hit = next(f for f in out if f.rule_id == "PE-SI-007")
|
||||
assert "FAIL" in hit.finding
|
||||
assert hit.evidence_status == "SUFFICIENT"
|
||||
|
||||
|
||||
def test_ai_z0_flag_without_stackup_is_insufficient_no_ohm():
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm")]
|
||||
out = investigate_hf_hypotheses(graph, {}, layout, flags)
|
||||
assert len(out) == 1
|
||||
f = out[0]
|
||||
assert f.rule_id == "PE-AF-001"
|
||||
assert f.evidence_status == "INSUFFICIENT"
|
||||
assert "90" not in f.finding
|
||||
assert "50" not in f.finding
|
||||
assert "Ω" not in f.finding and "ohm" not in f.finding.lower()
|
||||
|
||||
|
||||
def test_ai_flag_on_missing_superspeed_net_is_silence():
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
flags = [HfHypothesis(net="USB3_SSTX_P", issue_class="stub", why="SS pair")]
|
||||
out = investigate_hf_hypotheses(graph, _stub_constraints(), layout, flags)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_run_pcb_checks_still_finds_stub_without_af_ai():
|
||||
"""AF+AI is extra: the PCB check list must still emit PE-SI-007 alone."""
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
pcb = run_pcb_checks(graph, _stub_constraints(), layout)
|
||||
assert any(f.rule_id == "PE-SI-007" for f in pcb)
|
||||
hf = check_hf_lines(graph, _stub_constraints(), layout)
|
||||
assert any(f.rule_id == "PE-SI-007" for f in hf)
|
||||
|
||||
|
||||
def test_z0_with_stackup_does_not_invent_ohm_number():
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
layout.stackup = LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||
)
|
||||
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="check Z0")]
|
||||
out = investigate_hf_hypotheses(graph, {}, layout, flags)
|
||||
for f in out:
|
||||
assert "50 Ω" not in (f.finding or "")
|
||||
assert "90 Ω" not in (f.finding or "")
|
||||
|
||||
|
||||
def test_append_investigated_keeps_pcb_checks_and_adds_pe_af():
|
||||
graph = _usb_graph()
|
||||
layout = _stub_layout()
|
||||
cons = _stub_constraints()
|
||||
pcb = run_pcb_checks(graph, cons, layout)
|
||||
n_si = sum(1 for f in pcb if f.rule_id == "PE-SI-007")
|
||||
assert n_si >= 1
|
||||
flags = [
|
||||
HfHypothesis(net="USB_D+", issue_class="stub", why="looks long"),
|
||||
HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm"),
|
||||
]
|
||||
added = append_investigated(pcb, graph, cons, layout, flags)
|
||||
assert any(f.rule_id == "PE-AF-001" for f in added)
|
||||
assert sum(1 for f in pcb if f.rule_id == "PE-SI-007") == n_si
|
||||
assert "si_check" in _pcb_checks_source()
|
||||
assert "hf_line" in _pcb_checks_source()
|
||||
|
||||
|
||||
def test_hypotheses_from_rows_drops_unknown_class():
|
||||
rows = [
|
||||
{"net": "USB_D+", "issue_class": "stub", "why": "x"},
|
||||
{"net": "USB_D+", "issue_class": "clearance", "why": "drc"},
|
||||
{"net": "", "issue_class": "z0", "why": "empty"},
|
||||
]
|
||||
hyps = hypotheses_from_rows(rows)
|
||||
assert len(hyps) == 1
|
||||
assert hyps[0].issue_class == "stub"
|
||||
|
||||
|
||||
def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
|
||||
import inspect
|
||||
|
||||
from backend.periscopex import pcb_checks
|
||||
from backend.services import pcb_pipeline
|
||||
|
||||
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
|
||||
assert "run_pcb_checks" in src
|
||||
assert "check_af_traces" in src
|
||||
assert "_run_af_ai_section" in src
|
||||
assert src.find("run_pcb_checks") < src.find("check_af_traces")
|
||||
assert src.find("check_af_traces") < src.find("_run_af_ai_section")
|
||||
checks = inspect.getsource(pcb_checks.run_pcb_checks)
|
||||
assert '("si_check"' in checks or '"si_check"' in checks
|
||||
assert '"hf_line"' in checks
|
||||
assert "af_ai" not in checks
|
||||
assert "check_af_traces" not in checks
|
||||
assert "investigate_hf_hypotheses" not in checks
|
||||
|
||||
|
||||
def _pcb_checks_source() -> str:
|
||||
import inspect
|
||||
|
||||
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||
|
||||
return inspect.getsource(run_pcb_checks)
|
||||
+5
-2
@@ -16,8 +16,11 @@ for p in (
|
||||
REPO_ROOT,
|
||||
):
|
||||
s = str(p)
|
||||
if p.is_dir() and s not in sys.path:
|
||||
sys.path.insert(0, s)
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if s in sys.path:
|
||||
sys.path.remove(s)
|
||||
sys.path.insert(0, s)
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Shared component library: persist datasheets, catalog listing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.datasheet_store import resolve_datasheet
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
|
||||
PDF = b"%PDF-1.4\n" + b"x" * 8000
|
||||
|
||||
|
||||
def _client(tmp_path) -> TestClient:
|
||||
from backend.main import app
|
||||
|
||||
app.state.storage = LocalStorageBackend(tmp_path)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_library_alias_resolves_family_mpn(storage):
|
||||
from backend.services.datasheet_store import resolve_datasheet, store_datasheet_bytes
|
||||
|
||||
store_datasheet_bytes(
|
||||
storage, PDF, "ESP32-S31-WROOM-3",
|
||||
extra_mpns=["ESP32-S31-WROOM-3-N16R16V"],
|
||||
)
|
||||
assert resolve_datasheet(storage, "ESP32-S31-WROOM-3")
|
||||
assert resolve_datasheet(storage, "ESP32-S31-WROOM-3-N16R16V")
|
||||
assert proj_svc.library_has_datasheet(storage, "ESP32-S31-WROOM-3")
|
||||
|
||||
|
||||
def test_save_datasheet_also_stores_in_library(storage):
|
||||
meta = proj_svc.create_project(storage, "local", "board")
|
||||
key = proj_svc.save_datasheet(storage, "local", meta.id, "CH340E", PDF)
|
||||
assert key.endswith("CH340E.pdf")
|
||||
assert storage.exists(key)
|
||||
assert resolve_datasheet(storage, "CH340E")
|
||||
assert proj_svc.library_has_datasheet(storage, "CH340E")
|
||||
|
||||
|
||||
def test_library_catalog_lists_ics_passives_and_pdfs(storage):
|
||||
storage.write_json(
|
||||
"library/extracted/CH340E.json",
|
||||
{
|
||||
"mpn": "CH340E",
|
||||
"pintable": [{"pin": 1}, {"pin": 2}],
|
||||
"component_subtype": "usb-uart",
|
||||
"absolute_maximum_ratings": {"vcc": "5V"},
|
||||
},
|
||||
)
|
||||
storage.write_json(
|
||||
"library/patterns/samsung_c.json",
|
||||
{
|
||||
"name": "Samsung CL10",
|
||||
"component_type": "capacitor",
|
||||
"description": "Samsung 0603 MLCC",
|
||||
"regex": r"^CL10",
|
||||
},
|
||||
)
|
||||
storage.write_json(
|
||||
"library/passives/CL10B474KA8NNNC.json",
|
||||
{
|
||||
"mpn": "CL10B474KA8NNNC",
|
||||
"specs": {
|
||||
"specs_type": "capacitor",
|
||||
"component_subtype": "mlcc",
|
||||
"values": {"capacitance": "470n"},
|
||||
},
|
||||
},
|
||||
)
|
||||
proj_svc.remember_datasheet(storage, "CH340E", PDF)
|
||||
|
||||
cat = proj_svc.list_library_catalog(storage)
|
||||
assert len(cat["ics"]) == 1
|
||||
assert cat["ics"][0]["mpn"] == "CH340E"
|
||||
assert cat["ics"][0]["pin_count"] == 2
|
||||
assert cat["ics"][0]["has_datasheet"] is True
|
||||
assert cat["passives"][0]["mpn"] == "Samsung CL10"
|
||||
assert cat["passive_parts"][0]["mpn"] == "CL10B474KA8NNNC"
|
||||
assert cat["passive_parts"][0]["param_count"] >= 1
|
||||
assert cat["simple"] == []
|
||||
assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"])
|
||||
|
||||
|
||||
def test_library_http_catalog_and_pdf(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
empty = client.get("/api/library")
|
||||
assert empty.status_code == 200
|
||||
body = empty.json()
|
||||
assert body["ics"] == []
|
||||
assert body["datasheets"] == []
|
||||
|
||||
meta = client.post("/api/projects", json={"name": "lib"}).json()
|
||||
resp = client.post(
|
||||
f"/api/projects/{meta['id']}/upload/datasheets",
|
||||
params={"mpn": "MSPM0G3507"},
|
||||
files={"file": ("msp.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
catalog = client.get("/api/library").json()
|
||||
assert any(d["mpn"] == "MSPM0G3507" for d in catalog["datasheets"])
|
||||
|
||||
pdf = client.get("/api/library/datasheet/MSPM0G3507")
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.content.startswith(b"%PDF-")
|
||||
|
||||
|
||||
def test_fetch_datasheet_persists_to_library(tmp_path, monkeypatch):
|
||||
from backend.services.datasheet_finder import DatasheetHit
|
||||
|
||||
async def fake_find(mpn, lcsc_id=None):
|
||||
return DatasheetHit(
|
||||
mpn,
|
||||
pdf_bytes=PDF,
|
||||
url="https://datasheet.lcsc.com/x.pdf",
|
||||
source="lcsc",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"backend.services.datasheet_finder.find_datasheet", fake_find,
|
||||
)
|
||||
client = _client(tmp_path)
|
||||
resp = client.get("/api/datasheets/fetch", params={"mpn": "CH340E"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content.startswith(b"%PDF-")
|
||||
|
||||
catalog = client.get("/api/library").json()
|
||||
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
||||
assert client.get("/api/library/datasheet/CH340E").status_code == 200
|
||||
|
||||
|
||||
def test_library_import_pdf_without_project(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
before = client.get("/api/projects").json()
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E", "kind": "ic"},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mpn"] == "CH340E"
|
||||
assert body["has_extraction"] is False
|
||||
assert body["component_type"] == "ic"
|
||||
assert body["status"] == "needs_pintable"
|
||||
assert client.get("/api/projects").json() == before
|
||||
catalog = client.get("/api/library").json()
|
||||
assert any(d["mpn"] == "CH340E" and d.get("has_component") for d in catalog["datasheets"])
|
||||
ics = [c for c in catalog["ics"] if c["mpn"] == "CH340E"]
|
||||
assert len(ics) == 1
|
||||
assert ics[0]["pin_count"] == 0
|
||||
assert ics[0]["library_status"] == "needs_pintable"
|
||||
pdf = client.get("/api/library/datasheet/CH340E")
|
||||
assert pdf.status_code == 200
|
||||
assert pdf.content.startswith(b"%PDF-")
|
||||
card = client.get("/api/library/components/ic/CH340E")
|
||||
assert card.status_code == 200
|
||||
assert card.json()["status"] == "needs_pintable"
|
||||
from backend.main import app
|
||||
from backend.services.library import library_has_extraction
|
||||
|
||||
assert library_has_extraction(app.state.storage, "CH340E") is None
|
||||
assert not app.state.storage.exists("library/extracted/CH340E.json")
|
||||
|
||||
|
||||
def test_library_import_rejects_non_pdf(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E"},
|
||||
files={"file": ("notes.txt", b"not a pdf", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert client.get("/api/library").json()["datasheets"] == []
|
||||
|
||||
|
||||
def test_library_import_rejects_blank_mpn(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": " "},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code in (400, 422)
|
||||
|
||||
|
||||
def test_library_get_put_component(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
from backend.main import app
|
||||
|
||||
storage = app.state.storage
|
||||
storage.write_json(
|
||||
"library/extracted/CH340E.json",
|
||||
{
|
||||
"mpn": "CH340E",
|
||||
"component_subtype": "ic.interface.usb_uart_bridge",
|
||||
"pintable": [
|
||||
{"number": "1", "name": "VCC"},
|
||||
{"number": "2", "name": "GND"},
|
||||
],
|
||||
},
|
||||
)
|
||||
got = client.get("/api/library/components/ic/CH340E")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["component_subtype"] == "ic.interface.usb_uart_bridge"
|
||||
|
||||
payload = got.json()
|
||||
payload["component_subtype"] = "ic.interface.usb_uart"
|
||||
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["component_subtype"] == "ic.interface.usb_uart"
|
||||
again = client.get("/api/library/components/ic/CH340E").json()
|
||||
assert again["component_subtype"] == "ic.interface.usb_uart"
|
||||
|
||||
|
||||
def test_library_put_rejects_empty_pintable(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.put(
|
||||
"/api/library/components/ic/FAKEIC",
|
||||
json={"mpn": "FAKEIC", "pintable": []},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
|
||||
|
||||
|
||||
def test_library_import_ic_put_pintable_promotes_out_of_inbox(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
assert client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E", "kind": "ic"},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
).status_code == 200
|
||||
payload = {
|
||||
"mpn": "CH340E",
|
||||
"component_subtype": "ic.interface.usb_uart_bridge",
|
||||
"pintable": [
|
||||
{"number": "1", "name": "VCC"},
|
||||
{"number": "2", "name": "GND"},
|
||||
],
|
||||
"absolute_maximum_ratings": [],
|
||||
"rules": [],
|
||||
}
|
||||
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
||||
assert saved.status_code == 200
|
||||
from backend.main import app
|
||||
|
||||
assert app.state.storage.exists("library/extracted/CH340E.json")
|
||||
assert not app.state.storage.exists("library/inbox/CH340E.json")
|
||||
extracted = app.state.storage.read_json("library/extracted/CH340E.json")
|
||||
assert "status" not in extracted
|
||||
assert extracted["pintable"][0]["name"] == "VCC"
|
||||
cat = client.get("/api/library").json()
|
||||
ic = next(c for c in cat["ics"] if c["mpn"] == "CH340E")
|
||||
assert ic["pin_count"] == 2
|
||||
assert ic["library_status"] == "extracted"
|
||||
|
||||
|
||||
def test_library_import_passive_without_exam(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CL10B474KA8NNNC", "kind": "passive_part"},
|
||||
files={"file": ("c.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["component_type"] == "passive_part"
|
||||
assert client.get("/api/projects").json() == []
|
||||
cat = client.get("/api/library").json()
|
||||
assert any(p["mpn"] == "CL10B474KA8NNNC" for p in cat["passive_parts"])
|
||||
got = client.get("/api/library/components/passive_part/CL10B474KA8NNNC")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["specs"]["specs_type"] == "passive"
|
||||
|
||||
|
||||
def test_library_import_rejects_unknown_kind(tmp_path):
|
||||
client = _client(tmp_path)
|
||||
resp = client.post(
|
||||
"/api/library/datasheets",
|
||||
data={"mpn": "CH340E", "kind": "project"},
|
||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert client.get("/api/library").json()["ics"] == []
|
||||
@@ -7,3 +7,4 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project"
|
||||
TAXONOMY = REPO_ROOT / "periscope" / "src" / "taxonomy"
|
||||
FIXTURES = REPO_ROOT / "tests" / "fixtures"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""AF trace v1: trigger, visible skips, pair-together, no invented ohms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.periscopex.af_rlgc import cascade_sparam
|
||||
from backend.periscopex.af_trace_check import check_af_traces
|
||||
from backend.periscopex.af_trigger import evaluate_trigger, iter_af_units
|
||||
from backend.periscopex.finding_engine import lookup_rule
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutDielectric,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
LayoutStackup,
|
||||
LayoutVia,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
|
||||
_HUB = Path("/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb")
|
||||
|
||||
|
||||
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _net(name: str, *pairs: tuple[str, str]) -> Net:
|
||||
return Net(
|
||||
name=name, net_type=NetType.SIGNAL,
|
||||
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
|
||||
)
|
||||
|
||||
|
||||
def _usb_graph() -> DesignGraph:
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
|
||||
"J2": Component(
|
||||
reference="J2", value="USB_C", footprint="",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
pins={"A6": "USB_D+", "A7": "USB_D-"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
|
||||
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _stack() -> LayoutStackup:
|
||||
return LayoutStackup(
|
||||
copper_layers=["F.Cu", "B.Cu"],
|
||||
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
|
||||
copper_thickness_mm=0.035,
|
||||
)
|
||||
|
||||
|
||||
def _pair_layout(*, length: float = 10.0, elbow: bool = False) -> LayoutGraph:
|
||||
segs = [
|
||||
LayoutSegment(start=(0, 0), end=(length, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||||
LayoutSegment(start=(0, 0.4), end=(length, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
|
||||
]
|
||||
if elbow:
|
||||
segs.append(LayoutSegment(
|
||||
start=(length, 0), end=(length, 3), width=0.2, layer="F.Cu", net="USB_D+",
|
||||
))
|
||||
return LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", footprint="P", x=0, y=0,
|
||||
pads=[LayoutPad(number="1", x=0, y=0, net="USB_D+"),
|
||||
LayoutPad(number="2", x=0, y=0.4, net="USB_D-")],
|
||||
),
|
||||
"J2": LayoutFootprint(
|
||||
reference="J2", footprint="P", x=length, y=0,
|
||||
pads=[LayoutPad(number="A6", x=length, y=0, net="USB_D+"),
|
||||
LayoutPad(number="A7", x=length, y=0.4, net="USB_D-")],
|
||||
),
|
||||
},
|
||||
segments=segs,
|
||||
stackup=_stack(),
|
||||
)
|
||||
|
||||
|
||||
def _rules(*extra: dict) -> dict:
|
||||
rules = list(extra)
|
||||
return {
|
||||
"PHY": ComponentConstraints(
|
||||
mpn="PHY", pintable=[Pin(number="1", name="D+")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
layout_rules=rules,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_pe_af_ids():
|
||||
for rid in ("PE-AF-001", "PE-AF-002", "PE-AF-020", "PE-AF-050", "PE-AF-060"):
|
||||
rec = lookup_rule(rid)
|
||||
assert rec is not None, rid
|
||||
assert rec.domain == "pcb"
|
||||
|
||||
|
||||
def test_pair_is_one_unit():
|
||||
units = iter_af_units(_pair_layout(), _usb_graph())
|
||||
assert len(units) == 1
|
||||
assert set(units[0].nets) == {"USB_D+", "USB_D-"}
|
||||
|
||||
|
||||
def test_gpio_is_not_a_candidate():
|
||||
g = DesignGraph(
|
||||
components={"U1": _ic("U1", {"1": "SDA"})},
|
||||
nets={"SDA": _net("SDA", ("U1", "1"))},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
segments=[LayoutSegment(start=(0, 0), end=(80, 0), width=0.2, layer="F.Cu", net="SDA")],
|
||||
stackup=_stack(),
|
||||
)
|
||||
assert check_af_traces(g, {}, layout) == []
|
||||
|
||||
|
||||
def test_missing_tr_f_is_visible_skip_no_ohm():
|
||||
layout = _pair_layout()
|
||||
out = check_af_traces(_usb_graph(), {}, layout)
|
||||
assert out
|
||||
assert all(f.rule_id == "PE-AF-002" for f in out)
|
||||
assert len(out) == 1
|
||||
text = out[0].finding
|
||||
assert "Pista ad alta frequenza non controllata per mancanza di" in text
|
||||
assert "tr" in text and "f" in text
|
||||
assert out[0].evidence_status == "INSUFFICIENT"
|
||||
assert out[0].finding_class == "REVIEW"
|
||||
blob = (out[0].finding + out[0].facts).lower()
|
||||
assert "90" not in blob and "50" not in blob and "ω" not in blob and "ohm" not in blob
|
||||
|
||||
|
||||
def test_short_pair_with_tr_is_not_af():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 5.0})
|
||||
layout = _pair_layout(length=8.0)
|
||||
trig = evaluate_trigger(_usb_graph(), cons, layout, iter_af_units(layout, _usb_graph())[0])
|
||||
assert trig.af is False
|
||||
assert check_af_traces(_usb_graph(), cons, layout) == []
|
||||
|
||||
|
||||
def test_triggered_without_z_target_is_visible_skip():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0)
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
skips = [f for f in out if f.rule_id == "PE-AF-002"]
|
||||
assert skips
|
||||
assert "Z target datasheet" in skips[0].finding
|
||||
assert all("90" not in f.finding for f in out)
|
||||
|
||||
|
||||
def test_triggered_zdiff_window_uses_datasheet_not_folklore():
|
||||
cons = _rules(
|
||||
{"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05},
|
||||
{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90, "tolerance_pct": 10},
|
||||
)
|
||||
layout = _pair_layout(length=40.0)
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
zhits = [f for f in out if f.rule_id == "PE-AF-050"]
|
||||
if zhits:
|
||||
assert zhits[0].net in {"USB_D+", "USB_D-"}
|
||||
assert "90" in zhits[0].finding or "90" in zhits[0].facts
|
||||
assert zhits[0].calculation == "" or "Z_ref" in zhits[0].calculation
|
||||
assert sum(1 for f in out if f.rule_id == "PE-AF-050") <= 1
|
||||
|
||||
|
||||
def test_right_angle_on_triggered_pair():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0, elbow=True)
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
assert any(f.rule_id == "PE-AF-020" for f in out)
|
||||
|
||||
|
||||
def test_via_without_span_is_visible_skip():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0)
|
||||
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3)]
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
assert any("span via" in f.finding for f in out if f.rule_id == "PE-AF-002")
|
||||
|
||||
|
||||
def test_via_with_span_is_not_a_pad():
|
||||
cons = _rules({"kind": "rise_time", "net_class": "usb2", "tr_ns": 0.05})
|
||||
layout = _pair_layout(length=40.0)
|
||||
layout.vias = [LayoutVia(x=5, y=0, net="USB_D+", drill=0.3, layers=("F.Cu", "B.Cu"))]
|
||||
out = check_af_traces(_usb_graph(), cons, layout)
|
||||
assert any(f.rule_id == "PE-AF-060" for f in out)
|
||||
assert all(f.rule_id != "PE-VIA-001" for f in out)
|
||||
|
||||
|
||||
def test_si_stub_still_fires_without_af_module():
|
||||
layout = _pair_layout(length=10.0, elbow=True)
|
||||
cons = _rules({"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0, "note": "USB stub"})
|
||||
pcb = run_pcb_checks(_usb_graph(), cons, layout)
|
||||
assert any(f.rule_id == "PE-SI-007" for f in pcb)
|
||||
|
||||
|
||||
def test_cascade_needs_positive_zref():
|
||||
assert cascade_sparam([50.0], [0.04], 3.5, 1e9, 0.0) is None
|
||||
r = cascade_sparam([50.0], [0.04], 3.5, 1e9, 50.0)
|
||||
assert r is not None
|
||||
assert r.n_sections == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HUB.is_file(), reason="HubAudio board not on this machine")
|
||||
def test_hubaudio_usb_skip_has_no_invented_ohm():
|
||||
layout = parse_kicad_pcb(_HUB)
|
||||
graph = DesignGraph(
|
||||
components={"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"})},
|
||||
nets={
|
||||
"USB_D+": _net("USB_D+", ("U1", "1")),
|
||||
"USB_D-": _net("USB_D-", ("U1", "2")),
|
||||
},
|
||||
)
|
||||
# Real net names may be hierarchical; still must not invent ohms on AF skips.
|
||||
out = check_af_traces(graph, {}, layout)
|
||||
for f in out:
|
||||
if f.rule_id == "PE-AF-002":
|
||||
low = f.finding.lower()
|
||||
assert "90 Ω" not in f.finding and "50 Ω" not in f.finding
|
||||
assert "90 ohm" not in low
|
||||
@@ -0,0 +1,523 @@
|
||||
"""PE-BOM-011 joins pintable × lib × PCB by name. pin_count is not authority."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.bom_pcb_check import (
|
||||
_is_ep_land,
|
||||
check_bom_pcb_datasheet,
|
||||
)
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
PackageInfo,
|
||||
Pin,
|
||||
)
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
|
||||
_HUB_PCB = Path(
|
||||
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
|
||||
)
|
||||
|
||||
_USB_C_PINS = [
|
||||
"A1", "A4", "A5", "A6", "A7", "A8", "A9", "A12",
|
||||
"B1", "B4", "B5", "B6", "B7", "B8", "B9", "B12",
|
||||
]
|
||||
|
||||
|
||||
def _usb_c_cons(*, pintable: bool) -> ComponentConstraints:
|
||||
pins = [Pin(number=n, name=n) for n in _USB_C_PINS] if pintable else []
|
||||
return ComponentConstraints(
|
||||
mpn="TYPE-C-31-M-12",
|
||||
package_info=PackageInfo(
|
||||
base_family="USB", package="USB-C-16P", pin_count=16,
|
||||
),
|
||||
pintable=pins,
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
|
||||
|
||||
def _j2_graph(pins: dict[str, str] | None = None) -> DesignGraph:
|
||||
keys = pins or {n: "SIG" for n in _USB_C_PINS}
|
||||
keys.setdefault("SH", "GND")
|
||||
return DesignGraph(
|
||||
components={
|
||||
"J2": Component(
|
||||
reference="J2",
|
||||
value="USB_C_Receptacle_USB2.0_16P",
|
||||
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
|
||||
component_type=ComponentType.CONNECTOR,
|
||||
mpn="TYPE-C-31-M-12",
|
||||
pins=keys,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _j2_layout() -> LayoutGraph:
|
||||
pads = [
|
||||
LayoutPad(number=n, x=0, y=0, net="SIG", pinfunction=f"PIN_{n}")
|
||||
for n in _USB_C_PINS
|
||||
]
|
||||
pads.extend(
|
||||
LayoutPad(number="SH", x=i, y=0, net="GND", pinfunction="SHIELD_SH")
|
||||
for i in range(4)
|
||||
)
|
||||
return LayoutGraph(
|
||||
footprints={
|
||||
"J2": LayoutFootprint(
|
||||
reference="J2",
|
||||
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
|
||||
x=0, y=0, layer="F.Cu",
|
||||
pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ids(findings) -> set[str]:
|
||||
return {f.rule_id for f in findings}
|
||||
|
||||
|
||||
def test_a5_b5_are_not_ep():
|
||||
assert _is_ep_land("A5", "CC1_A5", pin_count=16) is False
|
||||
assert _is_ep_land("B5", "CC2_B5", pin_count=16) is False
|
||||
assert _is_ep_land("EP", pin_count=8) is True
|
||||
assert _is_ep_land("9", "EP", pin_count=8) is True
|
||||
assert _is_ep_land("9", pin_count=8) is True
|
||||
assert _is_ep_land("9_1", pin_count=8) is True
|
||||
|
||||
|
||||
def test_usb_c_pin_count_16_without_pintable_is_not_error():
|
||||
"""The 16 vs 1 SH false ERROR: pin_count alone must not fire PE-BOM-011."""
|
||||
findings = check_bom_pcb_datasheet(
|
||||
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=False)}, _j2_layout(),
|
||||
)
|
||||
assert "PE-BOM-011" not in _ids(findings)
|
||||
assert not any("pin_count=16" in (f.finding or "") for f in findings)
|
||||
|
||||
|
||||
def test_usb_c_pintable_matches_contacts_ignores_sh_and_mount():
|
||||
findings = check_bom_pcb_datasheet(
|
||||
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=True)}, _j2_layout(),
|
||||
)
|
||||
assert "PE-BOM-011" not in _ids(findings)
|
||||
|
||||
|
||||
def test_hubaudio_j2_is_not_16_vs_1_signal_pad():
|
||||
if not _HUB_PCB.is_file():
|
||||
return
|
||||
layout = parse_kicad_pcb(_HUB_PCB)
|
||||
j2 = layout.footprints.get("J2")
|
||||
assert j2 is not None
|
||||
numbers = {p.number for p in j2.pads}
|
||||
assert "A5" in numbers and "B5" in numbers
|
||||
assert "SH" in numbers
|
||||
slim = LayoutGraph(footprints={"J2": j2})
|
||||
findings = check_bom_pcb_datasheet(
|
||||
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=False)}, slim,
|
||||
)
|
||||
bom011 = [f for f in findings if f.rule_id == "PE-BOM-011"]
|
||||
assert bom011 == []
|
||||
findings_named = check_bom_pcb_datasheet(
|
||||
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=True)}, slim,
|
||||
)
|
||||
assert "PE-BOM-011" not in _ids(findings_named)
|
||||
|
||||
|
||||
def test_qfn_ep_still_excluded_from_join():
|
||||
cons = ComponentConstraints(
|
||||
mpn="SW",
|
||||
package_info=PackageInfo(base_family="TI", package="SON-8-EP", pin_count=8),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 9)]
|
||||
pads += [
|
||||
LayoutPad(number="9", x=0, y=0, net="GND", pinfunction="EP"),
|
||||
LayoutPad(number="9_1", x=0.2, y=0, net="GND"),
|
||||
LayoutPad(number="9_2", x=0.4, y=0, net="GND"),
|
||||
]
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="SW", footprint="SON-8",
|
||||
component_type=ComponentType.IC, mpn="SW",
|
||||
pins={str(i): "GND" for i in range(1, 9)},
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)},
|
||||
)
|
||||
assert "PE-BOM-011" not in _ids(
|
||||
check_bom_pcb_datasheet(graph, {"SW": cons}, layout),
|
||||
)
|
||||
|
||||
|
||||
def test_missing_signal_pad_still_fires():
|
||||
cons = ComponentConstraints(
|
||||
mpn="IC24",
|
||||
package_info=PackageInfo(base_family="QFN", package="QFN-24", pin_count=24),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 24)]
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="IC", footprint="QFN-24",
|
||||
component_type=ComponentType.IC, mpn="IC24",
|
||||
pins={str(i): "SIG" for i in range(1, 25)},
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)},
|
||||
)
|
||||
findings = check_bom_pcb_datasheet(graph, {"IC24": cons}, layout)
|
||||
assert "PE-BOM-011" in _ids(findings)
|
||||
assert any("24" in (f.finding or "") for f in findings if f.rule_id == "PE-BOM-011")
|
||||
|
||||
|
||||
def _write_mod(root: Path, lib: str, name: str, pads: list[str]) -> Path:
|
||||
pretty = root / f"{lib}.pretty"
|
||||
pretty.mkdir(parents=True, exist_ok=True)
|
||||
body = [f'(footprint "{lib}:{name}" (version 20240108) (layer "F.Cu")\n']
|
||||
for i, n in enumerate(pads):
|
||||
body.append(
|
||||
f' (pad "{n}" smd rect (at {i} 0) (size 0.3 0.8) '
|
||||
f'(layers "F.Cu" "F.Paste" "F.Mask") (pinfunction "{n}"))\n'
|
||||
)
|
||||
body.append(")\n")
|
||||
path = pretty / f"{name}.kicad_mod"
|
||||
path.write_text("".join(body))
|
||||
return path
|
||||
|
||||
|
||||
def test_lib_vs_datasheet_is_lib_finding_not_pcb_pad_count(tmp_path: Path):
|
||||
_write_mod(tmp_path, "Pkg", "QFN-8", [str(i) for i in range(1, 8)])
|
||||
cons = ComponentConstraints(
|
||||
mpn="IC8",
|
||||
package_info=PackageInfo(base_family="QFN", package="QFN-8", pin_count=8),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 9)]
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="IC", footprint="Pkg:QFN-8",
|
||||
component_type=ComponentType.IC, mpn="IC8",
|
||||
pins={str(i): "SIG" for i in range(1, 9)},
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", footprint="Pkg:QFN-8", x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = [
|
||||
f for f in check_bom_pcb_datasheet(
|
||||
graph, {"IC8": cons}, layout, footprint_dirs=[tmp_path],
|
||||
)
|
||||
if f.rule_id == "PE-BOM-011"
|
||||
]
|
||||
assert findings
|
||||
text = " ".join(f.finding for f in findings)
|
||||
assert "library" in text.lower()
|
||||
assert "pin_count=" not in text
|
||||
assert "signal pads=" not in text
|
||||
|
||||
|
||||
def test_lib_vs_embedded_pcb_is_separate_finding(tmp_path: Path):
|
||||
_write_mod(tmp_path, "Pkg", "QFN-8", [str(i) for i in range(1, 9)])
|
||||
cons = ComponentConstraints(
|
||||
mpn="IC8",
|
||||
package_info=PackageInfo(base_family="QFN", package="QFN-8", pin_count=8),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 8)]
|
||||
pads.append(LayoutPad(number="99", x=1, y=0, net="SIG"))
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="IC", footprint="Pkg:QFN-8",
|
||||
component_type=ComponentType.IC, mpn="IC8",
|
||||
pins={str(i): "SIG" for i in range(1, 9)},
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", footprint="Pkg:QFN-8", x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
findings = [
|
||||
f for f in check_bom_pcb_datasheet(
|
||||
graph, {"IC8": cons}, layout, footprint_dirs=[tmp_path],
|
||||
)
|
||||
if f.rule_id == "PE-BOM-011"
|
||||
]
|
||||
assert any("embedded" in f.finding.lower() or "PCB" in f.finding for f in findings)
|
||||
|
||||
|
||||
def _ic(
|
||||
ref: str,
|
||||
mpn: str,
|
||||
footprint: str,
|
||||
n_sch: int,
|
||||
) -> Component:
|
||||
return Component(
|
||||
reference=ref, value=mpn, footprint=footprint,
|
||||
component_type=ComponentType.IC, mpn=mpn,
|
||||
pins={str(i): "SIG" for i in range(1, n_sch + 1)},
|
||||
)
|
||||
|
||||
|
||||
def test_wroom_module_pads_vs_die_pintable_not_bom_011():
|
||||
"""U11: WROOM land ≠ ESP32 die 61-pin pintable."""
|
||||
cons = ComponentConstraints(
|
||||
mpn="ESP32-S31-WROOM-3",
|
||||
package_info=PackageInfo(
|
||||
base_family="ESP", package="QFN-56", pin_count=61,
|
||||
),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 100)]
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U11": _ic(
|
||||
"U11", "ESP32-S31-WROOM-3",
|
||||
"MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 41,
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U11": LayoutFootprint(
|
||||
reference="U11",
|
||||
footprint="MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3",
|
||||
x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
ids = _ids(check_bom_pcb_datasheet(graph, {"ESP32-S31-WROOM-3": cons}, layout))
|
||||
assert "PE-BOM-011" not in ids
|
||||
|
||||
|
||||
def test_wroom_missing_die_pin_still_fires():
|
||||
cons = ComponentConstraints(
|
||||
mpn="ESP32-S31-WROOM-3",
|
||||
package_info=PackageInfo(
|
||||
base_family="ESP", package="QFN-56", pin_count=61,
|
||||
),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 60)]
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U11": _ic(
|
||||
"U11", "ESP32-S31-WROOM-3",
|
||||
"MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 40,
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U11": LayoutFootprint(
|
||||
reference="U11",
|
||||
footprint="MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3",
|
||||
x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
assert "PE-BOM-011" in _ids(
|
||||
check_bom_pcb_datasheet(graph, {"ESP32-S31-WROOM-3": cons}, layout),
|
||||
)
|
||||
|
||||
|
||||
def test_qfn48_truncated_pintable_24_not_extra_error():
|
||||
"""U12: pintable 1–24 vs QFN-48 lands is extraction, not a wrong footprint."""
|
||||
cons = ComponentConstraints(
|
||||
mpn="Si4684-A10-GM",
|
||||
package_info=PackageInfo(
|
||||
base_family="Skyworks", package="QFN-48", pin_count=24,
|
||||
),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 49)]
|
||||
pads.append(LayoutPad(number="49", x=0, y=0, net="GND", pinfunction="EP"))
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U12": _ic(
|
||||
"U12", "Si4684-A10-GM",
|
||||
"Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias",
|
||||
48,
|
||||
),
|
||||
},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U12": LayoutFootprint(
|
||||
reference="U12",
|
||||
footprint="Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias",
|
||||
x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
assert "PE-BOM-011" not in _ids(
|
||||
check_bom_pcb_datasheet(graph, {"Si4684-A10-GM": cons}, layout),
|
||||
)
|
||||
|
||||
|
||||
def test_qfn24_extra_pad_30_still_fires():
|
||||
cons = ComponentConstraints(
|
||||
mpn="IC24",
|
||||
package_info=PackageInfo(base_family="QFN", package="QFN-24", pin_count=24),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 25)]
|
||||
pads.append(LayoutPad(number="30", x=1, y=0, net="SIG"))
|
||||
graph = DesignGraph(
|
||||
components={"U1": _ic("U1", "IC24", "Package_DFN_QFN:QFN-24-1EP", 24)},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U1": LayoutFootprint(
|
||||
reference="U1", footprint="Package_DFN_QFN:QFN-24-1EP",
|
||||
x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
assert "PE-BOM-011" in _ids(
|
||||
check_bom_pcb_datasheet(graph, {"IC24": cons}, layout),
|
||||
)
|
||||
|
||||
|
||||
def test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010():
|
||||
"""U19: datasheet QFN vs CAD VQFN-24 4×4 is the same land family."""
|
||||
cons = ComponentConstraints(
|
||||
mpn="LAN8720A",
|
||||
package_info=PackageInfo(
|
||||
base_family="SMS", package="QFN-24", pin_count=24,
|
||||
),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
)
|
||||
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 25)]
|
||||
pads.append(LayoutPad(number="25", x=0, y=0, net="GND", pinfunction="EP"))
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U19": _ic(
|
||||
"U19", "LAN8720A",
|
||||
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias",
|
||||
24,
|
||||
),
|
||||
},
|
||||
schematic_fields={
|
||||
"U19": {
|
||||
"footprint": (
|
||||
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias"
|
||||
),
|
||||
},
|
||||
},
|
||||
bom_fields={"U19": {"footprint": "VQFN-24"}},
|
||||
)
|
||||
layout = LayoutGraph(
|
||||
footprints={
|
||||
"U19": LayoutFootprint(
|
||||
reference="U19",
|
||||
footprint=(
|
||||
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias"
|
||||
),
|
||||
x=0, y=0, pads=pads,
|
||||
),
|
||||
},
|
||||
)
|
||||
ids = _ids(check_bom_pcb_datasheet(graph, {"LAN8720A": cons}, layout))
|
||||
assert "PE-BOM-010" not in ids
|
||||
assert "PE-BOM-011" not in ids
|
||||
|
||||
|
||||
def test_hubaudio_u11_u12_u19_software_not_error():
|
||||
if not _HUB_PCB.is_file():
|
||||
return
|
||||
layout = parse_kicad_pcb(_HUB_PCB)
|
||||
slim = LayoutGraph(
|
||||
footprints={k: layout.footprints[k] for k in ("U11", "U12", "U19")
|
||||
if k in layout.footprints},
|
||||
)
|
||||
graph = DesignGraph(
|
||||
components={
|
||||
"U11": _ic(
|
||||
"U11", "ESP32-S31-WROOM-3",
|
||||
"MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 61,
|
||||
),
|
||||
"U12": _ic(
|
||||
"U12", "Si4684-A10-GM",
|
||||
"Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias",
|
||||
48,
|
||||
),
|
||||
"U19": _ic(
|
||||
"U19", "LAN8720A",
|
||||
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias",
|
||||
24,
|
||||
),
|
||||
},
|
||||
)
|
||||
cmap = {
|
||||
"ESP32-S31-WROOM-3": ComponentConstraints(
|
||||
mpn="ESP32-S31-WROOM-3",
|
||||
package_info=PackageInfo(base_family="ESP", package="QFN-56", pin_count=61),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
),
|
||||
"Si4684-A10-GM": ComponentConstraints(
|
||||
mpn="Si4684-A10-GM",
|
||||
package_info=PackageInfo(base_family="Sky", package="QFN-48", pin_count=24),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
),
|
||||
"LAN8720A": ComponentConstraints(
|
||||
mpn="LAN8720A",
|
||||
package_info=PackageInfo(base_family="SMS", package="QFN-24", pin_count=24),
|
||||
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
|
||||
absolute_maximum_ratings=[],
|
||||
rules=[],
|
||||
),
|
||||
}
|
||||
findings = check_bom_pcb_datasheet(graph, cmap, slim)
|
||||
err = [
|
||||
f for f in findings
|
||||
if f.rule_id in {"PE-BOM-010", "PE-BOM-011"} and f.status == "ERROR"
|
||||
]
|
||||
assert err == [], [f.finding for f in err]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user