Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20733f0ec6 | ||
|
|
69d5dd9313 | ||
|
|
309644e654 |
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
@@ -210,3 +211,138 @@ def check_return_path(
|
||||
pins=[],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _pad_xy(layout: LayoutGraph, ref: str) -> list[tuple[float, float, str]]:
|
||||
fp = layout.footprints.get(ref)
|
||||
if not fp:
|
||||
return []
|
||||
return [(p.x, p.y, p.net) for p in fp.pads]
|
||||
|
||||
|
||||
def _min_pad_distance_mm(
|
||||
a: list[tuple[float, float, str]],
|
||||
b: list[tuple[float, float, str]],
|
||||
net: str,
|
||||
) -> float | None:
|
||||
best = None
|
||||
for ax, ay, an in a:
|
||||
if an and not kicad_nets_match(an, net):
|
||||
continue
|
||||
for bx, by, bn in b:
|
||||
if bn and not kicad_nets_match(bn, net):
|
||||
continue
|
||||
d = math.hypot(ax - bx, ay - by)
|
||||
if best is None or d < best:
|
||||
best = d
|
||||
return best
|
||||
|
||||
|
||||
def check_esd_distance(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
"""TVS–connector pad distance. Vias are not pads. Skip without both lands."""
|
||||
if layout is None:
|
||||
return []
|
||||
from backend.periscopex.constraints_lookup import match_constraints as match_cons
|
||||
from backend.periscopex.si_check import _num, _quote
|
||||
|
||||
tvs = [
|
||||
(ref, comp) for ref, comp in sorted(graph.components.items())
|
||||
if _is_esd_part(comp)
|
||||
]
|
||||
connectors = [
|
||||
(ref, comp) for ref, comp in sorted(graph.components.items())
|
||||
if _is_j_connector(comp)
|
||||
]
|
||||
if not tvs or not connectors:
|
||||
return []
|
||||
out: list[Finding] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for j_ref, j_comp in connectors:
|
||||
j_pads = _pad_xy(layout, j_ref)
|
||||
if not j_pads:
|
||||
continue
|
||||
for d_ref, d_comp in tvs:
|
||||
d_pads = _pad_xy(layout, d_ref)
|
||||
if not d_pads:
|
||||
continue
|
||||
nets = sorted({
|
||||
n for n in list(j_comp.pins.values()) + list(d_comp.pins.values()) if n
|
||||
})
|
||||
for net in nets:
|
||||
if _skip_esd_net(net):
|
||||
continue
|
||||
key = (j_ref, d_ref, normalize_kicad_hierarchy_net(net))
|
||||
if key in seen:
|
||||
continue
|
||||
dist = _min_pad_distance_mm(j_pads, d_pads, net)
|
||||
if dist is None:
|
||||
continue
|
||||
seen.add(key)
|
||||
lim = None
|
||||
quote = ""
|
||||
cons = match_cons(d_comp.mpn or d_comp.value, constraints_map or {})
|
||||
if cons:
|
||||
for rule in cons.layout_rules or []:
|
||||
if str(rule.get("kind") or "") not in {"esd", "keepout"}:
|
||||
continue
|
||||
lim = _num(rule.get("max_distance_mm"))
|
||||
quote = _quote(rule)
|
||||
if lim is not None:
|
||||
break
|
||||
facts = (
|
||||
f"pad_distance_mm={dist:.3f}; connector={j_ref}; tvs={d_ref}; "
|
||||
f"net={net}; vias_not_used=1."
|
||||
)
|
||||
calc = f"min hypot between {j_ref} pads and {d_ref} pads = {dist:.3f} mm."
|
||||
if lim is None:
|
||||
rec = (
|
||||
"Re-extract the TVS layout millimetre. "
|
||||
"Do not assume a default ESD gap."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
|
||||
finding=(
|
||||
f"Unverified: {d_ref} is {dist:.2f} mm from {j_ref} — "
|
||||
"library has no ESD millimetre FACT."
|
||||
),
|
||||
facts=facts, calculation=calc,
|
||||
requirement=(
|
||||
"Datasheet ESD max_distance_mm from TVS to connector "
|
||||
"(none on file)."
|
||||
),
|
||||
inference="INSUFFICIENT — not inventing an ESD keep-in millimetre.",
|
||||
why="Distance is pad-to-pad; vias and tracks are not lands.",
|
||||
status="INFO", recommendation=rec, action=rec,
|
||||
source="esd_return_check", rule_id="PE-ESD-002",
|
||||
finding_class="INFO", provenance="TYPICAL",
|
||||
evidence_status="INSUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
continue
|
||||
fail = dist > lim + 1e-9
|
||||
rec = (
|
||||
f"Move {d_ref} within {lim:g} mm of {j_ref}."
|
||||
if fail
|
||||
else "TVS-to-connector distance meets the datasheet millimetre."
|
||||
)
|
||||
out.append(Finding(
|
||||
designator=d_ref, mpn=d_comp.mpn or "", aspect="esd",
|
||||
finding=(
|
||||
f"{'FAIL' if fail else 'PASS'}: {d_ref}–{j_ref} "
|
||||
f"{dist:.2f} mm (max {lim:g} mm)."
|
||||
),
|
||||
facts=facts, calculation=calc,
|
||||
requirement=f"ESD max_distance_mm={lim:g} ({quote or 'layout_rules'}).",
|
||||
inference="FAIL vs datasheet millimetre." if fail else "",
|
||||
why="Pad-to-pad only; a via on the net is not the TVS land.",
|
||||
status="WARNING" if fail else "INFO",
|
||||
recommendation=rec, action=rec,
|
||||
source="esd_return_check", rule_id="PE-ESD-002",
|
||||
finding_class="RISK" if fail else "INFO",
|
||||
provenance="RECOMMENDED" if fail else "TYPICAL",
|
||||
evidence_status="SUFFICIENT", net=net, pins=[],
|
||||
))
|
||||
return out
|
||||
|
||||
@@ -152,6 +152,12 @@ def _seed() -> None:
|
||||
requirement="Series resistor on the HS net vs datasheet ohms.")
|
||||
_add("PE-SI-010", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="HS bus measured; library has no SI FACT — not 90 Ω folklore.")
|
||||
_add("PE-SI-007", "RECOMMENDED", "RISK", domain="pcb",
|
||||
requirement="Dangling track stub vs datasheet millimetres; pad/via/zone are not stubs.")
|
||||
_add("PE-SI-011", "TYPICAL", "REVIEW", domain="pcb",
|
||||
requirement="Continuous GND pour under an HF pair when that pour exists (not IEC).")
|
||||
_add("PE-SI-012", "TYPICAL", "INFO", domain="pcb",
|
||||
requirement="BOM termination on an HF net is reported; missing terminator is a skip.")
|
||||
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
|
||||
requirement="Keepout from layout_rules — not a DRC for pad copper.")
|
||||
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
|
||||
@@ -224,6 +230,42 @@ 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.")
|
||||
|
||||
|
||||
_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 "",
|
||||
|
||||
@@ -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=[],
|
||||
)]
|
||||
@@ -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
|
||||
@@ -17,7 +17,10 @@ Classe dichiarata o inferita (FACT/REQUIREMENT da BOM, net, footprint, sheet). S
|
||||
| PE-USBC-001…005 | c’è un receptacle USB-C | classe USB2/USB3, CC1/CC2, Rd/Rp 5.1 kΩ, VBUS/GND, SuperSpeed solo se USB3 |
|
||||
| PE-ETH-001…004 | c’è un RJ45 | 10/100 vs GbE, coppie TX/RX o 4 MDI, magnetics se GbE, Bob Smith REVIEW |
|
||||
| PE-POE-001…003 | evidenza PoE sul jack | classe/tipo se citati; isolamento/magnetics; **skip** se RJ45 nudo |
|
||||
| PE-POE-004 | PoE + `isolation_v` | tensione di isolamento solo se sourced; niente IEC inventato |
|
||||
| PE-DDR-* | solo se c’è un dispositivo DDR | non inventato su HubAudio (nessun DDR in BOM) |
|
||||
| PE-CPU-* | solo se c’è un bus parallelo D/A/controllo | SRAM/NOR/FMC/mux AD; niente bus inventato |
|
||||
| PE-FPGA-* | solo se c’è un FPGA | bank/IO rails e flash di config se presenti; niente FPGA inventata |
|
||||
|
||||
Motore: `periscopex/interface_class_check.py` (grafo, non geometria). Schema `MODE=run` e PCB `MODE=pcb`. L’IA spiega i FACT, non certifica. Nessun I inventato: PE-PWR **skip** se il datasheet non riporta I_load/Imax/I_abs.
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net
|
||||
| PE-SI-001 | `length_match` mm on USB/HDMI/PCIe/ETH/LVDS/DDR nets (not I2C/GPIO/CC) |
|
||||
| PE-SI-002 | ImpedenceFinder Zavg/min/max vs `impedance` window |
|
||||
| PE-SI-003…009 | max length, spacing, ref plane, vias, return, series R — each a datasheet number |
|
||||
| PE-SI-007 | dangling **track** stub vs datasheet mm; via ≠ pad ≠ zone; INFO if no mm |
|
||||
| PE-SI-010 | HS bus measured, library has no SI FACT (not 90 Ω folklore) |
|
||||
| PE-SI-011 | GND-pour gap under HF pair when stackup + pour exist (REVIEW, not IEC) |
|
||||
| PE-SI-012 | BOM terminator on HF net (FACT); skip if none |
|
||||
| PE-PWR-001 | I_load/Imax/I_abs + width; **skip** se manca I (no 500 mA, no INFO); IPC solo con thickness e Tjmax |
|
||||
| PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR |
|
||||
| PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN |
|
||||
@@ -115,6 +118,8 @@ Gate: library `layout_rules` kind + `net_class`/quote on that bus. Empty cell =
|
||||
| SGMII | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
|
||||
| DDR3 CLK/DQS/DQ/ADDR | gated | gated | gated | gated | gated | gated | gated | gated | per-subclass if no Z |
|
||||
| HDMI / PCIe / LVDS | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
|
||||
| MIPI DSI/CSI | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
|
||||
| HF clock (not XTAL) | gated | gated | gated | gated | gated | gated | gated | gated | per-bus |
|
||||
| I2C / GPIO / EN / CC | skip | skip | skip | skip | skip | skip | skip | skip | skip |
|
||||
|
||||
Emmaforo USB: extract with **no Z number** → Zavg ~88 Ω is **PE-SI-010** INFO (insufficient), **not** a 90 Ω FAIL. If library later has 90 Ω ±10% (81–99), Zavg ~88 Ω **PASS** on average and **MARGIN/FAIL** if min is outside the window; ~2.5 mm intra-pair skew **FAIL** only when `length_match` mm exists. PE-SI-009 must not fire on USB from ESP32 EN RC 10 kΩ / 1 µF p.28.
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
What's new in Periscope.
|
||||
|
||||
## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B)
|
||||
|
||||
Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVS–connector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM.
|
||||
|
||||
- [New] `PE-STK-001` board copper thickness vs sourced fab stackup (never 1 oz).
|
||||
- [New] `PE-VIA-002` I + via count/drill FACT; IPC via ampacity is not applied.
|
||||
- [New] `PE-ESD-002` TVS–connector **pad** distance (via ≠ pad); INFO if no mm.
|
||||
- [New] `PE-PD-001` USB-PD only with a PD controller (not USB2 Rd).
|
||||
- [New] `PE-POE-004` isolation voltage only from a sourced number.
|
||||
- [New] `PE-ANT-001`/`002` antenna keepout and matching if an antenna exists.
|
||||
- [New] `PE-PDN-001` Z(f) only with frequency-domain FACT; SI Z0 is not PDN.
|
||||
|
||||
## 2.62.0 — 2026-09-21 — HF lines/buses in PCB checks (Phase A)
|
||||
|
||||
USB / Eth / DDR / MIPI / PCIe / HDMI / LVDS / HF clock in `run_pcb_checks` at the same finding quality as 2.61.2. Pair integrity: stub (track only; via ≠ pad ≠ zone), reference-split under the line, BOM termination. CPU data/address/control and FPGA only if that device is on the graph. Skip if no evidence. Not DRC. Not FEM. Datasheet millimetres only; no invented Z/I.
|
||||
|
||||
- [New] `PE-SI-007` dangling track stub vs datasheet mm (INFO if no mm).
|
||||
- [New] `PE-SI-011` GND-pour gap under an HF pair (REVIEW; skip without stackup/zone).
|
||||
- [New] `PE-SI-012` BOM series/parallel terminator FACT (skip if none).
|
||||
- [New] `PE-DDR-001`…`003` DRAM class / CK-DQS-DQ / VTT only if DRAM present.
|
||||
- [New] `PE-CPU-001`…`004` parallel CPU bus only if D/A/control exist.
|
||||
- [New] `PE-FPGA-001`…`003` FPGA class / config flash / VCCINT·VCCIO only if FPGA present.
|
||||
- [Changed] `bus_class` adds MIPI and leftover HF clocks; XTAL/OSCIN stay out.
|
||||
|
||||
## 2.61.2 — 2026-09-21 — Certifiers only for connectors on this board
|
||||
|
||||
USB-C / RJ45 / PoE / DDR3 are the method, not a catalog. If this graph has a USB-C receptacle, run USB-C questions. If it has RJ45, run Ethernet. PoE only with PoE evidence — never because RJ45 exists. No connector → no that certifier (not N/A). HubAudio has USB-C USB2 + PoE 10/100 MagJack and no DDR: no DDR findings. Skip-I (2.61.1) unchanged. No FEM, no via IPC, no invented I/Z/PoE/SuperSpeed.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "periscope-web",
|
||||
"version": "2.61.2",
|
||||
"version": "2.62.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"sync-version": "node scripts/sync-version.mjs",
|
||||
|
||||
@@ -18,9 +18,18 @@ 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" ||
|
||||
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,6 +43,10 @@ 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") ||
|
||||
/^PE-BOM-01[0-4]$/.test(rid)
|
||||
);
|
||||
}
|
||||
@@ -65,6 +78,10 @@ export function isPcbExamFinding(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") ||
|
||||
/^PE-BOM-01[0-4]$/.test(rid)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = "2.62.1";
|
||||
export const APP_VERSION_DATE = "2026-09-21";
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"ic.memory.eeprom": {
|
||||
"description": "EEPROM"
|
||||
},
|
||||
"ic.memory.ddr": {
|
||||
"description": "DDR SDRAM",
|
||||
"example_mpn": "MT41K256M16TW"
|
||||
},
|
||||
"ic.memory.sram": {
|
||||
"description": "Parallel SRAM",
|
||||
"example_mpn": "IS61WV51216"
|
||||
},
|
||||
"ic.fpga": {
|
||||
"description": "Field-programmable gate array",
|
||||
"example_mpn": "XC7A35T"
|
||||
},
|
||||
"ic.logic.buffer": {
|
||||
"description": "Buffer or line driver"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -559,7 +559,9 @@ def test_run_pcb_checks_skips_absent_interfaces():
|
||||
assert "PE-USBC-001" not in ids
|
||||
assert "PE-ETH-001" not in ids
|
||||
assert "PE-POE-001" not in ids
|
||||
if_f = [f for f in findings if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR"))]
|
||||
if_f = [f for f in findings if (f.rule_id or "").startswith(
|
||||
("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR", "PE-CPU", "PE-FPGA")
|
||||
)]
|
||||
assert if_f == []
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -6,14 +6,17 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "periscope" / "src" / "frontend" / "src" / "components/upload/file-upload-zone.tsx"
|
||||
SKIP = ROOT / "periscope" / "src" / "frontend" / "src" / "lib" / "kicad-project-files.ts"
|
||||
|
||||
|
||||
def test_upload_zone_is_src():
|
||||
text = SRC.read_text(encoding="utf-8")
|
||||
skip = SKIP.read_text(encoding="utf-8")
|
||||
assert "Native Periscope overlay" not in text[:400]
|
||||
assert "export function FileUploadZone" in text
|
||||
assert "webkitGetAsEntry" in text
|
||||
assert "webkitRelativePath" in text
|
||||
assert "-backups" in text
|
||||
assert "skipWalkDirName" in text
|
||||
assert "-backups" in skip
|
||||
assert "border-dashed" in text
|
||||
assert "border-emerald-500/40" in text
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user