Compare commits

..
Author SHA1 Message Date
michele c87adf11a9 Add AF trace analysis after PCB checks (2.63.5).
Trigger λ/10 and tr/(6 tpd) from FACT; pairs analyzed together; Z only from
datasheet; visible INSUFFICIENT skips; lossless RLGC cascade (no OpenEMS).
2026-09-22 08:39:37 +02:00
michele 8b4f7710cf Show Error groups with max child severity (2.63.4).
PCB tree folders are Error / Warning / Info. U18 with PE-PLC-002
ERROR is ERROR, not WARNING. J2 and other connectors list with ICs.
PE-PLC stays in the PCB exam bucket. No deploy.
2026-09-22 01:04:08 +02:00
michele 8f434ffd31 Skip module-die and truncated QFN pintable false PE-BOM hits (2.63.3).
WROOM lands are not the ESP32 die table (U11). QFN-48 pads 25–48 are
not extras when extraction stopped at 1–24 (U12). VQFN is the QFN
family (U19 24-QFN 4×4). Real extra signal pads still ERROR.
2026-09-22 00:57:57 +02:00
michele cd7a7ca3a6 Join PE-BOM-011 pins by name, not pin_count (2.63.2).
USB-C A5/B5 are contacts, not EP. Datasheet pintable, KiCad
.kicad_mod, and embedded PCB pads join by pin identity. Missing
pintable skips; scalar pin_count is never an ERROR authority.
2026-09-22 00:47:58 +02:00
michele 6b751522b7 Keep rust-criteri and conformita-coding in the local docs tree.
Absolute paths in the headings so the files open from
~/Development/periscope/docs. PCB 2.62.1 check list unchanged.
2026-09-21 22:40:12 +02:00
michele 6d9d21a023 Register library components from a datasheet without an exam (2.63.1).
POST /api/library/datasheets now writes an IC inbox card or a
passive/discrete model. Empty pintables never enter library/extracted.
AF Board+AI runs after unchanged run_pcb_checks (PE-SI / HF line stay).
2026-09-21 22:33:30 +02:00
michele 4df04df5d4 Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
2026-09-21 22:12:00 +02:00
michele 20733f0ec6 Add remaining PCB analysis checks to run_pcb_checks (2.62.1).
Phase B: stackup vs fab spec, via current with datasheet I (no IPC via
chart), ESD pad distance, USB-PD, PoE isolation voltage, antenna, and
PDN Z(f). Skip without evidence. Not DRC or FEM.
2026-09-21 20:49:21 +02:00
michele 69d5dd9313 Add HF line and bus analysis to run_pcb_checks (2.62.0).
Phase A: stub, return-split, and BOM termination on buses present on the
graph; DDR/CPU/FPGA class certifiers only when that device exists. Skip
without evidence. Via, pad, track, and zone stay distinct. Not DRC or FEM.
2026-09-21 20:42:55 +02:00
michele 309644e654 Rewrite README as Periscope PCB-analysis product copy.
Replace the schematic-only DeepSeek scaffold with what this tree actually is: full board analysis for fab, gated interface certifiers, evidence rules, and the run/Docker/update commands that exist on disk.
2026-09-21 17:51:41 +02:00
135 changed files with 7238 additions and 673 deletions
+62 -55
View File
@@ -1,55 +1,45 @@
# Periscope (DeepSeek)
# Periscope
Periscope reviews schematics the way a good senior engineer does: with the datasheets open.
Periscope is full **PCB analysis** for a board you intend to send to fab with confidence. Schematic review is a stage, not the deliverable.
This tree is adapted from [manvalan/pinscope](https://github.com/manvalan/pinscope) so the pipeline talks to the **DeepSeek API** (`deepseek-flash`, with legacy aliases still accepted). Do not use Anthropic.
Give it a netlist, a BOM, datasheet PDFs, and a KiCad PCB. It builds a queryable graph of the design, extracts manufacturer constraints once into a shared library, then checks both the circuit and the copper: pads, tracks, vias, zones, measured geometry, and the interfaces that are actually on **this** board.
Give it a netlist, a BOM, and your datasheet PDFs. It builds a graph of your design, reads each IC's datasheet, and checks the circuit around every part against what the manufacturer actually specifies — reference application, pin functions, absolute maximums, recommended operating conditions. Every finding points at the datasheet page that backs it up.
Live instance: [https://periscope.michelebigi.it](https://periscope.michelebigi.it)
## What changed for DeepSeek
This tree is the Periscope product (operator: Michele Bigi). It is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope); Faradworks does not operate this instance.
DeepSeek's Chat Completions API is OpenAI-compatible but **does not accept native PDF documents**. Periscope therefore:
## What it checks — and what it will not invent
1. **Extracts datasheet text** with `pypdf` (page-marked) and sends it as chat content.
2. **Renders pages to JPEG** with PyMuPDF when the stage uses a vision model, so pin diagrams and tables survive.
3. **Runs extraction skills locally.** `skills/*/SKILL.md` is inlined as the system prompt; `validate.py` runs in-process. You do not need Anthropic Console Skills.
4. **Round-trips `reasoning_content`** when DeepSeek thinking mode is on, so multi-turn review and tool calls do not 400.
USB pair impedance, Ethernet class, and trace current versus width are **examples** of gated checks. They run when that bus, connector, or datasheet number exists on the board. They are not a catalog of every interface in electronics.
Default routing:
**Semantic objects stay distinct.** A via is not a pad. A track is not a via. A zone is not a pad. Sharing a net, sitting inside a courtyard, or having nearby coordinates does not change the object type.
| Stage | Model |
**Evidence before conclusion.** If the datasheet, netlist, BOM, or `.kicad_pcb` does not supply the number, Periscope records `INSUFFICIENT` (or skips the check). It does not invent current, impedance, copper weight, geometry, IEC creepage, or “typical USB 500 mA”.
**Trace current uses datasheet current only.** Width versus load (`PE-PWR`) runs when the datasheet reports `I_load` / `Imax` / `I_abs`. Missing that current is a skip — not a guessed ampacity table, not via-IPC folklore.
**Interface class certifiers are gated on parts that are present.** USB-C, RJ45/Ethernet, PoE, and DDR-style certifiers fire only if that connector or device is on the graph. No USB-C receptacle → no USB-C findings (silence, not N/A). A bare RJ45 is Ethernet, not PoE. No DDR device → no DDR findings.
Periscope does **not** claim FEM, thermal spreading, OpenEMS, or a field solver. Closed-form Z0 comes from vendored ImpedenceFinder (`vendor/impedancefinder/`; upstream license UNKNOWN).
Findings keep FACT, REQUIREMENT, and INFERENCE apart. Recommended datasheet notes are not errors. The same finding object is used for schematic (`MODE=run`) and PCB (`MODE=pcb`).
## Repository layout
| Path | Role |
| --- | --- |
| Pintable / pattern / specs extraction | `deepseek-flash` (native vision) |
| Per-IC datasheet review | `deepseek-flash` |
| Auto-resolve / normalize | `deepseek-flash` |
| `periscope/src/` | Native Periscope (finding engine, PCB/placement, DeepSeek, Dockerfiles, KiCad plugin) |
| `periscope/dependency/` | Inherited PinScope (in-tree AGPL dependency — do not delete) |
| `backend/__init__.py` | Merges the two `backend` packages for local imports |
| `scripts/` | `update-periscope.sh`, `materialize-frontend.sh`, smoke wrapper |
| `vendor/impedancefinder/` | Third-party Z0 core (not FEM) |
| `LICENSE` | GNU AGPL v3 |
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `.env`. See `periscope/src/backend/.env.example`.
See `periscope/README.md` for the physical split.
## Layout
## Run locally
- `periscope/src/` — native Periscope
- `periscope/dependency/` — inherited PinScope (AGPL in-tree dependency; do not delete)
- `LICENSE` — GNU AGPL v3 (visible at repo root)
- `vendor/impedancefinder/` — third party (license UNKNOWN)
See `periscope/README.md` and `periscope/src/docs/development/PINSCOPE_INDEPENDENCE_PLAN.md`.
## How it works
<p align="center">
<img src="periscope/dependency/docs/how-it-works.svg" width="920" alt="Pipeline: the netlist and BOM are parsed into a design graph; datasheet PDFs are extracted into pin tables and specs; a per-IC review reads both and files findings cited to datasheet pages; the derating table and BOM roll-up are computed straight from the graph, no model involved.">
</p>
1. **Parse** the BOM (CSV/XLSX) and netlist (PADS-PCB `.asc` or EDIF 2.0.0 `.edn`) into a queryable bipartite graph of components and nets.
2. **Extract** pin tables and specs from the PDFs. Large datasheets are trimmed to the relevant pages first, and every extraction is cached in a shared library.
3. **Review** each IC in isolation. The model gets the datasheet plus that IC's circuit neighborhood, can query the graph, and files findings with severity, reasoning, and page citations. Extraction now also stores absolute-maximum ratings so the reviewer does not have to rediscover supply limits from a 300-page PDF.
4. **Compute** the deterministic parts deterministically — BOM roll-up and a capacitor voltage-derating table come straight from the graph.
## Try it on the bundled design
`periscope/dependency/simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO.
You need Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/):
Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/) for live extraction and review. Offline graph/smoke does not need the key.
```bash
python3 -m venv .venv
@@ -58,13 +48,27 @@ pip install -r periscope/src/backend/requirements.txt
cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
python3 -m uvicorn backend.main:app --reload --host 127.0.0.1 --port 18741
```
# in another terminal
cd periscope/src/frontend && npm install
Frontend — overlay native UI onto the inherited shell, then start Next.js (this is what CI runs):
```bash
./scripts/materialize-frontend.sh
cd .merge/frontend && npm install
NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostname 127.0.0.1
```
Open the frontend URL, create a project, and feed it the netlist and BOM from `periscope/dependency/simple_project/`. Datasheets are fetched automatically (LCSC, TI, optional DigiKey); you can still drop in PDFs by hand. Fetched PDFs and extracted pin tables land in the **Library** (sidebar) and are reused on later projects. Everything runs locally against your own DeepSeek key; projects and the extraction library live in `data/`. Skills are local `periscope/src/skills/*/SKILL.md` — do not run `scripts/upload_skills.py`.
Open the frontend URL, create a project, and upload a netlist plus BOM. A `.kicad_pcb` is required for the PCB job; without it the project can complete schematic review only. `periscope/dependency/simple_project/` is a small bundled schematic fixture (MSPM0G3507 + CH340E + SPX3819).
Projects and the extraction library live in `data/` (gitignored). Extraction skills are local `periscope/src/skills/*/SKILL.md`. Do not run an Anthropic Console uploader.
Tests from the repo root:
```bash
pip install pytest pytest-asyncio
pytest tests/ -q
python3 scripts/smoke_simple_project.py # offline
```
## Docker
@@ -73,27 +77,30 @@ cp periscope/src/backend/.env.example .env # set DEEPSEEK_API_KEY
docker compose up --build
```
Backend on port 8080, frontend on port 3000.
Backend listens on port 8080, frontend on port 3000 (`docker-compose.yml`).
### Update a live instance (e.g. periscope.michelebigi.it)
### Live host
One checkout on the VPS: **`/root/periscope`**. The GitHub clone URL may still be `manvalan/pinscope`; clone into that path so the folder is not `pinscope`:
```bash
git clone git@github.com:manvalan/pinscope.git /root/periscope
```
Do not keep a second live tree under `/root/pinscope` (or `/opt/pinscope`). Stop compose there, then deploy only from the canonical root:
The public site is rebuilt from **`/root/periscope`** with the script that actually ships:
```bash
cd /root/periscope
./scripts/update-periscope.sh
```
The script cds via `dirname "$0"/..` (no `find`). It refuses to run if the resolved root is not `/root/periscope`. Compose project name is `periscope`. After `up`, the script connects `periscope-frontend` / `periscope-backend` to Docker network **`pinscope_pinscope`** (where `railway-caddy` reverse_proxies `periscope-frontend:3000`). It pulls the current branch, writes `NEXT_PUBLIC_API_URL` / `CORS_ORIGINS` for `https://periscope.michelebigi.it`, rebuilds both Docker images, and leaves `data/` alone. First run: put `DEEPSEEK_API_KEY` in `.env` at the repo root (compose reads that file). `--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL.
`--no-pull` skips git. `SITE=https://other.host ./scripts/update-periscope.sh` overrides the public URL. The script refuses to run unless the checkout is `/root/periscope` (override with `CANONICAL_ROOT` only if the host layout differs). It does not touch `data/`.
Do not set `ENVIRONMENT=production` unless Clerk auth is configured — that flag refuses to boot with auth disabled.
## Source
| Remote | URL |
| --- | --- |
| GitHub | [https://github.com/manvalan/pinscope](https://github.com/manvalan/pinscope) (`git@github.com:manvalan/pinscope.git`) |
| Gitea | [http://192.168.1.71:3000/michele/periscope.git](http://192.168.1.71:3000/michele/periscope.git) |
Push both: `git push github && git push gitea` (no force).
## License
AGPL-3.0 (see `LICENSE`). This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). Faradworks does not operate this instance. For a commercial license of **upstream Pinscope**, write to Faradworks as they publish it (`dev@faradworks.com` historically). For this Periscope instance, contact the operator (Michele Bigi, mikbigi@gmail.com).
[AGPL-3.0](LICENSE) — GNU Affero General Public License v3.0.
This tree is derived from [Faradworks/Pinscope](https://github.com/Faradworks/Pinscope). For a commercial license of **upstream Pinscope**, contact Faradworks as they publish it. For this Periscope instance, contact Michele Bigi (mikbigi@gmail.com).
+669
View File
@@ -0,0 +1,669 @@
# Albero delle analisi Periscope
Versione codice **2.63.1** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa lanalisi, non implementa.
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
Due pipeline più una porta libreria (non è un esame):
0. **Libreria**`POST /api/library/datasheets` registra un componente da PDF senza `MODE=run`/`MODE=pcb`.
1. **Schematico / review AI**`MODE=run`, `services/pipeline.py``validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
2. **Esame PCB**`MODE=pcb`, `services/pcb_pipeline.py``run_pcb_checks` (`pcb_checks.py`, lista 2.62.1 **invariata**) + sezione additiva AF Board+AI (`af_ai_hf.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/AI). Test pytest citati sotto i nodi: nomi reali da `tests/`.
---
```
Periscope 2.63.1
├── 0. Libreria (porta, non esame)
│ └── PDF + MPN → scheda (inbox IC / passivo / discreto) senza MODE=run/pcb
├── 1. Schematico / review AI (MODE=run)
│ ├── ingest (non è analisi: parser → grafo)
│ ├── estrazione datasheet
│ ├── check deterministici schema
│ ├── certifier di classe (condivisi con PCB)
│ └── review AI per-IC + post-pass
└── 2. Esame PCB (MODE=pcb)
├── ingest layout (non è DRC)
├── run_pcb_checks ← 2.62.1 invariato (SI + HF line inclusi)
│ ├── net / footprint
│ ├── placement vs layout_rules
│ ├── SI + linee HF (2.62.0 Fase A)
│ ├── DDR / CPU / FPGA (2.62.0)
│ ├── potenza / via / termico
│ ├── derating / timing / PI
│ ├── ESD / return / EMI / SPOF
│ ├── BOM ↔ PCB ↔ datasheet
│ └── resto Fase B (2.62.1)
├── AF Board + AI (additivo: flag → investigate_hf_hypotheses; non sostituisce SI/HF)
├── review AI PCB
└── tab antenna RF (non in run_pcb_checks)
```
---
## 1. Schematico / review AI (`MODE=run`)
Orchestrazione: `pipeline.py` stadi `bom_parse``ic_extraction``simple_extraction``passive_extraction``graph_build``validation`. Check deterministici: `_run_deterministic_checks` in `validation.py`. Finding ID schema `PS-{ref}-{nnn}` (non `PCB-`).
### 1.1 Ingest (prerequisito, non analisi)
Parser e grafo non emettono PE-*. Skip MPN: `ic_mpn_skip_reason` (silenzio onesto, niente MPN inventato).
- Test parser/grafo: `tests/test_bom_parser.py` (`test_parse_bom_uses_value_for_ic_when_mpn_column_empty`, `test_parse_bom_keeps_pnm_and_datasheet_url`); `tests/test_hubaudio_bom.py` (tutti e 4); `tests/test_netlist_parser.py` (tutti e 3); `tests/test_edif_parser.py` (14); `tests/test_kicad_parser.py` (17); `tests/test_kicad_project.py` (19); `tests/test_netlist_bundle.py` (9); `tests/test_functional_groups.py` (8).
- Test overlay parser: `tests/test_periscope_parsers_rewrite.py`, `tests/test_periscope_graph_rewrite.py`, `tests/test_periscope_models_rewrite.py`.
### 1.2 Estrazione datasheet (processo)
Skip se manca PDF o lo skill non ha numero. Libreria `library/extracted` condivisa schema+PCB. PCB review non rilegge il PDF (`pdf_path=None`).
#### `extract-pintable` — skill `skills/extract-pintable/`
Modulo: `services/datasheet_extract.py` + `llm/local_skill.py`. Analizza: pin table, package, taxonomy IC.
- Skip se: niente PDF; IC già in libreria alla `model_version` minima.
- Test: `tests/test_native_datasheet_extract.py` (`test_coerce_abs_max_matches_inherited`, `test_native_extract_has_no_anthropic_import`); `tests/test_pdf_ingest.py` (6); `tests/test_deepseek_provider.py` (`test_local_skills_load`, `test_extract_pdf_text_includes_page_markers`, `test_wroom_rejects_bare_soc_pin1_ant`); `tests/test_periscope_taxonomy_skills_src.py` (`test_pintable_skill_mentions_save_tool_and_wroom`, `test_skills_dir_is_src_and_local`).
#### `extract-specs` — skill `skills/extract-specs/`
Analizza: specs discreti/connettori/cristalli; parametri fuori taxonomy → scartati.
- Skip se: niente PDF o tipo non in `SIMPLE_TYPES`.
- Test: `tests/test_pdf_ingest.py` (`test_coerce_abs_max_keeps_valid_drops_junk`); `tests/test_layout_rules.py` (5 — `layout_rules` numerici, niente mm inventati).
#### `extract-pattern` — skill `skills/extract-pattern/`
Analizza: pattern MPN passivi (R/C/L/FB).
- Skip se: MPN opaco; DigiKey/value fallback copre prima.
- Test: `tests/test_inductor_specs.py` (13, Z ferrite solo se il PDF dice N Ω @ freq); `tests/test_pcb_software_bugs.py` (`test_lqw_decoder_and_unknown_skip`, `test_fb1_blm_is_bead_not_dcr_resistor`).
#### Resolve DigiKey / catalogo (esatto MPN)
Modulo: `services/digikey.py`, `passive_from_distributor.py`, `passive_from_mpn.py`. Analizza: parametri catalogo per auto-resolve. Hit fuzzy rifiutati.
- Skip se: MPN non esatto; API assente (fail-soft).
- Test: `tests/test_digikey_match.py` (3); `tests/test_passive_from_distributor.py` (7); `tests/test_passive_from_mpn.py` (7); `tests/test_datasheet_finder.py` (17); `tests/test_library.py` (5).
#### Resolve da stringa Value (solo progetto)
Modulo: `services/passive_from_value.py`. Specs value-derived **non** in libreria condivisa.
- Skip se: stringa ambigua / placeholder.
- Test: `tests/test_passive_from_value.py` (8).
### 1.3 Check deterministici schema
Fail-soft per modulo. Senza evidenza: skip (lista vuota), non folklore.
#### `PE-MUX-001` — `pin_mux_check.py`
Analizza: funzione pin vs net (UART/SPI/I2C mux).
Skip se: `functions` vuote; link inter-device stesso peripheral; net opaco.
- Test: `tests/test_pin_mux_check.py``test_real_defect_uart5_swapped_is_error`, `test_correct_assignment_no_finding`, `test_inter_device_same_peripheral_link_is_skipped`, `test_transceiver_peer_without_peripheral_still_fires`, `test_empty_functions_skipped`, `test_pin_exposes_peripheral_but_not_signal_no_complement`, `test_opaque_net_not_flagged`, `test_token_parser_and_normalizer`, `test_spi_legacy_net_names_match_modern_pin_functions`, `test_spi_controller_peripheral_names_are_synonyms`, `test_simple_project_uart0_nets_are_feasible_on_mspm0_pins`, `test_simple_project_uart0_swapped_on_mspm0_is_error`, `test_spi_genuine_infeasibility_still_fires_with_modern_names`, `test_finding_prints_full_raw_capability_list_and_intent_caveat`, `test_legacy_report_without_source_validates`.
#### `PE-NC-001` — `nc_pin_check.py`
Analizza: pin NC collegato a net attivo.
Skip se: niente pintable.
- Test: `tests/test_nc_pin_check.py``test_nc_pin_on_active_net_warns`, `test_nc_pin_on_nc_net_silent`, `test_no_pintable_silent`.
#### `PE-DEC-001` / `PE-DEC-002` — `passive_rail_check.py` `check_supply_decoupling`
Analizza: condensatore sul net di alimentazione IC (001 presenza; 002 valore vs datasheet).
Skip se: net NC; pin enable strap; manca valore per 002.
- Test: `tests/test_passive_rail_check.py``test_missing_decoupling_is_warning`, `test_cap_to_gnd_clears_decoupling`, `test_enable_strapped_to_rail_is_not_decoupling`, `test_nc_supply_net_is_skipped`, `test_ldo_vout_needs_cout`, `test_ldo_vout_100n_only_is_value_warning`, `test_vdd_100n_is_not_a_value_warning`, `test_ki_cad_voltage_prefix_is_power`, `test_fb_and_rn_prefixes`.
#### `PE-I2C-001` / `PE-I2C-002` — `passive_rail_check.py` `check_i2c_pullups`
Analizza: pull-up I2C (001 presenza; 002 valore vs banda).
Skip se: pin SDA è alias SPI; pull-up senza valore (002 non sizato); bus assente.
- Test: `tests/test_passive_rail_check.py``test_i2c_missing_pullup`, `test_i2c_pullup_present`, `test_i2c_pullup_to_3v3_digital_typed_as_signal`, `test_spi_pin_alias_sda_is_not_i2c`, `test_i2c_from_slash_alias_in_pin_name`, `test_i2c_4k7_pullup_is_in_nxp_wide_band`, `test_i2c_100ohm_pullup_is_too_stiff`, `test_i2c_100k_pullup_is_too_weak`, `test_i2c_pullup_without_value_is_not_sized`.
#### `PE-RST-001` / `PE-RST-002` — `passive_rail_check.py` `check_reset_pullups`
Analizza: reset/enable pull (001 presenza; 002 verso vs datasheet).
Skip se: GPIO guida il reset.
- Test: `tests/test_passive_rail_check.py``test_reset_no_finding_when_gpio_drives`, `test_reset_floating_is_warning`, `test_nrst_pulldown_is_warning`, `test_nrst_pullup_is_not_pulldown`.
#### `PE-BOM-001` / `PE-BOM-002` — `bom_match_check.py`
Analizza: MPN schema vs BOM (001 mismatch; 002 riga BOM orfana).
Skip se: mappa schematica vuota (PADS/EDIF senza properties).
- Test: `tests/test_bom_match_check.py``test_matching_mpns_produce_no_findings`, `test_mpn_mismatch_is_error_ps_bom_001`, `test_orphan_bom_ref_is_warning`, `test_mpn_case_and_whitespace_are_not_a_mismatch`, `test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch`, `test_empty_schematic_map_skips_check`, `test_legacy_design_graph_without_source_fields_still_validates`, `test_build_graph_kicad_mpn_mismatch_surfaces`.
#### `PE-DNP-001` — `dnp_check.py`
Analizza: DNP vs enable floating.
Skip se: colonna DNP assente.
- Test: `tests/test_dnp_check.py``test_dnp_pull_leaves_enable_floating`, `test_fitted_pull_is_silent`, `test_no_dnp_column_skips_floating_enable`, `test_parse_bom_reads_dnp_and_skips_when_column_absent`.
#### LED corrente — `led_current_check.py` (nessun `PE-*`)
Analizza: I_f Ohm vs rating canale. Source `led_current_check`.
Skip se: niente `forward_current_*`; tensione rail sconosciuta (non si indovina).
- Test: `tests/test_led_current_check.py``test_over_current_is_error`, `test_proper_resistor_no_finding`, `test_unknown_rail_no_error`, `test_no_rating_skipped`, `test_parse_resistance`.
#### `PE-TH-001` / `PE-TH-002` / `PE-TH-003` — `thermal_check.py`
Analizza: P = I_load × drop; Tj = Ta + P·θJA (001 INFO senza θJA o Tj ok; 002 WARNING Tj alto — **non in catalogo `finding_engine`**; 003 I²R shunt).
Skip se: manca I_load (Iout_max non è carico).
- Test: `tests/test_thermal_check.py``test_ldo_without_theta_ja_is_info`, `test_iout_max_is_not_used_as_load`, `test_ldo_hot_tj_is_warning`, `test_shunt_over_rating_is_warning`, `test_led_resistor_within_rating_is_silent`.
#### `PE-PWR-001` (schema) — `power_margin_check.py`
Analizza: Σ IQ + I_load vs Iout regolatore; caduta su R serie. Source `power_margin_check`.
Skip se: manca I_load; IQ assente non è fail.
- Test: `tests/test_power_margin_check.py``test_load_over_iout_max_is_ps_pwr_001`, `test_missing_iq_is_not_guessed_into_margin_fail`, `test_series_r_ir_drop_uses_i_load_not_trace`, `test_no_series_r_does_not_invent_trace_drop`.
#### `PE-SEQ-001` — `sequencing_check.py`
Analizza: PG → EN da note datasheet.
Skip se: niente spec `power_sequence`.
- Test: `tests/test_sequencing_check.py``test_pg_not_tied_to_en_is_warning`, `test_pg_tied_to_en_is_silent`, `test_no_power_sequence_spec_skips_even_if_pg_open`.
#### `PE-FLT-001` / `PE-FLT-002` / `PE-FLT-003` — `filter_check.py`
Analizza: topologia filtro (001), pezzo (002), fc vs ADC / DCR ferrite (003).
Skip se: manca C; pull-up+decoupling non è filtro; DCR ferrite senza limite datasheet.
- Test: `tests/test_filter_check.py``test_rc_reports_fc_info_without_adc_rate`, `test_rc_vs_adc_rate_is_warning`, `test_pullup_plus_decoupling_is_not_a_filter`, `test_missing_c_value_does_not_invent_fc_warning`, `test_pi_and_t_need_l_and_c`, `test_ferrite_dcr_warns_only_with_datasheet_limit`.
#### `PE-XTAL-001` / `PE-XTAL-002` / `PE-XTAL-003` — `crystal_cl_check.py`
Analizza: CL vs C1/C2 (001 coppia; 002 serie/stray; 003 drive). Cstray solo se in specs.
Skip se: niente CL in specs.
- Test: `tests/test_crystal_cl_check.py``test_no_cl_in_specs_is_silent`, `test_series_above_cl_without_stray_warns`, `test_with_stray_mismatch_warns`, `test_simple_project_without_cl_silent`.
#### `PE-LF-001` / `PE-LF-002` / `PE-LF-003` — `lifecycle.py`
Analizza: Obsolete / NRND / RoHS non-compliant.
Skip se: catalogo assente.
- Test: `tests/test_lifecycle_check.py``test_obsolete_is_warning_and_uses_distributor_replacement`, `test_nrnd_is_info`, `test_explicit_rohs_non_compliant_is_warning`, `test_active_is_silent`, `test_rohs_not_applicable_is_not_a_fail`, `test_missing_catalog_and_missing_substitute_are_not_guessed`.
#### `PE-INT-001` — `internal_features_check.py`
Analizza: ESD/pull interni vs net.
Skip se: `internal_features` vuoto (non si indovina open-drain).
- Test: `tests/test_internal_features_check.py``test_listed_open_drain_without_pull_is_warning`, `test_listed_pin_with_pullup_is_silent`, `test_empty_features_does_not_guess_open_drain`.
#### `PE-ESR-001` — `hf_coverage_check.py`
Analizza: copertura HF/ESR (bulk vs 100 n).
Skip se: valore C sconosciuto.
- Test: `tests/test_hf_coverage_check.py``test_bulk_only_is_info_ps_esr_001`, `test_bulk_plus_100n_is_silent`, `test_unknown_cap_value_is_not_guessed`.
#### `PE-ERRATA-001` — `errata_check.py`
Analizza: errata pubblicata vs pull sul pin.
Skip se: MPN sconosciuto o entry senza URL.
- Test: `tests/test_errata_check.py``test_missing_errata_pullup_is_ps_errata_001`, `test_pullup_present_is_silent`, `test_unknown_mpn_and_url_less_entry_are_silent`.
Suite deterministica end-to-end: `tests/test_validation_deterministic_seed.py` `test_deterministic_findings_seeded_and_not_reviewed`; `tests/test_eval_report.py` `test_simple_project_eval_matches_committed_golden`.
### 1.4 Certifier di classe (grafo — schema **e** PCB)
Moduli: `interface_class_check.py` (schema **e** PCB), `hf_bus_class.py` (2.62.0, solo `run_pcb_checks`), `usb_pd_check.py` (2.62.1, solo PCB). Niente geometria. Niente connettore/dispositivo → **silenzio**.
#### USB-C — solo se receptacle USB-C sul grafo
- **`PE-USBC-001`** classe USB2 vs USB3 da BOM/footprint/MPN/net.
- **`PE-USBC-002`** CC1 e CC2.
- **`PE-USBC-003`** Rd 5.1 kΩ ±10% o Rp 56/22/10 kΩ. Skip/INSUFFICIENT se R ignota o CC verso controller senza R.
- **`PE-USBC-004`** VBUS e GND.
- **`PE-USBC-005`** SuperSpeed solo se classe USB3; USB2 = N/A (non ERROR).
Test: `tests/test_interface_class_check.py``test_no_connector_emits_nothing`, `test_usbc_rd_vbus_gnd_certified`, `test_usbc_missing_cc2_is_error`, `test_usbc_wrong_rd_is_error`, `test_usbc_cc_to_controller_without_r_is_insufficient_not_error`, `test_usbc_unknown_r_value_is_insufficient`, `test_usb3_class_without_ss_pairs_is_error`, `test_usb3_with_ss_pairs_is_certified`, `test_missing_vbus_is_error`, `test_simple_project_usbc_not_false_error`, `test_hubaudio_like_usbc_usb2_and_poe_magjack`, `test_usbc_vbus_does_not_invent_usb_500ma`, `test_does_not_use_layout_geometry`, `test_rule_catalog_shared_not_si`.
#### Ethernet — solo se RJ45/MagJack
- **`PE-ETH-001`** classe 10/100 vs GbE. Skip/INSUFFICIENT se velocità assente (non si inventa GbE).
- **`PE-ETH-002`** TX/RX (10/100) o quattro MDI (GbE).
- **`PE-ETH-003`** magnetics se GbE.
- **`PE-ETH-004`** Bob Smith 75 Ω: REVIEW (spesso in MagJack).
Test: `tests/test_interface_class_check.py``test_ethernet_10_100_magjack_no_gbe_magnetics_error`, `test_gbe_without_magnetics_is_error`, `test_gbe_with_magnetics_is_certified`, `test_rj45_without_speed_is_insufficient_not_invented_gbe`.
#### PoE — solo con evidenza PoE (RJ45 nudo = skip)
- **`PE-POE-001`** evidenza PoE.
- **`PE-POE-002`** classe/tipo (802.3af/at/bt, Class n) — mai inventata.
- **`PE-POE-003`** magnetics/isolamento (MagJack o LAN transformer).
- **`PE-POE-004`** (2.62.1) tensione di isolamento solo da numero sourced. Skip senza V.
Test: `tests/test_interface_class_check.py``test_bare_rj45_does_not_invent_poe`, `test_poe_with_evidence_requires_magnetics_isolation`, `test_poe_evidence_on_bare_jack_is_isolation_error`, `test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr`, `test_run_pcb_checks_skips_absent_interfaces`; `tests/test_pcb_phase_b.py``test_poe_isolation_skips_without_voltage_fact`.
#### DDR — solo se DRAM sul grafo (2.62.0)
- **`PE-DDR-001`** classe DRAM.
- **`PE-DDR-002`** CK, DQS, ≥8 DQ.
- **`PE-DDR-003`** VTT/VREF solo se quei net esistono (altrimenti skip, non VTT inventato).
Test: `tests/test_hf_line_check.py``test_no_ddr_cpu_fpga_on_usb_only_graph`, `test_ddr_present_class_and_nets_skip_invented_vtt`, `test_rule_catalog_hf_ids`.
#### CPU bus parallelo — solo se D/A/controllo esistono (2.62.0)
- **`PE-CPU-001`** presenza bus parallelo.
- **`PE-CPU-002`** ≥8 D/DQ/AD.
- **`PE-CPU-003`** address o AD mux.
- **`PE-CPU-004`** CS/WE/OE (o FMC).
Test: `tests/test_hf_line_check.py``test_cpu_parallel_bus_only_when_data_addr_control_exist`.
#### FPGA — solo se FPGA sul grafo (2.62.0)
- **`PE-FPGA-001`** classe FPGA.
- **`PE-FPGA-002`** flash di config se c’è un flash IC (altrimenti skip).
- **`PE-FPGA-003`** VCCINT/VCCIO solo se net nominati.
Test: `tests/test_hf_line_check.py``test_fpga_only_when_present_config_flash_optional`.
#### USB-PD contratto — `usb_pd_check.py` (2.62.1)
- **`PE-PD-001`** controller PD sul grafo (FUSB302/TPS257/…). Non è USB2 Rd. PDO non inventati.
Test: `tests/test_pcb_phase_b.py``test_usb_pd_skips_without_pd_controller`.
### 1.5 Review AI per-IC + post-pass
Moduli: `review_session.py`, `pcb_review.py` non entra qui. DeepSeek, PDF una volta per IC, fail-soft. Prompt tools grafo (`find_connected_components`, `get_net_for_pin`, `get_pintable`, `get_datasheet_excerpt`). Finding LLM = REVIEW, mai RULE ERROR (`finding_engine`).
Skip se: niente MPN; niente PDF (`not_reviewed`); fingerprint invariato (`review_fingerprint.py`).
- Review loop: `tests/test_native_review_loop.py` (5); `tests/test_validation_no_tool_recovery.py` `test_no_tool_calls_triggers_forced_submit_next_turn`; `tests/test_validation_concurrency.py` (3); `tests/test_validation_trace_log.py` (3); `tests/test_review_parse.py` (2); `tests/test_quote_verify.py` (5); `tests/test_finding_engine.py` (`test_llm_review_is_never_rule`, `test_review_and_risk_cannot_stay_error`, `test_insufficient_evidence_does_not_stay_error`); `tests/test_fase_b_checks.py` `test_llm_error_review_clamped_in_complete_finding`; `tests/test_review_fingerprint.py` (2); `tests/test_reprocess.py` (4); `tests/test_deepseek_provider.py` (`test_thinking_only_echoes_reasoning_content_on_follow_up` e roundtrip tool).
- Excerpt cross-IC: `tests/test_datasheet_excerpt_tool.py` (12); `tests/test_cache_breakpoint_limit.py` (3).
- **Normalize** (downgrade-only): `tests/test_normalize_findings.py` (14); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_normalize_cannot_promote_warning`.
- **Dedupe** cross-IC: `tests/test_dedupe_findings.py` (11); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_dedupe_collapses_both_endpoints`.
---
## 2. Esame PCB (`MODE=pcb`)
Stadi: `ensure_graph``parse_pcb``classify``inventory`**`run_pcb_checks`** → `ai_review``pcb_report.json`. ID finding `PCB-{ref}-{001}`. Mutex vs analisi schema e vs placement. Inventario net (`pcb_inventory.py`) è numeri, non finding.
KiCad DRC **non** è un nodo Periscope. `tests/test_pcb_software_bugs.py` `test_drc_unconnected_items_count` e `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude` servono solo a **non** copiare il DRC.
Suite orchestrazione: `tests/test_pcb_review.py` `test_run_pcb_checks_assigns_pcb_ids_and_recommendations`, `test_pcb_pipeline_imports_build_placement_plan`; `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`, `test_phase_b_rule_catalog`; `tests/test_interface_class_check.py` `test_merge_drops_duplicate_pcb_interface_findings`; `tests/test_pcb_sse_terminal.py` (4).
### 2.1 Layout ingest (non DRC)
`parsers_kicad_pcb.py``LayoutGraph`. Nessun default 1 oz.
- Test: `tests/test_kicad_pcb.py` (14); `tests/test_pcb_via_not_pad.py` (geometria via≠pad); `tests/test_pcb_software_bugs.py` `test_kicad10_name_only_nets_fill_index`.
### 2.2 Net / footprint — `pcb_net_match.py` + `pcb_checks._incomplete_layout_finding`
#### `PE-LAY-001`
Pad PCB su net diverso dallo schema (stesso pin).
Skip se: layout assente.
- Test: `tests/test_pcb_review.py``test_pad_net_mismatch_is_pe_lay_001`, `test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001`.
#### `PE-LAY-002`
Ref in schema, nessun footprint (tranne #PWR). Distingue “manca sulla PCB”.
- Test: `tests/test_pcb_review.py` `test_missing_footprint_is_pe_lay_002`; `tests/test_incomplete_pcb.py` `test_unplaced_ref_is_lay_002_not_invented_xy`.
#### `PE-LAY-003`
Stesso net, solo prefisso `/sheet/` KiCad (INFO, una volta).
- Test: `tests/test_pcb_review.py` `test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad`.
#### `PE-LAY-004` — `pcb_checks.py` (**non in catalogo `finding_engine`**)
Pad net senza rame (track **o** via **o** zone). Skip NC `unconnected-(…)`. INFO INSUFFICIENT, non FAIL inventato.
- Test: `tests/test_incomplete_pcb.py` `test_run_pcb_checks_on_partial_layout_does_not_invent_tracks`; `tests/test_pcb_software_bugs.py``test_lay004_zone_counts_as_copper`, `test_lay004_skips_unconnected_nc_and_matches_sheet_prefix`, `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude`.
### 2.3 Placement vs `layout_rules` — `placement_check.py`
Senza mm in `layout_rules`: skip. Niente 3 mm default. Distanza euclidea pad-pad **non** usata.
#### `PE-PLC-001`
Path rame (segmenti) vs `max_distance_mm`.
- Test: `tests/test_placement_check.py``test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001`, `test_crystal_load_cap_within_max_distance_mm_is_silent`, `test_track_path_longer_than_max_distance_mm_is_ps_plc_001`; `tests/test_pcb_review.py` `test_close_decoupling_has_no_plc_001`; `tests/test_placement_check.py` `test_simple_project_without_pcb_has_no_ps_plc`.
#### `PE-PLC-002`
Via in courtyard vs `min_via_count`. Skip senza vertici courtyard e senza min.
- Test: `tests/test_placement_check.py` `test_via_count_is_calculated_from_courtyard_and_min_parameter` (geometria `_in_poly`; non emette ancora il finding end-to-end).
#### `PE-PLC-003`
`same_layer` decoupling.
- Test: `tests/test_placement_check.py``test_same_layer_param_opposite_layers_is_ps_plc_003`, `test_same_layer_param_same_copper_is_silent`, `test_opposite_layers_without_same_layer_param_is_silent`, `test_opposite_layers_with_via_in_courtyard_is_silent`.
#### `PE-PLC-004`
Keepout: net estraneo nel courtyard. Non è DRC rame-pad.
- Test: `tests/test_placement_check.py``test_keepout_foreign_track_in_courtyard_is_ps_plc_004`, `test_ic_courtyard_pad_nets_are_not_keepout_violations`, `test_keepout_own_net_in_courtyard_is_silent`, `test_keepout_without_courtyard_is_silent`.
#### `PE-PLC-005` (**non in catalogo `finding_engine`**)
Proximity non misurabile perché manca la **traccia**.
- Test: `tests/test_placement_check.py` `test_unrouted_crystal_cap_is_insufficient_not_euclidean`.
Schema runner omette i check layout: `tests/test_placement_check.py` `test_schema_deterministic_runner_omits_layout_checks`.
### 2.4 SI — `si_check.py` + ImpedenceFinder
Gate: `layout_rules` kind + `net_class`/quote sul **bus citato**. Skip I2C/GPIO/EN/CC/strap RC. Bus: USB2, USB3, ETH MDI, RGMII, SGMII, DDR3 CLK/DQS/DQ/ADDR, HDMI, PCIe, LVDS; 2.62.0 aggiunge MIPI e clock HF (`bus_class`); XTAL/OSCIN fuori.
Z0: `impedance.py` / `impedance_traces.py`. CPWG → errore, non numero inventato. Senza stackup: skip misura.
Suite gate: `tests/test_si_check.py` `test_skip_i2c_gpio_cc_regn`, `test_i2c_not_checked_as_50_ohm`, `test_simple_project_without_pcb_has_no_ps_si_001`; `tests/test_hf_line_check.py` `test_bus_class_mipi_hf_clk_not_xtal`. Calcolatrice: `tests/test_impedance.py` (20); misura net: `tests/test_impedance_traces.py` (10).
#### `PE-SI-001` length_match / skew coppia
Skip senza mm datasheet.
- Test: `tests/test_si_check.py` `test_length_match_2_5mm_vs_1mm_is_fail`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
#### `PE-SI-002` Zavg/min/max vs finestra datasheet
Mai folklore 90 Ω.
- Test: `tests/test_si_check.py` `test_emmaforo_usb_avg_in_window_min_out_is_margin`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
#### `PE-SI-003` lunghezza max mm
- Test: `tests/test_si_check.py` `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`.
#### `PE-SI-004` spacing intra-coppia mm
Skip senza mm. Copertura indiretta nella stessa suite SI gated.
#### `PE-SI-005` piano di riferimento / layer
Skip senza FACT datasheet.
#### `PE-SI-006` conteggio via HS vs min/max datasheet
Skip senza min/max.
#### `PE-SI-008` return via accanto alla coppia (`return_path`)
Skip senza kind.
#### `PE-SI-009` serie R ohm sul net HS (non EN-RC)
- Test: `tests/test_si_check.py` `test_en_rc_series_resistor_does_not_paint_usb`.
#### `PE-SI-010` bus HS misurato, libreria senza FACT Z → INFO, non FAIL 90 Ω
- Test: `tests/test_si_check.py` `test_usb_without_library_z_is_insufficient_not_90ohm_fail`, `test_empty_net_class_impedance_does_not_paint_usb`; `tests/test_hf_line_check.py` `test_existing_si_usb_not_polluted_by_hf_geometry`.
Refresh extract SI vecchio: `tests/test_pcb_plan_closeout.py` `test_si_refresh_when_old_extract_has_only_decoupling`, `test_si_extract_needed_skips_board_ics_only_not_shared_library`.
### 2.5 Linee HF extra — `hf_line_check.py` (2.62.0 Fase A)
Track ≠ via ≠ pad ≠ zona. Stub = traccia dangling.
#### `PE-SI-007` stub vs mm datasheet
INFO se misura c’è e mm mancano (INSUFFICIENT). Skip: pad-pad, via, zone.
- Test: `tests/test_hf_line_check.py``test_no_layout_skips_hf_geometry`, `test_pad_to_pad_usb_has_no_stub`, `test_via_is_not_a_stub`, `test_zone_is_not_a_track_stub`, `test_dangling_track_stub_vs_datasheet_mm_is_fail`, `test_measured_stub_without_datasheet_mm_is_insufficient`.
#### `PE-SI-011` gap pour GND sotto coppia HF (REVIEW)
Skip senza stackup/zone.
- Test: `tests/test_hf_line_check.py` `test_split_under_pair_needs_stackup_and_zone`.
#### `PE-SI-012` terminazione BOM serie/parallelo FACT
Skip se manca il terminatore (non si inventa 50 Ω).
- Test: `tests/test_hf_line_check.py` `test_bom_termination_is_fact_missing_is_skip`.
Catalogo HF: `tests/test_hf_line_check.py` `test_rule_catalog_hf_ids`. DDR/CPU/FPGA: §1.4 (stesso `check_memory_fpga_classes` in `run_pcb_checks`).
### 2.6 Potenza / via / termico sul board — `pcb_power_thermal.py`
#### `PE-PWR-001` (PCB) — `check_pcb_power_traces`
Larghezza **traccia** × spessore rame vs I_load/Imax/I_abs. IPC-2221 solo con thickness **e** ΔT da Tjmax. Iout_max non è carico. Niente 500 mA USB.
Skip se: manca I datasheet (**skip vero**, non INFO INSUFFICIENT — 2.61.1).
- Test: `tests/test_pcb_review.py``test_power_trace_ipc2221_errors_when_load_exceeds_ampacity`, `test_power_trace_uses_parsed_width_and_thickness`, `test_power_trace_skips_without_i_load`, `test_power_trace_skips_iout_max_as_load`, `test_power_trace_uses_imax_when_i_load_absent`, `test_power_trace_uses_i_abs_when_i_load_absent`; `tests/test_interface_class_check.py` `test_usbc_vbus_does_not_invent_usb_500ma`.
#### `PE-VIA-001` — `check_pcb_via_current`
Se I c’è e sul net **zero via**: INFO conteggio. Se le via ci sono: silenzio — **nessuna tabella ampacity**. Via ≠ traccia.
Skip se: manca I.
- Test: `tests/test_pcb_software_bugs.py` `test_via_current_skips_without_i_load`.
#### `PE-VIA-002` (2.62.1)
I + conteggio/drill FACT. Tabella IPC via **non** applicata.
Skip se: manca I.
- Test: `tests/test_pcb_phase_b.py` `test_via_current_skips_without_i_does_not_invent_ipc`.
#### `PE-THM-001` — `check_pcb_thermal_copper`
P = I_load × drop; courtyard senza pour/via.
Skip se: P sconosciuta.
- Test: `tests/test_pcb_review.py` `test_thermal_copper_warns_without_pour_or_vias`.
#### `PE-THM-002` — `check_pcb_junction_temp`
Tj = Ta + P·θJA solo con P, θJA, area rame, conteggio via. Non è FEM.
Skip/INSUFFICIENT se manca un fattore.
- Test: `tests/test_pcb_plan_closeout.py``test_tj_when_theta_copper_vias_exist`, `test_tj_insufficient_without_theta`.
#### `PE-KEL-001`
Pin Kelvin/sense sullo stesso net di carico. Ignora CRS/IN±.
- Test: `tests/test_pcb_review.py` `test_kelvin_sense_pin_on_shared_net`; `tests/test_pcb_software_bugs.py` `test_kelvin_ignores_crs_and_in_plus`.
#### `PE-STCH-001`
Segnale sopra pour GND senza via GND nel bbox. Skip GPIO minuscoli.
- Test: `tests/test_pcb_review.py``test_gnd_stitch_skips_tiny_gpio`, `test_gnd_stitch_info_when_signal_has_pour_but_no_via`.
### 2.7 Derating / gerarchia / timing / PI
#### `PE-DRT-001` / `PE-DRT-002` / `PE-DRT-003` — `pcb_checks.check_pcb_derating` + `derating.py`
Vop vs Vrated: ERROR / MARGIN / PASS INFO. Niente % dielettrico inventato. Skip se manca Vop o Vr.
- Test: `tests/test_fase_b_checks.py` `test_derating_pass_margin_risk`; `tests/test_derating.py` (7, C_eff stima — non è un PE-* a parte).
#### `PE-HIER-001` — `hierarchy.py`
Pin IC senza net nominato.
- Test: `tests/test_fase_b_checks.py` `test_hierarchy_component_pin_net_block`.
#### `PE-TIM-001` / `PE-TIM-002` — `timing_check.py`
001 RC reset vs t_reset; 002 strap vs Vih/Vil.
Skip senza R/C e t_reset o Vih/Vil.
- Test: `tests/test_fase_b_checks.py``test_timing_skips_without_numbers`, `test_timing_reset_rc_vs_t_reset`. **`PE-TIM-002` non ha un test che fa scattare il finding** (solo skip condiviso).
#### `PE-PI-001` / `PE-PI-002` — `pi_check.py`
001 cap locale sul rail IC; 002 locale vs bulk se i valori C esistono. Distanza mm solo da `layout_rules` (altrimenti è PE-PLC-001). Skip net NC.
- Test: `tests/test_fase_b_checks.py``test_pi_missing_local_is_risk_not_error`, `test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local`, `test_pi_bulk_only_does_not_claim_no_local`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`. **`PE-PI-002` non ha un assert dedicato sul `rule_id`.**
### 2.8 ESD / return / EMI / SPOF
#### `PE-ESD-001` — `esd_return_check.check_esd`
Net J* ∩ IC senza parte ESD: REVIEW, non ERROR. Niente GPIO/NC/rail spam.
- Test: `tests/test_fase_b_checks.py``test_esd_without_part_is_review_not_error`, `test_esd_skips_gpio_nc_unconnected_and_onboard_power`, `test_esd_one_finding_per_connector_ic_path`, `test_esd_skips_when_protection_part_on_net`.
#### `PE-ESD-002` (2.62.1) — `check_esd_distance`
Distanza **pad** TVSconnettore (via ≠ pad). INFO se no mm.
- Test: `tests/test_pcb_phase_b.py` `test_esd_distance_uses_pads_not_vias`.
#### `PE-RET-001` — `check_return_path`
Net HS sopra pour GND senza via GND nel bbox: REVIEW.
- Test: `tests/test_fase_b_checks.py` `test_return_path_hs_is_review`.
#### `PE-EMI-001` — `emi_check.py`
Solo se `layout_rules` cita choke/ferrite/shield. Non IEC 61000. Skip NC.
- Test: `tests/test_pcb_plan_closeout.py` `test_emi_only_with_datasheet_fact`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`.
#### `PE-SPOF-001` — `spof_check.py`
Un regolatore o XTAL condiviso: REVIEW, mai ERROR.
- Test: `tests/test_pcb_plan_closeout.py` `test_spof_review_single_ldo`.
### 2.9 BOM ↔ PCB ↔ datasheet — `bom_pcb_check.py`
Skip se manca un lato (BOM, footprint, o numero datasheet).
#### `PE-BOM-010` famiglia package
VQFN/WQFN/HVQFN = QFN. LQFP ≠ QFN.
- Test: `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_bom_011_named_join.py` `test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010`.
#### `PE-BOM-011` pintable vs pad, join per nome
Join datasheet pintable × `.kicad_mod` lib × pad embedded. Non `pin_count` scalare. A5/B5 USB-C sono pin, non EP. EP/SH/mount classificati; via ≠ pad. Senza pintable: skip.
- Test: `tests/pcb/test_bom_011_named_join.py`; `tests/pcb/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`; `tests/pcb/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/pcb/test_pcb_via_not_pad.py`.
#### `PE-BOM-012` Vop vs rating/abs-max legato al **pin**
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`; `tests/test_pcb_software_bugs.py` `test_vddcr_absmax_does_not_bind_3v3_io`.
#### `PE-BOM-013` Top vs abs-max T
- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`.
#### `PE-BOM-014` I_load vs rating
Skip se manca I.
- Test: `tests/test_pcb_plan_closeout.py``test_abs_max_voltage_and_temp_and_current`, `test_inductor_current_rating`.
### 2.10 Resto Fase B (2.62.1)
#### `PE-STK-001` — `stackup_check.py`
Spessore rame board vs stackup fab sourced. Mai 1 oz default.
Skip se: niente stackup layout o niente FACT fab.
- Test: `tests/test_pcb_phase_b.py``test_stackup_skips_without_fab_spec`, `test_stackup_vs_fab_spec_is_review_not_invented_oz`.
#### `PE-ANT-001` / `PE-ANT-002` — `antenna_layout_check.py`
Keepout zona e matching L/C sul feed. Solo se antenna/net RF sul grafo. Niente 50 Ω inventati.
Skip se: antenna assente; matching manca e non c’è FACT (002 non emette).
- Test: `tests/test_pcb_phase_b.py``test_antenna_skips_when_absent`, `test_antenna_keepout_and_match_are_facts`.
#### `PE-PDN-001` — `pdn_check.py`
Z(f) solo con FACT in frequenza. Z0 SI **non** è PDN.
- Test: `tests/test_pcb_phase_b.py` `test_pdn_skips_without_zf`.
Silenzio Fase B se assente: `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`. Catalogo: `test_phase_b_rule_catalog`.
### 2.11 Review AI PCB
Modulo: `pcb_review.py` + `services/pcb_validation.py`. Stesso oggetto finding. Spiega FACT deterministici; **non** rilegge il PDF; non inventa interfacce / PoE / SuperSpeed / mm / Z / I.
Skip se: niente JSON libreria per lIC.
- Test: `tests/test_pcb_review.py``test_pcb_ai_finding_gets_action`, `test_parse_review_action_field_without_recommendation`, `test_layout_context_includes_via_counts`, `test_layout_context_includes_domain_and_group`, `test_pcb_ai_skips_without_library_extraction`, `test_library_constraints_fill_from_storage`, `test_inventory_lists_net_length_and_pair`; `tests/test_finding_engine.py` `test_pcb_review_ai_finding_action_without_recommendation`; `tests/test_pcb_software_bugs.py` `test_thinking_reasoning_content_echo_still_present`.
### 2.12 Tab antenna RF (non in `run_pcb_checks`)
Moduli: `antenna_rf.py`, `antenna_geometry.py`. Topologia matching + ricetta routing. No EM/VSWR/CPWG. Distinto da `PE-ANT-001/002`.
- Test: `tests/test_antenna_rf.py``test_verify_finds_matching_path_to_ant_footprint`, `test_verify_missing_matching_is_warning`, `test_design_recipe_needs_marker_without_ant`, `test_design_recipe_ready_with_ant_and_stackup`, `test_geometry_templates_produce_export`, `test_geometry_overflow_tiny_zone`, `test_design_recipe_meander_template`.
Placement/auto-place **non** è esame PCB: `tests/test_placement_pack.py`, `tests/test_placement_pipeline.py` (fuori da questo albero di analisi).
---
## Motore finding (condiviso)
`finding_engine.py`: FACT / REQUIREMENT / INFERENCE; ERROR solo RULE+MANDATORY; `decisions.json` non ri-naga.
- Test: `tests/test_finding_engine.py` (11); `tests/test_finding_schema.py` (7); `tests/test_fase_b_checks.py` `test_sort_findings_error_then_class`; `tests/test_review_workflow.py` (10 — stati finding UI, non check PE). CAD bridge: `tests/test_cad_bridge.py` (5).
---
## Test che esistono ma non sono analisi
`tests/` ha **114 file / ~730 test**. Oltre ai nodi sopra restano overlay nativi, upload, auth, billing seam, UI rewrite. Non sono check PE-*.
**Upload / ingest cartella** (non analisi): `tests/test_periscope_frontend_upload_rewrite.py` `test_upload_zone_is_src`; `tests/test_kicad_project.py` (picker cartella, `.history`, Safari stem); `tests/test_netlist_bundle.py` (zip, zip-slip); `tests/test_kicad_pcb.py` `test_upload_pcb_sets_has_pcb`, `test_upload_sch_as_pcb_is_rejected`; `tests/test_purple_parts_resolver.py` (upload BOM/LCSC); `tests/test_reopen_replace.py` `test_reopen_then_replace_bom_and_netlist`.
**Auth**: i test auth esistono (`tests/test_local_auth.py`, `tests/test_periscope_auth_rewrite.py`, `tests/test_pinscope_compat.py`, più overlay che tengono il contratto login). Non si descrivono segreti, JWT, password, o flussi di autenticazione.
Altri non-analisi: rewrite frontend/Docker (`test_periscope_*_rewrite.py`, `test_periscope_leftover_package_requirements.py`, `test_periscope_boot_without_dependency_product.py`); delete progetto; cost estimator; event bridge; job runner; `test_deepseek_only.py` (routing modello, non finding).
---
## Conteggio nodi
| Gruppo | Nodi |
| --- | --- |
| Check solo schema (27 PE-* + LED senza ID) | 28 |
| USB-C / ETH / PoE (schema e PCB) | 13 |
| `PE-PWR-001` (due source: schema e PCB) | 1 |
| DDR / CPU / FPGA / PD (grafo, invocati da `run_pcb_checks`) | 11 |
| Check solo PCB (layout, SI/HF, potenza, Fase B, …) | 47 |
| Processo: 3 skill + DigiKey + value + IC AI + PCB AI + tab RF | 8 |
| **Totale nodi analisi** | **108** |
Check PE-* in codice: **99** (tutti elencati). Più LED senza `rule_id`. `PE-LAY-004`, `PE-PLC-005`, `PE-TH-002` sono in codice; `PE-LAY-004` / `PE-PLC-005` non sono in `_seed()` di `finding_engine.py`.
Buchi di test noti (il check c’è, il fire test no): `PE-TIM-002`, `PE-PI-002` (nessun assert `rule_id`), `PE-PLC-002` (solo geometria `_in_poly`), `PE-SI-004` / `PE-SI-005` / `PE-SI-006` / `PE-SI-008` (gate SI, niente caso dedicato).
+142
View File
@@ -0,0 +1,142 @@
# Conformità coding — Periscope intero
Percorso locale: `/Users/michelebigi/Development/periscope/docs/conformita-coding.md`.
Costituzione: `docs/development/CODING_CONSTITUTION.md` (*code that fits in your head*). **Per sezione**, non per funzione: conforme sì / no e perché. Taglio 2.62.1 + questa macro-fase (libreria come porta, test riorganizzati, AF+AI extra). Auth/JWT/users: **non toccati**; la sezione esiste e viene giudicata, non modificata.
Giudizio: **conforme** = un ingegnere legge la sezione senza ricostruire un sistema nascosto. **Non conforme** = troppe responsabilità, stato implicito, eccezioni ingoiate, o semantica a rischio. Un file lungo può essere conforme se il flusso è ovvio; uno corto no se fa tre mestieri.
Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non si “passa” la costituzione dichiarandole OK.
---
## 1. Albero nativo / dependency
**Non conforme.** `periscope/src` (nativo) e `periscope/dependency` (PinScope ereditato) si fondono con `sys.path` + `pkgutil.extend_path`. Chi vince dipende dallordine di insert (`backend/__init__.py` vs `tests/conftest.py`). Due `projects.py`, due `pipeline.py`, due `extraction.py`. Per capire un import serve la mappa dei shadow, non il modulo. È un adattatore temporaneo con path di rimozione (Fase C) ma **oggi** non sta in testa.
## 2. Glue di root (Docker, compose, `backend/__init__.py`, script)
**Conforme a metà.** `backend/__init__.py` è corto e dice il mestiere. `scripts/update-periscope.sh` è una procedura esplicita (`/root/periscope`, niente `data/`). `docker-compose.yml` è piccolo. Il debito è il merge dei due tree a boot, non gli script.
## 3. Motore finding (`periscopex/finding_engine.py`, schema)
**Conforme.** Un oggetto finding, FACT / REQUIREMENT / INFERENCE, clamp verso il basso, `INSUFFICIENT`. Flusso visibile. Test stretti su tipo, codice, severity. ~450 righe: lungo ma un mestiere.
## 4. Modello semantico (`models.py`)
**Conforme.** Pad, via, track, zone, pin, net sono tipi distinti. Pydantic, niente god-class UI. ~534 righe di dati, non di orchestrazione.
## 5. Parser schematico (PADS, EDIF, KiCad sch)
**Conforme a metà.** Funzioni pure, fixture `simple_project`. `parsers_kicad.py` (~760) e `parsers_edif.py` (~465) sono grandi; il mestiere resta “testo → grafo”. Non classificano via PCB come pad (coperto da test rewrite). Debito: taglia dei file, non la semantica.
## 6. Parser PCB KiCad (`parsers_kicad_pcb.py`)
**Conforme.** Via ≠ pad è esplicito e testato. Un input `.kicad_pcb``LayoutGraph`. Non è DRC. ~446 righe.
## 7. Grafo (`graph.py`, `netlist_bundle.py`)
**Conforme.** Costruzione da BOM+netlist, helper di attraversamento. Ferrite Z è un innesto a parte (`ferrite_z.py`), non un if U1.
## 8. Check deterministici PCB (`pcb_checks.py` + moduli `PE-*`)
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
AF Board+AI (`af_ai_hf.py`) è **additivo**: `pcb_pipeline` chiama `append_investigated` dopo `run_pcb_checks`. Non entra nella lista 2.62.1. Non è DRC.
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
**Conforme.** Un file ≈ un check, grafo in → finding out, niente mm inventati. Alcuni file >400 righe (`passive_rail_check`, `filter_check`) ma il flusso è lo stesso.
## 10. Review AI (validate, pcb_review, review_tools)
**Non conforme come sezione unica.** Lautorità deterministica è nei check; lLLM spiega — questo è il disegno giusto (costituzione §8). In pratica `review_tools.py` (~940) e `validation.py` (dependency + native) sono loop di tool + state. `pcb_review.py` (~327) è il prompt + vicinato: quello sta in testa. Il loop live dipende ancora da pezzi ereditati. AF+AI (ipotesi HF → indagine deterministica) è un modulo extra, non sostituisce `run_pcb_checks`.
## 11. Estrazione datasheet (skills, `datasheet_extract.py`, `extraction.py`)
**Non conforme.** `datasheet_extract.py` ~1322 righe e `extraction.py` ereditato ~1298: prompt, tool, coerce, taxonomy, auto-resolve nello stesso file. Il mestiere “PDF → JSON libreria” è uno, limplementazione no. Native vs inherited duplicato finché il pipeline non switcha. **Conforme nel ruolo:** LLM estrae, non è il verificatore.
## 12. Libreria componenti (store + porta HTTP/UI)
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e lempty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
**Dopo 2.63.1: conforme come porta.** `POST /api/library/datasheets` registra una scheda (inbox IC o modello passivo/discreto) senza esame. Pintable vuota non va in `library/extracted/` (`library_gate`). PUT promuove inbox → extracted. `projects.py` re-export resta debito. `list_library_catalog` logga e salta JSON rotti.
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
**Non conforme.** `pipeline.py` ~1522 righe: stage, storage, LLM, copy-in-library, graph. `pcb_pipeline.py` è più lineare (ensure_graph → parse → checks → **af_ai additivo** → AI → report) e si segue. Job/SSE sono un secondo asse di stato. Flusso reale: sì, ma non locale. `run_pcb_checks` 2.62.1 non è stato svuotato.
## 14. Storage (`storage.py`, GCS)
**Conforme.** Backend con chiavi stringa, locale vs GCS. Prefissi `users/…/projects/` e `library/` visibili.
## 15. HTTP — progetti, pipeline, report, impedance
**Non conforme per `routers/projects.py` (~1004)** e `pipeline.py` router (~783): troppi mestieri (CRUD, upload KiCad, DigiKey, LCSC). Report e impedance sono sezioni più piccole. La nuova `routers/library.py` è la porta libreria (un mestiere).
## 16. Auth / JWT / users
**Non toccata in questa macro-fase (vincolo).** La sezione **non è conforme** alla costituzione (Clerk + JWT locale + `admin.py` users nello stesso router, middleware che decide tre modi). Non si “sistema” qui. Non si aggiunge auth alla libreria.
## 17. Frontend — shell e pagina libreria
**Conforme a metà.** Overlay `src` su `dependency` (materialize) è un secondo albero da tenere in testa. Pagine progetto/report/dashboard sono lunghe ma a mestiere. `/library` era browse-only; ora è porta (import + modifica scheda) con form/editor spezzati. `admin/page.tsx` ~1780: **non conforme** (libreria + users + usage). Non toccato (users).
## 18. Frontend `api.ts` / types
**Non conforme.** `api.ts` nativo ~1179 righe, client unico per tutto. Types allineati ai modelli: sì. Un file non sta in testa. Nuove funzioni libreria restano lì per non inventare un secondo client.
## 19. Skills e taxonomy
**Conforme.** `skills/*/SKILL.md` + `validate.py` in-process; taxonomy JSON per tipo. `repo_paths` al posto di `Path(__file__)` magici (test rewrite). Non si chiama `upload_skills.py`.
## 20. Vendor ImpedenceFinder
**Conforme come confine, non come licenza.** Core Z0 chiuso, non FEM, non secondo set di formule (test). LICENSE UNKNOWN resta debito legale, non di forma del codice.
## 21. Test
**Prima: non conforme come mappa.** Un flat `tests/test_*.py` mescolava datasheet, libreria, schema, PCB, rewrite, auth. Copertura vera, ordine mentale no.
**Dopo: conforme come mappa, copertura tenuta.**
| Cartella | Mestiere |
| --- | --- |
| `tests/datasheet/` | preliminare PDF / pin / abs-max / I / Z / ferrite / DigiKey |
| `tests/library/` | porta libreria (anche senza esame) |
| `tests/schematic/` | net, BOM, review IC, check su grafo |
| `tests/pcb/` | `run_pcb_checks`, geometria, SI/HF già in 2.62.1 |
| `tests/impedancefinder/` | vendor Z0 (stesso nome package del vendor — non va sotto `pcb/`) |
| `tests/af_ai/` | extra: ipotesi HF → indagine deterministica (non DRC, no Z inventata) |
| `tests/` root | identity/rewrite/auth/job — non analisi |
`conftest.py` e `paths.py` restano in root. Test stretti (codice finding, via≠pad) restano la regola; i rewrite “il file sta in src” sono deboli ma sono recinti di albero, non di fisica.
## 22. Costituzioni Cursor (`.cursor/rules`, questo doc)
**Conforme.** Un master Markdown, `.mdc` operativi, niente Rust speculativo. Questo file è il giudizio, non una seconda costituzione.
## 23. Git / deploy
**Conforme al testo, fragile in pratica.** Commit+push a fine fase; deploy a fine macro-fase; no force. Il worktree locale era un pointer a `~/Development/pinscope` sparito: history recuperata da `github` `cursor/pcb-hf-analisi-675d` @ `20733f0` (2.62.1). Non si tocca `~/Development/pinscope` per edit di prodotto.
---
## Sintesi
| Sezione | Conforme |
| --- | --- |
| Finding engine + modelli + parser PCB | sì |
| Check PE-* come moduli | sì (dispatcher fail-soft no) |
| Skills/taxonomy/storage/Z0 vendor | sì |
| Dual tree src/dependency | no |
| Pipeline + extract ~1.3k + projects router | no |
| Auth/admin users | no (non toccare) |
| Libreria come porta | sì dopo questa fase (re-export debito) |
| Test a cartelle di mestiere | sì dopo questa fase |
Priorità debito (non questa fase): spezzare `pipeline.py` / `datasheet_extract.py`; togliere lo shadow dependency quando un modulo nativo è provato; non ingoiare Exception nei check PCB (distinguere tool failure).
**Niente Rust in questa macro-fase** — vedi `docs/rust-criteri.md`. FEM fuori. DRC = KiCad.
+35
View File
@@ -0,0 +1,35 @@
# Libreria componenti — porta prodotto
La libreria **non** vive dentro lesame. È una porta a sé: pagina `/library` + API `/api/library`. Lanalisi *usa* la libreria; non è lunico modo per riempirla.
## Flusso
```text
PDF + MPN + kind (ic | passive_part | simple)
POST /api/library/datasheets
blob MD5 + ref MPN
scheda componente
IC → library/inbox/{mpn}.json (niente pintable inventata)
passive_part → library/passives/{mpn}.json
simple → library/models/{mpn}.json
```
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
`library/extracted/` si scrive **solo** con pintable valida (`library_gate`: ≥2 pin, almeno un nome). PUT `/api/library/components/ic/{mpn}` promuove inbox → extracted.
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
## Cosa non è
- Non è admin/Clerk/JWT (non si tocca auth).
- Non è DRC, non è FEM.
- Non inventa pin dal PDF. LLLM prelim resta un mestiere a parte; la scheda c’è comunque.
- Non sostituisce `library_has_*` usati dalla pipeline (`library_has_extraction` resta falso finché non c’è pintable).
## Test
`tests/library/` — catalogo, alias MPN, import PDF senza progetto (scheda IC in inbox, passivo in passives), GET/PUT scheda, promozione pintable, rifiuto PDF non valido / kind ignoto.
+154
View File
@@ -0,0 +1,154 @@
# Piano di implementazione: analisi AF della traccia (v1)
DRC = KiCad. Periscope = analisi. **Stesura ora = Python.** Niente rustup, Auth/JWT, OpenEMS, FEM 3D, field solver 2.5D. Pad ≠ via ≠ track ≠ zone. Non si inventano Z, I, mm, εr, tr, f.
Michele 2026-09-22: piano ok con correzioni sotto. SI/HF 2.62 e `PE-AF-001` restano **additivi**. ImpedenceFinder vendored (`vendor/impedancefinder`) non si riscrive.
Repo: `~/Development/periscope`. Copia di questo file anche in `docs/piano-af-analisi.md`.
---
## Paletti obbligatori (Michele)
1. **Z target solo da datasheet** (`z0_ohm` / `zdiff_ohm` / finestra `z_min``z_max` in `layout_rules`). Manca → **skip del confronto Z**, finding visibile INSUFFICIENT, **niente 50/90 Ω**.
2. **Coppia differenziale: i due membri insieme** (Zdiff, accoppiamento intra-coppia, lunghezze/skew). Mai una traccia sola se esiste il partner `_P/_N` o `+/-` sulla board.
3. **Simulazione numerica sì, OpenEMS no.** Niente FEM 3D né solver di campo 2.5D della spec. Metodo contenuto: sezioni da ImpedenceFinder Z0(x) → RLGC lossless per sezione → cascade ABCD → S11/S21 **solo se** Z_ref (= target datasheet) è FACT.
4. **C++ vs Rust:** valutati in §15. Implementazione v1 in Python. Niente rustup.
5. **Skip AF non silenziosi.** In report: *«Pista ad alta frequenza non controllata per mancanza di …»* (tr, f, εr, stackup, Z target datasheet, span via). Finding **INSUFFICIENT / REVIEW**. Non INFO con Z/I inventati. **Se il trigger è falso** (calcolato con FACT e `l` sotto soglia) → **niente finding** AF su quella net/coppia.
---
## 1. Perimetro vs spec
La spec (`specifica-af.md`) descrive Gerber/ODB++, field solver 2.5D, S/TDR completi. Periscope esamina `.kicad_pcb` + grafo + libreria.
| Spec | v1 Periscope |
| :--- | :--- |
| Parser Gerber/ODB++ | No. KiCad `LayoutGraph`. |
| Trigger λ/10 e tr/(6 tpd) | Sì, FACT only. |
| Gomiti, taper, 3W, piano 3H | Sì, solo net/coppie **triggerate**. Non DRC. |
| Via stub / L_via | Sì se drill + **span layer** + h. Altrimenti PE-AF-002 span via. |
| Field solver 2.5D / OpenEMS | **Fuori.** |
| RLGC + S-param | Cascade sezioni lossless + Z0 ImpedenceFinder. S11/S21 se Z_ref datasheet. |
| TDR IFFT | Fuori. Profilo Z0(x) non si chiama TDR. |
| Wheeler allegato B | Non usato. Hammerstad/Cohn del vendor. |
`run_pcb_checks` (SI/HF) **invariato**.
---
## 2. Trigger
Candidati: net con rame **traccia**, non power/GND, non `skip_si_net` (I2C/GPIO/EN/CC/strap), non XTAL. Coppia = ununità: `l = max(l_p, l_n)`; se un membro è candidato, lo è la coppia.
Costanti: c = 2.99792458×10⁸ m/s. `tpd = √εr_eff / c`. `λ = c / (f √εr_eff)`.
- λ/10 se `f` FACT oppure `f = 0.35/tr` con `tr` FACT, e εr_eff da stackup+geometria (ImpedenceFinder).
- Rise time: `l ≥ tr / (6 tpd)` con `tr` FACT (non la forma tr·vp/2).
**Trigger vero** → analisi discontinuità / Z / cascade / via.
**Trigger falso** (entrambe le soglie calcolate e `l` sotto) → silenzio AF.
**Trigger non calcolabile** su un candidato (manca tr **e** f, o manca stackup/εr) → **PE-AF-002 visibile**, testo italiano con la lista di ciò che manca. Non è “trigger falso”.
---
## 3. Skip visibili (non silenziosi)
| Manca | Testo (es.) | Quando |
| :--- | :--- | :--- |
| stackup / εr | mancanza di stackup, εr | candidato, trigger non valutabile |
| tr, f | mancanza di tr, f | candidato, serve almeno uno |
| Z target datasheet | mancanza di Z target datasheet | trigger **vero**, niente confronto ±10% e niente S |
| span via | mancanza di span via | trigger vero **e** c’è un via sulla net/coppia senza layers |
Niente ohm, niente ampere, niente 1 ns USB. Status WARNING, class REVIEW, `evidence_status=INSUFFICIENT` (il clamp del motore finding non deve promuovere RULE/ERROR).
`PE-AF-001` (AI Z0 senza stackup) resta; è già INSUFFICIENT visibile.
---
## 4. Coppia differenziale
Partner: ImpedenceFinder + `partner_net`. Un finding per coppia, non due cloni. Zdiff da `diff_microstrip_z0` / `diff_stripline_z0`. Intra-coppia: lunghezze, non regola 3W (il mate non è aggressore). 3W solo vs **altre** net.
---
## 5. Simulazione numerica (non OpenEMS)
Per net/coppia triggerata con campioni Z0:
1. Sezioni di lunghezza campionata, Z0 ImpedenceFinder (flag `plane_broken` esclusi dal voto ohm).
2. Lossless: `L' = Z0 · tpd`, `C' = tpd / Z0` per metro.
3. ABCD di cascata; S11/S21 rispetto a **Z_ref = target datasheet**.
4. Confronto ΔZ0 ±10% vs stessa finestra datasheet. Duplicato `PE-SI-002`: non secondo FAIL.
Senza Z_ref: PE-AF-002, cascade non pubblica S.
---
## 6. Dati
- Stackup KiCad già parsato (`epsilon_r`, thickness; mai 1 oz default).
- `LayoutVia.layers` da `(layers "F.Cu" "B.Cu")`. Size anulare ≠ drill ≠ track.
- `tr`/`f` da `layout_rules` del driver sulla net (`rise_time`, `tr_ns`, `f_hz`, …) e `net_class` espanso come SI.
---
## 7. Finding PE-*
| ID | Ruolo |
| :--- | :--- |
| PE-AF-001 | AI Z0 senza evidenza (esistente) |
| PE-AF-002 | Skip visibile “non controllata per mancanza di …” |
| PE-AF-020 | Gomito ~90° |
| PE-AF-021 | Gradino W / taper |
| PE-AF-030 | `plane_broken` |
| PE-AF-031 | Return 3H |
| PE-AF-032 | `plane_split_nearby` |
| PE-AF-040 | 3W vs aggressore (non il mate) |
| PE-AF-050 | Z0/Zdiff vs finestra datasheet + cascade in `calculation` |
| PE-AF-051 | CPWG/unknown: no ohm |
| PE-AF-060 | L_via se drill+h+span |
| PE-AF-061 | Via stub vs λ/20 |
---
## 8. Moduli e pipeline
`af_trigger.py`, `af_rlgc.py`, `af_trace_check.py`. Dopo `run_pcb_checks`, prima `af_ai`. Non dentro la lista 2.62. Stage SSE `af_trace`.
---
## 9. Test HubAudio
Path opzionale `~/Development/HubAudio/.../HubAudio.kicad_pcb`. USB senza tr FACT → PE-AF-002 (tr/f), zero 90 Ω. GPIO → niente AF. Trigger sintetico corto con tr FACT → zero finding. Lunga + target datasheet → PE-AF-050. Coppia: un finding, non D+ solo. SI stub `PE-SI-007` resta.
---
## 10. Fasi v1 (questa implementazione)
Parser via layers; trigger; skip visibili; coppia; Z/cascade se target; gomiti/width; 3W; via se span; pipeline; changelog; pytest.
---
## 11. Fuori scope
OpenEMS, FEM, 2.5D, TDR IFFT, NEXT/FEXT in volt, 5W default, auto-miter sul PCB, Gerber/ODB++, Auth/JWT, rustup.
---
## 15. C++ vs Rust (valutazione; stesura Python)
Criteri (come `docs/rust-criteri.md`): geometria pad≠via≠track≠zone al confine; memoria/ownership su HubAudio; hot path profilato; determinismo; FFI grosso (struct in → struct out).
| Criterio | AF v1 (trigger + Shapely + cascade) | C++ | Rust |
| :--- | :--- | :--- | :--- |
| Geometria | Tipi già distinti in Python; Shapely nel vendor. Un port deve **non** fondere via/pad. | Possibile (CGAL/Clipper), confine pericoloso se si “semplifica” AABB. | Stesso rischio; ownership aiuta i buffer, non la semantica PCB. |
| Memoria | HubAudio è un `.kicad_pcb` grande; il costo oggi è parse + zone Shapely, non la cascade (O(sezioni)). Nessuna misura che Python non basti. | Arena/SoA utili dopo profilo. | Stesso, dopo misura. |
| Hot path | Non profilato. Candidati *futuri* restano parser PCB e point-in-poly, non PE-AF isolato. | Solo se il profilo lo dice. | Idem. |
| Determinismo | Cascade ABCD e Z0 chiusi sono deterministici. | ok | ok (niente HashMap nel testo finding). |
| FFI grosso | Un rewrite ora = due runtime + duplicare LayoutGraph. **Complicherebbe** (Michele). | pybind/nanobind, grosso se si passa tutta la board. | PyO3, stesso costo. ImpedenceFinder resterebbe Python/Shapely. |
**Decisione:** v1 Python. Nessun pezzo scelto per C++ o Rust. Michele può volere Rust un giorno; sequenza obbligatoria resta: Python corretto → misura HubAudio → profilo → scelta esplicita. ImpedenceFinder non si riscrive “per Rust”. OpenEMS non è candidato a nessun linguaggio in Periscope.
+85
View File
@@ -0,0 +1,85 @@
# Criteri Rust — Periscope
Percorso locale: `/Users/michelebigi/Development/periscope/docs/rust-criteri.md`.
Costituzione: Python è il default. Rust **non** è un default. Questo documento dice *quando* un pezzo può entrare in Rust. Non sceglie ancora un pezzo. **Niente `rustup`, crate, né FFI finché una riga sotto non è scelta *e* misurata.**
FEM / OpenEMS / field solver: fuori dal prodotto. Non sono candidati Rust.
DRC: KiCad. Non è un candidato Rust in Periscope.
## Non solo velocità
Un bottleneck di CPU non basta. Un pezzo va in Rust solo se **tutti** i punti seguenti sono veri.
1. **Correttezza geometrica** — pad ≠ via ≠ traccia ≠ zona deve sopravvivere al confine. Se Rust collassa oggetti per “è più facile in un AABB”, il pezzo è rifiutato anche se è più veloce.
2. **Memoria / ownership** — pressione reale (PCB grandi, segmenti, zone) o lifetime che Python non può possedere senza copie cieche. Non “magari un giorno”.
3. **Parser o hot path** — il costo sta in un ciclo stretto (parse `.kicad_pcb`, walk segmenti, predicate geometriche) dopo profiling. Non nellorchestrazione, non nellI/O HTTP, non nellLLM.
4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dallLLM.
5. **Confine Python grosso** — una chiamata: struct in → struct out. Vietato un round-trip per pad, per via, per “is_point_in_poly” dal loop Python.
Se manca anche uno solo di questi, il pezzo resta in Python.
## Sequenza obbligatoria (costituzione §§3336)
```text
implementazione corretta in Python
misura (wall + allocazioni, board reale: HubAudio / Emmaforo)
profiling (il hot path ha un nome di funzione)
scelta esplicita del pezzo in questo file
Rust con confine grosso
benchmark sulla stessa board
accetta / rifiuta sui numeri e sulla semantica (via resta via)
```
Finché la riga “Pezzo scelto” sotto è vuota: **nessun rustup**.
## Cosa non va in Rust
| Area | Perché |
| --- | --- |
| Pipeline, job, SSE, FastAPI | orchestrazione |
| Estrazione datasheet, skills, LLM | I/O + modello |
| Review AI, normalize, dedup | non deterministico in autorità |
| Project CRUD, storage, report JSON | I/O |
| Frontend | altro runtime |
| Auth / JWT / users | fuori perimetro; non toccare |
| FEM, OpenEMS, ampacity IPC via, DRC | non è Periscope |
| ImpedenceFinder venduto | già un core chiuso; non riscrivere “per Rust” |
| Un check `PE-*` isolato | il check è regola + evidenza; il costo è il parser/geometria a monte |
## Candidati *solo dopo misura* (nessuno scelto)
Questi sono i posti dove un profilo *potrebbe* mostrare un hot path. Non sono un piano di porting.
- Parser `.kicad_pcb` (`parsers_kicad_pcb.py`) se il wall su HubAudio è il parse, non i check.
- Predicate geometriche usate da stub / zone / courtyard (track graph, point-in-poly) se il walk segmenti domina.
- Walk grafo net→pin su BOM grandi, se misurato.
Non candidati: `run_pcb_checks` come god-object Rust; “tutto il core”; Z inventata più veloce.
## Pezzo scelto
Nessuno.
Nessuna misura registrata in questo file. Quindi: niente toolchain Rust in questa macro-fase.
## Come registrare una scelta (futuro)
Quando una misura esiste, aggiungere qui:
```text
Pezzo: <modulo / funzione>
Board: <HubAudio / Emmaforo / …>
Python wall: <s> alloc: <MB>
Hot path: <nome>
Ipotesi Rust: correttezza geometrica | memoria | parser | determinismo | confine
Benchmark dopo: <s> e test via≠pad ancora verdi
Decisione: accetta | rifiuta
```
@@ -492,6 +492,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(
+2
View File
@@ -21,6 +21,7 @@ from backend.routers import (
contact,
feedback,
impedance,
library,
pipeline,
projects,
reports,
@@ -163,6 +164,7 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
app.include_router(projects.router, prefix="/api")
app.include_router(library.router, prefix="/api")
app.include_router(pipeline.router, prefix="/api")
app.include_router(reports.router, prefix="/api")
app.include_router(impedance.router, prefix="/api")
@@ -0,0 +1,156 @@
"""AI flags probable HF issues; deterministic checks investigate.
Extra path — does not replace ``run_pcb_checks`` / SI / HF. Not DRC.
Never invents Z, I, or millimetres. Missing net → silence (no N/A).
"""
from __future__ import annotations
from dataclasses import dataclass
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import check_si
SOURCE = "af_ai_hf"
RULE_Z_NO_EVIDENCE = "PE-AF-001"
ALLOWED_ISSUE_CLASSES = frozenset({
"stub",
"split_reference",
"unterminated",
"z0",
})
@dataclass(frozen=True)
class HfHypothesis:
"""Structured AI flag. ``why`` is INFERENCE, not FACT."""
net: str
issue_class: str
why: str
def _net_on_board(graph: DesignGraph, layout: LayoutGraph | None, net: str) -> bool:
if net in graph.nets:
return True
if layout is None:
return False
for seg in layout.segments:
if seg.net and kicad_nets_match(seg.net, net):
return True
return False
def _has_z0_facts(layout: LayoutGraph | None) -> bool:
if layout is None or not layout.segments or layout.stackup is None:
return False
stack = layout.stackup
layers = getattr(stack, "copper_layers", None) or []
diel = getattr(stack, "dielectrics", None) or []
return bool(layers) and bool(diel)
def _z0_insufficient(net: str, why: str) -> Finding:
rec = (
"Provide stackup (copper + dielectric) and track geometry before Z0. "
"Do not assume 50 Ω or 90 Ω."
)
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=f"Unverified: AI flagged Z0 on {net} — no stackup+track FACT.",
facts="stackup or HF track geometry missing.",
requirement="Closed-form Z0 needs board stackup and width on this net.",
inference=why,
why="AI hypothesis is not a measured ohm.",
status="INFO",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=RULE_Z_NO_EVIDENCE,
finding_class="INFO",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=net,
pins=[],
)
def investigate_hf_hypotheses(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
hypotheses: list[HfHypothesis],
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
"""Run existing HF/SI checks only on nets the AI flagged."""
out: list[Finding] = []
flagged: set[str] = set()
for hyp in hypotheses:
kind = (hyp.issue_class or "").strip().lower()
net = (hyp.net or "").strip()
if not net or kind not in ALLOWED_ISSUE_CLASSES:
continue
if not _net_on_board(graph, layout, net):
continue
if kind == "z0" and not _has_z0_facts(layout):
out.append(_z0_insufficient(net, hyp.why))
continue
flagged.add(net)
if not flagged or layout is None:
return out
probed = check_hf_lines(graph, constraints_map, layout)
probed.extend(check_si(graph, constraints_map, layout, impedance_nets))
for finding in probed:
net = finding.net or ""
if any(kicad_nets_match(net, flag) for flag in flagged if net):
out.append(finding)
return out
def hypotheses_from_rows(rows: list | None) -> list[HfHypothesis]:
"""Parse structured AI flags. Unknown classes and empty nets are dropped."""
out: list[HfHypothesis] = []
for row in rows or []:
if not isinstance(row, dict):
continue
net = str(row.get("net") or "").strip()
kind = str(row.get("issue_class") or "").strip().lower()
why = str(row.get("why") or "").strip()
if not net or kind not in ALLOWED_ISSUE_CLASSES:
continue
out.append(HfHypothesis(net=net, issue_class=kind, why=why))
return out
def append_investigated(
pcb_findings: list[Finding],
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
hypotheses: list[HfHypothesis],
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
"""Append AF+AI extras. Never drops ``run_pcb_checks`` rows.
Duplicate SI/HF clones (same rule_id+net already present) are skipped so
a stub FAIL is not counted twice. ``PE-AF-001`` is always additive.
"""
extra = investigate_hf_hypotheses(
graph, constraints_map, layout, hypotheses, impedance_nets,
)
seen = {(f.rule_id, f.net) for f in pcb_findings}
added: list[Finding] = []
for finding in extra:
key = (finding.rule_id, finding.net)
if finding.rule_id == RULE_Z_NO_EVIDENCE or key not in seen:
pcb_findings.append(finding)
added.append(finding)
seen.add(key)
return added
@@ -0,0 +1,76 @@
"""Lossless RLGC sections + ABCD cascade. Not a field solver. Not OpenEMS."""
from __future__ import annotations
import cmath
import math
from dataclasses import dataclass
from backend.periscopex.af_trigger import C_MPS
@dataclass(frozen=True)
class CascadeResult:
s11: complex
s21: complex
z_ref_ohm: float
n_sections: int
def _abcd_line(z0: float, tpd_s_per_m: float, length_m: float, omega: float) -> tuple[complex, complex, complex, complex]:
beta = omega * tpd_s_per_m
bl = beta * length_m
j = 1j
a = cmath.cos(bl)
b = j * z0 * cmath.sin(bl)
c = (j / z0) * cmath.sin(bl) if z0 else 0j
d = a
return a, b, c, d
def _mul(
p: tuple[complex, complex, complex, complex],
q: tuple[complex, complex, complex, complex],
) -> tuple[complex, complex, complex, complex]:
a1, b1, c1, d1 = p
a2, b2, c2, d2 = q
return (
a1 * a2 + b1 * c2,
a1 * b2 + b1 * d2,
c1 * a2 + d1 * c2,
c1 * b2 + d1 * d2,
)
def cascade_sparam(
z0_ohms: list[float],
length_m: list[float],
er_eff: float,
f_hz: float,
z_ref_ohm: float,
) -> CascadeResult | None:
"""Uniform-per-section lossless cascade. Z_ref must be a datasheet FACT."""
if (
not z0_ohms or len(z0_ohms) != len(length_m)
or er_eff <= 0 or f_hz <= 0 or z_ref_ohm <= 0
):
return None
tpd = math.sqrt(er_eff) / C_MPS
omega = 2.0 * math.pi * f_hz
abcd = (1 + 0j, 0j, 0j, 1 + 0j)
n = 0
for z0, ell in zip(z0_ohms, length_m, strict=True):
if z0 <= 0 or ell <= 0:
continue
abcd = _mul(abcd, _abcd_line(z0, tpd, ell, omega))
n += 1
if n == 0:
return None
a, b, c, d = abcd
zr = z_ref_ohm
denom = a + b / zr + c * zr + d
if denom == 0:
return None
s11 = (b - c * zr * zr) / denom
s21 = 2.0 / denom
return CascadeResult(s11=s11, s21=s21, z_ref_ohm=zr, n_sections=n)
@@ -0,0 +1,593 @@
"""Triggered AF trace analysis. Additive. Not DRC. Not OpenEMS.
Skip without FACT is a visible PE-AF-002, never invented 50/90 Ω.
Differential pairs are one unit. Trigger false → no finding.
"""
from __future__ import annotations
import logging
import math
from collections import defaultdict
from backend.periscopex.af_rlgc import cascade_sparam
from backend.periscopex.af_trigger import (
AfUnit,
TriggerResult,
dielectric_height_mm,
evaluate_trigger,
iter_af_units,
z_window_for_unit,
)
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.hf_line_check import COORD_QUANT_MM, ENDPOINT_SNAP_MM
from backend.periscopex.impedance import GeometryError
from backend.periscopex.impedance_traces import analyze_specified_nets
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutVia
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import partner_net
log = logging.getLogger(__name__)
SOURCE = "af_trace_check"
_RIGHT_DOT = 0.087 # ~5° from 90°
_WIDTH_STEP_MM = 0.05
_PARALLEL_MIN_MM = 5.0
def check_af_traces(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
existing: list[Finding] | None = None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
out: list[Finding] = []
seen_si_z = {
f.net for f in (existing or [])
if f.rule_id == "PE-SI-002" and f.net
}
zrows = _z_rows(impedance_nets)
for unit in iter_af_units(layout, graph):
trig = evaluate_trigger(graph, constraints_map, layout, unit)
if trig.missing and not trig.af:
out.append(_skip(unit, trig.missing))
continue
if not trig.af:
continue
out.extend(_analyze_triggered(graph, constraints_map, layout, trig, zrows, seen_si_z))
complete_findings(out)
return out
def _z_rows(impedance_nets: list[dict] | dict | None) -> list[dict]:
raw = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("nets") or [])
return [r for r in (raw or []) if isinstance(r, dict) and not r.get("error")]
def _label(unit: AfUnit) -> str:
return " / ".join(unit.nets)
def _skip(unit: AfUnit, missing: tuple[str, ...], extra: str = "") -> Finding:
miss = ", ".join(missing)
text = f"Pista ad alta frequenza non controllata per mancanza di {miss}."
if extra:
text = f"{text} {extra}".strip()
rec = (
f"Fornire {miss} dal datasheet o dallo stackup KiCad. "
"Non si assume 50 Ω o 90 Ω."
)
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=text,
facts=f"nets={_label(unit)}; missing={miss}; l={unit.length_mm:.3f} mm.",
requirement="AF checks need FACT tr or f, stackup/εr, and datasheet Z for Z0.",
inference="INSUFFICIENT — skip, not an invented ohm or ampere.",
why="An HF candidate without evidence is reported, not guessed.",
status="WARNING",
recommendation=rec,
action=rec,
source=SOURCE,
rule_id="PE-AF-002",
finding_class="REVIEW",
provenance="TYPICAL",
evidence_status="INSUFFICIENT",
net=unit.nets[0],
pins=[],
)
def _finding(
*,
rule_id: str,
unit: AfUnit,
finding: str,
facts: str,
requirement: str,
rec: str,
status: str = "WARNING",
cls: str = "RISK",
evidence: str = "SUFFICIENT",
calculation: str = "",
) -> Finding:
return Finding(
designator="layout",
mpn="",
aspect="si",
finding=finding,
facts=facts,
requirement=requirement,
inference="Triggered AF net: l ≥ λ/10 or l ≥ tr/(6 tpd).",
why=requirement,
status=status, # type: ignore[arg-type]
recommendation=rec,
action=rec,
source=SOURCE,
rule_id=rule_id,
finding_class=cls, # type: ignore[arg-type]
provenance="RECOMMENDED" if cls != "REVIEW" else "TYPICAL",
evidence_status=evidence, # type: ignore[arg-type]
net=unit.nets[0],
pins=[],
calculation=calculation,
)
def _analyze_triggered(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
trig: TriggerResult,
zrows: list[dict],
seen_si_z: set[str],
) -> list[Finding]:
unit = trig.unit
out: list[Finding] = []
out.extend(_corner_findings(layout, unit))
out.extend(_width_findings(layout, unit))
out.extend(_coupling_findings(layout, unit))
samples = _samples(layout, unit)
out.extend(_plane_findings(layout, unit, samples, trig))
win = z_window_for_unit(graph, constraints_map, unit)
if win is None:
out.append(_skip(unit, ("Z target datasheet",)))
else:
already = any(n in seen_si_z or any(kicad_nets_match(n, s) for s in seen_si_z) for n in unit.nets)
if not already:
zf = _z_finding(unit, samples, win, trig)
if zf is not None:
out.append(zf)
out.extend(_via_findings(layout, unit, trig))
return out
def _samples(layout: LayoutGraph, unit: AfUnit) -> list[dict]:
if layout.stackup is None:
return []
try:
rows = analyze_specified_nets(layout, list(unit.nets), 1.0)
except GeometryError:
return []
except Exception:
log.exception("ImpedenceFinder walk failed for %s", unit.nets)
return []
return [r for r in rows if isinstance(r, dict)]
def _z_vals(samples: list[dict], diff: bool) -> list[float]:
keys = ("zdiff_avg_ohms", "zdiff_ohms", "z0_avg_ohms", "z0_min_ohms") if diff else (
"z0_avg_ohms", "z0_min_ohms", "z0_max_ohms",
)
out: list[float] = []
for row in samples:
flags = str(row.get("flags") or "")
if "plane_broken" in flags or "topology_not_supported" in flags:
continue
for k in keys:
v = row.get(k)
if isinstance(v, (int, float)) and v > 0:
out.append(float(v))
break
return out
def _z_finding(
unit: AfUnit,
samples: list[dict],
window: tuple[float, float],
trig: TriggerResult,
) -> Finding | None:
diff = len(unit.nets) == 2
if any("topology_not_supported" in str(r.get("flags") or "") for r in samples) and not _z_vals(samples, diff):
rec = "CPWG is not solved by ImpedenceFinder; no ohm invented."
return _finding(
rule_id="PE-AF-051", unit=unit,
finding=f"Unverified: topology not supported on {_label(unit)} — no Z0 number.",
facts=f"flags from ImpedenceFinder; nets={_label(unit)}.",
requirement="Closed-form Z0 only for microstrip/stripline.",
rec=rec, cls="REVIEW", evidence="INSUFFICIENT",
)
vals = _z_vals(samples, diff)
if not vals:
return None
lo, hi = window
zmin, zmax = min(vals), max(vals)
zavg = sum(vals) / len(vals)
calc = ""
if trig.f_hz and trig.er_eff:
z0s = [zavg]
ell = [unit.length_mm / 1000.0]
casc = cascade_sparam(z0s, ell, trig.er_eff, trig.f_hz, (lo + hi) / 2.0)
if casc is not None:
calc = (
f"cascade lossless n={casc.n_sections} "
f"S11={casc.s11.real:.4f}{casc.s11.imag:+.4f}j "
f"S21={casc.s21.real:.4f}{casc.s21.imag:+.4f}j "
f"Z_ref={casc.z_ref_ohm:g} Ω (datasheet)."
)
kind = "Zdiff" if diff else "Z0"
facts = (
f"{kind} avg={zavg:.3f} min={zmin:.3f} max={zmax:.3f} Ω "
f"(ImpedenceFinder); window=[{lo:g}, {hi:g}] Ω; nets={_label(unit)}."
)
rec = f"Adjust geometry so {kind} stays in the datasheet window [{lo:g}, {hi:g}] Ω."
if zmin < lo or zmax > hi:
return _finding(
rule_id="PE-AF-050", unit=unit,
finding=f"FAIL: {kind} {zavg:.2f} Ω outside datasheet [{lo:g}, {hi:g}] Ω on {_label(unit)}.",
facts=facts, requirement="ΔZ vs datasheet target ± window.", rec=rec,
calculation=calc,
)
return None
def _plane_findings(
layout: LayoutGraph, unit: AfUnit, samples: list[dict], trig: TriggerResult,
) -> list[Finding]:
out: list[Finding] = []
broken = any("plane_broken" in str(r.get("flags") or "") for r in samples)
split = any("plane_split_nearby" in str(r.get("flags") or "") for r in samples)
if broken:
rec = "Restore continuous reference copper under the pair/net."
out.append(_finding(
rule_id="PE-AF-030", unit=unit,
finding=f"Reference plane broken under {_label(unit)} (ImpedenceFinder).",
facts=f"plane_broken on sampled walk; nets={_label(unit)}.",
requirement="AF return path must exist under the track.", rec=rec,
))
if split:
rec = "Move the track away from the plane split or close the void."
out.append(_finding(
rule_id="PE-AF-032", unit=unit,
finding=f"Reference-plane split nearby on {_label(unit)}.",
facts=f"plane_split_nearby; nets={_label(unit)}.",
requirement="3W proximity to a plane edge is REVIEW.", rec=rec, cls="REVIEW",
))
stack = layout.stackup
if stack is not None:
for net in unit.nets:
layer, _w = _any_layer_width(layout, net)
if not layer:
continue
h = dielectric_height_mm(stack, layer)
if h is None:
continue
if not _copper_within_3h(layout, net, layer, h):
rec = "Provide a reference pour within 3H of the track."
out.append(_finding(
rule_id="PE-AF-031", unit=unit,
finding=f"No reference copper within 3H under {net}.",
facts=f"H={h:g} mm; 3H={3 * h:g} mm; net={net}.",
requirement="Return path within 3H (not KiCad clearance).", rec=rec,
))
break
return out
def _any_layer_width(layout: LayoutGraph, net: str) -> tuple[str, float]:
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net) and s.layer and s.width > 0:
return s.layer, s.width
return "", 0.0
def _copper_within_3h(layout: LayoutGraph, net: str, layer: str, h: float) -> bool:
from backend.periscopex.placement_check import _in_poly
stack = layout.stackup
if stack is None:
return True
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return True
ref = None
if idx + 1 < len(stack.copper_layers):
ref = stack.copper_layers[idx + 1]
elif idx > 0:
ref = stack.copper_layers[idx - 1]
if not ref:
return True
pts: list[tuple[float, float]] = []
for s in layout.segments:
if s.net and kicad_nets_match(s.net, net):
pts.append(s.start)
pts.append(s.end)
if not pts:
return True
margin = 3.0 * h
for z in layout.zones:
if z.keepout or z.layer != ref:
continue
for ring in z.outlines:
if len(ring) < 3:
continue
for x, y in pts:
if _in_poly(x, y, ring):
return True
for px, py in ring:
if math.hypot(px - x, py - y) <= margin:
return True
return not any(z.layer == ref and not z.keepout for z in layout.zones)
def _qxy(x: float, y: float) -> tuple[float, float]:
q = COORD_QUANT_MM
return (round(x / q) * q, round(y / q) * q)
def _track_edges(layout: LayoutGraph, net: str) -> list[tuple[tuple[float, float], tuple[float, float], float, str]]:
edges = []
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net):
continue
a, b = _qxy(*s.start), _qxy(*s.end)
if a == b:
continue
edges.append((a, b, s.width, s.layer))
return edges
def _corner_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
out: list[Finding] = []
for net in unit.nets:
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
for a, b, w, ly in _track_edges(layout, net):
nbrs[a].append((b, w, ly))
nbrs[b].append((a, w, ly))
hit = False
for node, friends in nbrs.items():
if len(friends) != 2:
continue
(p1, w1, ly1), (p2, w2, ly2) = friends
if ly1 != ly2:
continue
v1 = (p1[0] - node[0], p1[1] - node[1])
v2 = (p2[0] - node[0], p2[1] - node[1])
n1 = math.hypot(*v1)
n2 = math.hypot(*v2)
if n1 < ENDPOINT_SNAP_MM or n2 < ENDPOINT_SNAP_MM:
continue
dot = (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)
if abs(dot) <= _RIGHT_DOT:
hit = True
break
if hit:
rec = "Replace the 90° corner with a 45° miter or an arc R ≥ 3W."
out.append(_finding(
rule_id="PE-AF-020", unit=unit,
finding=f"Right-angle track corner on {net}.",
facts=f"two tracks meet at ~90°; net={net}; pair={_label(unit)}.",
requirement="AF corners should be mitered or curved (not DRC clearance).",
rec=rec,
))
return out
def _width_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
out: list[Finding] = []
for net in unit.nets:
nbrs: dict[tuple[float, float], list[tuple[tuple[float, float], float, str]]] = defaultdict(list)
for a, b, w, ly in _track_edges(layout, net):
nbrs[a].append((b, w, ly))
nbrs[b].append((a, w, ly))
for node, friends in nbrs.items():
if len(friends) != 2:
continue
(_p1, w1, ly1), (_p2, w2, ly2) = friends
if ly1 != ly2 or abs(w1 - w2) < _WIDTH_STEP_MM:
continue
rec = "Insert a taper L ≥ 2|W1W2|; pads are not the track width."
out.append(_finding(
rule_id="PE-AF-021", unit=unit,
finding=f"Abrupt width step {w1:.3f}{w2:.3f} mm on {net}.",
facts=f"W1={w1:g} mm W2={w2:g} mm at {_fmt(node)}; net={net}.",
requirement="Width steps on AF tracks need a taper (track ≠ pad).",
rec=rec,
))
break
return out
def _fmt(pt: tuple[float, float]) -> str:
return f"({pt[0]:g},{pt[1]:g})"
def _coupling_findings(layout: LayoutGraph, unit: AfUnit) -> list[Finding]:
mates = set(unit.nets)
out: list[Finding] = []
for net in unit.nets:
segs = [s for s in layout.segments if s.net and kicad_nets_match(s.net, net)]
if not segs:
continue
w = segs[0].width or 0.0
if w <= 0:
continue
for other in {s.net for s in layout.segments if s.net}:
if other in mates or any(kicad_nets_match(other, m) for m in mates):
continue
if partner_net(net) and kicad_nets_match(other, partner_net(net) or ""):
continue
ov, gap = _parallel_overlap_gap(layout, net, other)
if ov < _PARALLEL_MIN_MM or gap is None:
continue
# 3W is centre-to-centre; gap here is edge-to-edge.
c2c = gap + w / 2.0 + _mean_width(layout, other) / 2.0
if c2c < 3.0 * w:
rec = "Increase spacing to S ≥ 3W versus the aggressor (not KiCad clearance)."
out.append(_finding(
rule_id="PE-AF-040", unit=unit,
finding=f"Spacing to {other} is {c2c:.3f} mm (< 3W={3 * w:.3f} mm) on {net}.",
facts=f"overlap={ov:.2f} mm; edge_gap={gap:.3f} mm; W={w:g} mm.",
requirement="3W coupling proxy on triggered AF nets; pair mate excluded.",
rec=rec, cls="REVIEW",
))
return out
return out
def _mean_width(layout: LayoutGraph, net: str) -> float:
ws = [s.width for s in layout.segments if s.net and kicad_nets_match(s.net, net) and s.width > 0]
return sum(ws) / len(ws) if ws else 0.0
def _parallel_overlap_gap(layout: LayoutGraph, a: str, b: str) -> tuple[float, float | None]:
sa = [s for s in layout.segments if s.net and kicad_nets_match(s.net, a)]
sb = [s for s in layout.segments if s.net and kicad_nets_match(s.net, b)]
best_gap = None
overlap = 0.0
for x in sa:
for y in sb:
if x.layer != y.layer:
continue
ov = _axis_overlap(x.start, x.end, y.start, y.end)
overlap = max(overlap, ov)
g = _segment_gap(x.start, x.end, x.width, y.start, y.end, y.width)
if g is not None and (best_gap is None or g < best_gap):
best_gap = g
return overlap, best_gap
def _axis_overlap(
a0: tuple[float, float], a1: tuple[float, float],
b0: tuple[float, float], b1: tuple[float, float],
) -> float:
if abs(a0[1] - a1[1]) < COORD_QUANT_MM and abs(b0[1] - b1[1]) < COORD_QUANT_MM:
return _interval_overlap(a0[0], a1[0], b0[0], b1[0])
if abs(a0[0] - a1[0]) < COORD_QUANT_MM and abs(b0[0] - b1[0]) < COORD_QUANT_MM:
return _interval_overlap(a0[1], a1[1], b0[1], b1[1])
return 0.0
def _interval_overlap(a: float, b: float, c: float, d: float) -> float:
lo = max(min(a, b), min(c, d))
hi = min(max(a, b), max(c, d))
return max(0.0, hi - lo)
def _segment_gap(
a0: tuple[float, float], a1: tuple[float, float], wa: float,
b0: tuple[float, float], b1: tuple[float, float], wb: float,
) -> float | None:
pts_a = (a0, a1)
pts_b = (b0, b1)
best = None
for p in pts_a:
for q in pts_b:
d = math.hypot(p[0] - q[0], p[1] - q[1]) - wa / 2.0 - wb / 2.0
if best is None or d < best:
best = d
return best
def _via_span_h(layout: LayoutGraph, via: LayoutVia) -> float | None:
stack = layout.stackup
if stack is None or len(via.layers) < 2:
return None
names = list(stack.copper_layers)
try:
i0 = names.index(via.layers[0])
i1 = names.index(via.layers[-1])
except ValueError:
return None
lo, hi = min(i0, i1), max(i0, i1)
h = 0.0
for i in range(lo, hi):
if i < len(stack.dielectrics):
h += stack.dielectrics[i].height_mm
return h if h > 0 else None
def _via_findings(layout: LayoutGraph, unit: AfUnit, trig: TriggerResult) -> list[Finding]:
out: list[Finding] = []
vias = [
v for v in layout.vias
if v.net and any(kicad_nets_match(v.net, n) for n in unit.nets)
]
if not vias:
return out
if any(len(v.layers) < 2 for v in vias):
out.append(_skip(unit, ("span via",)))
return out
for v in vias:
if v.drill is None or v.drill <= 0:
continue
h = _via_span_h(layout, v)
if h is None:
continue
ratio = 4.0 * h / v.drill
if ratio <= 1.0:
continue
l_nh = 0.2 * h * (math.log(ratio) + 1.0)
rec = "Treat the via as an RF discontinuity; add a return via or shorten the barrel."
out.append(_finding(
rule_id="PE-AF-060", unit=unit,
finding=f"Via L≈{l_nh:.3f} nH on {_label(unit)} (drill={v.drill:g} mm, h={h:g} mm).",
facts=f"drill={v.drill:g} mm; h={h:g} mm; layers={v.layers}; L={l_nh:.4f} nH.",
requirement="Via is not a pad or a track; L_via from drill and span.",
rec=rec, cls="REVIEW",
))
tracks = {
s.layer for s in layout.segments
if s.net and any(kicad_nets_match(s.net, n) for n in unit.nets) and s.layer
}
stack = layout.stackup
if stack is None or len(v.layers) < 2 or not tracks:
continue
names = list(stack.copper_layers)
try:
used = [names.index(ly) for ly in tracks if ly in names]
span = [names.index(v.layers[0]), names.index(v.layers[-1])]
except ValueError:
continue
if not used:
continue
stub_idx = max(span) - max(used)
stub_lo = min(used) - min(span)
layers_stub = max(stub_idx, stub_lo, 0)
stub_h = 0.0
lo, hi = min(span), max(span)
unused = [i for i in range(lo, hi) if i < min(used) or i >= max(used)]
for i in unused:
if 0 <= i < len(stack.dielectrics):
stub_h += stack.dielectrics[i].height_mm
if stub_h <= 0 or trig.lambda_10_mm is None:
continue
lam = trig.lambda_10_mm * 10.0
if stub_h > lam / 20.0:
rec = "Back-drill or use a blind via; unused barrel is a λ/4 stub risk."
out.append(_finding(
rule_id="PE-AF-061", unit=unit,
finding=f"Via stub {stub_h:.3f} mm > λ/20 on {_label(unit)}.",
facts=f"L_stub={stub_h:g} mm; λ={lam:g} mm; unused_layers~{layers_stub}.",
requirement="Via stub > λ/20 on a triggered AF net.",
rec=rec,
))
return out
@@ -0,0 +1,329 @@
"""AF trigger: λ/10 and tr/(6 tpd) from FACT only. Pad/via/zone are not length."""
from __future__ import annotations
import math
from dataclasses import dataclass
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.models import ComponentType, DesignGraph, LayoutGraph, LayoutStackup, NetType
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.si_check import (
_bus_in_targets,
_expand_bus_token,
bus_class,
net_length_mm,
partner_net,
skip_si_net,
)
C_MPS = 2.99792458e8
# Geometry snap for width pick — not an SI millimetre limit.
_MIN_W_MM = 1e-6
@dataclass(frozen=True)
class AfUnit:
"""One net or a differential pair analyzed together."""
nets: tuple[str, ...]
length_mm: float
bus: str | None
@dataclass(frozen=True)
class TriggerResult:
unit: AfUnit
af: bool
missing: tuple[str, ...]
er_eff: float | None
tr_s: float | None
f_hz: float | None
lambda_10_mm: float | None
l_crit_mm: float | None
def pair_partner(layout: LayoutGraph, net: str) -> str | None:
p = partner_net(net)
if not p:
return None
names = {s.net for s in layout.segments if s.net}
for n in names:
if kicad_nets_match(p, n):
return n
return None
def is_candidate_net(net: str, graph: DesignGraph | None) -> bool:
if not net or skip_si_net(net):
return False
if graph is not None:
rec = graph.nets.get(net)
if rec is not None and rec.net_type in (NetType.POWER, NetType.GROUND):
return False
return bus_class(net) is not None
def iter_af_units(layout: LayoutGraph, graph: DesignGraph | None) -> list[AfUnit]:
routed = sorted({s.net for s in layout.segments if s.net and s.width > 0})
seen: set[str] = set()
out: list[AfUnit] = []
for net in routed:
if net in seen or not is_candidate_net(net, graph):
continue
partner = pair_partner(layout, net)
if partner and is_candidate_net(partner, graph):
nets = tuple(sorted((net, partner)))
for n in nets:
seen.add(n)
lp = net_length_mm(layout, nets[0])
ln = net_length_mm(layout, nets[1])
out.append(AfUnit(
nets=nets, length_mm=max(lp, ln),
bus=bus_class(nets[0]) or bus_class(nets[1]),
))
else:
seen.add(net)
out.append(AfUnit(
nets=(net,), length_mm=net_length_mm(layout, net),
bus=bus_class(net),
))
return out
def _num(v) -> float | None:
if v is None or isinstance(v, bool):
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
return x if math.isfinite(x) else None
def _tr_from_rule(rule: dict) -> float | None:
s = _num(rule.get("tr_s"))
if s is not None and s > 0:
return s
ns = _num(rule.get("tr_ns")) or _num(rule.get("rise_time_ns")) or _num(rule.get("t_r_ns"))
if ns is not None and ns > 0:
return ns * 1e-9
ps = _num(rule.get("tr_ps"))
if ps is not None and ps > 0:
return ps * 1e-12
kind = str(rule.get("kind") or "").lower()
raw = _num(rule.get("tr")) or _num(rule.get("value"))
if kind in {"rise_time", "tr", "t_r"} and raw is not None and raw > 0:
if raw > 1e-3:
return raw * 1e-9
return raw
return None
def _f_from_rule(rule: dict) -> float | None:
hz = _num(rule.get("f_hz")) or _num(rule.get("f_max_hz")) or _num(rule.get("frequency_hz"))
if hz is not None and hz > 0:
return hz
mhz = _num(rule.get("f_mhz"))
if mhz is not None and mhz > 0:
return mhz * 1e6
ghz = _num(rule.get("f_ghz"))
if ghz is not None and ghz > 0:
return ghz * 1e9
kind = str(rule.get("kind") or "").lower()
raw = _num(rule.get("f")) or _num(rule.get("value"))
if kind in {"frequency", "f_max", "f_clk"} and raw is not None and raw > 0:
if raw < 1e4:
return raw * 1e6
return raw
return None
def tr_f_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float | None, float | None]:
tr_s: float | None = None
f_hz: float | None = None
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
on_ic = any(
any(kicad_nets_match(n, icn) for icn in ic_nets)
for n in unit.nets
)
if not on_ic:
continue
for rule in cons.layout_rules or []:
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
continue
t = _tr_from_rule(rule)
f = _f_from_rule(rule)
if t is not None:
tr_s = t
if f is not None:
f_hz = f
return tr_s, f_hz
def z_window_for_unit(graph: DesignGraph, constraints_map: dict, unit: AfUnit) -> tuple[float, float] | None:
from backend.periscopex.si_check import _z_window
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
ic_nets = {n for n in (comp.pins.values()) if n}
on_ic = any(
any(kicad_nets_match(n, icn) for icn in ic_nets)
for n in unit.nets
)
if not on_ic:
continue
for rule in cons.layout_rules or []:
kind = str(rule.get("kind") or "")
if kind not in {"impedance", "zdiff", "z0", "si"}:
continue
if kind == "si" and str(rule.get("parameter") or "").lower() not in {"impedance", "zdiff", "z0"}:
continue
targets = _expand_bus_token(str(rule.get("net_class") or ""))
if targets and unit.bus and not _bus_in_targets(unit.bus, targets):
continue
win = _z_window(rule)
if win:
return win
return None
def _dominant_trace(layout: LayoutGraph, net: str) -> tuple[str, float] | None:
by_layer: dict[str, list[float]] = {}
for s in layout.segments:
if not s.net or not kicad_nets_match(s.net, net) or s.width <= _MIN_W_MM:
continue
layer = s.layer or ""
by_layer.setdefault(layer, []).append(s.width)
if not by_layer:
return None
layer = max(by_layer, key=lambda ly: len(by_layer[ly]))
widths = sorted(by_layer[layer])
w = widths[len(widths) // 2]
return layer, w
def _er_eff(layout: LayoutGraph, unit: AfUnit) -> tuple[float | None, list[str]]:
missing: list[str] = []
stack = layout.stackup
if stack is None or not stack.copper_layers or not stack.dielectrics:
return None, ["stackup", "εr"]
t = stack.copper_thickness_mm
if t is None or t <= 0:
return None, ["stackup"]
picked = None
for net in unit.nets:
picked = _dominant_trace(layout, net)
if picked:
break
if picked is None:
return None, ["εr"]
layer, w = picked
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return None, ["εr"]
outer = idx == 0 or idx == len(stack.copper_layers) - 1
if outer:
diel_i = 0 if idx == 0 else len(stack.dielectrics) - 1
if diel_i < 0 or diel_i >= len(stack.dielectrics):
return None, ["εr"]
d = stack.dielectrics[diel_i]
if d.er <= 0 or d.height_mm <= 0:
return None, ["εr"]
z = zsolver.microstrip_z0(w, d.height_mm, d.er, t)
zair = zsolver.microstrip_z0(w, d.height_mm, 1.0, t)
if z <= 0 or zair <= 0:
return None, ["εr"]
return (zair / z) ** 2, missing
if idx <= 0 or idx >= len(stack.dielectrics):
return None, ["εr"]
d = stack.dielectrics[idx]
if d.er <= 0:
return None, ["εr"]
return d.er, missing
def evaluate_trigger(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph,
unit: AfUnit,
) -> TriggerResult:
missing: list[str] = []
er_eff, er_miss = _er_eff(layout, unit)
missing.extend(er_miss)
tr_s, f_hz = tr_f_for_unit(graph, constraints_map, unit)
if f_hz is None and tr_s is not None:
f_hz = 0.35 / tr_s
if tr_s is None:
missing.append("tr")
if f_hz is None and tr_s is None:
missing.append("f")
elif f_hz is None:
missing.append("f")
l_m = unit.length_mm / 1000.0
lam10 = None
lcrit = None
af = False
if er_eff is not None and er_eff > 0:
tpd = math.sqrt(er_eff) / C_MPS
if f_hz is not None and f_hz > 0:
lam = C_MPS / (f_hz * math.sqrt(er_eff))
lam10 = lam / 10.0 * 1000.0
if l_m >= lam / 10.0:
af = True
if tr_s is not None and tr_s > 0:
lcrit_m = tr_s / (6.0 * tpd)
lcrit = lcrit_m * 1000.0
if l_m >= lcrit_m:
af = True
trigger_ready = er_eff is not None and (tr_s is not None or f_hz is not None)
if trigger_ready:
miss_trig = tuple()
else:
miss_trig = tuple(dict.fromkeys(missing))
return TriggerResult(
unit=unit,
af=bool(af and trigger_ready),
missing=miss_trig,
er_eff=er_eff,
tr_s=tr_s,
f_hz=f_hz,
lambda_10_mm=lam10,
l_crit_mm=lcrit,
)
def dielectric_height_mm(stack: LayoutStackup, layer: str) -> float | None:
try:
idx = stack.copper_layers.index(layer)
except ValueError:
return None
if idx == 0:
d = stack.dielectrics[0] if stack.dielectrics else None
elif idx == len(stack.copper_layers) - 1:
d = stack.dielectrics[-1] if stack.dielectrics else None
else:
d = stack.dielectrics[idx] if idx < len(stack.dielectrics) else None
if d is None or d.height_mm <= 0:
return None
return d.height_mm
@@ -0,0 +1,87 @@
"""Antenna keepout and matching — only if an antenna is on the graph.
No invented 50 Ω. No FEM. Skip when the antenna is absent.
"""
from __future__ import annotations
import re
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
SOURCE = "antenna_layout_check"
_ANT_REF_RE = re.compile(r"^ANT", re.I)
_FEED_RE = re.compile(r"ANT_FEED|RF_ANT|ANTENNA_FEED", re.I)
_ZONE_RE = re.compile(r"antenna|ant_zone|rf_antenna", re.I)
def check_antenna_layout(
graph: DesignGraph,
layout: LayoutGraph | None,
) -> list[Finding]:
ants = [
c for c in graph.components.values()
if _ANT_REF_RE.match(c.reference or "")
]
feeds = [n for n in graph.nets if _FEED_RE.search(n)]
if not ants and not feeds:
return []
out: list[Finding] = []
keepout = []
if layout is not None:
keepout = [
z for z in layout.zones
if z.keepout and (z.net and _ZONE_RE.search(z.net) or _ZONE_RE.search(z.name or ""))
]
ref = ants[0].reference if ants else "ANT"
if keepout:
rec = "Keep copper out of the antenna keepout listed on the board."
names = [z.net or z.name or "keepout" for z in keepout]
out.append(Finding(
designator=ref, mpn="", aspect="antenna",
finding=f"Antenna keepout present: {', '.join(names)}.",
facts=f"keepout_zones={names}; antenna_refs={[c.reference for c in ants]}; feeds={feeds}.",
requirement="Report antenna keepout when that zone exists on the PCB.",
inference="No keepout millimetre is invented.",
why="Antenna checks run only when an antenna marker/net exists.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[],
))
else:
rec = "Add a keepout zone around the antenna if the antenna datasheet requires one."
out.append(Finding(
designator=ref, mpn="", aspect="antenna",
finding="Antenna is present; keepout zone is not evidenced.",
facts=f"antenna_refs={[c.reference for c in ants]}; feeds={feeds}; keepout_zones=0.",
requirement="Antenna keepout millimetres from the antenna datasheet (none on file).",
inference="INSUFFICIENT — not inventing a keepout distance.",
why="Missing keepout is not an invented millimetre FAIL.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-ANT-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="INSUFFICIENT", net=feeds[0] if feeds else None, pins=[],
))
match_refs = []
for name in feeds:
net = graph.nets.get(name)
if not net:
continue
for pin in net.pins:
c = graph.components.get(pin.component_ref)
if c and c.component_type in {ComponentType.INDUCTOR, ComponentType.CAPACITOR}:
match_refs.append(c.reference)
if match_refs:
rec = "No matching-network change required; this is a BOM FACT."
out.append(Finding(
designator=ref, mpn="", aspect="antenna",
finding=f"Antenna matching parts on feed: {', '.join(sorted(set(match_refs)))}.",
facts=f"matching={sorted(set(match_refs))}; feeds={feeds}.",
requirement="Report L/C matching that exists on the antenna feed.",
inference="Missing match network is a skip unless a datasheet matching FACT exists.",
why="Do not invent a 50 Ω match.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-ANT-002", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", net=feeds[0] if feeds else None, pins=[],
))
return out
+347 -65
View File
@@ -1,12 +1,16 @@
"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings.
Skip when a side is missing. No invented JEDEC land patterns or IEC
clearances. Thermal/EP pads may differ by one from pin_count.
clearances. PE-BOM-011 joins pintable × lib × PCB by pin name — never
by scalar pin_count. Pad ≠ via ≠ track ≠ zone. USB-C A5/B5 are pins,
not EP.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
from backend.periscopex.models import (
AbsMaxRating,
@@ -27,6 +31,7 @@ from backend.periscopex.thermal_check import (
_specs_values,
)
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.parsers_kicad_pcb import parse_kicad_mod_pads
_FAMILIES = (
"LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN",
@@ -35,11 +40,23 @@ _FAMILIES = (
"WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70",
"TO-252", "TO252", "TO-263", "QFP",
)
# Same land family: VQFN/WQFN/HVQFN are QFN (24-QFN 4×4 ≠ a different package).
_FAMILY_CANON = {
"VQFN": "QFN",
"WQFN": "QFN",
"HVQFN": "QFN",
}
_QFN_PIN_N = re.compile(r"(?:HVQFN|VQFN|WQFN|QFN)[_-](\d+)\b", re.I)
_MODULE_FP = re.compile(
r"WROOM|WROVER|ESP-WROOM|RF[_-]?MODULE|CASTELLAT",
re.I,
)
_EP_PAD = re.compile(
r"^(?:EP|EXP|EPAD|PAD|TAB|TH|THERMAL|DIEPAD|THERMAL[\s_]?PAD)(?:[_-]?\d+)?$",
re.I,
)
_EP_SPLIT = re.compile(r"^\d+_\d+$")
_SH_PAD = re.compile(r"^(?:SH|SHIELD)(?:[_-].*)?$", re.I)
_V_PARAM = re.compile(
r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)",
re.I,
@@ -60,7 +77,7 @@ def _family(blob: str) -> str | None:
return None
for fam in _FAMILIES:
if fam.replace("-", "") in u.replace("-", ""):
return fam
return _FAMILY_CANON.get(fam, fam)
return None
@@ -74,9 +91,16 @@ def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str:
return str(row.get("footprint") or comp.footprint or "")
def _norm_pin(s: str) -> str:
return str(s).strip().upper()
def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None) -> bool:
"""Named EP / thermal / split / numeric pin_count+1. Not USB-C A5/B5."""
s = str(number).strip()
pf = str(pinfunction or "").strip()
if _SH_PAD.match(s) or (pf and _SH_PAD.match(pf)):
return False
if _EP_PAD.match(s) or (pf and _EP_PAD.match(pf)):
return True
if s.lower().replace(" ", "") in {"thermalpad", "thermal"}:
@@ -85,35 +109,187 @@ def _is_ep_land(number: str, pinfunction: str = "", pin_count: int | None = None
return True
if pin_count and s.isdigit() and int(s) == pin_count + 1:
return True
if pin_count and pin_count <= 48 and re.match(r"^[A-Z]\d+$", s):
return True
return False
def _signal_pads(numbers: list[str], pin_count: int | None = None) -> set[str]:
out: set[str] = set()
for n in numbers:
s = str(n).strip()
if not s or _is_ep_land(s, pin_count=pin_count):
continue
out.add(s)
return out
def _is_shield_land(number: str, pinfunction: str = "") -> bool:
s = str(number).strip()
pf = str(pinfunction or "").strip()
return bool(_SH_PAD.match(s) or (pf and _SH_PAD.match(pf)))
def _pcb_signal_pads(fp_layout, pin_count: int | None = None) -> set[str]:
"""Component lands only. Vias are not in ``fp.pads``; EP/thermal names skip."""
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]:
"""TVSconnector pad distance. Vias are not pads. Skip without both lands."""
if layout is None:
return []
from backend.periscopex.constraints_lookup import match_constraints as match_cons
from backend.periscopex.si_check import _num, _quote
tvs = [
(ref, comp) for ref, comp in sorted(graph.components.items())
if _is_esd_part(comp)
]
connectors = [
(ref, comp) for ref, comp in sorted(graph.components.items())
if _is_j_connector(comp)
]
if not tvs or not connectors:
return []
out: list[Finding] = []
seen: set[tuple[str, str, str]] = set()
for j_ref, j_comp in connectors:
j_pads = _pad_xy(layout, j_ref)
if not j_pads:
continue
for d_ref, d_comp in tvs:
d_pads = _pad_xy(layout, d_ref)
if not d_pads:
continue
nets = sorted({
n for n in list(j_comp.pins.values()) + list(d_comp.pins.values()) if n
})
for net in nets:
if _skip_esd_net(net):
continue
key = (j_ref, d_ref, normalize_kicad_hierarchy_net(net))
if key in seen:
continue
dist = _min_pad_distance_mm(j_pads, d_pads, net)
if dist is None:
continue
seen.add(key)
lim = None
quote = ""
cons = match_cons(d_comp.mpn or d_comp.value, constraints_map or {})
if cons:
for rule in cons.layout_rules or []:
if str(rule.get("kind") or "") not in {"esd", "keepout"}:
continue
lim = _num(rule.get("max_distance_mm"))
quote = _quote(rule)
if lim is not None:
break
facts = (
f"pad_distance_mm={dist:.3f}; connector={j_ref}; tvs={d_ref}; "
f"net={net}; vias_not_used=1."
)
calc = f"min hypot between {j_ref} pads and {d_ref} pads = {dist:.3f} mm."
if lim is None:
rec = (
"Re-extract the TVS layout millimetre. "
"Do not assume a default ESD gap."
)
out.append(Finding(
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
finding=(
f"Unverified: {d_ref} is {dist:.2f} mm from {j_ref}"
"library has no ESD millimetre FACT."
),
facts=facts, calculation=calc,
requirement=(
"Datasheet ESD max_distance_mm from TVS to connector "
"(none on file)."
),
inference="INSUFFICIENT — not inventing an ESD keep-in millimetre.",
why="Distance is pad-to-pad; vias and tracks are not lands.",
status="INFO", recommendation=rec, action=rec,
source="esd_return_check", rule_id="PE-ESD-002",
finding_class="INFO", provenance="TYPICAL",
evidence_status="INSUFFICIENT", net=net, pins=[],
))
continue
fail = dist > lim + 1e-9
rec = (
f"Move {d_ref} within {lim:g} mm of {j_ref}."
if fail
else "TVS-to-connector distance meets the datasheet millimetre."
)
out.append(Finding(
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
finding=(
f"{'FAIL' if fail else 'PASS'}: {d_ref}{j_ref} "
f"{dist:.2f} mm (max {lim:g} mm)."
),
facts=facts, calculation=calc,
requirement=f"ESD max_distance_mm={lim:g} ({quote or 'layout_rules'}).",
inference="FAIL vs datasheet millimetre." if fail else "",
why="Pad-to-pad only; a via on the net is not the TVS land.",
status="WARNING" if fail else "INFO",
recommendation=rec, action=rec,
source="esd_return_check", rule_id="PE-ESD-002",
finding_class="RISK" if fail else "INFO",
provenance="RECOMMENDED" if fail else "TYPICAL",
evidence_status="SUFFICIENT", net=net, pins=[],
))
return out
@@ -152,6 +152,12 @@ def _seed() -> None:
requirement="Series resistor on the HS net vs datasheet ohms.")
_add("PE-SI-010", "TYPICAL", "INFO", domain="pcb",
requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.")
_add("PE-SI-007", "RECOMMENDED", "RISK", domain="pcb",
requirement="Dangling track stub vs datasheet millimetres; pad/via/zone are not stubs.")
_add("PE-SI-011", "TYPICAL", "REVIEW", domain="pcb",
requirement="Continuous GND pour under an HF pair when that pour exists (not IEC).")
_add("PE-SI-012", "TYPICAL", "INFO", domain="pcb",
requirement="BOM termination on an HF net is reported; missing terminator is a skip.")
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Keepout from layout_rules — not a DRC for pad copper.")
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
@@ -188,7 +194,7 @@ def _seed() -> None:
_add("PE-BOM-010", "MANDATORY", "RULE", domain="pcb",
requirement="BOM / PCB footprint family must match datasheet package.")
_add("PE-BOM-011", "MANDATORY", "RULE", domain="pcb",
requirement="Datasheet pin_count / pintable must match PCB pads.")
requirement="Datasheet pintable names must join KiCad lib and PCB pads (not pin_count).")
_add("PE-BOM-012", "MANDATORY", "RULE", domain="pcb",
requirement="Operating voltage must not exceed the sourced rating / abs-max.")
_add("PE-BOM-013", "MANDATORY", "RULE", domain="pcb",
@@ -224,6 +230,66 @@ 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.")
_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 "",
@@ -500,6 +500,7 @@ class LayoutVia(BaseModel):
y: float
net: str = ""
drill: float | None = None
layers: tuple[str, ...] = ()
class LayoutDielectric(BaseModel):
@@ -236,6 +236,38 @@ def _is_npth_pad(pad: object) -> bool:
return _pad_kind(pad) in {"np_thru_hole", "np_through_hole", "npth"}
def footprint_pad_ids(node: object) -> list[tuple[str, str]]:
"""``(number, pinfunction)`` lands. Via and NPTH are not pads."""
out: list[tuple[str, str]] = []
for pad in _kids(node, "pad"):
if _is_footprint_via(pad) or _is_npth_pad(pad):
continue
num = str(pad[1]) if len(pad) > 1 and not isinstance(pad[1], list) else ""
if not num:
continue
out.append((num, _val(pad, "pinfunction")))
return out
def parse_kicad_mod_pads(path: str | Path) -> list[tuple[str, str]] | None:
"""Pad ids from a ``.kicad_mod``. None if the file is missing or not a footprint."""
p = Path(path)
if not p.is_file():
return None
try:
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
except (OSError, ValueError):
return None
tag = _tag(tree)
if tag in {"footprint", "module"}:
return footprint_pad_ids(tree)
if tag == "kicad_pcb":
for node in tree[1:]:
if isinstance(node, list) and _tag(node) in {"footprint", "module"}:
return footprint_pad_ids(node)
return None
def _is_footprint_via(pad: object) -> bool:
"""Footprint child that is a via, never a component pin.
@@ -259,6 +291,15 @@ def _is_footprint_via(pad: object) -> bool:
return True
def _via_copper_layers(node: object) -> tuple[str, ...]:
"""KiCad ``(layers "F.Cu" "B.Cu")``. Empty if the file has no span."""
el = _kid(node, "layers")
if not el:
return ()
names = tuple(str(x) for x in el[1:] if not isinstance(x, list) and str(x).strip())
return names
def _via_from_node(
node: object,
nets: dict[str, int],
@@ -275,7 +316,7 @@ def _via_from_node(
rx, ry = _rotate(vx, vy, frot)
vx, vy = fx + rx, fy + ry
net = _net_name(node, nets)
return LayoutVia(x=vx, y=vy, net=net, drill=drill)
return LayoutVia(x=vx, y=vy, net=net, drill=drill, layers=_via_copper_layers(node))
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
@@ -376,7 +417,8 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
drill_el = _kid(pad, "drill")
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
vias.append(LayoutVia(
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad), drill=drill,
x=ax, y=ay, net=_net_name(pad, nets) or _pad_net(pad),
drill=drill, layers=_via_copper_layers(pad),
))
continue
if _is_npth_pad(pad):
+14 -1
View File
@@ -8,7 +8,7 @@ from collections import Counter
from backend.periscopex.derating import build_derating_table
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.emi_check import check_emi
from backend.periscopex.esd_return_check import check_esd, check_return_path
from backend.periscopex.esd_return_check import check_esd, check_esd_distance, check_return_path
from backend.periscopex.finding_engine import complete_findings
from backend.periscopex.functional_groups import FunctionalGroupsReport
from backend.periscopex.hierarchy import check_hierarchy
@@ -29,6 +29,12 @@ from backend.periscopex.pcb_power_thermal import (
from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.placement_check import check_placement
from backend.periscopex.interface_class_check import SOURCE as IF_SOURCE, check_interface_classes
from backend.periscopex.antenna_layout_check import check_antenna_layout
from backend.periscopex.hf_bus_class import check_memory_fpga_classes
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.pdn_check import check_pdn
from backend.periscopex.stackup_check import check_stackup
from backend.periscopex.usb_pd_check import check_usb_pd
from backend.periscopex.si_check import check_si
from backend.periscopex.spof_check import check_spof
from backend.periscopex.timing_check import check_timing
@@ -270,6 +276,7 @@ def run_pcb_checks(
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
("si_check", lambda: check_si(graph, constraints_map, layout, impedance_nets)),
("hf_line", lambda: check_hf_lines(graph, constraints_map, layout)),
("pcb_derating", lambda: check_pcb_derating(graph)),
("pcb_power_traces", lambda: check_pcb_power_traces(graph, constraints_map, layout)),
("pcb_via_current", lambda: check_pcb_via_current(graph, constraints_map, layout)),
@@ -280,12 +287,18 @@ def run_pcb_checks(
("timing_check", lambda: check_timing(graph, constraints_map)),
("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)),
("esd_check", lambda: check_esd(graph, constraints_map)),
("esd_distance", lambda: check_esd_distance(graph, constraints_map, layout)),
("return_path", lambda: check_return_path(graph, layout)),
("stackup_check", lambda: check_stackup(graph, constraints_map, layout)),
("usb_pd", lambda: check_usb_pd(graph)),
("antenna_layout", lambda: check_antenna_layout(graph, layout)),
("pdn_check", lambda: check_pdn(graph, layout, impedance_nets)),
("bom_pcb", lambda: check_bom_pcb_datasheet(graph, constraints_map, layout)),
("spof_check", lambda: check_spof(graph, constraints_map)),
("emi_check", lambda: check_emi(graph, constraints_map, layout)),
("pcb_junction_temp", lambda: check_pcb_junction_temp(graph, constraints_map, layout)),
("interface_class", lambda: check_interface_classes(graph)),
("hf_bus_class", lambda: check_memory_fpga_classes(graph)),
):
try:
out.extend(fn())
@@ -278,10 +278,37 @@ def check_pcb_via_current(
if key in seen:
continue
seen.add(key)
n, _drill = _via_stats(layout, net)
n, drill = _via_stats(layout, net)
if n > 0:
# Geometry is present; no via-ampacity table in the library — do not
# invent a rating or claim the vias were not parsed.
rec = (
"Supply a datasheet via current or plating thickness before judging "
"via ampacity. IPC via charts are not applied."
)
drill_txt = f"{drill:g}" if drill is not None else ""
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_power",
finding=(
f"{net} carries I_load={i_load:.3g} A on {n} via(s) "
f"(min drill {drill_txt} mm); no via ampacity table in the library."
),
why="I from datasheet; via count and drill from the board. No invented IPC via k.",
status="INFO",
recommendation=rec,
action=rec,
source="pcb_power_thermal",
rule_id="PE-VIA-002",
evidence_status="INSUFFICIENT",
facts=(
f"I_load={i_load:.3g} A; via_count={n}; min_drill_mm={drill_txt}; "
f"board_vias={len(layout.vias)}."
),
requirement="Via ampacity table is not in the library (IPC via chart not applied).",
inference="INSUFFICIENT — not inventing via ampacity from an IPC chart.",
net=net,
pins=[],
))
continue
out.append(Finding(
designator=ref,
@@ -0,0 +1,42 @@
"""PDN Z(f). Skip without a frequency-domain FACT. SI Z0 is not PDN."""
from __future__ import annotations
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph
SOURCE = "pdn_check"
def check_pdn(
graph: DesignGraph,
layout: LayoutGraph | None,
impedance_nets: list[dict] | dict | None = None,
) -> list[Finding]:
"""Require explicit PDN Z(f) rows. ImpedenceFinder SI Z0 is not Z(f)."""
_ = graph
_ = layout
raw = impedance_nets
if isinstance(impedance_nets, dict):
raw = list(impedance_nets.get("pdn") or impedance_nets.get("pdn_nets") or [])
rows = [r for r in (raw or []) if isinstance(r, dict)]
pdn = [
r for r in rows
if str(r.get("kind") or "").lower() == "pdn"
or r.get("frequency_hz") is not None
or r.get("z_ohm_at_hz") is not None
]
if not pdn:
return []
rec = "Use measured PDN Z(f); do not invent a target impedance."
facts = f"pdn_rows={len(pdn)}."
return [Finding(
designator="PDN", mpn="", aspect="pdn",
finding=f"PDN Z(f) FACT present ({len(pdn)} row(s)).",
facts=facts,
requirement="PDN Z(f) is reported only with frequency-domain measurements.",
inference="No target Z(f) is invented.",
why="PDN is last; SI differential Z0 is a different check.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-PDN-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", pins=[],
)]
+22 -3
View File
@@ -1,9 +1,9 @@
"""SI checks: datasheet layout_rules vs board + ImpedenceFinder.
USB2 / USB3 SuperSpeed / Ethernet MDI / RGMII / SGMII / DDR3 / HDMI /
PCIe / LVDS are checked only against a rule whose quote or ``net_class``
names that bus. I2C, GPIO, EN, analog REGN, USB CC, and strap/EN RC are
not HS pairs. No invented USB/IEC 90 Ω.
PCIe / LVDS / MIPI / HF clock are checked only against a rule whose quote
or ``net_class`` names that bus. I2C, GPIO, EN, analog REGN, USB CC,
strap/EN RC, and crystal XTAL nets are not HS pairs. No invented USB/IEC 90 Ω.
"""
from __future__ import annotations
@@ -30,6 +30,11 @@ _SKIP_RE = re.compile(
re.I,
)
_XTAL_RE = re.compile(
r"XTAL|OSCIN|OSCOUT|HFXIN|LFXIN|\bXIN\b|\bXOUT\b",
re.I,
)
_HS_CLASS_RE = (
("usb3", re.compile(
r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]|SS[_]?RX", re.I,
@@ -43,6 +48,7 @@ _HS_CLASS_RE = (
r"ETH.?(?:TD|RD|TX|RX|TP)[+\-_PN0-3]|1000BASE|RJ45",
re.I,
)),
("mipi", re.compile(r"MIPI|\bDSI\b|\bCSI\b|CSI[_-]|DSI[_-]", re.I)),
("lvds", re.compile(r"LVDS", re.I)),
("ddr3_clk", re.compile(r"DDR3?.*CK|(?:^|[_/])CK[_]?[PN](?:$|[_/])", re.I)),
("ddr3_dqs", re.compile(r"DQS", re.I)),
@@ -51,6 +57,10 @@ _HS_CLASS_RE = (
r"DDR3?.*(?:A\d+|ADDR|BA\d+|RAS|CAS|WE|ODT|CKE|(?:^|[_/])CS)",
re.I,
)),
("hf_clk", re.compile(
r"GTX_CLK|(?:^|[_/])CLK[_]?[PN](?:$|[_/])|(?:^|[_/])MCLK[_]?[PN]",
re.I,
)),
)
_STRAP_RULE_RE = re.compile(
@@ -94,6 +104,11 @@ _BUS_TOKEN_EXPAND: dict[str, frozenset[str]] = {
"hdmi": frozenset({"hdmi"}),
"pcie": frozenset({"pcie"}),
"lvds": frozenset({"lvds"}),
"mipi": frozenset({"mipi"}),
"dsi": frozenset({"mipi"}),
"csi": frozenset({"mipi"}),
"hf_clk": frozenset({"hf_clk"}),
"clock": frozenset({"hf_clk"}),
}
_SI_KINDS = frozenset({
@@ -151,6 +166,8 @@ def bus_class(net: str) -> str | None:
if skip_si_net(net):
return None
leaf = _leaf(net)
if _XTAL_RE.search(leaf):
return None
u = leaf.upper()
if re.search(r"USB3|SSTX|SSRX|USB[_]?SS|SS[_]?T[XR]", u):
return "usb3"
@@ -217,6 +234,8 @@ def _rule_target_buses(rule: dict) -> frozenset[str]:
(r"hdmi", "hdmi"),
(r"pcie|pci[\s-]*express", "pcie"),
(r"lvds", "lvds"),
(r"mipi|\bdsi\b|\bcsi\b", "mipi"),
(r"gtx_clk|hf\s*clock|ref\s*clk", "hf_clk"),
(r"usb\s*2|d\s*\+|d\s*|d\s*-|dp\s*/\s*dm|dp/dm", "usb2"),
(r"\busb\b", "usb2"),
(r"\bethernet\b|\beth\b", "eth_mdi"),
@@ -0,0 +1,63 @@
"""Stackup vs fab spec. Skip without a sourced fab FACT. Never default 1 oz."""
from __future__ import annotations
from backend.periscopex.constraints_lookup import match_constraints as _match_constraints
from backend.periscopex.models import ComponentType, DesignGraph, Finding, LayoutGraph
from backend.periscopex.si_check import _num, _quote
SOURCE = "stackup_check"
def check_stackup(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or layout.stackup is None:
return []
board_t = layout.stackup.copper_thickness_mm
fab_t = None
quote = ""
for _ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
if not cons:
continue
for rule in cons.layout_rules or []:
if str(rule.get("kind") or "") != "stackup":
continue
fab_t = _num(rule.get("copper_thickness_mm"))
quote = _quote(rule)
if fab_t is not None:
break
if fab_t is not None:
break
if fab_t is None or board_t is None or board_t <= 0:
return []
same = abs(board_t - fab_t) <= 1e-9
rec = (
"Match the board stackup copper thickness to the fab spec, then re-run."
if not same else "Board copper thickness matches the fab spec."
)
return [Finding(
designator="layout", mpn="", aspect="stackup",
finding=(
f"{'PASS' if same else 'REVIEW'}: board copper_thickness_mm={board_t:g} "
f"vs fab {fab_t:g} mm."
),
facts=(
f"board_copper_thickness_mm={board_t:g}; fab_copper_thickness_mm={fab_t:g}; "
f"copper_layers={layout.stackup.copper_layers}."
),
requirement=f"Fab stackup copper thickness {fab_t:g} mm ({quote or 'layout_rules'}).",
inference="" if same else "REVIEW — no invented 1 oz default or IPC copper weight.",
why="Compare parsed KiCad stackup to a sourced fab thickness only.",
status="INFO" if same else "WARNING",
recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-STK-001",
finding_class="INFO" if same else "REVIEW",
provenance="TYPICAL",
evidence_status="SUFFICIENT", pins=[],
)]
@@ -0,0 +1,41 @@
"""USB-PD power contract. Skip without a PD controller. Not SI. Not USB2 Rd."""
from __future__ import annotations
import re
from backend.periscopex.models import ComponentType, DesignGraph, Finding
SOURCE = "usb_pd_check"
_PD_RE = re.compile(
r"FUSB302|STUSB|IP2721|TPS257|CYPD|CH224|HUSB238|"
r"USB[\s\-]?PD|POWER\s*DELIVERY",
re.I,
)
def check_usb_pd(graph: DesignGraph) -> list[Finding]:
chips = [
c for c in graph.components.values()
if c.component_type == ComponentType.IC and _PD_RE.search(
" ".join(str(x or "") for x in (c.mpn, c.value, c.component_subtype))
)
]
if not chips:
return []
out: list[Finding] = []
rec = "No USB-PD contract change from this class FACT."
for comp in sorted(chips, key=lambda c: c.reference):
out.append(Finding(
designator=comp.reference, mpn=comp.mpn or "", aspect="usb_pd",
finding=f"{comp.reference} USB-PD controller is on this graph.",
facts=f"mpn={comp.mpn!r}; value={comp.value!r}.",
requirement="USB-PD contract runs only with a PD IC FACT — not USB2 Rd/CC.",
inference="PDO voltages are not invented; missing PDO is a skip.",
why="USB-PD is a power contract, not SuperSpeed SI.",
status="INFO", recommendation=rec, action=rec, source=SOURCE,
rule_id="PE-PD-001", finding_class="INFO", provenance="TYPICAL",
evidence_status="SUFFICIENT", pins=[],
))
return out
+122
View File
@@ -0,0 +1,122 @@
"""Standalone component library API — browse, import PDF, update cards.
No project id. Not admin/users. Analysis pipeline keeps using library_has_*.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from backend.routers.deps import get_storage
from backend.services import library as lib_svc
from backend.services.library import LibraryError, MAX_DATASHEET_BYTES
log = logging.getLogger(__name__)
router = APIRouter(tags=["library"])
class LibraryCheckRequest(BaseModel):
ic_mpns: list[str] = []
passive_mpns: list[str] = []
simple_mpns: list[str] = []
@router.post("/library/check")
async def check_library(body: LibraryCheckRequest, request: Request):
storage = get_storage(request)
ics = [mpn for mpn in body.ic_mpns if lib_svc.library_has_extraction(storage, mpn)]
patterns = lib_svc.load_library_patterns(storage) if body.passive_mpns else []
passives: list[str] = []
if body.passive_mpns:
from backend.periscopex.resolve_passives import resolve_mpn
passives = [
mpn for mpn in body.passive_mpns
if resolve_mpn(mpn, patterns) is not None
or lib_svc.library_has_passive_model(storage, mpn) is not None
]
simples = [mpn for mpn in body.simple_mpns if lib_svc.library_has_model(storage, mpn)]
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
sheets = [
mpn for mpn in wanted
if lib_svc.library_has_datasheet(storage, mpn, patterns=patterns)
]
return {
"ic_resolved": ics,
"passive_resolved": passives,
"simple_resolved": simples,
"datasheets_available": sheets,
}
@router.get("/library")
async def get_library(request: Request):
storage = get_storage(request)
return JSONResponse(
content=lib_svc.list_library_catalog(storage),
headers={"Cache-Control": "no-store"},
)
@router.get("/library/datasheet/{mpn:path}")
async def get_library_datasheet(mpn: str, request: Request):
storage = get_storage(request)
key = lib_svc.library_has_datasheet(storage, mpn)
if not key:
raise HTTPException(404, f"Datasheet not in library: {mpn}")
return Response(
content=storage.read_bytes(key),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
)
@router.post("/library/datasheets")
async def post_library_datasheet(
request: Request,
mpn: str = Form(...),
file: UploadFile = File(...),
kind: str = Form("ic"),
):
raw = await file.read()
if len(raw) > MAX_DATASHEET_BYTES:
raise HTTPException(
413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
)
try:
row = lib_svc.import_component_from_datasheet(
get_storage(request), mpn, raw, kind=kind,
)
except LibraryError as exc:
raise HTTPException(400, str(exc)) from exc
return row
@router.get("/library/components/{component_type}/{name:path}")
async def get_library_component(component_type: str, name: str, request: Request):
try:
data = lib_svc.get_component(get_storage(request), component_type, name)
except LibraryError as exc:
raise HTTPException(400, str(exc)) from exc
if data is None:
raise HTTPException(404, f"Component not found: {name}")
return JSONResponse(content=data, headers={"Cache-Control": "no-store"})
@router.put("/library/components/{component_type}/{name:path}")
async def put_library_component(
component_type: str, name: str, request: Request,
):
payload = await request.json()
try:
body = lib_svc.update_component(
get_storage(request), component_type, name, payload,
)
except LibraryError as exc:
raise HTTPException(400, str(exc)) from exc
return JSONResponse(content=body)
+2 -61
View File
@@ -1,4 +1,4 @@
"""Project CRUD, library lookup, and file ingestion."""
"""Project CRUD and file ingestion."""
from __future__ import annotations
@@ -181,65 +181,6 @@ def _passive_hit_json(mpn: str, safe: str, model_data: dict, *, cached: bool, lc
}
# --- Library ---
class LibraryCheckRequest(BaseModel):
ic_mpns: list[str] = []
passive_mpns: list[str] = []
simple_mpns: list[str] = []
@router.post("/library/check")
async def check_library(body: LibraryCheckRequest, request: Request):
storage = get_storage(request)
ics = [mpn for mpn in body.ic_mpns if proj_svc.library_has_extraction(storage, mpn)]
patterns = proj_svc.load_library_patterns(storage) if body.passive_mpns else []
passives: list[str] = []
if body.passive_mpns:
from backend.periscopex.resolve_passives import resolve_mpn
passives = [
mpn for mpn in body.passive_mpns
if resolve_mpn(mpn, patterns) is not None
or proj_svc.library_has_passive_model(storage, mpn) is not None
]
simples = [mpn for mpn in body.simple_mpns if proj_svc.library_has_model(storage, mpn)]
wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns)
sheets = [
mpn for mpn in wanted
if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns)
]
return {
"ic_resolved": ics,
"passive_resolved": passives,
"simple_resolved": simples,
"datasheets_available": sheets,
}
@router.get("/library")
async def get_library(request: Request):
storage = get_storage(request)
return JSONResponse(
content=proj_svc.list_library_catalog(storage),
headers={"Cache-Control": "no-store"},
)
@router.get("/library/datasheet/{mpn:path}")
async def get_library_datasheet(mpn: str, request: Request):
storage = get_storage(request)
key = proj_svc.library_has_datasheet(storage, mpn)
if not key:
raise HTTPException(404, f"Datasheet not in library: {mpn}")
return Response(
content=storage.read_bytes(key),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
)
# --- CRUD ---
@@ -775,7 +716,7 @@ async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = No
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
)
except Exception:
pass
log.exception("library remember after auto-fetch failed for %s", mpn)
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
+425
View File
@@ -0,0 +1,425 @@
"""Shared component library — catalog, datasheet import, component JSON.
Not project CRUD. Analysis reads this store; it is not the only writer.
"""
from __future__ import annotations
import logging
from pathlib import Path
from backend.periscopex.library_gate import should_promote_extraction
from backend.periscopex.utils import safe_mpn
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet, store_datasheet_bytes
from backend.services.storage import LocalStorageBackend, StorageBackend
log = logging.getLogger(__name__)
MAX_DATASHEET_BYTES = 30 * 1024 * 1024
COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple")
# Import-from-PDF kinds. "passive" is a series regex, not a single part.
IMPORT_KINDS = ("ic", "passive_part", "simple")
INBOX_PREFIX = "library/inbox/"
class LibraryError(ValueError):
"""Caller-visible library failure (bad MPN, not a PDF, empty pintable)."""
def _specs_param_count(specs: dict) -> int:
if not isinstance(specs, dict):
return 0
values = specs.get("values")
if isinstance(values, dict):
return sum(1 for v in values.values() if v not in (None, "", []))
skip = {"specs_type", "component_subtype"}
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
specs = data.get("specs", {}) or {}
return {
"mpn": mpn,
"type": row_type,
"specs_type": specs.get("specs_type", ""),
"subtype": specs.get("component_subtype", ""),
"param_count": _specs_param_count(specs),
}
def component_key(component_type: str, name: str) -> str:
if component_type not in COMPONENT_TYPES:
raise LibraryError(f"Unknown component type: {component_type}")
safe = safe_mpn(name)
if not safe:
raise LibraryError("Invalid MPN")
if component_type == "ic":
return f"library/extracted/{safe}.json"
if component_type == "passive":
return f"library/patterns/{safe}.json"
if component_type == "passive_part":
return f"library/passives/{safe}.json"
return f"library/models/{safe}.json"
def library_has_extraction(
storage: StorageBackend, mpn: str, min_version: str | None = None,
) -> str | None:
key = f"library/extracted/{safe_mpn(mpn)}.json"
if not storage.exists(key):
return None
data = storage.read_json(key)
if not data.get("pintable"):
return None
if min_version:
from backend.services.admin_settings import version_is_stale
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
return None
return key
def library_has_datasheet(
storage: StorageBackend, mpn: str, patterns: list | None = None,
) -> str | None:
hit = resolve_datasheet(storage, mpn)
if hit:
return hit
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
if storage.exists(legacy):
return legacy
if patterns:
from backend.periscopex.resolve_passives import resolve_mpn
match = resolve_mpn(mpn, patterns)
if match is not None:
ds = match[0].datasheet_key
if ds and storage.exists(ds):
return ds
return None
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
key = f"library/models/{safe_mpn(mpn)}.json"
return key if storage.exists(key) else None
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
safe = safe_mpn(mpn)
key = f"library/passives/{safe}.json"
if storage.exists(key):
return key
legacy = f"library/models/{safe}.json"
return legacy if storage.exists(legacy) else None
def inbox_key(name: str) -> str:
safe = safe_mpn(name)
if not safe:
raise LibraryError("Invalid MPN")
return f"{INBOX_PREFIX}{safe}.json"
def drop_inbox(storage: StorageBackend, name: str) -> None:
"""Remove the pending IC card once a real pintable exists."""
try:
key = inbox_key(name)
except LibraryError:
return
if storage.exists(key):
storage.delete_key(key)
def save_to_library(
storage: StorageBackend, src_key: str, category: str, filename: str,
) -> str:
dst = f"library/{category}/{filename}"
storage.copy_object(src_key, dst)
if category == "extracted":
drop_inbox(storage, filename.replace(".json", ""))
return dst
def import_datasheet(
storage: StorageBackend,
mpn: str,
data: bytes,
extra_mpns: list[str] | None = None,
) -> dict:
"""Store a PDF under the shared library. No project and no exam required."""
name = (mpn or "").strip()
if not name:
raise LibraryError("MPN is required")
if not data:
raise LibraryError("Empty file")
if len(data) > MAX_DATASHEET_BYTES:
raise LibraryError(
f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
)
if not data.startswith(b"%PDF"):
raise LibraryError("File is not a PDF")
blob = store_datasheet_bytes(storage, data, name, extra_mpns=extra_mpns)
return {
"mpn": name,
"blob_key": blob,
"has_extraction": library_has_extraction(storage, name) is not None,
"has_model": (
library_has_model(storage, name) is not None
or library_has_passive_model(storage, name) is not None
),
}
def _register_ic_card(storage: StorageBackend, name: str) -> dict:
"""Pending IC card. Empty pintable never goes to library/extracted."""
if library_has_extraction(storage, name) is not None:
return {"component_type": "ic", "status": "extracted"}
key = inbox_key(name)
if not storage.exists(key):
storage.write_json(key, {
"mpn": name,
"kind": "ic",
"status": "needs_pintable",
"source": "library_import",
"pintable": [],
"absolute_maximum_ratings": [],
"rules": [],
})
return {"component_type": "ic", "status": "needs_pintable"}
def _register_model_card(
storage: StorageBackend, name: str, kind: str,
) -> dict:
key = component_key(kind, name)
if kind == "passive_part" and not storage.exists(key):
legacy = f"library/models/{safe_mpn(name)}.json"
if storage.exists(legacy):
return {"component_type": kind, "status": "registered"}
if not storage.exists(key):
specs_type = "passive" if kind == "passive_part" else "discrete"
storage.write_json(key, {
"mpn": name,
"specs": {"specs_type": specs_type, "values": {}},
})
return {"component_type": kind, "status": "registered"}
def import_component_from_datasheet(
storage: StorageBackend,
mpn: str,
data: bytes,
kind: str = "ic",
extra_mpns: list[str] | None = None,
) -> dict:
"""PDF + MPN → library component. No project, no MODE=run/pcb."""
kind_n = (kind or "ic").strip().lower()
if kind_n not in IMPORT_KINDS:
raise LibraryError(f"Unknown component kind: {kind}")
row = import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
if kind_n == "ic":
extra = _register_ic_card(storage, row["mpn"])
else:
extra = _register_model_card(storage, row["mpn"], kind_n)
row.update(extra)
row["has_extraction"] = library_has_extraction(storage, row["mpn"]) is not None
row["has_model"] = (
library_has_model(storage, row["mpn"]) is not None
or library_has_passive_model(storage, row["mpn"]) is not None
)
return row
def remember_datasheet(
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
) -> None:
"""Exam/pipeline path: log and keep going if the shared store fails."""
try:
import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
except Exception:
log.exception("library datasheet store failed for %s", mpn)
def get_component(storage: StorageBackend, component_type: str, name: str) -> dict | None:
key = component_key(component_type, name)
if component_type == "passive_part" and not storage.exists(key):
key = f"library/models/{safe_mpn(name)}.json"
if storage.exists(key):
return storage.read_json(key)
if component_type == "ic" and storage.exists(inbox_key(name)):
return storage.read_json(inbox_key(name))
return None
def update_component(
storage: StorageBackend, component_type: str, name: str, payload: dict,
) -> dict:
if not isinstance(payload, dict):
raise LibraryError("Component body must be a JSON object")
body = dict(payload)
body.setdefault("mpn", name.strip())
if str(body.get("mpn") or "").strip() != name.strip():
raise LibraryError("Body MPN must match the path")
if component_type == "ic":
ok, reason = should_promote_extraction(body)
if not ok:
raise LibraryError(f"IC rejected: {reason}")
body.pop("kind", None)
body.pop("status", None)
if body.get("source") == "library_import":
body.pop("source")
key = component_key(component_type, name)
storage.write_json(key, body)
if component_type == "ic":
drop_inbox(storage, name)
return body
def list_library_catalog(storage: StorageBackend) -> dict:
ics: list[dict] = []
seen_ic: set[str] = set()
for key in storage.list_prefix("library/extracted/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ic:
continue
seen_ic.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype", ""),
"pin_count": len(data.get("pintable", [])),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
"library_status": "extracted",
})
except Exception:
log.exception("skip unreadable IC catalog key %s", key)
seen_inbox: set[str] = set()
for key in storage.list_prefix(INBOX_PREFIX):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if not mpn or mpn in seen_ic:
continue
seen_ic.add(mpn)
seen_inbox.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype") or "",
"pin_count": len(data.get("pintable") or []),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
"library_status": data.get("status") or "needs_pintable",
})
except Exception:
log.exception("skip unreadable inbox catalog key %s", key)
passives: list[dict] = []
seen_p: set[str] = set()
for key in storage.list_prefix("library/patterns/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
if name in seen_p:
continue
seen_p.add(name)
passives.append({
"mpn": name,
"type": "passive",
"subtype": data.get("component_type", ""),
"description": data.get("description", ""),
"regex": data.get("regex", ""),
})
except Exception:
log.exception("skip unreadable pattern catalog key %s", key)
simple_models: list[dict] = []
passive_parts: list[dict] = []
seen_m: set[str] = set()
for prefix, row_type, dest in (
("library/passives/", "passive_part", passive_parts),
("library/models/", "simple", simple_models),
):
for key in storage.list_prefix(prefix):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
row = _catalog_model_row(data, key, row_type=row_type)
if row["mpn"] in seen_m:
continue
seen_m.add(row["mpn"])
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
dest.append(row)
except Exception:
log.exception("skip unreadable model catalog key %s", key)
datasheets: list[dict] = []
seen_d: set[str] = set()
for key in storage.list_prefix(REF_PREFIX):
if not key.endswith(".json"):
continue
try:
ref = storage.read_json(key)
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_d:
continue
seen_d.add(mpn)
datasheets.append({
"mpn": mpn,
"hash": ref.get("hash"),
"has_extraction": mpn in seen_ic and mpn not in seen_inbox,
"has_model": mpn in seen_m,
"has_component": mpn in seen_ic or mpn in seen_m,
})
except Exception:
log.exception("skip unreadable datasheet ref %s", key)
ics.sort(key=lambda r: r["mpn"].lower())
passives.sort(key=lambda r: r["mpn"].lower())
passive_parts.sort(key=lambda r: r["mpn"].lower())
simple_models.sort(key=lambda r: r["mpn"].lower())
datasheets.sort(key=lambda r: r["mpn"].lower())
return {
"ics": ics,
"passives": passives,
"passive_parts": passive_parts,
"simple": simple_models,
"datasheets": datasheets,
}
def list_library_patterns(storage: StorageBackend) -> list[str]:
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
def load_library_patterns(storage: StorageBackend):
from backend.periscopex.resolve_passives import load_patterns
if isinstance(storage, LocalStorageBackend):
d = storage._path("library/patterns")
if not d.is_dir():
return []
return load_patterns(str(d))
import tempfile
keys = list_library_patterns(storage)
if not keys:
return []
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "patterns"
dest.mkdir()
for key in keys:
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
return load_patterns(str(dest))
+56 -2
View File
@@ -1,8 +1,9 @@
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
Stages: ensure_graph parse_pcb classify inventory checks
ai_review write_report. Uses ``pcb_status`` so analysis ``status`` is
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
af_trace af_ai ai_review write_report. Uses ``pcb_status`` so analysis ``status`` is
untouched. ``af_trace`` and ``af_ai`` are additive and do not replace
``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
"""
from __future__ import annotations
@@ -27,6 +28,8 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh
from backend.periscopex.models import (
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
)
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
@@ -162,6 +165,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 +306,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
+11 -221
View File
@@ -15,6 +15,17 @@ from typing import Any
from pydantic import AliasChoices, BaseModel, Field
from backend.periscopex.utils import natural_sort_key, safe_mpn
from backend.services.library import (
library_has_datasheet,
library_has_extraction,
library_has_model,
library_has_passive_model,
list_library_catalog,
list_library_patterns,
load_library_patterns,
remember_datasheet,
save_to_library,
)
from backend.services.storage import StaleGeneration, StorageBackend
log = logging.getLogger(__name__)
@@ -692,17 +703,6 @@ def save_pcb(
return key
def remember_datasheet(
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
) -> None:
try:
from backend.services.datasheet_store import store_datasheet_bytes
store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns)
except Exception:
log.exception("library datasheet store failed for %s", mpn)
def save_datasheet(
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes,
) -> str:
@@ -738,213 +738,3 @@ def get_datasheet_key(
) -> str | None:
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe_mpn(mpn)}.pdf"
return key if storage.exists(key) else None
def library_has_extraction(
storage: StorageBackend, mpn: str, min_version: str | None = None,
) -> str | None:
key = f"library/extracted/{safe_mpn(mpn)}.json"
if not storage.exists(key):
return None
data = storage.read_json(key)
if not data.get("pintable"):
return None
if min_version:
from backend.services.admin_settings import version_is_stale
if version_is_stale(data.get("model_version", "0.0.0"), min_version):
return None
return key
def library_has_datasheet(
storage: StorageBackend, mpn: str, patterns: list | None = None,
) -> str | None:
from backend.services.datasheet_store import resolve_datasheet
hit = resolve_datasheet(storage, mpn)
if hit:
return hit
legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf"
if storage.exists(legacy):
return legacy
if patterns:
from backend.periscopex.resolve_passives import resolve_mpn
match = resolve_mpn(mpn, patterns)
if match is not None:
ds = match[0].datasheet_key
if ds and storage.exists(ds):
return ds
return None
def library_has_model(storage: StorageBackend, mpn: str) -> str | None:
key = f"library/models/{safe_mpn(mpn)}.json"
return key if storage.exists(key) else None
def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
safe = safe_mpn(mpn)
key = f"library/passives/{safe}.json"
if storage.exists(key):
return key
legacy = f"library/models/{safe}.json"
return legacy if storage.exists(legacy) else None
def save_to_library(
storage: StorageBackend, src_key: str, category: str, filename: str,
) -> str:
dst = f"library/{category}/{filename}"
storage.copy_object(src_key, dst)
return dst
def _specs_param_count(specs: dict) -> int:
if not isinstance(specs, dict):
return 0
values = specs.get("values")
if isinstance(values, dict):
return sum(1 for v in values.values() if v not in (None, "", []))
skip = {"specs_type", "component_subtype"}
return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", []))
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
specs = data.get("specs", {}) or {}
return {
"mpn": mpn,
"type": row_type,
"specs_type": specs.get("specs_type", ""),
"subtype": specs.get("component_subtype", ""),
"param_count": _specs_param_count(specs),
}
def list_library_catalog(storage: StorageBackend) -> dict:
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
ics: list[dict] = []
seen_ic: set[str] = set()
for key in storage.list_prefix("library/extracted/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_ic:
continue
seen_ic.add(mpn)
ics.append({
"mpn": mpn,
"type": "ic",
"subtype": data.get("component_subtype", ""),
"pin_count": len(data.get("pintable", [])),
"has_ratings": bool(data.get("absolute_maximum_ratings")),
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
})
except Exception:
continue
passives: list[dict] = []
seen_p: set[str] = set()
for key in storage.list_prefix("library/patterns/"):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
if name in seen_p:
continue
seen_p.add(name)
passives.append({
"mpn": name,
"type": "passive",
"subtype": data.get("component_type", ""),
"description": data.get("description", ""),
"regex": data.get("regex", ""),
})
except Exception:
continue
simple_models: list[dict] = []
passive_parts: list[dict] = []
seen_m: set[str] = set()
for prefix, row_type, dest in (
("library/passives/", "passive_part", passive_parts),
("library/models/", "simple", simple_models),
):
for key in storage.list_prefix(prefix):
if not key.endswith(".json"):
continue
try:
data = storage.read_json(key)
row = _catalog_model_row(data, key, row_type=row_type)
if row["mpn"] in seen_m:
continue
seen_m.add(row["mpn"])
row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"]))
dest.append(row)
except Exception:
continue
datasheets: list[dict] = []
seen_d: set[str] = set()
for key in storage.list_prefix(REF_PREFIX):
if not key.endswith(".json"):
continue
try:
ref = storage.read_json(key)
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
if mpn in seen_d:
continue
seen_d.add(mpn)
datasheets.append({
"mpn": mpn,
"hash": ref.get("hash"),
"has_extraction": mpn in seen_ic,
"has_model": mpn in seen_m,
})
except Exception:
continue
ics.sort(key=lambda r: r["mpn"].lower())
passives.sort(key=lambda r: r["mpn"].lower())
passive_parts.sort(key=lambda r: r["mpn"].lower())
simple_models.sort(key=lambda r: r["mpn"].lower())
datasheets.sort(key=lambda r: r["mpn"].lower())
return {
"ics": ics,
"passives": passives,
"passive_parts": passive_parts,
"simple": simple_models,
"datasheets": datasheets,
}
def list_library_patterns(storage: StorageBackend) -> list[str]:
return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")]
def load_library_patterns(storage: StorageBackend):
from backend.periscopex.resolve_passives import load_patterns
from backend.services.storage import LocalStorageBackend
if isinstance(storage, LocalStorageBackend):
d = storage._path("library/patterns")
if not d.is_dir():
return []
return load_patterns(str(d))
import tempfile
keys = list_library_patterns(storage)
if not keys:
return []
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / "patterns"
dest.mkdir()
for key in keys:
storage.download_to_local(key, dest / key.rsplit("/", 1)[-1])
return load_patterns(str(dest))
@@ -17,7 +17,10 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S
| PE-USBC-001…005 | c’è un receptacle USB-C | classe USB2/USB3, CC1/CC2, Rd/Rp 5.1 kΩ, VBUS/GND, SuperSpeed solo se USB3 |
| PE-ETH-001…004 | c’è un RJ45 | 10/100 vs GbE, coppie TX/RX o 4 MDI, magnetics se GbE, Bob Smith REVIEW |
| PE-POE-001…003 | evidenza PoE sul jack | classe/tipo se citati; isolamento/magnetics; **skip** se RJ45 nudo |
| PE-POE-004 | PoE + `isolation_v` | tensione di isolamento solo se sourced; niente IEC inventato |
| PE-DDR-* | solo se c’è un dispositivo DDR | non inventato su HubAudio (nessun DDR in BOM) |
| PE-CPU-* | solo se c’è un bus parallelo D/A/controllo | SRAM/NOR/FMC/mux AD; niente bus inventato |
| PE-FPGA-* | solo se c’è un FPGA | bank/IO rails e flash di config se presenti; niente FPGA inventata |
Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. LIA spiega i FACT, non certifica. Nessun I inventato: PE-PWR **skip** se il datasheet non riporta I_load/Imax/I_abs.
+5
View File
@@ -57,7 +57,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
| PE-SI-001 | `length_match` mm on USB/HDMI/PCIe/ETH/LVDS/DDR nets (not I2C/GPIO/CC) |
| PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window |
| PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number |
| PE-SI-007 | dangling **track** stub vs datasheet mm; via ≠ pad ≠ zone; INFO if no mm |
| PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) |
| PE-SI-011 | GND-pour gap under HF pair when stackup + pour exist (REVIEW, not IEC) |
| PE-SI-012 | BOM terminator on HF net (FACT); skip if none |
| PE-PWR-001 | I_load/Imax/I_abs + width; **skip** se manca I (no 500 mA, no INFO); IPC solo con thickness e Tjmax |
| PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR |
| PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN |
@@ -115,6 +118,8 @@ Gate: library `layout_rules` kind + `net_class`/quote on that bus. Empty cell =
| SGMII | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| DDR3 CLK/DQS/DQ/ADDR | gated | gated | gated | gated | gated | gated | gated | gated | per-subclass if no Z |
| HDMI / PCIe / LVDS | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| MIPI DSI/CSI | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| HF clock (not XTAL) | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
| I2C / GPIO / EN / CC | skip | skip | skip | skip | skip | skip | skip | skip | skip |
Emmaforo USB: extract with **no Z number** → Zavg ~88 Ω is **PE-SI-010** INFO (insufficient), **not** a 90 Ω FAIL. If library later has 90 Ω ±10% (8199), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists. PE-SI-009 must not fire on USB from ESP32 EN RC 10 kΩ / 1 µF p.28.
@@ -2,6 +2,80 @@
What's new in Periscope.
## 2.63.5 — 2026-09-22 — AF trace analysis (trigger, cascade, visible skips)
PCB exam adds **AF trace analysis** after unchanged `run_pcb_checks` (SI/HF stay). Trigger is λ/10 or tr/(6 tpd) from FACT only. Differential pairs are one unit. Z0/Zdiff only vs a **datasheet** window. Missing tr/f/εr/stackup/Z target/via span is a visible INSUFFICIENT finding («Pista ad alta frequenza non controllata per mancanza di …»), not invented 50/90 Ω. Trigger false → no AF finding. Numerical cascade is lossless RLGC sections (ImpedenceFinder Z0); not OpenEMS/FEM. Python. PE-AF-001 AI path unchanged.
- [New] `check_af_traces` / `af_trigger` / `af_rlgc`; pipeline stage `af_trace`.
- [New] `PE-AF-002` visible skip; `PE-AF-020``061` on triggered nets.
- [New] Via `(layers …)` parsed on `LayoutVia` (span; size is not a track).
- [Changed] Changelog / PCB progress lists AF trace before AF+AI.
## 2.63.4 — 2026-09-22 — Report Error group, J2, U18 max severity
The findings tree always has an **Error** folder when ERROR findings are visible. Group badge is the worst child (U18 PE-PLC-002 ERROR is not labeled WARNING). Connectors (J2) sit in the same sidebar as U*. PE-PLC stays in the PCB exam bucket. No auth change.
- [Fixed] Status folders Error / Warning / Info; severity = max of children.
- [Fixed] J2 and other non-U refs listed with ICs.
- [Fixed] `isPcbExamFinding` matches layout findings (PE-PLC with PE-BOM).
## 2.63.3 — 2026-09-22 — Module die, truncated QFN pintable, QFN=VQFN
HubAudio software leftovers on PE-BOM-010/011: a WROOM module is not the ESP32 die pintable; a QFN-48 land is not wrong because extraction stopped at pins 124; VQFN-24 4×4 is the same QFN family as datasheet QFN-24. Extra signal pads above the declared QFN-n still ERROR. Pad ≠ via. Kelvin unchanged.
- [Fixed] PE-BOM-011 skips die-pintable extras on WROOM/WROVER module footprints (U11).
- [Fixed] PE-BOM-011 skips QFN-n pads the pintable never listed (U12 2548).
- [Fixed] PE-BOM-010 treats VQFN/WQFN/HVQFN as QFN (U19).
## 2.63.2 — 2026-09-22 — PE-BOM-011 joins pins by name, not pin_count
Datasheet `pin_count` and PCB pad cardinality are incommensurable (USB-C 16P vs SH/mount/EP). PE-BOM-011 joins the pintable × KiCad `.kicad_mod` × embedded PCB pads by **pin name**. USB-C A5/B5 are CC pins, not EP. Missing pintable is a skip, never ERROR on `pin_count=16`. The library footprint is checked against the datasheet; lib vs embedded PCB is a separate mismatch. AI still extracts the table and runs review/class/SI — it does not count pads. Pad ≠ via.
- [Fixed] PE-BOM-011 no longer treats `[A-Z]\\d+` as EP (HubAudio J2 16 vs 1 SH).
- [Fixed] Scalar `package_info.pin_count` is not an ERROR authority.
- [Changed] Named join: datasheet pintable vs lib vs PCB; SH/EP/mount classified first.
## 2.63.1 — 2026-09-21 — Library component from PDF; AF+AI beside PCB checks
Library import registers a **component** (not only a PDF blob) with no exam. AF Board+AI is a pipeline section **after** unchanged `run_pcb_checks` (PE-SI / HF line stay in 2.62.1).
- [Fix] `POST /api/library/datasheets` writes an IC inbox card or a passive/discrete model. Empty pintable never lands in `library/extracted/`.
- [Fix] PCB pipeline stage `af_ai` calls `append_investigated` beside `run_pcb_checks`. Duplicate SI clones are not double-counted. `PE-AF-001` stays additive.
- [New] Report tree root “AF Board + AI”. Progress step between deterministic checks and PCB AI exam.
## 2.63.0 — 2026-09-21 — Library as its own product door
Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad.
- [New] `POST /api/library/datasheets` stores a PDF by MPN with no project id.
- [New] `GET`/`PUT /api/library/components/{type}/{mpn}` for extracted cards.
- [New] Library page import form; empty library no longer says “run a review”.
- [New] `investigate_hf_hypotheses` (`PE-AF-001`) — AI Z0 flag without stackup is INSUFFICIENT, never 50/90 Ω. Existing `run_pcb_checks` unchanged.
## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B)
Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVSconnector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM.
- [New] `PE-STK-001` board copper thickness vs sourced fab stackup (never 1 oz).
- [New] `PE-VIA-002` I + via count/drill FACT; IPC via ampacity is not applied.
- [New] `PE-ESD-002` TVSconnector **pad** distance (via ≠ pad); INFO if no mm.
- [New] `PE-PD-001` USB-PD only with a PD controller (not USB2 Rd).
- [New] `PE-POE-004` isolation voltage only from a sourced number.
- [New] `PE-ANT-001`/`002` antenna keepout and matching if an antenna exists.
- [New] `PE-PDN-001` Z(f) only with frequency-domain FACT; SI Z0 is not PDN.
## 2.62.0 — 2026-09-21 — HF lines/buses in PCB checks (Phase A)
USB / Eth / DDR / MIPI / PCIe / HDMI / LVDS / HF clock in `run_pcb_checks` at the same finding quality as 2.61.2. Pair integrity: stub (track only; via ≠ pad ≠ zone), reference-split under the line, BOM termination. CPU data/address/control and FPGA only if that device is on the graph. Skip if no evidence. Not DRC. Not FEM. Datasheet millimetres only; no invented Z/I.
- [New] `PE-SI-007` dangling track stub vs datasheet mm (INFO if no mm).
- [New] `PE-SI-011` GND-pour gap under an HF pair (REVIEW; skip without stackup/zone).
- [New] `PE-SI-012` BOM series/parallel terminator FACT (skip if none).
- [New] `PE-DDR-001``003` DRAM class / CK-DQS-DQ / VTT only if DRAM present.
- [New] `PE-CPU-001``004` parallel CPU bus only if D/A/control exist.
- [New] `PE-FPGA-001``003` FPGA class / config flash / VCCINT·VCCIO only if FPGA present.
- [Changed] `bus_class` adds MIPI and leftover HF clocks; XTAL/OSCIN stay out.
## 2.61.2 — 2026-09-21 — Certifiers only for connectors on this board
USB-C / RJ45 / PoE / DDR3 are the method, not a catalog. If this graph has a USB-C receptacle, run USB-C questions. If it has RJ45, run Ethernet. PoE only with PoE evidence — never because RJ45 exists. No connector → no that certifier (not N/A). HubAudio has USB-C USB2 + PoE 10/100 MagJack and no DDR: no DDR findings. Skip-I (2.61.1) unchanged. No FEM, no via IPC, no invented I/Z/PoE/SuperSpeed.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "periscope-web",
"version": "2.61.2",
"version": "2.63.4",
"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"
},
"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>
@@ -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>
);
}
@@ -10,7 +10,9 @@ const PCB_STAGES = [
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, power traces, thermal, Kelvin" },
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, HF lines, power traces, thermal, Kelvin" },
{ id: "af_trace", title: "AF trace analysis", description: "λ/10 and tr trigger, closed-form Z0, RLGC cascade (not OpenEMS)" },
{ id: "af_ai", title: "AF Board + AI", description: "AI flags HF issues, then deterministic investigate (does not replace SI/HF)" },
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
] as const;
+51
View File
@@ -369,6 +369,7 @@ export interface LibraryIC {
pin_count: number;
has_ratings: boolean;
has_datasheet: boolean;
library_status?: string;
}
export interface LibraryPassive {
@@ -402,6 +403,7 @@ export interface LibraryDatasheet {
hash: string | null;
has_extraction: boolean;
has_model: boolean;
has_component?: boolean;
}
export interface LibraryCatalog {
@@ -423,6 +425,55 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
return URL.createObjectURL(await res.blob());
}
export async function importLibraryDatasheet(
mpn: string,
file: File,
kind: "ic" | "passive_part" | "simple" = "ic",
): Promise<{
mpn: string;
blob_key: string;
has_extraction: boolean;
has_model: boolean;
component_type?: string;
status?: string;
}> {
const body = new FormData();
body.append("mpn", mpn);
body.append("file", file);
body.append("kind", kind);
const res = await send("/api/library/datasheets", { method: "POST", body });
return jsonOrThrow(res, "Failed to add component to the library");
}
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
export async function fetchLibraryComponent(
componentType: LibraryComponentType,
name: string,
): Promise<Record<string, unknown>> {
const res = await send(
`/api/library/components/${componentType}/${encodeURIComponent(name)}`,
{ cache: "no-store" },
);
return jsonOrThrow(res, "Failed to load component");
}
export async function updateLibraryComponent(
componentType: LibraryComponentType,
name: string,
payload: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const res = await send(
`/api/library/components/${componentType}/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
return jsonOrThrow(res, "Failed to update component");
}
async function postPipeline(path: string, fallback: string, body?: unknown) {
const res = await send(path, {
method: "POST",
@@ -0,0 +1,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,20 @@ 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" ||
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 +45,27 @@ 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") ||
/^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");
}
+2 -2
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.61.0";
export const APP_VERSION_DATE = "2026-09-21";
export const APP_VERSION = "2.63.4";
export const APP_VERSION_DATE = "2026-09-22";
+12
View File
@@ -44,6 +44,18 @@
"ic.memory.eeprom": {
"description": "EEPROM"
},
"ic.memory.ddr": {
"description": "DDR SDRAM",
"example_mpn": "MT41K256M16TW"
},
"ic.memory.sram": {
"description": "Parallel SRAM",
"example_mpn": "IS61WV51216"
},
"ic.fpga": {
"description": "Field-programmable gate array",
"example_mpn": "XC7A35T"
},
"ic.logic.buffer": {
"description": "Buffer or line driver"
},
View File
+208
View File
@@ -0,0 +1,208 @@
"""AF board + AI: hypotheses of probable HF issues, then deterministic checks.
Does not replace run_pcb_checks. Not DRC. Never invents Z.
"""
from __future__ import annotations
from backend.periscopex.af_ai_hf import (
HfHypothesis,
append_investigated,
hypotheses_from_rows,
investigate_hf_hypotheses,
)
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutDielectric,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.pcb_checks import run_pcb_checks
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> Component:
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str]) -> Net:
return Net(
name=name, net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-"},
),
},
nets={
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
},
)
def _fp(ref: str, pads: list[LayoutPad], x: float = 0.0) -> LayoutFootprint:
return LayoutFootprint(reference=ref, footprint="P", x=x, y=0.0, pads=pads)
def _stub_layout() -> LayoutGraph:
return LayoutGraph(
footprints={
"U1": _fp("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
def _stub_constraints() -> dict:
return {
"PHY": ComponentConstraints(
mpn="PHY", pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0,
"note": "USB stub < 1 mm", "source_page": 12,
}],
),
}
def test_ai_stub_flag_then_deterministic_pe_si_007():
graph = _usb_graph()
layout = _stub_layout()
cons = _stub_constraints()
flags = [HfHypothesis(net="USB_D+", issue_class="stub", why="looks long")]
out = investigate_hf_hypotheses(graph, cons, layout, flags)
codes = [f.rule_id for f in out]
assert "PE-SI-007" in codes
hit = next(f for f in out if f.rule_id == "PE-SI-007")
assert "FAIL" in hit.finding
assert hit.evidence_status == "SUFFICIENT"
def test_ai_z0_flag_without_stackup_is_insufficient_no_ohm():
graph = _usb_graph()
layout = _stub_layout()
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm")]
out = investigate_hf_hypotheses(graph, {}, layout, flags)
assert len(out) == 1
f = out[0]
assert f.rule_id == "PE-AF-001"
assert f.evidence_status == "INSUFFICIENT"
assert "90" not in f.finding
assert "50" not in f.finding
assert "Ω" not in f.finding and "ohm" not in f.finding.lower()
def test_ai_flag_on_missing_superspeed_net_is_silence():
graph = _usb_graph()
layout = _stub_layout()
flags = [HfHypothesis(net="USB3_SSTX_P", issue_class="stub", why="SS pair")]
out = investigate_hf_hypotheses(graph, _stub_constraints(), layout, flags)
assert out == []
def test_run_pcb_checks_still_finds_stub_without_af_ai():
"""AF+AI is extra: the PCB check list must still emit PE-SI-007 alone."""
graph = _usb_graph()
layout = _stub_layout()
pcb = run_pcb_checks(graph, _stub_constraints(), layout)
assert any(f.rule_id == "PE-SI-007" for f in pcb)
hf = check_hf_lines(graph, _stub_constraints(), layout)
assert any(f.rule_id == "PE-SI-007" for f in hf)
def test_z0_with_stackup_does_not_invent_ohm_number():
graph = _usb_graph()
layout = _stub_layout()
layout.stackup = LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
)
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="check Z0")]
out = investigate_hf_hypotheses(graph, {}, layout, flags)
for f in out:
assert "50 Ω" not in (f.finding or "")
assert "90 Ω" not in (f.finding or "")
def test_append_investigated_keeps_pcb_checks_and_adds_pe_af():
graph = _usb_graph()
layout = _stub_layout()
cons = _stub_constraints()
pcb = run_pcb_checks(graph, cons, layout)
n_si = sum(1 for f in pcb if f.rule_id == "PE-SI-007")
assert n_si >= 1
flags = [
HfHypothesis(net="USB_D+", issue_class="stub", why="looks long"),
HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm"),
]
added = append_investigated(pcb, graph, cons, layout, flags)
assert any(f.rule_id == "PE-AF-001" for f in added)
assert sum(1 for f in pcb if f.rule_id == "PE-SI-007") == n_si
assert "si_check" in _pcb_checks_source()
assert "hf_line" in _pcb_checks_source()
def test_hypotheses_from_rows_drops_unknown_class():
rows = [
{"net": "USB_D+", "issue_class": "stub", "why": "x"},
{"net": "USB_D+", "issue_class": "clearance", "why": "drc"},
{"net": "", "issue_class": "z0", "why": "empty"},
]
hyps = hypotheses_from_rows(rows)
assert len(hyps) == 1
assert hyps[0].issue_class == "stub"
def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
import inspect
from backend.periscopex import pcb_checks
from backend.services import pcb_pipeline
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
assert "run_pcb_checks" in src
assert "check_af_traces" in src
assert "_run_af_ai_section" in src
assert src.find("run_pcb_checks") < src.find("check_af_traces")
assert src.find("check_af_traces") < src.find("_run_af_ai_section")
checks = inspect.getsource(pcb_checks.run_pcb_checks)
assert '("si_check"' in checks or '"si_check"' in checks
assert '"hf_line"' in checks
assert "af_ai" not in checks
assert "check_af_traces" not in checks
assert "investigate_hf_hypotheses" not in checks
def _pcb_checks_source() -> str:
import inspect
from backend.periscopex.pcb_checks import run_pcb_checks
return inspect.getsource(run_pcb_checks)
View File
View File
+286
View File
@@ -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"] == []
+1
View File
@@ -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"
View File
+240
View File
@@ -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
+523
View File
@@ -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 124 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]
@@ -61,7 +61,7 @@ def test_annotate_does_not_overwrite_existing_cad_fields():
def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path):
from tests.test_kicad_parser import _resistor, _sch
from tests.schematic.test_kicad_parser import _resistor, _sch
child = tmp_path / "analog.kicad_sch"
child.write_text(_sch(
+398
View File
@@ -0,0 +1,398 @@
"""Phase A: HF pair integrity + CPU/FPGA/DDR class — only if present.
Via pad track zone. No invented Z/I/mm. Skip when evidence is missing.
"""
from __future__ import annotations
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.hf_bus_class import check_memory_fpga_classes
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.si_check import bus_class, check_si, skip_si_net
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY", subtype: str | None = None) -> Component:
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn,
component_subtype=subtype, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str], ntype: NetType = NetType.SIGNAL) -> Net:
return Net(
name=name, net_type=ntype,
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_with_pads(ref: str, pads: list[LayoutPad], x: float = 0.0, y: float = 0.0) -> LayoutFootprint:
return LayoutFootprint(reference=ref, footprint="P", x=x, y=y, pads=pads)
def test_bus_class_mipi_hf_clk_not_xtal():
assert bus_class("MIPI_D0_P") == "mipi"
assert bus_class("CSI_CLK_N") == "mipi"
assert bus_class("DSI_D1_P") == "mipi"
assert bus_class("GTX_CLK") == "hf_clk"
assert bus_class("PCIE_REFCLK") == "pcie"
assert bus_class("CLK_P") == "hf_clk"
assert bus_class("XTAL_IN") is None
assert bus_class("OSCIN") is None
assert bus_class("HFXIN") is None
assert skip_si_net("USB_CC1")
assert bus_class("USB_D+") == "usb2"
def test_no_layout_skips_hf_geometry():
assert check_hf_lines(_usb_graph(), {}, None) == []
assert check_hf_lines(_usb_graph(), {}, LayoutGraph()) == []
def test_pad_to_pad_usb_has_no_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [
LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+"),
LayoutPad(number="2", x=0.0, y=0.4, net="USB_D-"),
]),
"J2": _fp_with_pads("J2", [
LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+"),
LayoutPad(number="A7", x=10.0, y=0.4, 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=(0, 0.4), end=(10, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
def test_via_is_not_a_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(5, 0), end=(10, 0), width=0.2, layer="B.Cu", net="USB_D+"),
],
vias=[LayoutVia(x=5.0, y=0.0, net="USB_D+", drill=0.3)],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
assert all(not isinstance(v, LayoutPad) for v in layout.vias)
def test_zone_is_not_a_track_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
},
segments=[
LayoutSegment(start=(0, 0), end=(4, 0), width=0.2, layer="F.Cu", net="USB_D+"),
],
zones=[LayoutZone(
net="USB_D+", layer="F.Cu",
outlines=[[(3.5, -1.0), (8.0, -1.0), (8.0, 1.0), (3.5, 1.0)]],
)],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
def test_dangling_track_stub_vs_datasheet_mm_is_fail():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("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+"),
],
)
cons = 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,
}],
)
findings = check_hf_lines(_usb_graph(), {"PHY": cons}, layout)
stubs = [f for f in findings if f.rule_id == "PE-SI-007"]
assert len(stubs) == 1
assert stubs[0].finding.startswith("FAIL:")
assert stubs[0].facts.startswith("stub_mm=")
assert "1" in stubs[0].requirement
complete_finding(stubs[0])
assert stubs[0].status == "WARNING"
assert stubs[0].finding_class != "RULE" or stubs[0].provenance != "MANDATORY"
assert stubs[0].evidence_status == "SUFFICIENT"
def test_measured_stub_without_datasheet_mm_is_insufficient():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("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=(5, 0), end=(5, 4), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
stubs = [f for f in findings if f.rule_id == "PE-SI-007"]
assert len(stubs) == 1
assert stubs[0].status == "INFO"
assert stubs[0].evidence_status == "INSUFFICIENT"
assert "90" not in (stubs[0].requirement or "")
assert "FAIL" not in stubs[0].finding
def test_split_under_pair_needs_stackup_and_zone():
segs = [
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"),
]
bare = LayoutGraph(segments=segs)
assert [f for f in check_hf_lines(_usb_graph(), {}, bare) if f.rule_id == "PE-SI-011"] == []
stack = LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="dielectric_1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
)
no_zone = LayoutGraph(segments=segs, stackup=stack)
assert [f for f in check_hf_lines(_usb_graph(), {}, no_zone) if f.rule_id == "PE-SI-011"] == []
covered = LayoutGraph(
segments=segs, stackup=stack,
zones=[LayoutZone(
net="GND", layer="B.Cu",
outlines=[[(-1, -2), (21, -2), (21, 2), (-1, 2)]],
)],
)
assert [f for f in check_hf_lines(_usb_graph(), {}, covered) if f.rule_id == "PE-SI-011"] == []
split = LayoutGraph(
segments=segs, stackup=stack,
zones=[LayoutZone(
net="GND", layer="B.Cu",
outlines=[[(0, -2), (5, -2), (5, 2), (0, 2)]],
)],
)
hits = [f for f in check_hf_lines(_usb_graph(), {}, split) if f.rule_id == "PE-SI-011"]
assert len(hits) == 1
assert hits[0].status == "WARNING"
assert hits[0].finding_class == "REVIEW"
assert hits[0].facts
assert hits[0].requirement
assert hits[0].inference
complete_finding(hits[0])
assert hits[0].status != "ERROR"
def test_bom_termination_is_fact_missing_is_skip():
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
g = _usb_graph()
assert [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"] == []
g.components["R22"] = Component(
reference="R22", value="22R", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "USB_D+", "2": "USB_D+_PHY"},
specs=ResistorSpecs(value_ohms=22.0, value_formatted="22"),
)
g.nets["USB_D+_PHY"] = _net("USB_D+_PHY", ("R22", "2"))
g.nets["USB_D+"].pins.append(PinConnection(component_ref="R22", pin_number="1"))
found = [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"]
assert len(found) == 1
assert found[0].status == "INFO"
assert found[0].evidence_status == "SUFFICIENT"
assert "R22" in found[0].facts
assert "22" in found[0].facts
assert "50 Ω" not in found[0].requirement
def test_no_ddr_cpu_fpga_on_usb_only_graph():
findings = check_memory_fpga_classes(_usb_graph())
assert findings == []
ids = {f.rule_id for f in run_pcb_checks(_usb_graph(), {}, None)}
assert not any(str(i).startswith(("PE-DDR", "PE-CPU", "PE-FPGA")) for i in ids)
def test_ddr_present_class_and_nets_skip_invented_vtt():
pins = {str(i + 1): f"DDR3_DQ{i}" for i in range(8)}
pins["20"] = "DDR3_DQS0_P"
pins["21"] = "DDR3_DQS0_N"
pins["22"] = "DDR3_CK_P"
pins["23"] = "DDR3_CK_N"
pins["24"] = "DDR3_A0"
comps = {"U10": _ic("U10", pins, mpn="MT41K256M16TW", subtype="ic.memory.ddr")}
nets = {n: _net(n, ("U10", p)) for p, n in pins.items()}
g = DesignGraph(components=comps, nets=nets)
findings = check_memory_fpga_classes(g)
for f in findings:
complete_finding(f)
assert [f.rule_id for f in findings if f.rule_id == "PE-DDR-001"]
assert [f for f in findings if f.rule_id == "PE-DDR-001"][0].status == "INFO"
assert [f for f in findings if f.rule_id == "PE-DDR-002"][0].status == "INFO"
assert not any(f.rule_id == "PE-DDR-003" for f in findings)
assert not any((f.rule_id or "").startswith("PE-CPU") for f in findings)
assert not any((f.rule_id or "").startswith("PE-FPGA") for f in findings)
g.nets["DDR_VTT"] = _net("DDR_VTT", ("U10", "30"), ntype=NetType.POWER)
g.components["U10"].pins["30"] = "DDR_VTT"
with_vtt = check_memory_fpga_classes(g)
assert [f for f in with_vtt if f.rule_id == "PE-DDR-003"][0].status == "INFO"
def test_cpu_parallel_bus_only_when_data_addr_control_exist():
mcu_pins = {str(i + 1): f"MCU_D{i}" for i in range(8)}
for i in range(8):
mcu_pins[str(20 + i)] = f"MCU_A{i}"
mcu_pins["40"] = "MCU_NWE"
mcu_pins["41"] = "MCU_NOE"
mcu_pins["42"] = "MCU_NCS"
sram_pins = dict(mcu_pins)
comps = {
"U1": _ic("U1", mcu_pins, mpn="STM32F407", subtype="ic.mcu"),
"U2": _ic("U2", sram_pins, mpn="IS61WV51216", subtype="ic.memory.sram"),
}
nets = {}
for p, n in mcu_pins.items():
nets[n] = _net(n, ("U1", p), ("U2", p))
g = DesignGraph(components=comps, nets=nets)
findings = check_memory_fpga_classes(g)
ids = {f.rule_id for f in findings}
assert "PE-CPU-001" in ids
assert "PE-CPU-002" in ids
assert "PE-CPU-003" in ids
assert "PE-CPU-004" in ids
assert not any(str(i).startswith("PE-DDR") for i in ids)
assert not any(str(i).startswith("PE-FPGA") for i in ids)
gpio = DesignGraph(
components={"U1": _ic("U1", {"1": "GPIO0", "2": "GPIO1"}, mpn="MSPM0", subtype="ic.mcu")},
nets={
"GPIO0": _net("GPIO0", ("U1", "1")),
"GPIO1": _net("GPIO1", ("U1", "2")),
},
)
assert check_memory_fpga_classes(gpio) == []
def test_fpga_only_when_present_config_flash_optional():
fpga_pins = {"1": "IO_L1P", "2": "IO_L1N", "3": "VCCINT", "4": "VCCIO"}
g = DesignGraph(
components={"U5": _ic("U5", fpga_pins, mpn="XC7A35T", subtype="ic.fpga")},
nets={
"IO_L1P": _net("IO_L1P", ("U5", "1")),
"IO_L1N": _net("IO_L1N", ("U5", "2")),
"VCCINT": _net("VCCINT", ("U5", "3"), ntype=NetType.POWER),
"VCCIO": _net("VCCIO", ("U5", "4"), ntype=NetType.POWER),
},
)
findings = check_memory_fpga_classes(g)
ids = {f.rule_id for f in findings}
assert "PE-FPGA-001" in ids
assert "PE-FPGA-003" in ids
assert "PE-FPGA-002" not in ids
assert not any(str(i).startswith("PE-DDR") for i in ids)
assert not any(str(i).startswith("PE-CPU") for i in ids)
g.components["U6"] = _ic("U6", {"1": "FPGA_CS", "2": "FPGA_MOSI"}, mpn="W25Q64", subtype="ic.memory.flash")
g.nets["FPGA_CS"] = _net("FPGA_CS", ("U6", "1"), ("U5", "10"))
g.components["U5"].pins["10"] = "FPGA_CS"
with_flash = check_memory_fpga_classes(g)
assert any(f.rule_id == "PE-FPGA-002" for f in with_flash)
def test_rule_catalog_hf_ids():
for rid, domain in (
("PE-SI-007", "pcb"),
("PE-SI-011", "pcb"),
("PE-SI-012", "pcb"),
("PE-DDR-001", "shared"),
("PE-DDR-002", "shared"),
("PE-DDR-003", "shared"),
("PE-CPU-001", "shared"),
("PE-CPU-002", "shared"),
("PE-CPU-003", "shared"),
("PE-CPU-004", "shared"),
("PE-FPGA-001", "shared"),
("PE-FPGA-002", "shared"),
("PE-FPGA-003", "shared"),
):
rec = lookup_rule(rid)
assert rec is not None, rid
assert rec.domain == domain
def test_existing_si_usb_not_polluted_by_hf_geometry():
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
],
vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)],
)
findings = check_si(_usb_graph(), {}, layout, [
{
"net_name": "USB_D+", "partner_net_name": "USB_D-",
"z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0,
"length_mm": 10.0, "topologies": ["MICROSTRIP"],
},
{
"net_name": "USB_D-", "partner_net_name": "USB_D+",
"z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0,
"length_mm": 12.5, "topologies": ["MICROSTRIP"],
},
])
assert any(f.rule_id == "PE-SI-010" for f in findings)
assert all(f.rule_id != "PE-SI-002" for f in findings)
@@ -96,7 +96,7 @@ def test_run_pcb_checks_on_partial_layout_does_not_invent_tracks(tmp_path: Path)
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
from tests.test_pcb_via_not_pad import _qfn24_pcb
from tests.pcb.test_pcb_via_not_pad import _qfn24_pcb
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, board_vias=[(80.0, 80.0, "GND", 1)]))
u1 = layout.footprints["U1"]
@@ -60,6 +60,8 @@ def test_parse_segment_and_via_resolve_net_name(tmp_path: Path):
assert g.segments[0].net == "GND"
assert len(g.vias) == 1
assert g.vias[0].net == "GND"
assert g.vias[0].drill == pytest.approx(0.4)
assert g.vias[0].layers == ("F.Cu", "B.Cu")
def test_power_flag_footprint_is_skipped(tmp_path: Path):
+297
View File
@@ -0,0 +1,297 @@
"""Phase B: stackup, via I, ESD mm, USB-PD, PoE isolation, antenna, PDN.
Skip without evidence. No invented I/Z/mm/IPC via chart. Via pad track zone.
"""
from __future__ import annotations
from backend.periscopex.antenna_layout_check import check_antenna_layout
from backend.periscopex.esd_return_check import check_esd_distance
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
SimpleComponentSpecs,
)
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.pcb_power_thermal import check_pcb_via_current
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
def _ic(ref, pins, *, mpn="PART", specs=None, subtype=None):
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn,
component_subtype=subtype, pins=pins, specs=specs,
)
def _net(name, *pairs, ntype=NetType.SIGNAL):
return Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def test_stackup_skips_without_fab_spec():
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="GND")],
)
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND)})
assert check_stackup(g, {}, layout) == []
assert check_stackup(g, {}, None) == []
def test_stackup_vs_fab_spec_is_review_not_invented_oz():
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
cons = ComponentConstraints(
mpn="FAB", pintable=[Pin(number="1", name="GND")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stackup", "copper_thickness_mm": 0.070,
"note": "fab 2 oz", "source_page": 1,
}],
)
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"}, mpn="FAB")}, nets={})
findings = check_stackup(g, {"FAB": cons}, layout)
assert len(findings) == 1
assert findings[0].rule_id == "PE-STK-001"
assert findings[0].status == "WARNING"
assert findings[0].finding_class == "REVIEW"
assert "0.035" in findings[0].facts
assert "0.07" in findings[0].facts.replace("070", "0.07") or "0.070" in findings[0].facts
assert "1 oz" not in findings[0].finding.lower()
complete_finding(findings[0])
assert findings[0].status != "ERROR"
def test_via_current_skips_without_i_does_not_invent_ipc():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
vias=[LayoutVia(x=1, y=0, net="VOUT", drill=0.3)],
)
g = DesignGraph(
components={"U1": _ic("U1", {"1": "VIN", "2": "VOUT"}, mpn="LDO1",
specs=SimpleComponentSpecs(specs_type="discrete", values={}))},
nets={"VOUT": _net("VOUT", ("U1", "2"), ntype=NetType.POWER)},
)
cons = ComponentConstraints(mpn="LDO1", pintable=[Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[], rules=[])
assert check_pcb_via_current(g, {"LDO1": cons}, layout) == []
g.components["U1"].specs = SimpleComponentSpecs(
specs_type="discrete", values={"i_load": 1.2},
)
findings = check_pcb_via_current(g, {"LDO1": cons}, layout)
via2 = [f for f in findings if f.rule_id == "PE-VIA-002"]
assert len(via2) == 1
assert via2[0].status == "INFO"
assert via2[0].evidence_status == "INSUFFICIENT"
blob = f"{via2[0].finding} {via2[0].requirement} {via2[0].inference} {via2[0].facts}"
assert "1.2" in via2[0].facts
assert "0.3" in via2[0].facts
assert "IPC" not in via2[0].finding
assert "k =" not in blob.lower()
complete_finding(via2[0])
assert via2[0].status != "ERROR"
def test_esd_distance_uses_pads_not_vias():
g = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A6": "USB_D+"},
),
"D1": _ic("D1", {"1": "USB_D+"}, mpn="USBLC6", subtype="ic.protection.esd"),
},
nets={"USB_D+": _net("USB_D+", ("J2", "A6"), ("D1", "1"))},
)
layout = LayoutGraph(
footprints={
"J2": LayoutFootprint(reference="J2", x=0, y=0, pads=[
LayoutPad(number="A6", x=0.0, y=0.0, net="USB_D+"),
]),
"D1": LayoutFootprint(reference="D1", x=8.0, y=0.0, pads=[
LayoutPad(number="1", x=8.0, y=0.0, net="USB_D+"),
]),
},
vias=[LayoutVia(x=1.0, y=0.0, net="USB_D+", drill=0.3)],
segments=[LayoutSegment(start=(0, 0), end=(8, 0), width=0.2, layer="F.Cu", net="USB_D+")],
)
findings = check_esd_distance(g, {}, layout)
dist = [f for f in findings if f.rule_id == "PE-ESD-002"]
assert len(dist) == 1
assert dist[0].status == "INFO"
assert dist[0].evidence_status == "INSUFFICIENT"
assert "8.000" in dist[0].facts or "8.00" in dist[0].facts
assert "via" not in dist[0].facts.lower() or "pad" in dist[0].facts.lower()
assert dist[0].calculation
cons = ComponentConstraints(
mpn="USBLC6", pintable=[Pin(number="1", name="IO")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "esd", "max_distance_mm": 3.0,
"note": "TVS within 3 mm of connector", "source_page": 5,
}],
)
fail = check_esd_distance(g, {"USBLC6": cons}, layout)
hit = [f for f in fail if f.rule_id == "PE-ESD-002"][0]
assert hit.finding.startswith("FAIL:")
complete_finding(hit)
assert hit.status == "WARNING"
assert hit.status != "ERROR" or hit.provenance == "MANDATORY"
def test_usb_pd_skips_without_pd_controller():
g = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C_Receptacle_USB2.0_16P", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A5": "USB_CC1"},
),
},
nets={"USB_CC1": _net("USB_CC1", ("J2", "A5"))},
)
assert check_usb_pd(g) == []
g.components["U8"] = _ic("U8", {"1": "USB_CC1", "2": "VBUS"}, mpn="FUSB302B")
g.nets["VBUS"] = _net("VBUS", ("U8", "2"), ntype=NetType.POWER)
found = check_usb_pd(g)
assert [f.rule_id for f in found] == ["PE-PD-001"]
assert found[0].status == "INFO"
assert found[0].evidence_status == "SUFFICIENT"
def test_poe_isolation_skips_without_voltage_fact():
g = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45 PoE MagJack", footprint="",
component_type=ComponentType.CONNECTOR, mpn="ARJP11A",
pins={"1": "ETH_TX+"},
),
},
nets={"ETH_TX+": _net("ETH_TX+", ("J1", "1"))},
)
findings = check_interface_classes(g)
assert not any(f.rule_id == "PE-POE-004" for f in findings)
g.components["J1"].specs = SimpleComponentSpecs(
specs_type="connector", values={"isolation_v": 1500.0},
)
with_v = check_interface_classes(g)
iso = [f for f in with_v if f.rule_id == "PE-POE-004"]
assert len(iso) == 1
assert iso[0].status == "INFO"
assert "1500" in iso[0].facts
assert iso[0].evidence_status == "SUFFICIENT"
def test_antenna_skips_when_absent():
g = DesignGraph(
components={"U1": _ic("U1", {"1": "GPIO1"})},
nets={"GPIO1": _net("GPIO1", ("U1", "1"))},
)
layout = LayoutGraph(segments=[
LayoutSegment(start=(0, 0), end=(2, 0), width=0.2, layer="F.Cu", net="GPIO1"),
])
assert check_antenna_layout(g, layout) == []
def test_antenna_keepout_and_match_are_facts():
g = DesignGraph(
components={
"U1": _ic("U1", {"1": "ANT_FEED"}, mpn="ESP32"),
"ANT1": Component(
reference="ANT1", value="ANT", footprint="RF_Antenna:ANT",
component_type=ComponentType.UNKNOWN, pins={"1": "ANT_FEED"},
),
"L1": Component(
reference="L1", value="3.3nH", footprint="",
component_type=ComponentType.INDUCTOR, pins={"1": "ANT_FEED", "2": "RF_OUT"},
),
},
nets={
"ANT_FEED": _net("ANT_FEED", ("U1", "1"), ("ANT1", "1"), ("L1", "1")),
"RF_OUT": _net("RF_OUT", ("L1", "2")),
},
)
layout = LayoutGraph(
footprints={"ANT1": LayoutFootprint(reference="ANT1", x=10, y=10, pads=[
LayoutPad(number="1", x=10, y=10, net="ANT_FEED"),
])},
zones=[LayoutZone(
net="antenna", layer="F.Cu", keepout=True,
outlines=[[(8, 8), (14, 8), (14, 14), (8, 14)]],
)],
segments=[LayoutSegment(start=(0, 0), end=(10, 10), width=0.3, layer="F.Cu", net="ANT_FEED")],
)
findings = check_antenna_layout(g, layout)
ids = {f.rule_id for f in findings}
assert "PE-ANT-001" in ids
assert "PE-ANT-002" in ids
keep = [f for f in findings if f.rule_id == "PE-ANT-001"][0]
assert keep.status == "INFO"
assert keep.evidence_status == "SUFFICIENT"
assert "50" not in keep.requirement
def test_pdn_skips_without_zf():
g = DesignGraph(components={"U1": _ic("U1", {"1": "3V3"})}, nets={
"3V3": _net("3V3", ("U1", "1"), ntype=NetType.POWER),
})
layout = LayoutGraph(segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="3V3"),
])
assert check_pdn(g, layout, None) == []
assert check_pdn(g, layout, {"nets": [{"net_name": "USB_D+", "z0_avg_ohms": 88}]}) == []
def test_run_pcb_checks_phase_b_absent_is_silent():
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={
"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND),
})
ids = {f.rule_id for f in run_pcb_checks(g, {}, None)}
assert "PE-STK-001" not in ids
assert "PE-VIA-002" not in ids
assert "PE-ESD-002" not in ids
assert "PE-PD-001" not in ids
assert "PE-POE-004" not in ids
assert "PE-ANT-001" not in ids
assert "PE-PDN-001" not in ids
def test_phase_b_rule_catalog():
for rid in (
"PE-STK-001", "PE-VIA-002", "PE-ESD-002", "PE-PD-001",
"PE-POE-004", "PE-ANT-001", "PE-ANT-002", "PE-PDN-001",
):
rec = lookup_rule(rid)
assert rec is not None, rid
@@ -172,7 +172,7 @@ def test_missing_footprint_is_pe_lay_002():
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
from backend.periscopex.models import LayoutSegment
segs = [
@@ -190,7 +190,7 @@ def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
def test_close_decoupling_has_no_plc_001():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
from backend.periscopex.models import LayoutSegment
segs = [
@@ -39,8 +39,9 @@ from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.resolve_passives import decode_value
from backend.services.passive_from_mpn import specs_from_mpn
from backend.services.passive_from_value import specs_from_bom_value
from tests.paths import FIXTURES
_DRC = Path(__file__).parent / "fixtures" / "HubAudio-DRC.rpt"
_DRC = FIXTURES / "HubAudio-DRC.rpt"
_HUB_PCB = Path(
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
)
@@ -348,7 +349,7 @@ def test_thinking_reasoning_content_echo_still_present():
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
Path(__file__).resolve().parents[2]
/ "periscope" / "src" / "backend" / "services" / "llm" / "deepseek_provider.py"
)
text = src.read_text(encoding="utf-8")
@@ -15,7 +15,6 @@ from backend.periscopex.functional_groups import (
from backend.periscopex.models import DesignGraph
ROOT = Path(__file__).resolve().parents[1]
SIMPLE = SIMPLE_PROJECT
@@ -78,6 +78,9 @@ def test_skip_i2c_gpio_cc_regn():
assert bus_class("SGMII_TX_P") == "sgmii"
assert bus_class("DDR3_DQ0") == "ddr3_dq"
assert bus_class("DDR3_DQS0_P") == "ddr3_dqs"
assert bus_class("MIPI_D0_P") == "mipi"
assert bus_class("GTX_CLK") == "hf_clk"
assert bus_class("XTAL_IN") is None
assert bus_class("USB_CC1") is None
assert bus_class("I2C_SCL") is None
View File

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