Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc364f5926 | ||
|
|
8d2b85600f | ||
|
|
556306bf4d | ||
|
|
1d3cf12482 | ||
|
|
68af0fd432 | ||
|
|
d47ad91cee | ||
|
|
21e2be1cd0 | ||
|
|
86121e6547 | ||
|
|
54b0b07917 | ||
|
|
f5f21782d2 | ||
|
|
fc11df59fc | ||
|
|
e124ac2de7 | ||
|
|
4882c9206a | ||
|
|
d947128951 | ||
|
|
4c4b604cca | ||
|
|
2950446e12 | ||
|
|
30aaf55de6 | ||
|
|
2405093bbb | ||
|
|
5a69b380da | ||
|
|
c5b260b404 | ||
|
|
a1a2b2a944 | ||
|
|
aebe8f6cc3 | ||
|
|
14fd3ba890 | ||
|
|
b768cc95b1 | ||
|
|
a56dda2358 | ||
|
|
b3a47f0bec | ||
|
|
3f6082ae6d | ||
|
|
796cd9d8a2 | ||
|
|
412c32e9b2 | ||
|
|
ba2a5c1b29 | ||
|
|
0fafbbbb06 | ||
|
|
45e820fabc | ||
|
|
be0fed7979 | ||
|
|
de15402309 | ||
|
|
6fc2ac583d | ||
|
|
3e43bb6ecb | ||
|
|
7aed64cae5 | ||
|
|
aa375349bd | ||
|
|
78013bd404 | ||
|
|
a45a06e761 | ||
|
|
4403c38d96 | ||
|
|
4fca789517 | ||
|
|
e4fd6c3849 | ||
|
|
d31c04ba86 | ||
|
|
face8ec38d | ||
|
|
61f85f519b | ||
|
|
d454cf75af | ||
|
|
edbb47a08b | ||
|
|
2113e3975e | ||
|
|
7a255a9807 | ||
|
|
b1ee445da8 | ||
|
|
d6b8b0086c | ||
|
|
6bde06d2cc | ||
|
|
53db287ab4 | ||
|
|
4c54d064d3 | ||
|
|
cb001a8bbb | ||
|
|
96e2590a3c | ||
|
|
ea43b550ee | ||
|
|
f6eeb73cbf | ||
|
|
66ae877b7b | ||
|
|
a9238875dd | ||
|
|
e5e8c42966 | ||
|
|
90d8d3ed21 | ||
|
|
02d3cfdad7 | ||
|
|
ed75aabaf0 | ||
|
|
e84418f975 | ||
|
|
34bfe33cdf | ||
|
|
08cbbdc422 | ||
|
|
3be7d2fc21 | ||
|
|
19b4c11f62 | ||
|
|
dd46ce1da1 | ||
|
|
27a3ce8bd0 | ||
|
|
776d714754 | ||
|
|
946a7a67ab | ||
|
|
48246f31bd | ||
|
|
ab1c5b081c | ||
|
|
2c5d9d31b6 |
@@ -61,3 +61,7 @@ frontend/out/
|
||||
|
||||
# Local-only sample inputs (client schematics, test files)
|
||||
edif-files/
|
||||
|
||||
# Cloud agent scratch
|
||||
agent-tools/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pinscope — Agentic Schematic Validation
|
||||
# Periscope — Agentic Schematic Validation
|
||||
|
||||
Pinscope validates hardware schematics against component datasheets. It extracts constraints from PDFs, parses netlists and BOMs into a queryable graph, and runs an agentic validation loop to flag design violations.
|
||||
Periscope validates hardware schematics against component datasheets. It extracts constraints from PDFs, parses netlists and BOMs into a queryable graph, and runs an agentic validation loop to flag design violations.
|
||||
|
||||
> **Open-core note.** This is the open-source core. A small set of files are
|
||||
> "gateway-owned seams" — pass-through stubs here (`frontend/src/proxy.ts`,
|
||||
@@ -18,11 +18,11 @@ Three layers:
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
|-------|----------|---------|
|
||||
| **Core library** | `backend/pinscopex/` | Models, parsers, graph builder, agentic validator, passive resolver, taxonomy, BOM summary, derating |
|
||||
| **Core library** | `backend/periscopex/` | Models, parsers, graph builder, agentic validator, passive resolver, taxonomy, BOM summary, derating |
|
||||
| **Backend** | `backend/` | FastAPI app — async pipeline orchestration, SSE progress, project/file storage |
|
||||
| **Frontend** | `frontend/` | Next.js 16 app — project dashboard, pipeline progress, report viewer, derating, admin dashboard |
|
||||
|
||||
Plus `skills/` — Claude Console Skills for datasheet extraction (pintable, patterns, specs).
|
||||
Plus `skills/` — extraction prompts (pintable, patterns, specs) inlined locally for DeepSeek. Do not upload to Anthropic Console.
|
||||
|
||||
The pipeline stages: Parse BOM → Extract IC Pintables → Extract Simple Components → Extract Passives → DigiKey Auto-Resolve + Value Fallback → Build Graph → Direct Datasheet Review. Pipeline runs can be cancelled mid-execution via `POST /api/pipeline/{id}/cancel`.
|
||||
|
||||
@@ -42,10 +42,10 @@ Files: `.asc` (PADS-PCB netlist; `.edn` EDIF 2.0.0 also accepted), `.csv`/`.xlsx
|
||||
|
||||
- **Modular extractors** — Domain-specific extraction per component type, unified constraint schema
|
||||
- **Netlist as graph** — Queryable bipartite graph (components + nets) with traversal helpers
|
||||
- **Claude API for PDF extraction** — Forced tool calls for structured output (pintable, passive patterns, specs)
|
||||
- **Prompt caching** — Extraction and review API calls use `cache_control={"type": "ephemeral"}` on system prompts and input context to reduce cost on repeated calls
|
||||
- **Claude Console Skills** — Extraction prompts deployed as managed skills; skill_ids and versions loaded from `backend/skills_manifest.json` (upload your own via `scripts/upload_skills.py`)
|
||||
- **Direct datasheet review** — Claude reads the IC datasheet PDF and circuit neighborhood together, compares to reference application circuit, and flags issues via graph query tools (`find_connected_components`, `get_net_for_pin`, `get_pintable`)
|
||||
- **LLM API for PDF extraction** — Forced tool calls for structured output (pintable, passive patterns, specs). Default provider is DeepSeek.
|
||||
- **Prompt caching** — Anthropic stamps `cache_control`; Gemini uses CachedContent; DeepSeek uses automatic prefix cache (cache-hit tokens in usage).
|
||||
- **Local extraction skills** — `skills/*/SKILL.md` is inlined and `validate.py` runs in-process. Never call `scripts/upload_skills.py` (Anthropic Console).
|
||||
- **Direct datasheet review** — The model reads the IC datasheet plus circuit neighborhood, compares to the reference application circuit, and flags issues via graph query tools (`find_connected_components`, `get_net_for_pin`, `get_pintable`). DeepSeek converts PDFs to text (and page images on the vision model).
|
||||
- **Datasheet page trimming** — Large PDFs are keyword-trimmed to relevant pages before sending to Claude, reducing token cost (`pypdf`)
|
||||
- **DigiKey fallback (exact MPN only)** — When pattern-based and direct extraction fail, DigiKey API fetches product parameters for auto-resolve. DigiKey matches only on exact MPN; fuzzy hits are rejected to avoid polluting the shared library with wrong-dielectric / wrong-voltage parts.
|
||||
- **Value-string fallback** — When DigiKey misses an R/C/L/FB passive, a value-string resolver maps the BOM `Value` string to typed passive specs. Value-derived specs are persisted per-project only — never to the shared library.
|
||||
@@ -55,17 +55,17 @@ Files: `.asc` (PADS-PCB netlist; `.edn` EDIF 2.0.0 also accepted), `.csv`/`.xlsx
|
||||
- **Cross-IC finding dedup** — After all per-IC reviews complete, a single pass (`services/dedupe_findings.py`) collapses one physical interface defect reported from both endpoints into a single finding. Gated by `cross_ic_dedup_enabled`; fail-soft.
|
||||
- **Capacitor voltage derating** — Deterministic derating table computed from graph (ceramic/tantalum/electrolytic percentages, pass/fail per capacitor)
|
||||
- **Deterministic checks over heuristics** — Exact checks where possible
|
||||
- **Zero coupling between layers** — Backend calls pinscopex functions with paths; frontend talks to backend via REST + SSE
|
||||
- **Zero coupling between layers** — Backend calls periscopex functions with paths; frontend talks to backend via REST + SSE
|
||||
- **Library deduplication** — Shared library (`library/extracted/`, `library/patterns/`, `library/models/`, `library/passives/`, `library/datasheets/`) caches extractions across projects
|
||||
- **Content-addressed datasheets** — `library/datasheets/blobs/{md5}.pdf` stores unique PDFs once; `library/datasheets/refs/{safe_mpn}.json` maps MPNs to blobs (dedupe + multi-MPN sharing)
|
||||
- **Taxonomy-driven extraction** — Living component taxonomy (`taxonomy/`) with per-subtype classification and specs schemas
|
||||
- **Per-stage model config** — Each pipeline stage can use a different Claude model (e.g., Sonnet for review, Haiku for auto-resolve)
|
||||
- **API call logging** — Every Claude API call is logged with token counts, cost, and timing per pipeline run
|
||||
- **Report versioning** — Each project run is stamped with the current app version on the first `/start` transition (`ProjectMeta.pinscope_version`). The version comes from `frontend/content/changelog.md`'s latest `##` heading — single source of truth — read at backend startup via `backend/_version.py`.
|
||||
- **Report versioning** — Each project run is stamped with the current app version on the first `/start` transition (`ProjectMeta.periscope_version`). The version comes from `frontend/content/changelog.md`'s latest `##` heading — single source of truth — read at backend startup via `backend/_version.py`.
|
||||
|
||||
## Datasheet Extraction
|
||||
|
||||
Extracted data lives in `library/extracted/` (shared) or per-project under the storage backend. One JSON per MPN, schema in `backend/pinscopex/models.py`.
|
||||
Extracted data lives in `library/extracted/` (shared) or per-project under the storage backend. One JSON per MPN, schema in `backend/periscopex/models.py`.
|
||||
|
||||
Per-MPN IC extraction captures:
|
||||
1. **Pintable** — Pin number + name (required), description + alt functions (optional)
|
||||
@@ -75,7 +75,7 @@ Per-MPN IC extraction captures:
|
||||
For discrete/simple components:
|
||||
4. **Specs** — Component specs (value, tolerance, package, voltage rating, etc.); parameters are filtered against taxonomy specs schemas
|
||||
|
||||
Extraction uses **Claude Console Skills** (required, via `skill_id` in `backend/skills_manifest.json`). No inline fallback — raises error if skill not configured. Skills are defined in `skills/` and uploaded via `scripts/upload_skills.py` — run it once against your own Anthropic Console account to populate the manifest with your skill IDs.
|
||||
Extraction inlines **local skills** (`skills/*/SKILL.md` + `validate.py`) against DeepSeek. Do not use Anthropic Console Skills.
|
||||
|
||||
## Claude Console Skills
|
||||
|
||||
@@ -101,7 +101,7 @@ Key taxonomy features:
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/upload_skills.py` — Create, update, or list Claude Console Skills. Reads/writes skill IDs to `backend/skills_manifest.json`
|
||||
- `scripts/upload_skills.py` — leftover Claude Console uploader. **Do not run.** Skills are local + DeepSeek only.
|
||||
- `scripts/migrate_datasheets_to_library.py` — One-time migration: copy per-project datasheets to `library/datasheets/` (dry-run by default, `--apply` to execute)
|
||||
- `scripts/migrate_datasheets_to_blobs.py` — Migrate named-PDF datasheets into the content-addressed blobs/refs layout (dry-run by default, `--apply` to execute)
|
||||
- `scripts/dedup_library_datasheets.py` — Remove redundant per-MPN datasheet PDFs when a passive pattern already has a `datasheet_key` (dry-run by default, `--apply` to execute)
|
||||
@@ -110,12 +110,12 @@ Key taxonomy features:
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Core**: Python 3.12+, Pydantic 2.x, Anthropic SDK (async + sync), openpyxl (XLSX BOM support), pypdf (datasheet page trimming)
|
||||
- **Core**: Python 3.12+, Pydantic 2.x, OpenAI SDK (DeepSeek), Anthropic SDK (optional), google-genai (optional), openpyxl, pypdf, PyMuPDF
|
||||
- **Backend**: FastAPI, uvicorn, sse-starlette, pydantic-settings
|
||||
- **Frontend**: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS v4, shadcn/ui (Base UI), react-pdf
|
||||
- **AI**: Claude API with forced tool calls for extraction, direct datasheet review for validation
|
||||
- **Model**: `claude-sonnet-4-6` default for extraction and review, `claude-haiku-4-5` for DigiKey auto-resolve and passive value fallback (per-stage overrides via `.env`)
|
||||
- **Skills**: Claude Console Skills API for managed extraction prompts (3 active skills: pintable, pattern, specs)
|
||||
- **AI**: DeepSeek Chat Completions (OpenAI-compatible) with forced tool calls for extraction and agentic review. Do not route stages to Anthropic.
|
||||
- **Model**: `deepseek-flash` for extraction, review, auto-resolve, and normalize (per-stage overrides via `.env`)
|
||||
- **Skills**: Local SKILL.md + validate.py on DeepSeek
|
||||
- **External APIs**: DigiKey API v4 (OAuth2) — optional datasheet auto-fetch and parameter-based auto-resolve (`DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`)
|
||||
|
||||
## Extracted Model Versioning
|
||||
@@ -123,17 +123,17 @@ Key taxonomy features:
|
||||
All `ComponentConstraints` extracted JSON files carry a `model_version` semver field:
|
||||
|
||||
- **Initial value** — set from `default_model_version` in `backend/skills_manifest.json` (starts at `1.0.0`)
|
||||
- **Minor bump** — `default_model_version` in `skills_manifest.json` is incremented by `scripts/upload_skills.py --update`, so all new extractions after a skill update start at the new minor (e.g. `1.0.0` → `1.1.0`)
|
||||
- **Minor bump** — increment `default_model_version` in `skills_manifest.json` when extraction prompts change (do **not** run `upload_skills.py`).
|
||||
|
||||
**Rule**: When committing or pushing changes under `skills/`, run `python3 scripts/upload_skills.py --update` before the commit/push to sync skill versions and bump `default_model_version`.
|
||||
**Rule**: When committing changes under `skills/`, bump `default_model_version` locally. Never call Anthropic.
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- Write tests against `simple_project/` — it's the ground truth
|
||||
- Netlist parser and BOM parser are pure functions with no side effects
|
||||
- All data structures use Pydantic models in `backend/pinscopex/models.py`
|
||||
- Frontend types in `frontend/src/lib/types.ts` must stay in sync with `backend/pinscopex/models.py`
|
||||
- Extraction prompts live in `skills/` as Claude Console Skills (SKILL.md + schema.json + validate.py)
|
||||
- All data structures use Pydantic models in `backend/periscopex/models.py`
|
||||
- Frontend types in `frontend/src/lib/types.ts` must stay in sync with `backend/periscopex/models.py`
|
||||
- Extraction prompts live in `skills/` (SKILL.md + schema.json + validate.py) and run locally against DeepSeek
|
||||
- **Never swallow exceptions silently** — prefer logging or re-raising over bare `except: continue`. Silent failures hide real bugs.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
# Pinscope
|
||||
# Periscope (DeepSeek)
|
||||
|
||||
Pinscope reviews schematics the way a good senior engineer does: with the datasheets open.
|
||||
Periscope reviews schematics the way a good senior engineer does: with the datasheets open.
|
||||
|
||||
<img width="1912" height="1080" alt="pinscope-screenrecording" src="https://github.com/user-attachments/assets/7e9e4002-08df-423f-93c9-eefd52e88700" />
|
||||
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, 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.
|
||||
|
||||
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, so you can judge the call yourself instead of trusting a black box.
|
||||
## What changed for DeepSeek
|
||||
|
||||
The reason it exists: ERC passes boards that don't work. Your EDA tool has no idea that the CH340E you powered from 5 V drives its TXD at 4.5 V into an MCU pin that maxes out at 3.6 V, or that the net you labeled `UART5_TX` lands on a pin whose alternate-function table only offers `UART5_RX`, or that the LDO's bypass pin you left floating costs you an order of magnitude in output noise. None of that is an electrical *rule* violation. All of it is in the datasheet, and nobody has time to re-read 400 pages per part on every revision.
|
||||
DeepSeek's Chat Completions API is OpenAI-compatible but **does not accept native PDF documents**. Periscope therefore:
|
||||
|
||||
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.
|
||||
|
||||
Default routing:
|
||||
|
||||
| Stage | Model |
|
||||
| --- | --- |
|
||||
| Pintable / pattern / specs extraction | `deepseek-flash` (native vision) |
|
||||
| Per-IC datasheet review | `deepseek-flash` |
|
||||
| Auto-resolve / normalize | `deepseek-flash` |
|
||||
|
||||
Override with `PROVIDER_*` and `MODEL_*_DEEPSEEK` in `backend/.env`. See `backend/.env.example`.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -15,35 +31,53 @@ The reason it exists: ERC passes boards that don't work. Your EDA tool has no id
|
||||
<img src="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` — exportable from KiCad, Altium, OrCAD, Allegro, Xpedition, EasyEDA, Eagle) 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, so a given part number is only ever processed once.
|
||||
3. **Review** each IC in isolation. The model gets the trimmed datasheet plus that IC's circuit neighborhood, can query the graph (`find_connected_components`, `get_net_for_pin`, `get_pintable`) and pull pages from a *connected* part's datasheet when a finding spans an interface. It files findings with severity, reasoning, and page citations.
|
||||
4. **Compute** the deterministic parts deterministically — BOM roll-up and a capacitor voltage-derating table come straight from the graph, no model involved.
|
||||
|
||||
A post-pass normalizes findings conservatively: it can merge duplicates and downgrade severity, never upgrade. If the reviewer hedged, the report hedges.
|
||||
|
||||
It's a reviewer, not an oracle. It misses things, and it will occasionally question a choice you made on purpose — that's what the citations are for.
|
||||
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
|
||||
|
||||
`simple_project/` is a small MSPM0G3507 board with a CH340E USB-UART bridge and an SPX3819 LDO. Run it through and Pinscope flags, among other things, the LDO's bypass pin left unconnected (~300 µV<sub>RMS</sub> output noise instead of ~40) and the 5 V-powered CH340E driving the 3.3 V MCU directly — each with the page reference to check its work.
|
||||
`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 an [Anthropic API key](https://console.anthropic.com/):
|
||||
You need Python 3.12+, Node 20+, and a [DeepSeek API key](https://platform.deepseek.com/):
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r backend/requirements.txt
|
||||
cp backend/.env.example .env # set ANTHROPIC_API_KEY
|
||||
python3 scripts/upload_skills.py --update # one-time: registers the extraction prompts under your account
|
||||
python3 -m uvicorn backend.main:app --reload
|
||||
cd frontend && npm install && npm run dev
|
||||
cp backend/.env.example backend/.env # set DEEPSEEK_API_KEY
|
||||
|
||||
python3 -m uvicorn backend.main:app --reload --host 127.0.0.1 --port 18741
|
||||
|
||||
# in another terminal
|
||||
cd frontend && npm install
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:18741 npm run dev -- --port 18742 --hostname 127.0.0.1
|
||||
```
|
||||
|
||||
Open http://localhost:3000, create a project, and feed it the netlist and BOM from `simple_project/` plus datasheet PDFs for the ICs — grab those from the manufacturers, or set the optional DigiKey API keys and let it fetch them. Everything runs locally against your own key; projects and the extraction library live in `data/`. Architecture notes are in [CLAUDE.md](CLAUDE.md).
|
||||
Open the frontend URL, create a project, and feed it the netlist and BOM from `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 `skills/*/SKILL.md` — do not run `scripts/upload_skills.py`.
|
||||
|
||||
## Hosted version
|
||||
## Docker
|
||||
|
||||
This repo is the product minus accounts and billing. If you'd rather not run it yourself, [pinscope.ai](https://pinscope.ai) is the same code, hosted, with team workspaces and a shared parts library that's already warm.
|
||||
```bash
|
||||
cp backend/.env.example .env # set DEEPSEEK_API_KEY
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Backend on port 8080, frontend on port 3000.
|
||||
|
||||
### Update a live instance (e.g. periscope.michelebigi.it)
|
||||
|
||||
On the server, from the Periscope checkout:
|
||||
|
||||
```bash
|
||||
./scripts/update-periscope.sh
|
||||
```
|
||||
|
||||
The script 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.
|
||||
|
||||
Do not set `ENVIRONMENT=production` unless Clerk auth is configured — that flag refuses to boot with auth disabled.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0. For commercial licensing, write to dev@faradworks.com.
|
||||
AGPL-3.0, same as upstream Pinscope (Faradworks). For commercial licensing of the original, write to dev@faradworks.com.
|
||||
|
||||
+55
-33
@@ -1,41 +1,47 @@
|
||||
# Pinscope Backend — Environment Variables
|
||||
# Copy to .env and fill in values. Only ANTHROPIC_API_KEY is required.
|
||||
# Periscope Backend — Environment Variables
|
||||
# Copy to backend/.env and fill in values. Only DEEPSEEK_API_KEY is required.
|
||||
|
||||
# -- AI ----------------------------------------------------------------------
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||
# Per-stage Anthropic model overrides (leave empty to use ANTHROPIC_MODEL)
|
||||
MODEL_PINTABLE=
|
||||
MODEL_PATTERN=
|
||||
MODEL_VALIDATION=
|
||||
# -- AI (DeepSeek, default) --------------------------------------------------
|
||||
# Key from https://platform.deepseek.com/
|
||||
DEEPSEEK_API_KEY=sk-...
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_MODEL=deepseek-flash
|
||||
DEEPSEEK_VISION_MODEL=deepseek-flash
|
||||
# enabled (default) | disabled — thinking mode on DeepSeek V4
|
||||
DEEPSEEK_THINKING=enabled
|
||||
# Official values: low | high | max
|
||||
DEEPSEEK_REASONING_EFFORT=high
|
||||
# PDF ingest (DeepSeek cannot take native PDFs)
|
||||
# DEEPSEEK_PDF_MAX_CHARS=500000
|
||||
# DEEPSEEK_PDF_IMAGE_PAGES=32
|
||||
|
||||
# Per-stage DeepSeek model overrides (leave empty to use the defaults below)
|
||||
# V4.1 Flash is natively multimodal — extraction and review share deepseek-flash.
|
||||
# MODEL_PINTABLE_DEEPSEEK=deepseek-flash
|
||||
# MODEL_PATTERN_DEEPSEEK=deepseek-flash
|
||||
# MODEL_SPECS_DEEPSEEK=deepseek-flash
|
||||
# MODEL_VALIDATION_DEEPSEEK=deepseek-flash
|
||||
# MODEL_AUTO_RESOLVE_DEEPSEEK=deepseek-flash
|
||||
# MODEL_NORMALIZE_DEEPSEEK=deepseek-flash
|
||||
|
||||
# -- AI provider routing -----------------------------------------------------
|
||||
# Default provider for every stage; per-stage env vars override.
|
||||
# Valid values: anthropic | gemini
|
||||
PROVIDER_DEFAULT=anthropic
|
||||
# Set a specific stage to "gemini" to route just that stage to Gemini
|
||||
# (leaves the rest on Anthropic). Skills-based extraction stages
|
||||
# (pintable / pattern / specs) require Anthropic — Gemini has no
|
||||
# equivalent of Anthropic Console Skills.
|
||||
# PROVIDER_VALIDATION=gemini
|
||||
# PROVIDER_POWER_TREE=gemini
|
||||
# PROVIDER_AUTO_RESOLVE=
|
||||
# Valid values: deepseek | gemini (anthropic is ignored and coerced to deepseek)
|
||||
PROVIDER_DEFAULT=deepseek
|
||||
# PROVIDER_VALIDATION=deepseek
|
||||
# PROVIDER_AUTO_RESOLVE=deepseek
|
||||
|
||||
# -- Gemini (required when any PROVIDER_* is set to "gemini") ----------------
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-3-flash-preview
|
||||
# Per-stage Gemini model overrides (leave empty to use GEMINI_MODEL)
|
||||
# Anthropic is not used. Do not set ANTHROPIC_API_KEY.
|
||||
|
||||
# -- Gemini (optional) -------------------------------------------------------
|
||||
# GEMINI_API_KEY=
|
||||
# GEMINI_MODEL=gemini-3-flash-preview
|
||||
# MODEL_VALIDATION_GEMINI=
|
||||
# MODEL_POWER_TREE_GEMINI=
|
||||
|
||||
# -- Per-stage fallback ------------------------------------------------------
|
||||
# If set, the stage retries once with FALLBACK_PROVIDER_<STAGE> /
|
||||
# FALLBACK_MODEL_<STAGE> when the primary provider raises (e.g. Gemini 503
|
||||
# UNAVAILABLE). FALLBACK_MODEL_<STAGE> may be empty — defaults to that
|
||||
# provider's default model (ANTHROPIC_MODEL or GEMINI_MODEL). Leave
|
||||
# FALLBACK_PROVIDER_<STAGE> empty to disable fallback for that stage.
|
||||
# FALLBACK_PROVIDER_VALIDATION=anthropic
|
||||
# FALLBACK_MODEL_VALIDATION=claude-sonnet-4-6
|
||||
# If set, the stage retries once. Anthropic is ignored (DeepSeek only).
|
||||
# FALLBACK_PROVIDER_VALIDATION=deepseek
|
||||
# FALLBACK_MODEL_VALIDATION=deepseek-flash
|
||||
|
||||
# -- Storage -----------------------------------------------------------------
|
||||
# Set GCS_BUCKET to store projects/library in Google Cloud Storage.
|
||||
@@ -44,10 +50,22 @@ GCS_BUCKET=
|
||||
|
||||
# -- CORS --------------------------------------------------------------------
|
||||
# Frontend URL(s), JSON list
|
||||
CORS_ORIGINS=["http://localhost:3000"]
|
||||
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000","http://localhost:18742","http://127.0.0.1:18742"]
|
||||
|
||||
# -- DigiKey (optional) ------------------------------------------------------
|
||||
# Enables datasheet auto-fetch and parameter-based passive auto-resolve.
|
||||
# -- Auth (self-host) --------------------------------------------------------
|
||||
# Set AUTH_JWT_SECRET to enable Periscope email/password accounts and
|
||||
# multi-user project collaborators (invite by email). Clerk keys, if set,
|
||||
# take priority over local auth.
|
||||
# AUTH_JWT_SECRET=change-me-to-a-long-random-string
|
||||
# Comma-separated emails that are admin on register (first user is always admin)
|
||||
# AUTH_ADMIN_EMAILS=you@example.com
|
||||
|
||||
# -- Clerk (optional cloud auth) ---------------------------------------------
|
||||
# CLERK_SECRET_KEY=
|
||||
# CLERK_PUBLISHABLE_KEY=
|
||||
# CLERK_JWKS_URL=
|
||||
# Optional catalog datasheet source and parameter-based passive auto-resolve.
|
||||
# Order: BOM URL → LCSC → manufacturer PDF URLs → Mouser → DigiKey.
|
||||
# DIGIKEY_CLIENT_ID=
|
||||
# DIGIKEY_CLIENT_SECRET=
|
||||
# DIGIKEY_ENVIRONMENT=production
|
||||
@@ -55,6 +73,10 @@ CORS_ORIGINS=["http://localhost:3000"]
|
||||
# DIGIKEY_LOCALE_LANGUAGE=en
|
||||
# DIGIKEY_LOCALE_CURRENCY=USD
|
||||
|
||||
# -- Mouser (optional) -------------------------------------------------------
|
||||
# Search API key from mouser.com/api-hub. Same family/packing match as DigiKey.
|
||||
# MOUSER_API_KEY=
|
||||
|
||||
# -- Email notifications (optional) ------------------------------------------
|
||||
# Gmail API via domain-wide delegation. Leave EMAIL_SENDER empty to disable.
|
||||
# Service account credentials come from GOOGLE_APPLICATION_CREDENTIALS.
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
# Pinscope Backend
|
||||
# Periscope Backend
|
||||
|
||||
FastAPI application providing async pipeline orchestration, project storage, and SSE progress streaming. Wraps the `pinscopex/` core library — calls existing functions with local paths, adds no domain logic of its own.
|
||||
FastAPI application providing async pipeline orchestration, project storage, and SSE progress streaming. Wraps the `periscopex/` core library — calls existing functions with local paths, adds no domain logic of its own.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -9,7 +9,7 @@ FastAPI application providing async pipeline orchestration, project storage, and
|
||||
python3 -m uvicorn backend.main:app --reload # localhost:8000
|
||||
```
|
||||
|
||||
Config reads from `.env` at project root (see `config.py`). Key settings: `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` (default `claude-sonnet-4-6`), per-stage model overrides (`model_pintable`, `model_pattern`, `model_specs`, `model_validation`, `model_auto_resolve`), `CORS_ORIGINS`, `DIGIKEY_CLIENT_ID`, `DIGIKEY_CLIENT_SECRET`, `DIGIKEY_ENVIRONMENT`.
|
||||
Config reads from `backend/.env` (see `config.py`). Key settings: `DEEPSEEK_API_KEY`, `DEEPSEEK_MODEL`, per-stage DeepSeek overrides (`model_pintable_deepseek`, `model_validation_deepseek`, …), `PROVIDER_DEFAULT` (default `deepseek`), optional `ANTHROPIC_API_KEY` / `GEMINI_API_KEY`, `CORS_ORIGINS`, DigiKey keys.
|
||||
|
||||
For local mode, leave `GCS_BUCKET` empty — uses `LocalStorageBackend` (`data/` directory) and no auth (user_id defaults to `"local"`, admin access granted).
|
||||
|
||||
@@ -22,7 +22,7 @@ backend/
|
||||
├── _version.py # Reads app version from frontend/content/changelog.md (single source of truth)
|
||||
├── Dockerfile # Python 3.12-slim, copies taxonomy/ + changelog.md for runtime
|
||||
├── skills_manifest.json # Claude Console Skill IDs (extract-pintable, extract-pattern, extract-specs)
|
||||
├── pinscopex/ # Core library (models, parsers, graph, validator, taxonomy, derating)
|
||||
├── periscopex/ # Core library (models, parsers, graph, validator, taxonomy, derating)
|
||||
│ ├── utils.py # Shared utilities: safe_mpn(), natural_sort_key()
|
||||
│ └── resolve_passives.py # Passive MPN pattern matching + value decoders (R/C/L)
|
||||
├── middleware/
|
||||
@@ -63,7 +63,7 @@ All file I/O goes through `StorageBackend` (protocol in `services/storage.py`):
|
||||
|
||||
Storage keys follow GCS-style paths: `users/{user_id}/projects/{id}/uploads/bom.csv`
|
||||
|
||||
The `pinscopex/` core library is **unaware of storage** — it operates on local paths. During pipeline execution, `PipelineWorkspace` downloads files to a temp dir, runs `pinscopex/` functions locally, then uploads results back.
|
||||
The `periscopex/` core library is **unaware of storage** — it operates on local paths. During pipeline execution, `PipelineWorkspace` downloads files to a temp dir, runs `periscopex/` functions locally, then uploads results back.
|
||||
|
||||
## Project Storage
|
||||
|
||||
@@ -108,7 +108,7 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
3. **Extract Passives** — Pattern-based extraction per MPN group, then a specs fallback per MPN.
|
||||
3.5. **DigiKey Auto-Resolve (exact MPN)** — Fallback for unresolved passives; parameters mapped to taxonomy specs via Haiku. Requires exact MPN match so the shared `library/passives/` stays clean.
|
||||
3.6. **Value Fallback (R/C/L/FB only)** — When DigiKey misses, parse the BOM `Value` string via Haiku into typed passive specs. Per-project only; never written to the shared library.
|
||||
4. **Build Graph** — Call `pinscopex.graph.build_graph()` with local temp paths
|
||||
4. **Build Graph** — Call `periscopex.graph.build_graph()` with local temp paths
|
||||
5. **BOM Summary** — Collate components from design graph (no AI)
|
||||
6. **Derating Table** — Capacitor voltage derating computation (no AI)
|
||||
7. **Direct Datasheet Review** — Per-IC (isolated): Claude reads the datasheet PDF + circuit neighborhood from the graph, compares to reference application circuit, and submits findings via graph query tools. ICs are reviewed **concurrently**, up to `IC_CONCURRENCY` in flight at once.
|
||||
@@ -118,7 +118,7 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
## Key Patterns
|
||||
|
||||
- **StorageBackend protocol** — all file I/O is abstracted; swap local/GCS via `GCS_BUCKET` env var
|
||||
- **PipelineWorkspace** — downloads to temp dir, runs pinscopex locally, uploads results
|
||||
- **PipelineWorkspace** — downloads to temp dir, runs periscopex locally, uploads results
|
||||
- **BillingHook seam (open-core)** — core code reaches billing exclusively through `services/billing_hook.py:get_billing()`. In this repo that's `NullBilling`: every pipeline runs free and no billing routes are mounted. Never import billing modules directly from core code — go through the hook.
|
||||
- **Auth middleware** — JWT verification via a JWKS endpoint; disabled when `CLERK_JWKS_URL` is empty (local mode: `user_id="local"`, `is_admin()` returns True)
|
||||
- **AsyncAnthropic** for all Claude API calls — extraction and validation
|
||||
@@ -131,10 +131,10 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
- **Taxonomy specs schemas** — auto-generated via Claude per type/subtype; extraction discards parameters not in schema (`extra_specs`)
|
||||
- **Shared router deps** — `routers/deps.py` centralizes `get_storage()`, `get_user_id()`, `resolve_or_404()` across all routers
|
||||
- **DigiKey OAuth2** — Token caching in `services/digikey.py`; `_find_product` requires exact MPN (no silent first-match fallback)
|
||||
- **Version stamping** — `backend/_version.py` reads the latest `##` heading from `frontend/content/changelog.md` and exports `PINSCOPE_VERSION`; stamped onto `ProjectMeta.pinscope_version` at `/start`
|
||||
- **Version stamping** — `backend/_version.py` reads the latest `##` heading from `frontend/content/changelog.md` and exports `PERISCOPE_VERSION`; stamped onto `ProjectMeta.periscope_version` at `/start`
|
||||
- **Datasheet page trimming** — `_select_pages()` in `extraction.py` keyword-trims large PDFs to reduce token costs
|
||||
- **Content-addressed datasheets** — `datasheet_store.py` writes PDFs to `library/datasheets/blobs/{md5}.pdf` and maps MPNs via refs
|
||||
- **Passive value decoders** — `pinscopex/resolve_passives.py` decodes EIA-198, R-notation, letter-decimal, EIA3/EIA4 for R/C/L values
|
||||
- **Passive value decoders** — `periscopex/resolve_passives.py` decodes EIA-198, R-notation, letter-decimal, EIA3/EIA4 for R/C/L values
|
||||
- **Collaborator access** — `resolve_or_404()` grants access to both owner and collaborators
|
||||
- **Per-IC review isolation** — In `services/validation.py`, each IC review is wrapped so a single bad payload is captured as a skipped component rather than aborting the run
|
||||
|
||||
@@ -144,5 +144,5 @@ The pipeline runs async via `asyncio.create_task()`. Progress emitted as SSE eve
|
||||
- Keep all storage operations in `services/projects.py` (uses `StorageBackend`)
|
||||
- Routers are thin — validate input, call service, return response
|
||||
- Thread `user_id` from `request.state` through to all service calls
|
||||
- Don't import from `backend/` in `pinscopex/` — dependency flows one way
|
||||
- Don't import from `backend/` in `periscopex/` — dependency flows one way
|
||||
- CORS is configured for `localhost:3000` by default; override with `CORS_ORIGINS` env var
|
||||
|
||||
+9
-5
@@ -1,4 +1,5 @@
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -18,11 +19,14 @@ COPY backend/ /app/backend/
|
||||
# Taxonomy: fallback for local mode; GCS mode downloads from bucket
|
||||
COPY taxonomy/ /app/taxonomy/
|
||||
|
||||
# Changelog: single source of truth for the user-facing Pinscope version.
|
||||
# Read by backend/_version.py at startup and stamped onto each new pipeline run.
|
||||
# Staged into backend/ by cloudbuild before this step runs so the broad
|
||||
# `frontend/` exclude in .dockerignore doesn't block the COPY.
|
||||
COPY backend/_changelog.md /app/changelog.md
|
||||
# Extraction skills (SKILL.md + validate.py) — required for DeepSeek/Gemini
|
||||
COPY skills/ /app/skills/
|
||||
|
||||
# Changelog: single source of truth for the user-facing Periscope version.
|
||||
COPY frontend/content/changelog.md /app/changelog.md
|
||||
|
||||
# ImpedenceFinder closed-form engine (no OpenEMS / pcbnew).
|
||||
COPY vendor/ /app/vendor/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
"""Pinscope app version, sourced from frontend/content/changelog.md.
|
||||
"""Periscope app version, sourced from frontend/content/changelog.md.
|
||||
|
||||
The changelog is the single source of truth for the user-facing version.
|
||||
The Dockerfile copies it into the image at /app/changelog.md; locally we
|
||||
@@ -23,7 +23,7 @@ _VERSION_RE = re.compile(r"^##\s+(\d+\.\d+\.\d+)\b", re.MULTILINE)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_pinscope_version() -> str:
|
||||
def get_periscope_version() -> str:
|
||||
for path in _candidate_paths():
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -35,4 +35,4 @@ def get_pinscope_version() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
PINSCOPE_VERSION = get_pinscope_version()
|
||||
PERISCOPE_VERSION = get_periscope_version()
|
||||
|
||||
+111
-15
@@ -19,7 +19,32 @@ _SKILLS_MANIFEST: dict = (
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Anthropic
|
||||
# DeepSeek (default provider — OpenAI-compatible Chat Completions)
|
||||
deepseek_api_key: str = ""
|
||||
deepseek_base_url: str = "https://api.deepseek.com"
|
||||
deepseek_model: str = "deepseek-flash"
|
||||
deepseek_vision_model: str = "deepseek-flash"
|
||||
# "enabled" (default) or "disabled". DeepSeek V4 thinks by default;
|
||||
# disable to cut cost on simple mapping calls.
|
||||
deepseek_thinking: str = "enabled"
|
||||
# Official values: low | high | max. Review sessions with
|
||||
# max_tokens >= 16000 still bump to "high" in the provider.
|
||||
deepseek_reasoning_effort: str = "high"
|
||||
# PDF ingest: DeepSeek does not accept native PDFs. Text is always
|
||||
# extracted; page images are attached only when the stage model is a
|
||||
# vision model (see model_*_deepseek defaults below).
|
||||
deepseek_pdf_max_chars: int = 500_000
|
||||
deepseek_pdf_image_pages: int = 32
|
||||
|
||||
# Per-stage DeepSeek model overrides (fall back to deepseek_model)
|
||||
model_pintable_deepseek: str = "deepseek-flash"
|
||||
model_pattern_deepseek: str = "deepseek-flash"
|
||||
model_specs_deepseek: str = "deepseek-flash"
|
||||
model_validation_deepseek: str = "deepseek-flash"
|
||||
model_auto_resolve_deepseek: str = "deepseek-flash"
|
||||
model_normalize_deepseek: str = "deepseek-flash"
|
||||
|
||||
# Anthropic (optional fallback)
|
||||
anthropic_api_key: str = ""
|
||||
anthropic_model: str = "claude-sonnet-4-6"
|
||||
|
||||
@@ -44,9 +69,8 @@ class Settings(BaseSettings):
|
||||
model_normalize_gemini: str = ""
|
||||
|
||||
# Provider routing — provider_default is the global default; per-stage
|
||||
# overrides win when non-empty. Set provider_validation=gemini to route
|
||||
# the validation stage to Gemini while leaving extraction on Anthropic.
|
||||
provider_default: str = "anthropic"
|
||||
# overrides win when non-empty. Valid values: deepseek | anthropic | gemini.
|
||||
provider_default: str = "deepseek"
|
||||
provider_pintable: str = ""
|
||||
provider_pattern: str = ""
|
||||
provider_specs: str = ""
|
||||
@@ -55,10 +79,10 @@ class Settings(BaseSettings):
|
||||
provider_normalize: str = ""
|
||||
|
||||
# Per-stage fallback provider/model — used if the primary stage call
|
||||
# raises (e.g. Gemini 503 UNAVAILABLE). Leave empty to disable fallback
|
||||
# raises (e.g. DeepSeek 503). Leave empty to disable fallback
|
||||
# for that stage. If fallback_provider_<stage> is set but
|
||||
# fallback_model_<stage> is empty, the fallback uses that provider's
|
||||
# default model (anthropic_model or gemini_model).
|
||||
# default model (deepseek_model, anthropic_model, or gemini_model).
|
||||
fallback_provider_pintable: str = ""
|
||||
fallback_provider_pattern: str = ""
|
||||
fallback_provider_specs: str = ""
|
||||
@@ -90,15 +114,23 @@ class Settings(BaseSettings):
|
||||
# Paths (relative to project root, used by LocalStorageBackend)
|
||||
data_dir: Path = _PROJECT_ROOT / "data"
|
||||
taxonomy_dir: Path = _PROJECT_ROOT / "taxonomy"
|
||||
skills_dir: Path = _PROJECT_ROOT / "skills"
|
||||
|
||||
# GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend)
|
||||
gcs_bucket: str = ""
|
||||
|
||||
# Clerk authentication
|
||||
# Clerk authentication (cloud). When set, takes priority over local auth.
|
||||
clerk_secret_key: str = ""
|
||||
clerk_publishable_key: str = ""
|
||||
clerk_jwks_url: str = ""
|
||||
|
||||
# Local Periscope auth (self-host). Set AUTH_JWT_SECRET to enable email/password
|
||||
# accounts and multi-user project collaborators without Clerk.
|
||||
auth_jwt_secret: str = ""
|
||||
# Comma-separated emails that become admin on register (in addition to the
|
||||
# first account, which is always admin).
|
||||
auth_admin_emails: str = ""
|
||||
|
||||
# DigiKey API (optional — enables auto-fetch datasheets)
|
||||
digikey_client_id: str = ""
|
||||
digikey_client_secret: str = ""
|
||||
@@ -107,6 +139,9 @@ class Settings(BaseSettings):
|
||||
digikey_locale_language: str = "en"
|
||||
digikey_locale_currency: str = "USD"
|
||||
|
||||
# Mouser Search API (optional — fourth datasheet source)
|
||||
mouser_api_key: str = ""
|
||||
|
||||
# Purple Parts API (optional — converts LCSC codes to MPNs before DigiKey)
|
||||
purple_parts_url: str = ""
|
||||
purple_parts_api_key: str = ""
|
||||
@@ -139,10 +174,15 @@ class Settings(BaseSettings):
|
||||
survey_sheet_id: str = ""
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000"]
|
||||
cors_origins: list[str] = [
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:18742",
|
||||
"http://127.0.0.1:18742",
|
||||
]
|
||||
|
||||
# Cloud Run Job worker (pipeline runner)
|
||||
pipeline_worker_job_name: str = "pinscopex-pipeline-worker"
|
||||
pipeline_worker_job_name: str = "periscopex-pipeline-worker"
|
||||
pipeline_worker_region: str = "us-central1"
|
||||
pipeline_worker_project: str = "" # GCP project id; defaults to GOOGLE_CLOUD_PROJECT or metadata
|
||||
pipeline_worker_timeout_seconds: int = 3600
|
||||
@@ -166,6 +206,10 @@ class Settings(BaseSettings):
|
||||
def use_digikey(self) -> bool:
|
||||
return bool(self.digikey_client_id and self.digikey_client_secret)
|
||||
|
||||
@property
|
||||
def use_mouser(self) -> bool:
|
||||
return bool(self.mouser_api_key)
|
||||
|
||||
@property
|
||||
def use_purple_parts(self) -> bool:
|
||||
return bool(self.purple_parts_url and self.purple_parts_api_key)
|
||||
@@ -175,28 +219,48 @@ class Settings(BaseSettings):
|
||||
return bool(self.gcs_bucket)
|
||||
|
||||
@property
|
||||
def use_auth(self) -> bool:
|
||||
def use_clerk(self) -> bool:
|
||||
return bool(self.clerk_secret_key and self.clerk_jwks_url)
|
||||
|
||||
@property
|
||||
def use_local_auth(self) -> bool:
|
||||
"""Self-host email/password auth when JWT secret is set and Clerk is not."""
|
||||
return bool(self.auth_jwt_secret) and not self.use_clerk
|
||||
|
||||
@property
|
||||
def use_auth(self) -> bool:
|
||||
return self.use_clerk or self.use_local_auth
|
||||
|
||||
@property
|
||||
def use_email(self) -> bool:
|
||||
return bool(self.email_sender and self.email_frontend_url)
|
||||
|
||||
def provider_for_stage(self, stage: str) -> str:
|
||||
"""Return the LLM provider name for a pipeline stage."""
|
||||
"""Return the LLM provider name for a pipeline stage.
|
||||
|
||||
Anthropic is never used: any ``PROVIDER_*=anthropic`` override is
|
||||
coerced to DeepSeek.
|
||||
"""
|
||||
override = getattr(self, f"provider_{stage}", "")
|
||||
return override or self.provider_default
|
||||
name = override or self.provider_default
|
||||
if name == "anthropic":
|
||||
return "deepseek"
|
||||
return name
|
||||
|
||||
def model_for_stage(self, stage: str) -> str:
|
||||
"""Return the model for a pipeline stage, provider-aware.
|
||||
|
||||
For Anthropic: falls back to model_<stage>, then anthropic_model.
|
||||
For DeepSeek: falls back to model_<stage>_deepseek, then deepseek_model.
|
||||
For Gemini: falls back to model_<stage>_gemini, then gemini_model.
|
||||
For Anthropic: falls back to model_<stage>, then anthropic_model.
|
||||
"""
|
||||
provider = self.provider_for_stage(stage)
|
||||
if provider == "gemini":
|
||||
override = getattr(self, f"model_{stage}_gemini", "")
|
||||
return override or self.gemini_model
|
||||
if provider == "deepseek":
|
||||
override = getattr(self, f"model_{stage}_deepseek", "")
|
||||
return override or self.deepseek_model
|
||||
override = getattr(self, f"model_{stage}", "")
|
||||
return override or self.anthropic_model
|
||||
|
||||
@@ -206,13 +270,45 @@ class Settings(BaseSettings):
|
||||
when the primary provider raises.
|
||||
"""
|
||||
fb_provider = getattr(self, f"fallback_provider_{stage}", "")
|
||||
if not fb_provider:
|
||||
if not fb_provider or fb_provider == "anthropic":
|
||||
return None
|
||||
fb_model = getattr(self, f"fallback_model_{stage}", "")
|
||||
if not fb_model:
|
||||
fb_model = self.gemini_model if fb_provider == "gemini" else self.anthropic_model
|
||||
if fb_provider == "gemini":
|
||||
fb_model = self.gemini_model
|
||||
elif fb_provider == "deepseek":
|
||||
fb_model = self.deepseek_model
|
||||
else:
|
||||
fb_model = self.anthropic_model
|
||||
return (fb_provider, fb_model)
|
||||
|
||||
def default_model_for_provider(self, provider: str) -> str:
|
||||
if provider == "gemini":
|
||||
return self.gemini_model
|
||||
if provider == "deepseek":
|
||||
return self.deepseek_model
|
||||
return self.anthropic_model
|
||||
|
||||
def has_llm_credentials(self) -> bool:
|
||||
"""True if the configured default provider has an API key."""
|
||||
name = self.provider_default
|
||||
if name == "anthropic":
|
||||
name = "deepseek"
|
||||
if name == "deepseek":
|
||||
return bool(self.deepseek_api_key)
|
||||
if name == "gemini":
|
||||
return bool(self.gemini_api_key)
|
||||
return bool(self.deepseek_api_key)
|
||||
|
||||
def get_skill_or_none(self, name: str) -> tuple[str | None, str | None]:
|
||||
"""Return (skill_id, version) or (None, None) if the Anthropic
|
||||
Console skill is not in the manifest. DeepSeek/Gemini extraction
|
||||
inlines SKILL.md locally and does not need a skill_id."""
|
||||
entry = _SKILLS_MANIFEST.get(name)
|
||||
if not entry:
|
||||
return None, None
|
||||
return entry.get("skill_id"), entry.get("latest_version")
|
||||
|
||||
def get_default_model_version(self) -> str:
|
||||
"""Return the default model_version for new extractions from skills_manifest.json."""
|
||||
return _SKILLS_MANIFEST.get("default_model_version", "1.0.0")
|
||||
|
||||
+30
-13
@@ -1,4 +1,4 @@
|
||||
"""PinscopeX backend — FastAPI application."""
|
||||
"""PeriscopeX backend — FastAPI application."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from backend.config import settings
|
||||
from backend.routers import admin, contact, feedback, pipeline, projects, reports, survey
|
||||
from backend.routers import admin, auth, contact, feedback, impedance, pipeline, projects, reports, survey
|
||||
from backend.services.projects import ProjectNotFound
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
@@ -35,14 +35,18 @@ async def lifespan(app: FastAPI):
|
||||
env = os.getenv("ENVIRONMENT", "").lower()
|
||||
if env == "production" and not settings.use_auth:
|
||||
raise RuntimeError(
|
||||
"CLERK_JWKS_URL and CLERK_SECRET_KEY must be set in production. "
|
||||
"Authentication cannot be disabled in production."
|
||||
"Production requires authentication: set AUTH_JWT_SECRET "
|
||||
"(local Periscope accounts) or CLERK_JWKS_URL + CLERK_SECRET_KEY."
|
||||
)
|
||||
if not settings.use_auth:
|
||||
logger.warning(
|
||||
"Authentication is DISABLED — all users have full access. "
|
||||
"This is only safe for local development."
|
||||
)
|
||||
elif settings.use_local_auth:
|
||||
logger.info("Local Periscope authentication enabled (AUTH_JWT_SECRET)")
|
||||
elif settings.use_clerk:
|
||||
logger.info("Clerk authentication enabled")
|
||||
if not settings.billing_enabled:
|
||||
logger.warning(
|
||||
"Billing is DISABLED — pipelines run free and the billing/credits "
|
||||
@@ -55,9 +59,14 @@ async def lifespan(app: FastAPI):
|
||||
if isinstance(app.state.storage, LocalStorageBackend):
|
||||
base = settings.data_dir
|
||||
(base / "users").mkdir(parents=True, exist_ok=True)
|
||||
(base / "auth" / "users").mkdir(parents=True, exist_ok=True)
|
||||
(base / "auth" / "by_email").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "extracted").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "patterns").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "models").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "passives").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "datasheets" / "refs").mkdir(parents=True, exist_ok=True)
|
||||
(base / "library" / "datasheets" / "blobs").mkdir(parents=True, exist_ok=True)
|
||||
yield
|
||||
# Pipelines run in a separate Cloud Run Job worker (or local
|
||||
# subprocess in dev), so the API process has nothing to clean up
|
||||
@@ -81,31 +90,37 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Extract user_id from Clerk JWT or default to local dev user."""
|
||||
"""Extract user_id from JWT (Clerk or local) or default to local dev user."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Let CORS preflight through — browsers send OPTIONS without credentials
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
# Public endpoints that don't require authentication
|
||||
if request.url.path == "/api/contact":
|
||||
if request.url.path in {
|
||||
"/api/contact",
|
||||
"/api/auth/mode",
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
}:
|
||||
request.state.user_id = LOCAL_DEV_USER
|
||||
return await call_next(request)
|
||||
if settings.use_auth:
|
||||
from backend.middleware.auth import verify_clerk_token
|
||||
from backend.middleware.auth import verify_request_user
|
||||
|
||||
user_id = await verify_clerk_token(request)
|
||||
user_id = await verify_request_user(request)
|
||||
if user_id is None:
|
||||
is_production = os.getenv("ENVIRONMENT", "").lower() == "production"
|
||||
if is_production:
|
||||
# Local auth (and production) require a valid token for API routes.
|
||||
if is_production or settings.use_local_auth:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Authentication required"},
|
||||
)
|
||||
# Non-production: fall back to local dev user so Clerk config
|
||||
# doesn't block local development when no token is present.
|
||||
# Non-production Clerk: fall back so missing token doesn't block
|
||||
# local development when Clerk is configured but unused.
|
||||
user_id = LOCAL_DEV_USER
|
||||
request.state.user_id = user_id
|
||||
else:
|
||||
@@ -115,7 +130,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="PinscopeX",
|
||||
title="PeriscopeX",
|
||||
description="Agentic schematic validation API",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
@@ -131,7 +146,7 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["content-type", "authorization"],
|
||||
expose_headers=["X-Datasheet-Url"],
|
||||
expose_headers=["X-Datasheet-Url", "X-Datasheet-Source"],
|
||||
)
|
||||
|
||||
@app.exception_handler(ProjectNotFound)
|
||||
@@ -144,7 +159,9 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound):
|
||||
app.include_router(projects.router, prefix="/api")
|
||||
app.include_router(pipeline.router, prefix="/api")
|
||||
app.include_router(reports.router, prefix="/api")
|
||||
app.include_router(impedance.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
if settings.billing_enabled:
|
||||
# Import guarded too: with billing disabled the core never loads the
|
||||
# billing/credits routers (or, transitively, the Stripe SDK).
|
||||
|
||||
+59
-34
@@ -1,12 +1,7 @@
|
||||
"""Clerk JWT verification for FastAPI.
|
||||
|
||||
Validates JWT tokens from the Authorization header against Clerk's JWKS endpoint.
|
||||
Extracts user_id (sub claim) for per-user storage scoping.
|
||||
"""
|
||||
"""JWT verification for FastAPI (Clerk JWKS or local Periscope HS256)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
@@ -16,7 +11,17 @@ from backend.config import settings
|
||||
|
||||
# JWKS cache
|
||||
_jwks_client: jwt.PyJWKClient | None = None
|
||||
_SKIP_PATHS = {"/docs", "/openapi.json", "/redoc", "/health", "/api/billing/webhook"}
|
||||
_SKIP_PATHS = {
|
||||
"/docs",
|
||||
"/openapi.json",
|
||||
"/redoc",
|
||||
"/health",
|
||||
"/api/billing/webhook",
|
||||
"/api/auth/mode",
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/contact",
|
||||
}
|
||||
|
||||
|
||||
def _get_jwks_client() -> jwt.PyJWKClient:
|
||||
@@ -24,38 +29,30 @@ def _get_jwks_client() -> jwt.PyJWKClient:
|
||||
if _jwks_client is None:
|
||||
jwks_url = settings.clerk_jwks_url
|
||||
if not jwks_url:
|
||||
# Default Clerk JWKS URL derived from publishable key
|
||||
# Clerk publishable keys start with pk_test_ or pk_live_
|
||||
# JWKS is at https://{clerk-frontend-api}/.well-known/jwks.json
|
||||
# The user must set CLERK_JWKS_URL explicitly
|
||||
raise RuntimeError(
|
||||
"CLERK_JWKS_URL must be set for authentication. "
|
||||
"CLERK_JWKS_URL must be set for Clerk authentication. "
|
||||
"Find it in your Clerk dashboard under API Keys."
|
||||
)
|
||||
_jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True)
|
||||
return _jwks_client
|
||||
|
||||
|
||||
async def verify_clerk_token(request: Request) -> str | None:
|
||||
"""Verify Clerk JWT and return user_id, or None if invalid.
|
||||
def _bearer_or_query_token(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
return auth_header[7:]
|
||||
# EventSource/SSE can't send headers
|
||||
return request.query_params.get("token")
|
||||
|
||||
Returns None for:
|
||||
- Missing Authorization header
|
||||
- Invalid/expired token
|
||||
- Skip paths (docs, health)
|
||||
"""
|
||||
# Skip auth for docs/health endpoints
|
||||
|
||||
async def verify_clerk_token(request: Request) -> str | None:
|
||||
"""Verify Clerk JWT and return user_id, or None if invalid."""
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return "anonymous"
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
# Fallback: check query param (EventSource/SSE can't send headers)
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
return None
|
||||
else:
|
||||
token = auth_header[7:]
|
||||
token = _bearer_or_query_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
client = _get_jwks_client()
|
||||
@@ -67,19 +64,20 @@ async def verify_clerk_token(request: Request) -> str | None:
|
||||
algorithms=["RS256"],
|
||||
options={
|
||||
"verify_exp": True,
|
||||
"verify_aud": False, # Clerk doesn't always set aud
|
||||
"verify_aud": False,
|
||||
"verify_iss": True,
|
||||
},
|
||||
# Clerk tokens use the Clerk instance URL as issuer
|
||||
# e.g. https://abc123.clerk.accounts.dev from https://abc123.clerk.accounts.dev/.well-known/jwks.json
|
||||
issuer=settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") if settings.clerk_jwks_url else None,
|
||||
leeway=10, # 10 second clock skew tolerance
|
||||
issuer=(
|
||||
settings.clerk_jwks_url.replace("/.well-known/jwks.json", "")
|
||||
if settings.clerk_jwks_url
|
||||
else None
|
||||
),
|
||||
leeway=10,
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
return user_id
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
@@ -88,3 +86,30 @@ async def verify_clerk_token(request: Request) -> str | None:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def verify_local_token(request: Request) -> str | None:
|
||||
"""Verify Periscope local JWT and return user_id, or None if invalid."""
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return "anonymous"
|
||||
|
||||
token = _bearer_or_query_token(request)
|
||||
if not token:
|
||||
return None
|
||||
|
||||
from backend.services.local_jwt import decode_token
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
user_id = payload.get("sub")
|
||||
return str(user_id) if user_id else None
|
||||
|
||||
|
||||
async def verify_request_user(request: Request) -> str | None:
|
||||
"""Dispatch to Clerk or local JWT verification."""
|
||||
if settings.use_clerk:
|
||||
return await verify_clerk_token(request)
|
||||
if settings.use_local_auth:
|
||||
return await verify_local_token(request)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Parametric PCB antenna templates → segments, SVG, KiCad footprint.
|
||||
|
||||
Templates (IFA / meander / stub) use a documented λ/4 electrical length with
|
||||
εeff≈(εr+1)/2. This is a routing-first drawing aid — not an EM / VSWR result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
_C_MPS = 299_792_458.0
|
||||
|
||||
AntennaTemplate = Literal["ifa", "meander", "stub"]
|
||||
FitStatus = Literal["ok", "scaled", "overflow", "need_f0"]
|
||||
|
||||
_NOTE = (
|
||||
"Parametric template from λ/4 (εeff≈(εr+1)/2) — routing aid only, "
|
||||
"not an EM / VSWR result. Tune matching on the board."
|
||||
)
|
||||
|
||||
|
||||
class AntennaSegment(BaseModel):
|
||||
points: list[tuple[float, float]] # local mm, origin = feed
|
||||
width_mm: float
|
||||
|
||||
|
||||
class AntennaGeometry(BaseModel):
|
||||
template: AntennaTemplate
|
||||
fit: FitStatus
|
||||
segments: list[AntennaSegment] = Field(default_factory=list)
|
||||
total_length_mm: float | None = None
|
||||
length_ideal_mm: float | None = None
|
||||
scale: float = 1.0
|
||||
svg: str | None = None
|
||||
kicad_mod: str | None = None
|
||||
footprint_name: str | None = None
|
||||
note: str = _NOTE
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def quarter_wave_mm(f0_mhz: float, er: float) -> float:
|
||||
"""Electrical λ/4 in mm using εeff≈(εr+1)/2."""
|
||||
eeff = (er + 1.0) / 2.0
|
||||
f_hz = f0_mhz * 1e6
|
||||
return (_C_MPS / (4.0 * f_hz * math.sqrt(eeff))) * 1e3
|
||||
|
||||
|
||||
def build_geometry(
|
||||
template: AntennaTemplate,
|
||||
*,
|
||||
f0_mhz: float | None,
|
||||
w_mm: float,
|
||||
er: float,
|
||||
zone_bbox_mm: tuple[float, float, float, float] | None = None,
|
||||
feed_xy: tuple[float, float] | None = None,
|
||||
) -> AntennaGeometry:
|
||||
if f0_mhz is None or f0_mhz <= 0:
|
||||
return AntennaGeometry(
|
||||
template=template,
|
||||
fit="need_f0",
|
||||
detail="Set f0 (MHz) to generate radiator geometry.",
|
||||
)
|
||||
if w_mm <= 0:
|
||||
return AntennaGeometry(
|
||||
template=template,
|
||||
fit="overflow",
|
||||
detail="Feed width w_mm must be > 0.",
|
||||
)
|
||||
|
||||
ideal = quarter_wave_mm(f0_mhz, er)
|
||||
segs_local, length = _template_segments(template, ideal, w_mm)
|
||||
fit: FitStatus = "ok"
|
||||
scale = 1.0
|
||||
detail = f"{template.upper()} template at {f0_mhz:g} MHz."
|
||||
|
||||
avail = _available_span(zone_bbox_mm, feed_xy)
|
||||
if avail is not None:
|
||||
need_w, need_h = _bbox_size(segs_local)
|
||||
free_w, free_h = avail
|
||||
max_span = max(free_w, free_h)
|
||||
need_span = max(need_w, need_h)
|
||||
if need_span > max_span + 1e-6 and max_span > 0:
|
||||
scale = max_span / need_span
|
||||
min_scale = 0.45
|
||||
if scale < min_scale:
|
||||
return AntennaGeometry(
|
||||
template=template,
|
||||
fit="overflow",
|
||||
length_ideal_mm=round(ideal, 2),
|
||||
total_length_mm=None,
|
||||
scale=round(scale, 4),
|
||||
detail=(
|
||||
f"Zone too small for {template.upper()} "
|
||||
f"(need ~{need_span:.1f} mm, have {max_span:.1f} mm)."
|
||||
),
|
||||
)
|
||||
segs_local = _scale_segments(segs_local, scale)
|
||||
length *= scale
|
||||
fit = "scaled"
|
||||
detail = (
|
||||
f"Scaled to {scale:.2f}× to fit antenna zone "
|
||||
f"({max_span:.1f} mm free). Retune matching."
|
||||
)
|
||||
|
||||
name = f"Antenna_{template.upper()}_{int(round(f0_mhz))}"
|
||||
svg = _segments_to_svg(segs_local, w_mm)
|
||||
mod = _segments_to_kicad_mod(name, segs_local, w_mm, template)
|
||||
|
||||
return AntennaGeometry(
|
||||
template=template,
|
||||
fit=fit,
|
||||
segments=segs_local,
|
||||
total_length_mm=round(length, 2),
|
||||
length_ideal_mm=round(ideal, 2),
|
||||
scale=round(scale, 4),
|
||||
svg=svg,
|
||||
kicad_mod=mod,
|
||||
footprint_name=name,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _template_segments(
|
||||
template: AntennaTemplate,
|
||||
length_mm: float,
|
||||
w_mm: float,
|
||||
) -> tuple[list[AntennaSegment], float]:
|
||||
if template == "ifa":
|
||||
return _ifa(length_mm, w_mm)
|
||||
if template == "meander":
|
||||
return _meander(length_mm, w_mm)
|
||||
return _stub(length_mm, w_mm)
|
||||
|
||||
|
||||
def _ifa(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
|
||||
"""Inverted-F: shorting stub + horizontal arm; feed on the arm at origin.
|
||||
|
||||
Local: feed (0,0) on the arm. Shorting at x=-d toward -Y (GND edge).
|
||||
Arm runs to +X. Proportions: stub ≈ 0.12 L, feed offset ≈ 0.15 L.
|
||||
"""
|
||||
L = max(length_mm, 4.0 * w_mm)
|
||||
stub_h = max(0.12 * L, 2.0 * w_mm)
|
||||
d = max(0.15 * L, 2.0 * w_mm)
|
||||
open_x = L - d
|
||||
segs = [
|
||||
AntennaSegment(points=[(-d, 0.0), (-d, -stub_h)], width_mm=w_mm),
|
||||
AntennaSegment(points=[(-d, 0.0), (open_x, 0.0)], width_mm=w_mm),
|
||||
]
|
||||
path = stub_h + L
|
||||
return segs, path
|
||||
|
||||
|
||||
def _meander(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
|
||||
"""Serpentine that consumes ~length_mm inside a compact bbox."""
|
||||
pitch = max(3.0 * w_mm, 1.2)
|
||||
run = max(length_mm / 6.0, 4.0 * w_mm)
|
||||
pts: list[tuple[float, float]] = [(0.0, 0.0)]
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
going_up = True
|
||||
consumed = 0.0
|
||||
target = max(length_mm, 4.0 * w_mm)
|
||||
guard = 0
|
||||
while consumed < target - 1e-6 and guard < 80:
|
||||
guard += 1
|
||||
dy = run if going_up else -run
|
||||
remain = target - consumed
|
||||
if remain < abs(dy):
|
||||
dy = math.copysign(remain, dy)
|
||||
y2 = y + dy
|
||||
pts.append((x, y2))
|
||||
consumed += abs(dy)
|
||||
y = y2
|
||||
if consumed >= target - 1e-6:
|
||||
break
|
||||
remain = target - consumed
|
||||
dx = min(pitch, remain)
|
||||
x2 = x + dx
|
||||
pts.append((x2, y))
|
||||
consumed += dx
|
||||
x = x2
|
||||
going_up = not going_up
|
||||
segs = [AntennaSegment(points=pts, width_mm=w_mm)]
|
||||
return segs, consumed
|
||||
|
||||
|
||||
def _stub(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
|
||||
"""Open L-stub monopole: short vertical then horizontal arm."""
|
||||
L = max(length_mm, 4.0 * w_mm)
|
||||
h = max(0.2 * L, 2.0 * w_mm)
|
||||
arm = max(L - h, 2.0 * w_mm)
|
||||
segs = [
|
||||
AntennaSegment(points=[(0.0, 0.0), (0.0, -h)], width_mm=w_mm),
|
||||
AntennaSegment(points=[(0.0, -h), (arm, -h)], width_mm=w_mm),
|
||||
]
|
||||
return segs, h + arm
|
||||
|
||||
|
||||
def _available_span(
|
||||
zone_bbox: tuple[float, float, float, float] | None,
|
||||
feed_xy: tuple[float, float] | None,
|
||||
) -> tuple[float, float] | None:
|
||||
"""Free width/height from feed into the zone (mm)."""
|
||||
if zone_bbox is None or feed_xy is None:
|
||||
return None
|
||||
xmin, ymin, xmax, ymax = zone_bbox
|
||||
fx, fy = feed_xy
|
||||
fx = min(max(fx, xmin), xmax)
|
||||
fy = min(max(fy, ymin), ymax)
|
||||
free_w = max(fx - xmin, xmax - fx)
|
||||
free_h = max(fy - ymin, ymax - fy)
|
||||
return free_w, free_h
|
||||
|
||||
|
||||
def _bbox_size(segs: list[AntennaSegment]) -> tuple[float, float]:
|
||||
xs: list[float] = []
|
||||
ys: list[float] = []
|
||||
for s in segs:
|
||||
for x, y in s.points:
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
if not xs:
|
||||
return 0.0, 0.0
|
||||
return max(xs) - min(xs), max(ys) - min(ys)
|
||||
|
||||
|
||||
def _scale_segments(
|
||||
segs: list[AntennaSegment], scale: float,
|
||||
) -> list[AntennaSegment]:
|
||||
out: list[AntennaSegment] = []
|
||||
for s in segs:
|
||||
out.append(
|
||||
AntennaSegment(
|
||||
points=[(x * scale, y * scale) for x, y in s.points],
|
||||
width_mm=s.width_mm,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _segments_to_svg(segs: list[AntennaSegment], default_w: float) -> str:
|
||||
xs: list[float] = []
|
||||
ys: list[float] = []
|
||||
for s in segs:
|
||||
for x, y in s.points:
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
if not xs:
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"/>'
|
||||
pad = max(default_w * 2, 1.0)
|
||||
xmin, xmax = min(xs) - pad, max(xs) + pad
|
||||
ymin, ymax = min(ys) - pad, max(ys) + pad
|
||||
bw = max(xmax - xmin, 1e-3)
|
||||
bh = max(ymax - ymin, 1e-3)
|
||||
paths: list[str] = []
|
||||
for s in segs:
|
||||
if len(s.points) < 2:
|
||||
continue
|
||||
d_parts = []
|
||||
for i, (x, y) in enumerate(s.points):
|
||||
cmd = "M" if i == 0 else "L"
|
||||
d_parts.append(f"{cmd}{x:.3f},{-y:.3f}")
|
||||
sw = s.width_mm
|
||||
paths.append(
|
||||
f'<path d="{" ".join(d_parts)}" fill="none" stroke="#1a1a1a" '
|
||||
f'stroke-width="{sw:.3f}" stroke-linecap="round" '
|
||||
f'stroke-linejoin="round"/>'
|
||||
)
|
||||
paths.append(
|
||||
f'<circle cx="0" cy="0" r="{max(default_w, 0.3):.3f}" fill="#c45c26"/>'
|
||||
)
|
||||
vb = f"{xmin:.3f} {-ymax:.3f} {bw:.3f} {bh:.3f}"
|
||||
body = "\n ".join(paths)
|
||||
return (
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}" '
|
||||
f'width="280" height="160" style="background:#f7f5f2">'
|
||||
f"\n {body}\n</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _segments_to_kicad_mod(
|
||||
name: str,
|
||||
segs: list[AntennaSegment],
|
||||
w_mm: float,
|
||||
template: AntennaTemplate,
|
||||
) -> str:
|
||||
lines = [
|
||||
f'(footprint "{name}"',
|
||||
" (version 20240108)",
|
||||
' (generator "periscope")',
|
||||
' (layer "F.Cu")',
|
||||
f' (descr "Periscope {template.upper()} PCB antenna template '
|
||||
f'— not EM-validated")',
|
||||
" (attr smd)",
|
||||
f' (pad "1" smd circle (at 0 0) (size {w_mm * 2:.4f} {w_mm * 2:.4f}) '
|
||||
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000001))',
|
||||
]
|
||||
if template == "ifa" and segs:
|
||||
tip = segs[0].points[-1]
|
||||
lines.append(
|
||||
f' (pad "2" smd circle (at {tip[0]:.4f} {tip[1]:.4f}) '
|
||||
f"(size {w_mm * 2:.4f} {w_mm * 2:.4f}) "
|
||||
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000002))'
|
||||
)
|
||||
uid = 10
|
||||
for s in segs:
|
||||
pts = s.points
|
||||
for i in range(len(pts) - 1):
|
||||
x1, y1 = pts[i]
|
||||
x2, y2 = pts[i + 1]
|
||||
lines.append(
|
||||
f" (fp_line (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) "
|
||||
f"(stroke (width {s.width_mm:.4f}) (type default)) "
|
||||
f'(layer "F.Cu") (uuid 00000000-0000-4000-8000-{uid:012d}))'
|
||||
)
|
||||
uid += 1
|
||||
lines.append(")")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,541 @@
|
||||
"""RF antenna verify + design recipe (schema + optional PCB).
|
||||
|
||||
Verify: matching topology from IC ANT/RF pin toward ANT footprint / ANT_FEED.
|
||||
Design: KiCad marker (ANT* footprint or ANT_FEED/RF_ANT net) → microstrip w
|
||||
for target Z0 from stackup; optional λ/4 length if f0_mhz is given;
|
||||
parametric IFA / meander / stub geometry (segments + SVG + .kicad_mod).
|
||||
|
||||
No EM/VSWR. No CPWG clearance. Geometry is a documented routing template only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.antenna_geometry import (
|
||||
AntennaGeometry,
|
||||
AntennaTemplate,
|
||||
build_geometry,
|
||||
)
|
||||
from backend.periscopex.impedance import GeometryError, solve_width
|
||||
from backend.periscopex.models import (
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
LayoutGraph,
|
||||
)
|
||||
|
||||
_C_MPS = 299_792_458.0
|
||||
|
||||
_ANT_PIN_RE = re.compile(
|
||||
r"(?:^|[_/\-])(ANT|ANTENNA|RF(?:IO|OUT|IN)?|RF_OUT|RF_IN|LNA|TX|RX)(?:$|[_/\-\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FEED_NET_RE = re.compile(
|
||||
r"^(?:ANT_FEED|ANTENNA_FEED)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ZONE_NET_RE = re.compile(
|
||||
r"(?:^|[_/\-])(antenna|ant_zone|rf_antenna)(?:$|[_/\-])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
Topology = Literal[
|
||||
"direct", "series_L", "LC", "pi", "T", "unknown", "missing",
|
||||
]
|
||||
Status = Literal["ok", "warning", "info"]
|
||||
DesignStatus = Literal["ready", "need_pcb", "need_stackup", "need_marker"]
|
||||
|
||||
|
||||
class AntennaVerifyRow(BaseModel):
|
||||
ic_ref: str
|
||||
pin: str
|
||||
net: str
|
||||
topology: Topology
|
||||
parts: list[str] = []
|
||||
target_z_ohm: float = 50.0
|
||||
status: Status = "info"
|
||||
detail: str = ""
|
||||
feed_z0: float | None = None
|
||||
feed_length_mm: float | None = None
|
||||
marker_ref: str | None = None
|
||||
|
||||
|
||||
class AntennaFeedLine(BaseModel):
|
||||
kind: str = "microstrip"
|
||||
target_z_ohm: float = 50.0
|
||||
w_mm: float | None = None
|
||||
h_mm: float | None = None
|
||||
er: float | None = None
|
||||
t_mm: float | None = None
|
||||
|
||||
|
||||
class AntennaRadiator(BaseModel):
|
||||
length_mm_suggest: float | None = None
|
||||
f0_mhz: float | None = None
|
||||
note: str = (
|
||||
"λ/4 estimate using εeff≈(εr+1)/2 — routing-first only, not an EM result."
|
||||
)
|
||||
|
||||
|
||||
class AntennaZoneInfo(BaseModel):
|
||||
net: str
|
||||
layer: str
|
||||
bbox_mm: tuple[float, float, float, float] | None = None # xmin,ymin,xmax,ymax
|
||||
area_mm2: float | None = None
|
||||
|
||||
|
||||
class AntennaDesignRecipe(BaseModel):
|
||||
status: DesignStatus
|
||||
feed_point: dict[str, Any] | None = None
|
||||
feed_line: AntennaFeedLine | None = None
|
||||
radiator: AntennaRadiator | None = None
|
||||
geometry: AntennaGeometry | None = None
|
||||
zone: AntennaZoneInfo | None = None
|
||||
keepout_checklist: list[str] = []
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class AntennaReport(BaseModel):
|
||||
verify: list[AntennaVerifyRow] = []
|
||||
design: AntennaDesignRecipe | None = None
|
||||
marker_help: str = (
|
||||
"Mark the feed join in KiCad: footprint Ref starting with ANT, "
|
||||
"or net named ANT_FEED / RF_ANT. Optional zone net 'antenna' for the canvas."
|
||||
)
|
||||
|
||||
|
||||
def build_antenna_report(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph | None = None,
|
||||
*,
|
||||
impedance_nets: dict | None = None,
|
||||
f0_mhz: float | None = None,
|
||||
target_z_ohm: float = 50.0,
|
||||
h_mm: float | None = None,
|
||||
er: float | None = None,
|
||||
t_mm: float | None = None,
|
||||
template: AntennaTemplate = "ifa",
|
||||
) -> AntennaReport:
|
||||
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
|
||||
design = build_design_recipe(
|
||||
graph, layout,
|
||||
f0_mhz=f0_mhz,
|
||||
target_z_ohm=target_z_ohm,
|
||||
h_mm=h_mm,
|
||||
template=template,
|
||||
er=er,
|
||||
t_mm=t_mm,
|
||||
)
|
||||
return AntennaReport(verify=verify, design=design)
|
||||
|
||||
|
||||
def build_design_recipe(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph | None = None,
|
||||
*,
|
||||
f0_mhz: float | None = None,
|
||||
target_z_ohm: float = 50.0,
|
||||
h_mm: float | None = None,
|
||||
er: float | None = None,
|
||||
t_mm: float | None = None,
|
||||
template: AntennaTemplate = "ifa",
|
||||
) -> AntennaDesignRecipe:
|
||||
marker = _find_marker(graph, layout)
|
||||
zone = _find_antenna_zone(layout)
|
||||
checklist = [
|
||||
"Keep copper / pours out of the antenna keepout unless the antenna datasheet allows it.",
|
||||
"Short GND return from the matching network to the RF reference.",
|
||||
"Avoid long stubs and right angles on the 50 Ω feed.",
|
||||
"Place matching parts close to the RF pin / feed point.",
|
||||
"IFA pad 2 (shorting tip) must connect to RF ground / pour edge.",
|
||||
"Place the footprint with feed (pad 1) on the ANT* / ANT_FEED join.",
|
||||
]
|
||||
|
||||
stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm)
|
||||
if marker is None and layout is None:
|
||||
return AntennaDesignRecipe(
|
||||
status="need_pcb",
|
||||
zone=zone,
|
||||
keepout_checklist=checklist,
|
||||
detail="Upload a .kicad_pcb (and mark ANT* / ANT_FEED) to compute feed width.",
|
||||
)
|
||||
if marker is None:
|
||||
return AntennaDesignRecipe(
|
||||
status="need_marker",
|
||||
zone=zone,
|
||||
keepout_checklist=checklist,
|
||||
detail="No ANT* footprint or ANT_FEED/RF_ANT net found.",
|
||||
)
|
||||
if stack is None:
|
||||
return AntennaDesignRecipe(
|
||||
status="need_stackup",
|
||||
feed_point=marker,
|
||||
zone=zone,
|
||||
keepout_checklist=checklist,
|
||||
detail="PCB stackup missing εr/h — set stackup in KiCad or pass h/er in the request.",
|
||||
)
|
||||
|
||||
h, er_v, t = stack
|
||||
try:
|
||||
w = solve_width("microstrip", target_z_ohm, h, er_v, t, s=None)
|
||||
except GeometryError as exc:
|
||||
return AntennaDesignRecipe(
|
||||
status="need_stackup",
|
||||
feed_point=marker,
|
||||
zone=zone,
|
||||
keepout_checklist=checklist,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
feed = AntennaFeedLine(
|
||||
kind="microstrip",
|
||||
target_z_ohm=target_z_ohm,
|
||||
w_mm=round(w, 4),
|
||||
h_mm=h,
|
||||
er=er_v,
|
||||
t_mm=t,
|
||||
)
|
||||
radiator = None
|
||||
if f0_mhz is not None and f0_mhz > 0:
|
||||
eeff = (er_v + 1.0) / 2.0
|
||||
f_hz = f0_mhz * 1e6
|
||||
length_m = _C_MPS / (4.0 * f_hz * math.sqrt(eeff))
|
||||
radiator = AntennaRadiator(
|
||||
length_mm_suggest=round(length_m * 1e3, 2),
|
||||
f0_mhz=f0_mhz,
|
||||
)
|
||||
|
||||
feed_xy = None
|
||||
if marker.get("x") is not None and marker.get("y") is not None:
|
||||
feed_xy = (float(marker["x"]), float(marker["y"]))
|
||||
zone_bbox = zone.bbox_mm if zone else None
|
||||
geometry = build_geometry(
|
||||
template,
|
||||
f0_mhz=f0_mhz,
|
||||
w_mm=float(feed.w_mm or 0),
|
||||
er=er_v,
|
||||
zone_bbox_mm=zone_bbox,
|
||||
feed_xy=feed_xy,
|
||||
)
|
||||
|
||||
detail = "Recipe ready — feed at w_mm; geometry is a parametric template (not EM)."
|
||||
if geometry.fit == "need_f0":
|
||||
detail = "Feed w ready — set f0 to generate IFA / meander / stub geometry."
|
||||
elif geometry.fit == "scaled":
|
||||
detail = geometry.detail
|
||||
elif geometry.fit == "overflow":
|
||||
detail = geometry.detail
|
||||
|
||||
return AntennaDesignRecipe(
|
||||
status="ready",
|
||||
feed_point=marker,
|
||||
feed_line=feed,
|
||||
radiator=radiator,
|
||||
geometry=geometry,
|
||||
zone=zone,
|
||||
keepout_checklist=checklist,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def _verify(
|
||||
graph: DesignGraph,
|
||||
layout: LayoutGraph | None,
|
||||
impedance_nets: dict | None,
|
||||
target_z: float,
|
||||
) -> list[AntennaVerifyRow]:
|
||||
z_by_net = _z0_index(impedance_nets)
|
||||
rows: list[AntennaVerifyRow] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
for pin_num, net in comp.pins.items():
|
||||
if not net or not _looks_rf_pin(graph, ref, pin_num, net, comp.component_subtype):
|
||||
continue
|
||||
topo, parts, marker, detail, status = _classify_path(graph, ref, net)
|
||||
z0, length = None, None
|
||||
if net in z_by_net:
|
||||
z0 = z_by_net[net].get("z0_avg_ohms")
|
||||
length = z_by_net[net].get("length_mm")
|
||||
elif marker and marker.get("net") and marker["net"] in z_by_net:
|
||||
info = z_by_net[marker["net"]]
|
||||
z0 = info.get("z0_avg_ohms")
|
||||
length = info.get("length_mm")
|
||||
rows.append(AntennaVerifyRow(
|
||||
ic_ref=ref,
|
||||
pin=str(pin_num),
|
||||
net=net,
|
||||
topology=topo,
|
||||
parts=parts,
|
||||
target_z_ohm=target_z,
|
||||
status=status,
|
||||
detail=detail,
|
||||
feed_z0=z0,
|
||||
feed_length_mm=length,
|
||||
marker_ref=marker.get("ref") if marker else None,
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def _looks_rf_pin(
|
||||
graph: DesignGraph,
|
||||
ref: str,
|
||||
pin_num: str,
|
||||
net: str,
|
||||
subtype: str | None,
|
||||
) -> bool:
|
||||
if _FEED_NET_RE.match(net or ""):
|
||||
return True
|
||||
if _ANT_PIN_RE.search(net or ""):
|
||||
return True
|
||||
# Pin name from netlist is often just the net; subtype helps for modules.
|
||||
sub = (subtype or "").lower()
|
||||
if sub.startswith("ic.rf") and _ANT_PIN_RE.search(net or ""):
|
||||
return True
|
||||
if sub.startswith("ic.rf"):
|
||||
# Common module pad names appear as nets
|
||||
u = (net or "").upper()
|
||||
if any(k in u for k in ("ANT", "RF", "LNA", "WIFI")):
|
||||
return True
|
||||
return bool(_ANT_PIN_RE.search(str(pin_num)))
|
||||
|
||||
|
||||
def _classify_path(
|
||||
graph: DesignGraph,
|
||||
ic_ref: str,
|
||||
start_net: str,
|
||||
) -> tuple[Topology, list[str], dict | None, str, Status]:
|
||||
"""BFS a few hops of passives toward ANT marker / connector."""
|
||||
marker = _marker_on_net(graph, start_net)
|
||||
if marker:
|
||||
return "direct", [], marker, "Feed net is the antenna marker.", "ok"
|
||||
|
||||
parts: list[str] = []
|
||||
kinds: list[str] = []
|
||||
visited_nets = {start_net}
|
||||
frontier = [start_net]
|
||||
found_marker: dict | None = None
|
||||
found_connector = False
|
||||
|
||||
for _ in range(4):
|
||||
next_frontier: list[str] = []
|
||||
for net in frontier:
|
||||
for cref in _passives_on_net(graph, net):
|
||||
if cref in parts:
|
||||
continue
|
||||
other = graph.components[cref]
|
||||
ctype = other.component_type
|
||||
if ctype == ComponentType.CONNECTOR:
|
||||
found_connector = True
|
||||
parts.append(cref)
|
||||
continue
|
||||
if cref.upper().startswith("ANT"):
|
||||
found_marker = {"ref": cref, "net": net, "kind": "footprint"}
|
||||
parts.append(cref)
|
||||
continue
|
||||
if ctype not in (
|
||||
ComponentType.RESISTOR,
|
||||
ComponentType.CAPACITOR,
|
||||
ComponentType.INDUCTOR,
|
||||
):
|
||||
continue
|
||||
parts.append(cref)
|
||||
if ctype == ComponentType.INDUCTOR:
|
||||
kinds.append("L")
|
||||
elif ctype == ComponentType.CAPACITOR:
|
||||
kinds.append("C")
|
||||
elif ctype == ComponentType.RESISTOR:
|
||||
kinds.append("R")
|
||||
for n2 in other.pins.values():
|
||||
if not n2 or n2 in visited_nets:
|
||||
continue
|
||||
visited_nets.add(n2)
|
||||
next_frontier.append(n2)
|
||||
m = _marker_on_net(graph, n2)
|
||||
if m:
|
||||
found_marker = m
|
||||
frontier = next_frontier
|
||||
if found_marker or (found_connector and not frontier):
|
||||
break
|
||||
|
||||
if found_marker or found_connector:
|
||||
topo = _topo_from_kinds(kinds)
|
||||
who = found_marker.get("ref") if found_marker else "connector"
|
||||
return topo, parts, found_marker, f"Path to {who}: {topo}.", "ok"
|
||||
|
||||
if parts:
|
||||
return (
|
||||
"unknown",
|
||||
parts,
|
||||
None,
|
||||
"Passives on RF net but no ANT* / ANT_FEED / connector reached.",
|
||||
"warning",
|
||||
)
|
||||
return (
|
||||
"missing",
|
||||
[],
|
||||
None,
|
||||
"No matching network found between RF pin and antenna marker.",
|
||||
"warning",
|
||||
)
|
||||
|
||||
|
||||
def _topo_from_kinds(kinds: list[str]) -> Topology:
|
||||
s = "".join(kinds)
|
||||
if not s:
|
||||
return "direct"
|
||||
if s in ("L",):
|
||||
return "series_L"
|
||||
if s in ("LC", "CL"):
|
||||
return "LC"
|
||||
if s.count("C") >= 2 and "L" in s:
|
||||
return "pi"
|
||||
if s.count("L") >= 2 and "C" in s:
|
||||
return "T"
|
||||
if "L" in s and "C" in s:
|
||||
return "LC"
|
||||
if "L" in s:
|
||||
return "series_L"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _passives_on_net(graph: DesignGraph, net: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
net_obj = graph.nets.get(net)
|
||||
if not net_obj:
|
||||
return out
|
||||
for pc in net_obj.pins:
|
||||
cref = pc.component_ref
|
||||
comp = graph.components.get(cref)
|
||||
if not comp or comp.component_type == ComponentType.IC:
|
||||
continue
|
||||
out.append(cref)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _marker_on_net(graph: DesignGraph, net: str) -> dict | None:
|
||||
if _FEED_NET_RE.match(net or ""):
|
||||
return {"ref": None, "net": net, "kind": "net"}
|
||||
# Dedicated join alias only when an ANT* part sits on the net.
|
||||
for cref in _passives_on_net(graph, net):
|
||||
if cref.upper().startswith("ANT"):
|
||||
return {"ref": cref, "net": net, "kind": "footprint"}
|
||||
comp = graph.components[cref]
|
||||
if comp.component_type == ComponentType.CONNECTOR and (
|
||||
cref.upper().startswith("ANT")
|
||||
or _ANT_PIN_RE.search((comp.value or "") + cref)
|
||||
):
|
||||
return {"ref": cref, "net": net, "kind": "connector"}
|
||||
return None
|
||||
|
||||
|
||||
def _find_marker(graph: DesignGraph, layout: LayoutGraph | None) -> dict | None:
|
||||
# Prefer layout footprints ANT*
|
||||
if layout:
|
||||
for ref, fp in sorted(layout.footprints.items()):
|
||||
if ref.upper().startswith("ANT"):
|
||||
net = next((p.net for p in fp.pads if p.net), None)
|
||||
return {
|
||||
"ref": ref,
|
||||
"net": net,
|
||||
"kind": "footprint",
|
||||
"x": fp.x,
|
||||
"y": fp.y,
|
||||
"layer": fp.layer,
|
||||
}
|
||||
for net_name in layout.nets:
|
||||
if _FEED_NET_RE.match(net_name) or net_name.upper() == "RF_ANT":
|
||||
# RF_ANT as board join only if ANT* footprint uses it
|
||||
if net_name.upper() == "RF_ANT":
|
||||
if not any(
|
||||
r.upper().startswith("ANT")
|
||||
for r, fp in layout.footprints.items()
|
||||
if any(p.net == net_name for p in fp.pads)
|
||||
):
|
||||
continue
|
||||
return {"ref": None, "net": net_name, "kind": "net"}
|
||||
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if ref.upper().startswith("ANT"):
|
||||
nets = [n for n in comp.pins.values() if n]
|
||||
return {
|
||||
"ref": ref,
|
||||
"net": nets[0] if nets else None,
|
||||
"kind": "footprint",
|
||||
}
|
||||
for net in comp.pins.values():
|
||||
if net and _FEED_NET_RE.match(net):
|
||||
return {"ref": ref if comp.component_type != ComponentType.IC else None,
|
||||
"net": net, "kind": "net"}
|
||||
return None
|
||||
|
||||
|
||||
def _find_antenna_zone(layout: LayoutGraph | None) -> AntennaZoneInfo | None:
|
||||
if not layout:
|
||||
return None
|
||||
for z in layout.zones:
|
||||
if not _ZONE_NET_RE.search(z.net or ""):
|
||||
continue
|
||||
bbox, area = _outline_metrics(z.outlines)
|
||||
return AntennaZoneInfo(
|
||||
net=z.net,
|
||||
layer=z.layer,
|
||||
bbox_mm=bbox,
|
||||
area_mm2=area,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _outline_metrics(
|
||||
outlines: list[list[tuple[float, float]]],
|
||||
) -> tuple[tuple[float, float, float, float] | None, float | None]:
|
||||
pts: list[tuple[float, float]] = []
|
||||
for ring in outlines:
|
||||
pts.extend(ring)
|
||||
if len(pts) < 3:
|
||||
return None, None
|
||||
xs = [p[0] for p in pts]
|
||||
ys = [p[1] for p in pts]
|
||||
bbox = (min(xs), min(ys), max(xs), max(ys))
|
||||
# Shoelace on first ring only
|
||||
ring = outlines[0]
|
||||
area = 0.0
|
||||
for i in range(len(ring)):
|
||||
x1, y1 = ring[i]
|
||||
x2, y2 = ring[(i + 1) % len(ring)]
|
||||
area += x1 * y2 - x2 * y1
|
||||
return bbox, abs(area) / 2.0
|
||||
|
||||
|
||||
def _resolve_stackup(
|
||||
layout: LayoutGraph | None,
|
||||
*,
|
||||
h_mm: float | None,
|
||||
er: float | None,
|
||||
t_mm: float | None,
|
||||
) -> tuple[float, float, float] | None:
|
||||
if h_mm and er and h_mm > 0 and er > 0:
|
||||
return float(h_mm), float(er), float(t_mm or 0.035)
|
||||
if not layout or not layout.stackup or not layout.stackup.dielectrics:
|
||||
return None
|
||||
d = layout.stackup.dielectrics[0]
|
||||
if d.height_mm <= 0 or d.er <= 0:
|
||||
return None
|
||||
t = layout.stackup.copper_thickness_mm
|
||||
return float(d.height_mm), float(d.er), float(t if t and t > 0 else 0.035)
|
||||
|
||||
|
||||
def _z0_index(impedance_nets: dict | None) -> dict[str, dict]:
|
||||
if not impedance_nets:
|
||||
return {}
|
||||
rows = impedance_nets.get("nets") or []
|
||||
out: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
name = row.get("net_name") or row.get("net")
|
||||
if name:
|
||||
out[str(name)] = row
|
||||
return out
|
||||
@@ -0,0 +1,73 @@
|
||||
"""BOM vs schematic property matching.
|
||||
|
||||
Compares per-reference MPN/value from the schematic property table against
|
||||
the uploaded BOM. Silent when the schematic map is empty (PADS/EDIF) so
|
||||
we never invent orphans from a format that has no schematic properties.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.models import Finding
|
||||
|
||||
|
||||
def _norm_mpn(value: object) -> str:
|
||||
return " ".join(str(value or "").split()).upper()
|
||||
|
||||
|
||||
def check_bom_schematic_match(
|
||||
schematic: dict[str, dict],
|
||||
bom: dict[str, dict],
|
||||
) -> list[Finding]:
|
||||
if not schematic:
|
||||
return []
|
||||
|
||||
findings: list[Finding] = []
|
||||
refs = sorted(set(schematic) | set(bom))
|
||||
for ref in refs:
|
||||
if ref.startswith("#"):
|
||||
continue
|
||||
sch = schematic.get(ref) or {}
|
||||
bom_row = bom.get(ref) or {}
|
||||
if ref not in schematic:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=str(bom_row.get("mpn") or ""),
|
||||
aspect="bom_match",
|
||||
source="bom_match",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"BOM lists {ref} but the schematic has no such reference."
|
||||
),
|
||||
why=(
|
||||
"An extra BOM line that is not in the netlist will not be "
|
||||
"validated against a datasheet and may indicate a stale BOM."
|
||||
),
|
||||
recommendation=f"Remove {ref} from the BOM or add it to the schematic.",
|
||||
rule_id="PE-BOM-002",
|
||||
pins=[],
|
||||
))
|
||||
continue
|
||||
sch_mpn = _norm_mpn(sch.get("mpn"))
|
||||
bom_mpn = _norm_mpn(bom_row.get("mpn"))
|
||||
if sch_mpn and bom_mpn and sch_mpn != bom_mpn:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=str(sch.get("mpn") or ""),
|
||||
aspect="bom_match",
|
||||
source="bom_match",
|
||||
status="ERROR",
|
||||
finding=(
|
||||
f"{ref} schematic MPN '{sch.get('mpn')}' does not match "
|
||||
f"BOM MPN '{bom_row.get('mpn')}'."
|
||||
),
|
||||
why=(
|
||||
"Datasheet review and library lookup follow one MPN. "
|
||||
"A mismatch means the wrong die or a stale BOM row."
|
||||
),
|
||||
recommendation=(
|
||||
f"Make {ref}'s BOM and schematic MPN identical, then re-run."
|
||||
),
|
||||
rule_id="PE-BOM-001",
|
||||
pins=[ref],
|
||||
))
|
||||
return findings
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import ComponentType, DesignGraph
|
||||
from backend.pinscopex.utils import natural_sort_key
|
||||
from backend.periscopex.models import ComponentType, DesignGraph
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
|
||||
|
||||
def build_bom_summary(
|
||||
@@ -45,7 +45,7 @@ def build_bom_summary(
|
||||
# Drop None values and internal numeric fields
|
||||
raw = {
|
||||
k: v for k, v in raw.items()
|
||||
if v is not None and k not in ("value_ohms", "value_farads", "value_henries")
|
||||
if v is not None and k not in ("value_ohms", "value_farads", "value_henries", "impedance_ohm")
|
||||
}
|
||||
specs_dict = raw if raw else None
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""periscope-cad-bridge JSON (E2) for the KiCad action plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport
|
||||
|
||||
CAD_BRIDGE_VERSION = 1
|
||||
_PCB_RULE_PREFIXES = ("PE-PLC", "PE-SI", "PE-LAY", "PE-3W", "PE-CLR")
|
||||
|
||||
|
||||
def annotate_findings_cad(
|
||||
findings: list[Finding],
|
||||
cad_index: dict[str, CadIndexEntry] | None,
|
||||
) -> None:
|
||||
"""Fill cad_sheet/cad_uuid from the graph index when the finding omitted them."""
|
||||
if not cad_index:
|
||||
return
|
||||
for f in findings:
|
||||
entry = cad_index.get(f.designator)
|
||||
if not entry:
|
||||
continue
|
||||
if not f.cad_uuid and entry.uuid:
|
||||
f.cad_uuid = entry.uuid
|
||||
if not f.cad_sheet and entry.sheet:
|
||||
f.cad_sheet = entry.sheet
|
||||
|
||||
|
||||
def _pin_numbers(designator: str, pins: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
prefix = designator + "."
|
||||
for raw in pins:
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.upper().startswith(prefix.upper()):
|
||||
s = s[len(prefix):]
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _target_kind(rule_id: str | None) -> str:
|
||||
rid = rule_id or ""
|
||||
if any(rid.startswith(p) for p in _PCB_RULE_PREFIXES):
|
||||
return "pcb"
|
||||
return "sch"
|
||||
|
||||
|
||||
def build_cad_bridge(
|
||||
report: ValidationReport,
|
||||
project_id: str,
|
||||
*,
|
||||
url_base: str = "",
|
||||
) -> dict:
|
||||
"""E2 `periscope-cad-bridge` payload. Missing uuid/sheet stay empty strings."""
|
||||
findings: list[dict] = []
|
||||
for f in report.findings:
|
||||
fid = f.finding_id or ""
|
||||
url = ""
|
||||
if url_base and fid:
|
||||
sep = "&" if "?" in url_base else "?"
|
||||
url = f"{url_base}{sep}finding={fid}"
|
||||
findings.append({
|
||||
"rule_id": f.rule_id or f.source or "review",
|
||||
"ref": f.designator,
|
||||
"pins": _pin_numbers(f.designator, f.pins or []),
|
||||
"sheet": f.cad_sheet or "",
|
||||
"uuid": f.cad_uuid or "",
|
||||
"severity": (f.status or "WARNING").lower(),
|
||||
"message": f.finding,
|
||||
"url": url,
|
||||
"finding_id": fid,
|
||||
"net": f.net or "",
|
||||
"target": _target_kind(f.rule_id),
|
||||
})
|
||||
return {
|
||||
"version": CAD_BRIDGE_VERSION,
|
||||
"project_id": project_id,
|
||||
"findings": findings,
|
||||
}
|
||||
|
||||
|
||||
def write_cad_bridge(path: str | Path, payload: dict) -> None:
|
||||
Path(path).write_text(json.dumps(payload, indent=2) + "\n")
|
||||
|
||||
|
||||
def cad_index_from_graph(graph: DesignGraph) -> dict[str, CadIndexEntry]:
|
||||
return dict(graph.cad_index or {})
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Crystal load capacitance vs load caps — numbers only when present.
|
||||
|
||||
CL_eff ≈ (C1·C2)/(C1+C2) + Cstray. Cstray used only if specs list it;
|
||||
never invent a stray default. Without CL in specs → skip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.functional_groups import (
|
||||
_cap_farads,
|
||||
_is_ground_net,
|
||||
load_capacitance_farads,
|
||||
)
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
|
||||
_STRAY_KEYS = ("stray_capacitance_f", "board_stray_f", "cstray_f")
|
||||
|
||||
|
||||
def check_crystal_cl(graph: DesignGraph) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.CRYSTAL:
|
||||
continue
|
||||
cl = load_capacitance_farads(comp)
|
||||
if cl is None:
|
||||
continue
|
||||
load_caps = _load_caps_for_crystal(graph, comp)
|
||||
if len(load_caps) < 2:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="clock",
|
||||
source="crystal_cl_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} specifies CL={_fmt_f(cl)} but fewer than two load "
|
||||
f"capacitors were found on its non-ground nets "
|
||||
f"({[c.reference for c in load_caps] or 'none'})."
|
||||
),
|
||||
why="Crystal load capacitance needs a matched C1/C2 pair.",
|
||||
recommendation="Add or value the two load capacitors on XIN/XOUT.",
|
||||
reference="netlist topology",
|
||||
rule_id="PE-XTAL-001",
|
||||
pins=[ref],
|
||||
))
|
||||
continue
|
||||
|
||||
# Use the two caps with known farads closest to equal (typical C1≈C2).
|
||||
valued = [(c, _cap_farads(c)) for c in load_caps]
|
||||
known = [(c, f) for c, f in valued if f is not None]
|
||||
if len(known) < 2:
|
||||
continue
|
||||
known.sort(key=lambda x: x[1])
|
||||
# Prefer a pair with similar values: take the two largest known if many.
|
||||
c1, f1 = known[-2]
|
||||
c2, f2 = known[-1]
|
||||
series = (f1 * f2) / (f1 + f2) if (f1 + f2) > 0 else None
|
||||
if series is None:
|
||||
continue
|
||||
stray = _stray_farads(comp)
|
||||
c_eff = series + (stray or 0.0)
|
||||
|
||||
if stray is None:
|
||||
# Without stray: only flag when series alone already exceeds CL.
|
||||
if series > cl * 1.25:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="clock",
|
||||
source="crystal_cl_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} CL={_fmt_f(cl)}; C1={c1.reference} {_fmt_f(f1)} and "
|
||||
f"C2={c2.reference} {_fmt_f(f2)} give series≈{_fmt_f(series)} "
|
||||
f"(already above CL; board stray not in specs)."
|
||||
),
|
||||
why="Series combination of load caps exceeds specified CL without needing stray.",
|
||||
recommendation="Reduce load caps or confirm the datasheet CL value.",
|
||||
reference="netlist topology",
|
||||
rule_id="PE-XTAL-002",
|
||||
pins=[ref, c1.reference, c2.reference],
|
||||
))
|
||||
elif series < cl * 0.5:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="clock",
|
||||
source="crystal_cl_check",
|
||||
status="INFO",
|
||||
finding=(
|
||||
f"{ref} CL={_fmt_f(cl)}; series of {c1.reference}/{c2.reference} "
|
||||
f"≈{_fmt_f(series)} (stray unknown — verify against datasheet)."
|
||||
),
|
||||
why="Without stray capacitance in specs, effective CL cannot be fully checked.",
|
||||
recommendation="Confirm Cstray or populate load_capacitance / stray in crystal specs.",
|
||||
reference="netlist topology",
|
||||
rule_id="PE-XTAL-003",
|
||||
pins=[ref, c1.reference, c2.reference],
|
||||
))
|
||||
continue
|
||||
|
||||
if c_eff > cl * 1.25 or c_eff < cl * 0.75:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="clock",
|
||||
source="crystal_cl_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} CL={_fmt_f(cl)}; C_eff≈{_fmt_f(c_eff)} "
|
||||
f"(series {_fmt_f(series)} + stray {_fmt_f(stray)}) "
|
||||
f"from {c1.reference}/{c2.reference}."
|
||||
),
|
||||
why="Effective load capacitance should stay near the crystal's specified CL.",
|
||||
recommendation="Adjust C1/C2 so C_eff ≈ CL.",
|
||||
reference="netlist topology",
|
||||
rule_id="PE-XTAL-002",
|
||||
pins=[ref, c1.reference, c2.reference],
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _load_caps_for_crystal(graph: DesignGraph, crystal: Component) -> list[Component]:
|
||||
caps: dict[str, Component] = {}
|
||||
for net in crystal.pins.values():
|
||||
if not net or _is_ground_net(graph, net):
|
||||
continue
|
||||
for cref in graph.capacitors_on_net(net):
|
||||
cap = graph.components.get(cref)
|
||||
if cap:
|
||||
caps[cref] = cap
|
||||
return list(caps.values())
|
||||
|
||||
|
||||
def _stray_farads(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if not isinstance(specs, SimpleComponentSpecs):
|
||||
return None
|
||||
for key in _STRAY_KEYS:
|
||||
raw = specs.values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v >= 0:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_f(farads: float) -> str:
|
||||
if farads >= 1e-6:
|
||||
return f"{farads * 1e6:.3g}µF"
|
||||
if farads >= 1e-9:
|
||||
return f"{farads * 1e9:.3g}nF"
|
||||
return f"{farads * 1e12:.3g}pF"
|
||||
@@ -4,12 +4,63 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.pinscopex.models import ComponentType, DesignGraph, NetType
|
||||
from backend.pinscopex.utils import natural_sort_key
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, NetType
|
||||
from backend.periscopex.resolve_passives import _format_value
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
|
||||
# Dielectric strings that indicate ceramic capacitors
|
||||
_CERAMIC_DIELECTRICS = {"X7R", "X5R", "C0G", "NP0", "Y5V", "X7S", "X6S", "X8R", "C0G (NP0)"}
|
||||
|
||||
# Remaining C/C0 vs V/Vrated. Empirical stima, not a vendor lot curve.
|
||||
_BIAS_CURVES: dict[str, list[tuple[float, float]]] = {
|
||||
"c0g": [(0.0, 1.0), (1.2, 1.0)],
|
||||
"x7r": [(0.0, 1.0), (0.25, 0.90), (0.50, 0.70), (0.75, 0.45), (1.0, 0.30), (1.2, 0.22)],
|
||||
"x5r": [(0.0, 1.0), (0.25, 0.82), (0.50, 0.55), (0.75, 0.32), (1.0, 0.18), (1.2, 0.12)],
|
||||
"y5v": [(0.0, 1.0), (0.25, 0.50), (0.50, 0.20), (0.80, 0.12), (1.0, 0.10)],
|
||||
}
|
||||
|
||||
|
||||
def _lerp(curve: list[tuple[float, float]], x: float) -> float:
|
||||
if x <= curve[0][0]:
|
||||
return curve[0][1]
|
||||
for (x0, y0), (x1, y1) in zip(curve, curve[1:]):
|
||||
if x <= x1:
|
||||
if x1 == x0:
|
||||
return y1
|
||||
t = (x - x0) / (x1 - x0)
|
||||
return y0 + t * (y1 - y0)
|
||||
return curve[-1][1]
|
||||
|
||||
|
||||
def _bias_family(dielectric: str | None) -> str | None:
|
||||
if not dielectric:
|
||||
return None
|
||||
u = dielectric.upper()
|
||||
if "C0G" in u or "NP0" in u or "NPO" in u:
|
||||
return "c0g"
|
||||
if "Y5V" in u:
|
||||
return "y5v"
|
||||
if "X5R" in u or "X6S" in u:
|
||||
return "x5r"
|
||||
if "X7R" in u or "X7S" in u or "X8R" in u:
|
||||
return "x7r"
|
||||
return None
|
||||
|
||||
|
||||
def dc_bias_remaining(
|
||||
dielectric: str | None,
|
||||
v_op: float | None,
|
||||
rated_v: float | None,
|
||||
) -> float | None:
|
||||
"""Fraction of nominal C remaining under DC bias, or None if not modelled.
|
||||
|
||||
Labelled a *stima*: class-2 MLCC curves vary by lot, thickness and vendor.
|
||||
"""
|
||||
family = _bias_family(dielectric)
|
||||
if family is None or v_op is None or rated_v is None or rated_v <= 0:
|
||||
return None
|
||||
return _lerp(_BIAS_CURVES[family], max(0.0, v_op) / rated_v)
|
||||
|
||||
|
||||
def _parse_voltage_rating(s: str | None) -> float | None:
|
||||
"""Extract numeric voltage from a rating string like '16V', '25V', '2.5V'."""
|
||||
@@ -64,10 +115,12 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
rated_v: float | None = None
|
||||
value_fmt: str | None = None
|
||||
dielectric: str | None = None
|
||||
c_nom: float | None = None
|
||||
if comp.specs and hasattr(comp.specs, "voltage_rating_v"):
|
||||
rated_v = _parse_voltage_rating(comp.specs.voltage_rating_v)
|
||||
value_fmt = getattr(comp.specs, "value_formatted", None)
|
||||
dielectric = getattr(comp.specs, "dielectric", None)
|
||||
c_nom = getattr(comp.specs, "value_farads", None)
|
||||
|
||||
# Operating voltage: max non-zero voltage among connected nets
|
||||
op_voltage: float | None = None
|
||||
@@ -107,6 +160,10 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
net_minus = by_v[0][0]
|
||||
net_plus = by_v[-1][0]
|
||||
|
||||
factor = dc_bias_remaining(dielectric, op_voltage, rated_v)
|
||||
c_eff = (c_nom * factor) if (c_nom is not None and factor is not None) else None
|
||||
c_eff_fmt = _format_value(c_eff, "F") if c_eff is not None else None
|
||||
|
||||
rows.append({
|
||||
"designator": comp.reference,
|
||||
"mpn": comp.mpn,
|
||||
@@ -117,6 +174,12 @@ def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||
"net_plus": net_plus,
|
||||
"net_minus": net_minus,
|
||||
"dielectric_category": _dielectric_category(comp.component_subtype, dielectric),
|
||||
"dielectric": dielectric,
|
||||
"c_nominal_f": c_nom,
|
||||
"dc_bias_factor": factor,
|
||||
"c_eff_f": c_eff,
|
||||
"c_eff_formatted": c_eff_fmt,
|
||||
"dc_bias_model": "stima" if factor is not None else None,
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: natural_sort_key(r["designator"]))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Enable pins on the fitted variant: no pull and no driver is ERROR.
|
||||
|
||||
Runs only when the BOM actually marks DNP/fitted. Enable tied to a rail
|
||||
is a driver. DNP resistors are removed from the variant graph.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
)
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
_is_ground_net,
|
||||
_is_power_net,
|
||||
_pin_name_tokens,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_EN_RE = re.compile(
|
||||
r"(?:^|[_/])(EN|ENA|ENABLE|n?SHDN|nEN|EN_N|CHIP_EN)(?:$|[_/\d])",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _dnp_map(graph: DesignGraph) -> dict[str, bool] | None:
|
||||
"""Return {ref: is_dnp} if any BOM row carries DNP/fitted, else None."""
|
||||
fields = graph.bom_fields or {}
|
||||
if not fields:
|
||||
return None
|
||||
if not any("dnp" in (v or {}) or "fitted" in (v or {}) for v in fields.values()):
|
||||
return None
|
||||
out: dict[str, bool] = {}
|
||||
for ref in graph.components:
|
||||
row = fields.get(ref) or {}
|
||||
if "dnp" in row:
|
||||
out[ref] = bool(row.get("dnp"))
|
||||
elif "fitted" in row:
|
||||
out[ref] = not bool(row.get("fitted"))
|
||||
else:
|
||||
out[ref] = False
|
||||
return out
|
||||
|
||||
|
||||
def _is_fitted(dnp: dict[str, bool], ref: str) -> bool:
|
||||
return not dnp.get(ref, False)
|
||||
|
||||
|
||||
def check_dnp_enables(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
dnp = _dnp_map(graph)
|
||||
if dnp is None:
|
||||
return []
|
||||
cmap = constraints_map or {}
|
||||
findings: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if not _is_fitted(dnp, ref):
|
||||
continue
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
names = tokens or [net or "", pin_num]
|
||||
if not any(_EN_RE.search(t) for t in names):
|
||||
continue
|
||||
if _is_power_net(graph, net) or _is_ground_net(graph, net):
|
||||
continue
|
||||
has_pull = False
|
||||
has_driver = False
|
||||
for r in graph.components_on_net(net):
|
||||
if r == ref or not _is_fitted(dnp, r):
|
||||
continue
|
||||
other = graph.components.get(r)
|
||||
if not other:
|
||||
continue
|
||||
if other.component_type == ComponentType.IC:
|
||||
has_driver = True
|
||||
continue
|
||||
if other.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
others = {n for n in other.pins.values() if n != net}
|
||||
if any(_is_power_net(graph, n) or _is_ground_net(graph, n) for n in others):
|
||||
has_pull = True
|
||||
if has_pull or has_driver:
|
||||
continue
|
||||
variant = (graph.bom_fields.get(ref) or {}).get("variant")
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="dnp",
|
||||
source="dnp_check",
|
||||
status="ERROR",
|
||||
finding=(
|
||||
f"{ref} enable '{net}' has no fitted pull or driver "
|
||||
f"(DNP parts ignored)."
|
||||
),
|
||||
why="On the fitted variant the enable net is floating.",
|
||||
recommendation="Fit a pull, tie EN to a rail, or drive it from a PG/GPIO.",
|
||||
reference="BOM DNP/fitted",
|
||||
net=net,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-DNP-001",
|
||||
variant=str(variant) if variant else None,
|
||||
))
|
||||
return findings
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Errata workarounds from a known-URL catalog. No HTML scrape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, Finding
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
_pin_name_tokens,
|
||||
_resistor_to_power,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Exact MPN → URL + structured workarounds. Empty by default so eval is quiet.
|
||||
DEFAULT_ERRATA_CATALOG: dict[str, dict] = {}
|
||||
|
||||
|
||||
def check_errata(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None,
|
||||
catalog: dict[str, dict] | None = None,
|
||||
) -> list[Finding]:
|
||||
cmap = constraints_map or {}
|
||||
cat = DEFAULT_ERRATA_CATALOG if catalog is None else catalog
|
||||
findings: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
mpn = (comp.mpn or "").strip()
|
||||
if not mpn:
|
||||
continue
|
||||
entry = cat.get(mpn)
|
||||
if entry is None:
|
||||
continue
|
||||
url = (entry.get("url") or "").strip()
|
||||
if not url:
|
||||
log.info("errata skip %s: catalog row has no url", mpn)
|
||||
continue
|
||||
cons = _match_constraints(mpn, cmap)
|
||||
for wa in entry.get("workarounds") or []:
|
||||
kind = (wa.get("kind") or "").lower()
|
||||
pin_name = (wa.get("pin_name") or "").strip()
|
||||
if kind != "pullup" or not pin_name:
|
||||
continue
|
||||
net = _net_for_pin_name(graph, ref, cons, pin_name)
|
||||
if not net:
|
||||
continue
|
||||
if _resistor_to_power(graph, net):
|
||||
continue
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=mpn,
|
||||
aspect="errata",
|
||||
source="errata_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} {pin_name} is missing the errata pull-up on '{net}'."
|
||||
),
|
||||
why=wa.get("note") or "Vendor errata workaround is not on the schematic.",
|
||||
recommendation="Add the pull-up described in the errata, or confirm the die revision.",
|
||||
reference=url,
|
||||
net=net,
|
||||
pins=[f"{ref}.{pin_name}"],
|
||||
rule_id="PE-ERRATA-001",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _net_for_pin_name(
|
||||
graph: DesignGraph,
|
||||
ref: str,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_name: str,
|
||||
) -> str | None:
|
||||
comp = graph.components.get(ref)
|
||||
if not comp:
|
||||
return None
|
||||
want = pin_name.upper()
|
||||
for pin_num, net in comp.pins.items():
|
||||
if (net or "").upper() == want:
|
||||
return net
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
if any(t.upper() == want or _token_match(t, pin_name) for t in tokens):
|
||||
return net
|
||||
return None
|
||||
|
||||
|
||||
def _token_match(token: str, pin_name: str) -> bool:
|
||||
return bool(re.search(rf"(?:^|[_/]){re.escape(pin_name)}(?:$|[_/\d])", token, re.I))
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Score a validation report against a golden key set.
|
||||
|
||||
Used by the simple_project eval harness: finding counts, % Unverified,
|
||||
citation hit-rate among LLM quotes, precision/recall vs golden keys.
|
||||
Deterministic checks without a quote are excluded from the citation
|
||||
denominator so pin-mux/BOM noise cannot inflate the rate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.models import DesignGraph, Finding, ValidationReport
|
||||
from backend.periscopex.pin_mux_check import check_pin_mux_feasibility
|
||||
from backend.periscopex.led_current_check import check_led_current
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
check_i2c_pullups,
|
||||
check_reset_pullups,
|
||||
check_supply_decoupling,
|
||||
)
|
||||
from backend.periscopex.bom_match_check import check_bom_schematic_match
|
||||
from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage
|
||||
from backend.periscopex.filter_check import check_filters
|
||||
from backend.periscopex.thermal_check import check_thermal
|
||||
from backend.periscopex.power_margin_check import check_power_margin
|
||||
from backend.periscopex.sequencing_check import check_power_sequencing
|
||||
from backend.periscopex.dnp_check import check_dnp_enables
|
||||
from backend.periscopex.lifecycle import check_lifecycle
|
||||
from backend.periscopex.errata_check import check_errata
|
||||
from backend.periscopex.internal_features_check import check_internal_features
|
||||
from backend.periscopex.placement_check import check_placement
|
||||
from backend.periscopex.si_check import check_si
|
||||
|
||||
|
||||
class EvalScores(BaseModel):
|
||||
finding_count: int
|
||||
by_status: dict[str, int]
|
||||
unverified_pct: float
|
||||
citation_hit_rate: float | None
|
||||
precision: float
|
||||
recall: float
|
||||
extra_keys: list[str]
|
||||
missing_keys: list[str]
|
||||
graph_ok: bool = True
|
||||
graph_errors: list[str] = []
|
||||
|
||||
|
||||
def finding_key(f: Finding) -> str:
|
||||
if f.rule_id:
|
||||
return f"{f.rule_id}|{f.designator}|{f.net or ''}"
|
||||
return f"{f.source or 'review'}|{f.designator}|{f.net or f.finding}"
|
||||
|
||||
|
||||
def _is_review(f: Finding) -> bool:
|
||||
return not f.source or f.source == "review"
|
||||
|
||||
|
||||
def citation_hit_rate(findings: list[Finding]) -> float | None:
|
||||
quoted = [
|
||||
f for f in findings
|
||||
if _is_review(f) and (f.source_quote or "").strip()
|
||||
]
|
||||
if not quoted:
|
||||
return None
|
||||
hits = sum(1 for f in quoted if not (f.why or "").startswith("Unverified:"))
|
||||
return hits / len(quoted)
|
||||
|
||||
|
||||
def unverified_pct(findings: list[Finding]) -> float:
|
||||
if not findings:
|
||||
return 0.0
|
||||
n = sum(1 for f in findings if (f.why or "").startswith("Unverified:"))
|
||||
return 100.0 * n / len(findings)
|
||||
|
||||
|
||||
def score_keys(produced: set[str], golden: set[str]) -> tuple[float, float, list[str], list[str]]:
|
||||
extra = sorted(produced - golden)
|
||||
missing = sorted(golden - produced)
|
||||
precision = 1.0 if not produced else len(produced & golden) / len(produced)
|
||||
recall = 1.0 if not golden else len(produced & golden) / len(golden)
|
||||
return precision, recall, extra, missing
|
||||
|
||||
|
||||
def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
|
||||
cmap: dict = {}
|
||||
out: list[Finding] = []
|
||||
out.extend(check_pin_mux_feasibility(graph, cmap))
|
||||
out.extend(check_led_current(graph))
|
||||
out.extend(check_supply_decoupling(graph, cmap))
|
||||
out.extend(check_i2c_pullups(graph, cmap))
|
||||
out.extend(check_reset_pullups(graph, cmap))
|
||||
out.extend(check_bom_schematic_match(graph.schematic_fields, graph.bom_fields))
|
||||
out.extend(check_hf_decoupling_coverage(graph, cmap))
|
||||
out.extend(check_filters(graph, cmap))
|
||||
out.extend(check_thermal(graph, cmap))
|
||||
out.extend(check_power_margin(graph, cmap))
|
||||
out.extend(check_power_sequencing(graph, cmap))
|
||||
out.extend(check_dnp_enables(graph, cmap))
|
||||
out.extend(check_lifecycle(graph, {}))
|
||||
out.extend(check_errata(graph, cmap))
|
||||
out.extend(check_internal_features(graph, cmap))
|
||||
out.extend(check_placement(graph, cmap, None))
|
||||
out.extend(check_si(graph, cmap, None))
|
||||
return out
|
||||
|
||||
|
||||
def score_report(
|
||||
findings: list[Finding],
|
||||
golden_keys: set[str],
|
||||
*,
|
||||
graph: DesignGraph | None = None,
|
||||
golden_meta: dict | None = None,
|
||||
) -> EvalScores:
|
||||
keys = {finding_key(f) for f in findings}
|
||||
precision, recall, extra, missing = score_keys(keys, golden_keys)
|
||||
by_status: dict[str, int] = {"ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||
for f in findings:
|
||||
by_status[f.status] = by_status.get(f.status, 0) + 1
|
||||
graph_errors: list[str] = []
|
||||
if graph is not None and golden_meta:
|
||||
for ref in golden_meta.get("required_refs") or []:
|
||||
if ref not in graph.components:
|
||||
graph_errors.append(f"missing ref {ref}")
|
||||
min_c = golden_meta.get("min_components")
|
||||
if min_c and len(graph.components) < int(min_c):
|
||||
graph_errors.append(
|
||||
f"components {len(graph.components)} < {min_c}"
|
||||
)
|
||||
min_n = golden_meta.get("min_nets")
|
||||
if min_n and len(graph.nets) < int(min_n):
|
||||
graph_errors.append(f"nets {len(graph.nets)} < {min_n}")
|
||||
return EvalScores(
|
||||
finding_count=len(findings),
|
||||
by_status=by_status,
|
||||
unverified_pct=unverified_pct(findings),
|
||||
citation_hit_rate=citation_hit_rate(findings),
|
||||
precision=precision,
|
||||
recall=recall,
|
||||
extra_keys=extra,
|
||||
missing_keys=missing,
|
||||
graph_ok=not graph_errors,
|
||||
graph_errors=graph_errors,
|
||||
)
|
||||
|
||||
|
||||
def eval_simple_project(
|
||||
root: str | Path,
|
||||
report: ValidationReport | None = None,
|
||||
) -> EvalScores:
|
||||
root = Path(root)
|
||||
graph = DesignGraph.model_validate_json(
|
||||
(root / "design_graph.json").read_text()
|
||||
)
|
||||
golden = {}
|
||||
gpath = root / "eval_golden.json"
|
||||
if gpath.is_file():
|
||||
import json
|
||||
golden = json.loads(gpath.read_text())
|
||||
if report is not None:
|
||||
findings = list(report.findings)
|
||||
else:
|
||||
findings = run_deterministic_on_graph(graph)
|
||||
keys = set(golden.get("deterministic_keys") or [])
|
||||
return score_report(findings, keys, graph=graph, golden_meta=golden)
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Signal-filter topology: RC, LC, ferrite+C, π (C-L-C), T (L-C-L).
|
||||
|
||||
fc is reported only when R/L/C values are known. Sample-rate comparison
|
||||
and ferrite DCR limits fire only when the neighboring IC specs list them.
|
||||
Power-rail decoupling is not a signal filter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
InductorSpecs,
|
||||
)
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
_cap_farads,
|
||||
_is_ground_net,
|
||||
_is_power_net,
|
||||
_pin_name_tokens,
|
||||
_resistor_ohms,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_ADC_RATE_KEYS = ("adc_sample_rate", "adc_sample_rate_hz", "data_rate", "data_rate_hz")
|
||||
_DCR_MAX_KEYS = ("max_ferrite_dcr_ohms", "ferrite_dcr_max_ohms", "max_bead_dcr_ohms")
|
||||
_ANALOG_RE = re.compile(
|
||||
r"(?:^|[_/])(ADC|AIN|VDDA|AVDD|VREF)(?:$|[_/\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _inductor_henries(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, InductorSpecs) and specs.value_henries:
|
||||
return float(specs.value_henries)
|
||||
return None
|
||||
|
||||
|
||||
def _dcr_ohms(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, InductorSpecs) and specs.dcr_ohms is not None:
|
||||
return float(specs.dcr_ohms)
|
||||
return None
|
||||
|
||||
|
||||
def _is_ferrite(comp: Component) -> bool:
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
if "ferrite" in sub:
|
||||
return True
|
||||
specs = comp.specs
|
||||
if isinstance(specs, InductorSpecs) and specs.component_subtype:
|
||||
return "ferrite" in specs.component_subtype
|
||||
return comp.reference.upper().startswith("FB")
|
||||
|
||||
|
||||
def _two_nets(comp: Component) -> tuple[str, str] | None:
|
||||
nets = list(dict.fromkeys(comp.pins.values()))
|
||||
if len(nets) != 2:
|
||||
return None
|
||||
return nets[0], nets[1]
|
||||
|
||||
|
||||
def _gnd_caps(graph: DesignGraph, net: str) -> list[tuple[str, float | None]]:
|
||||
out: list[tuple[str, float | None]] = []
|
||||
for ref in graph.capacitors_on_net(net):
|
||||
cap = graph.components[ref]
|
||||
others = {n for n in cap.pins.values() if n != net}
|
||||
if any(_is_ground_net(graph, n) for n in others):
|
||||
out.append((ref, _cap_farads(cap)))
|
||||
return out
|
||||
|
||||
|
||||
def _sum_known_c(caps: list[tuple[str, float | None]]) -> float | None:
|
||||
vals = [c for _, c in caps if c is not None]
|
||||
if not vals or len(vals) != len(caps):
|
||||
return None
|
||||
return sum(vals)
|
||||
|
||||
|
||||
def _fc_rc(r: float, c: float) -> float:
|
||||
return 1.0 / (2.0 * math.pi * r * c)
|
||||
|
||||
|
||||
def _fc_lc(l: float, c: float) -> float:
|
||||
return 1.0 / (2.0 * math.pi * math.sqrt(l * c))
|
||||
|
||||
|
||||
def _ic_specs_values(comp: Component) -> dict:
|
||||
specs = comp.specs
|
||||
values = getattr(specs, "values", None) if specs else None
|
||||
return values if isinstance(values, dict) else {}
|
||||
|
||||
|
||||
def _adc_rate_hz(graph: DesignGraph, ic_refs: list[str]) -> float | None:
|
||||
for ref in ic_refs:
|
||||
values = _ic_specs_values(graph.components[ref])
|
||||
for key in _ADC_RATE_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _dcr_limit_ohms(graph: DesignGraph, ic_refs: list[str]) -> float | None:
|
||||
for ref in ic_refs:
|
||||
values = _ic_specs_values(graph.components[ref])
|
||||
for key in _DCR_MAX_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _ic_refs_on(graph: DesignGraph, *nets: str) -> list[str]:
|
||||
refs: list[str] = []
|
||||
for net in nets:
|
||||
for r in graph.components_on_net(net):
|
||||
c = graph.components.get(r)
|
||||
if c and c.component_type == ComponentType.IC and r not in refs:
|
||||
refs.append(r)
|
||||
return refs
|
||||
|
||||
|
||||
def _analog_net(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
*nets: str,
|
||||
) -> str | None:
|
||||
for net in nets:
|
||||
if _ANALOG_RE.search(net or ""):
|
||||
return net
|
||||
for ref in _ic_refs_on(graph, net):
|
||||
cons = _match_constraints(graph.components[ref].mpn or "", constraints_map)
|
||||
for pin_num, pin_net in graph.components[ref].pins.items():
|
||||
if pin_net != net:
|
||||
continue
|
||||
if _ANALOG_RE.search(net):
|
||||
return net
|
||||
for tok in _pin_name_tokens(cons, pin_num):
|
||||
if _ANALOG_RE.search(tok):
|
||||
return net
|
||||
return None
|
||||
|
||||
|
||||
def _filter_finding(
|
||||
*,
|
||||
kind: str,
|
||||
fc: float | None,
|
||||
designator: str,
|
||||
mpn: str,
|
||||
net: str,
|
||||
extra_why: str,
|
||||
adc_hz: float | None,
|
||||
) -> Finding:
|
||||
if fc is None:
|
||||
return Finding(
|
||||
designator=designator,
|
||||
mpn=mpn,
|
||||
aspect="filter",
|
||||
source="filter_check",
|
||||
status="INFO",
|
||||
finding=f"{kind} filter on '{net}' ({designator}); fc unknown (missing L/C/R value).",
|
||||
why=extra_why,
|
||||
recommendation="Populate passive values to compute cutoff.",
|
||||
reference="netlist topology",
|
||||
net=net,
|
||||
pins=[designator],
|
||||
rule_id="PE-FLT-001",
|
||||
)
|
||||
if adc_hz is not None and not (0.1 * adc_hz <= fc <= 20 * adc_hz):
|
||||
return Finding(
|
||||
designator=designator,
|
||||
mpn=mpn,
|
||||
aspect="filter",
|
||||
source="filter_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{kind} filter on '{net}' has fc ≈ {fc:.3g} Hz vs ADC/data rate "
|
||||
f"{adc_hz:.3g} Hz."
|
||||
),
|
||||
why=extra_why + " Compared only because the IC specs list a sample/data rate.",
|
||||
recommendation="Adjust R/C (or L) so fc sits nearer the sample rate, or confirm anti-alias intent.",
|
||||
reference="netlist topology",
|
||||
net=net,
|
||||
pins=[designator],
|
||||
rule_id="PE-FLT-002",
|
||||
)
|
||||
rec = (
|
||||
"fc is within a wide band of the IC sample/data rate."
|
||||
if adc_hz is not None
|
||||
else "Verify fc against the analog bandwidth; no datasheet rate was present."
|
||||
)
|
||||
return Finding(
|
||||
designator=designator,
|
||||
mpn=mpn,
|
||||
aspect="filter",
|
||||
source="filter_check",
|
||||
status="INFO",
|
||||
finding=f"{kind} filter on '{net}' ({designator}), fc ≈ {fc:.3g} Hz.",
|
||||
why=extra_why,
|
||||
recommendation=rec,
|
||||
reference="netlist topology",
|
||||
net=net,
|
||||
pins=[designator],
|
||||
rule_id="PE-FLT-001",
|
||||
)
|
||||
|
||||
|
||||
def _emit(
|
||||
findings: list[Finding],
|
||||
seen: set[tuple[str, str]],
|
||||
*,
|
||||
kind: str,
|
||||
ref: str,
|
||||
net: str,
|
||||
fc: float | None,
|
||||
mpn: str,
|
||||
extra_why: str,
|
||||
adc_hz: float | None,
|
||||
) -> None:
|
||||
key = (kind, ref)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
findings.append(_filter_finding(
|
||||
kind=kind, fc=fc, designator=ref, mpn=mpn, net=net,
|
||||
extra_why=extra_why, adc_hz=adc_hz,
|
||||
))
|
||||
|
||||
|
||||
def check_filters(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
cmap = constraints_map or {}
|
||||
findings: list[Finding] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
used_l: set[str] = set()
|
||||
|
||||
# T: two series L sharing a middle net that has C to GND.
|
||||
for mid in sorted(graph.nets):
|
||||
if _is_ground_net(graph, mid):
|
||||
continue
|
||||
caps = _gnd_caps(graph, mid)
|
||||
if not caps:
|
||||
continue
|
||||
inds = [
|
||||
r for r in graph.components_on_net(mid)
|
||||
if (c := graph.components.get(r)) is not None
|
||||
and c.component_type == ComponentType.INDUCTOR
|
||||
]
|
||||
if len(inds) != 2:
|
||||
continue
|
||||
ends: list[str] = []
|
||||
ok = True
|
||||
for r in inds:
|
||||
pair = _two_nets(graph.components[r])
|
||||
if not pair:
|
||||
ok = False
|
||||
break
|
||||
other = pair[1] if pair[0] == mid else pair[0]
|
||||
if _is_ground_net(graph, other):
|
||||
ok = False
|
||||
break
|
||||
ends.append(other)
|
||||
if not ok:
|
||||
continue
|
||||
lvals = [_inductor_henries(graph.components[r]) for r in inds]
|
||||
c_f = _sum_known_c(caps)
|
||||
l_eq = sum(lvals) if all(lvals) else None # type: ignore[arg-type]
|
||||
fc = _fc_lc(l_eq, c_f) if l_eq and c_f else None
|
||||
ics = _ic_refs_on(graph, mid, *ends)
|
||||
_emit(
|
||||
findings, seen, kind="T", ref="+".join(sorted(inds)), net=mid,
|
||||
fc=fc, mpn=graph.components[inds[0]].mpn or "",
|
||||
extra_why="T network (L-C-L).",
|
||||
adc_hz=_adc_rate_hz(graph, ics),
|
||||
)
|
||||
used_l.update(inds)
|
||||
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.INDUCTOR or ref in used_l:
|
||||
continue
|
||||
pair = _two_nets(comp)
|
||||
if not pair:
|
||||
continue
|
||||
n1, n2 = pair
|
||||
if _is_ground_net(graph, n1) or _is_ground_net(graph, n2):
|
||||
continue
|
||||
c1, c2 = _gnd_caps(graph, n1), _gnd_caps(graph, n2)
|
||||
ferrite = _is_ferrite(comp)
|
||||
lval = _inductor_henries(comp)
|
||||
ics = _ic_refs_on(graph, n1, n2)
|
||||
adc = _adc_rate_hz(graph, ics)
|
||||
if c1 and c2:
|
||||
if _is_power_net(graph, n1) and _is_power_net(graph, n2) and not ferrite:
|
||||
continue
|
||||
s1, s2 = _sum_known_c(c1), _sum_known_c(c2)
|
||||
c_eq = None
|
||||
if s1 and s2:
|
||||
c_eq = 1.0 / (1.0 / s1 + 1.0 / s2)
|
||||
fc = _fc_lc(lval, c_eq) if lval and c_eq else None
|
||||
_emit(
|
||||
findings, seen, kind="π", ref=ref, net=n1, fc=fc,
|
||||
mpn=comp.mpn or "", extra_why="π network (C-L-C).", adc_hz=adc,
|
||||
)
|
||||
elif c1 or c2:
|
||||
filt_net = n1 if c1 else n2
|
||||
if _is_power_net(graph, filt_net) and not ferrite:
|
||||
continue
|
||||
caps = c1 or c2
|
||||
c_f = _sum_known_c(caps)
|
||||
fc = _fc_lc(lval, c_f) if lval and c_f else None
|
||||
kind = "ferrite+C" if ferrite else "LC"
|
||||
_emit(
|
||||
findings, seen, kind=kind, ref=ref, net=filt_net, fc=fc,
|
||||
mpn=comp.mpn or "",
|
||||
extra_why="Series L/ferrite with shunt C to ground.",
|
||||
adc_hz=adc,
|
||||
)
|
||||
analog = _analog_net(graph, cmap, n1, n2)
|
||||
limit = _dcr_limit_ohms(graph, ics)
|
||||
dcr = _dcr_ohms(comp)
|
||||
if ferrite and analog and limit is not None and dcr is not None and dcr > limit:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="filter",
|
||||
source="filter_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} ferrite DCR {dcr:.3g} Ω on analog net '{analog}' "
|
||||
f"exceeds {limit:.3g} Ω."
|
||||
),
|
||||
why="Bead DCR vs the IC spec limit on an analog/ADC rail.",
|
||||
recommendation="Use a lower-DCR bead specified for analog, or 0 Ω.",
|
||||
reference="IC specs",
|
||||
net=analog,
|
||||
pins=[ref],
|
||||
rule_id="PE-FLT-003",
|
||||
))
|
||||
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
pair = _two_nets(comp)
|
||||
if not pair:
|
||||
continue
|
||||
n1, n2 = pair
|
||||
if _is_power_net(graph, n1) or _is_power_net(graph, n2):
|
||||
continue
|
||||
if _is_ground_net(graph, n1) or _is_ground_net(graph, n2):
|
||||
continue
|
||||
c1, c2 = _gnd_caps(graph, n1), _gnd_caps(graph, n2)
|
||||
if bool(c1) == bool(c2):
|
||||
continue
|
||||
filt_net, src_net, caps = (n1, n2, c1) if c1 else (n2, n1, c2)
|
||||
if _is_power_net(graph, filt_net):
|
||||
continue
|
||||
r_ohm = _resistor_ohms(comp)
|
||||
c_f = _sum_known_c(caps)
|
||||
fc = _fc_rc(r_ohm, c_f) if r_ohm and c_f else None
|
||||
ics = _ic_refs_on(graph, src_net, filt_net)
|
||||
_emit(
|
||||
findings, seen, kind="RC", ref=ref, net=filt_net, fc=fc,
|
||||
mpn=comp.mpn or "", extra_why="Series R, shunt C to ground (low-pass).",
|
||||
adc_hz=_adc_rate_hz(graph, ics),
|
||||
)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,546 @@
|
||||
"""Topology-only functional groups for Layout F1 (routing-first floorplan).
|
||||
|
||||
No millimetres. Domains = primary supply-rail clusters (not transitive
|
||||
POWER connectivity through converters); satellites = 1-hop neighbors
|
||||
classified with role_hint; layout_rules attached from IC extraction when present.
|
||||
|
||||
Self-contained helpers (no import of ``validate`` / Anthropic).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.models import (
|
||||
CapacitorSpecs,
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
NetType,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
from backend.periscopex.resolve_passives import _parse_spice_value
|
||||
|
||||
RoleHint = Literal[
|
||||
"decoupling",
|
||||
"bulk",
|
||||
"load_cap",
|
||||
"filter",
|
||||
"pullup",
|
||||
"series",
|
||||
"divider",
|
||||
"bridge",
|
||||
"crystal",
|
||||
"other",
|
||||
]
|
||||
|
||||
# Roles kept in satellites / assemble_order. Unclassified "other" is dropped.
|
||||
_ASSEMBLE_ROLES = frozenset({
|
||||
"decoupling", "bulk", "load_cap", "filter", "pullup",
|
||||
"series", "divider", "bridge", "crystal",
|
||||
})
|
||||
_POWER_SAT_ROLES = frozenset({"decoupling", "bulk", "filter", "pullup"})
|
||||
_SKIP_OTHER_TYPES = frozenset({
|
||||
ComponentType.CONNECTOR,
|
||||
ComponentType.SWITCH,
|
||||
ComponentType.TEST_POINT,
|
||||
ComponentType.FIDUCIAL,
|
||||
ComponentType.MECHANICAL,
|
||||
})
|
||||
# Bias / charge-pump / bootstrap nets often stay SIGNAL in the graph.
|
||||
_BIAS_NET_RE = re.compile(
|
||||
r"(?:^|[_/\-])(REGN|PMID|BTST|BOOT|SW|LX|BST|VREG|VLDO|VREF)"
|
||||
r"(?:$|[_/\-\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_STRAP_NET_RE = re.compile(
|
||||
r"(?:EN|ENABLE|RESET|NRST|BOOT|CHIP_PU|GPIO0)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_BULK_F = 1e-6 # >= 1 µF → bulk candidate
|
||||
_XTAL_RE = re.compile(
|
||||
r"(?:^|[_/])(X(?:IN|OUT)|XTAL|OSC|HFX(?:IN|OUT)|LFX(?:IN|OUT)|CLK(?:IN|OUT)?)(?:$|[_/\d])",
|
||||
re.I,
|
||||
)
|
||||
_SUPPLY_PIN_RE = re.compile(
|
||||
r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|"
|
||||
r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\d*)$", re.IGNORECASE)
|
||||
_NOT_SUPPLY_RE = re.compile(
|
||||
r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RANK_PREFIXES: list[tuple[str, int]] = [
|
||||
("ic.mcu", 0),
|
||||
("ic.mpu", 0),
|
||||
("ic.fpga", 0),
|
||||
("ic.soc", 0),
|
||||
("ic.power", 1),
|
||||
("ic.interface", 2),
|
||||
("ic.protection", 3),
|
||||
("ic.", 4),
|
||||
]
|
||||
|
||||
|
||||
class PlacementSatellite(BaseModel):
|
||||
ref: str
|
||||
component_type: str
|
||||
component_subtype: str | None = None
|
||||
nets: list[str] = []
|
||||
hop: int = 1
|
||||
role_hint: RoleHint = "other"
|
||||
|
||||
|
||||
class PlacementIcGroup(BaseModel):
|
||||
ref: str
|
||||
mpn: str | None = None
|
||||
component_subtype: str | None = None
|
||||
rank: int = 99
|
||||
nets: list[str] = []
|
||||
satellites: list[PlacementSatellite] = []
|
||||
layout_rules: list[dict[str, Any]] = []
|
||||
assemble_order: list[str] = []
|
||||
|
||||
|
||||
class PlacementDomain(BaseModel):
|
||||
domain_id: str
|
||||
power_nets: list[str] = []
|
||||
ic_refs: list[str] = []
|
||||
assemble_order: list[str] = []
|
||||
|
||||
|
||||
class FunctionalGroupsReport(BaseModel):
|
||||
"""Routing-first placement topology (no coordinates)."""
|
||||
objective: Literal["routing"] = "routing"
|
||||
domains: list[PlacementDomain] = []
|
||||
groups: list[PlacementIcGroup] = []
|
||||
|
||||
|
||||
def build_functional_groups(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> FunctionalGroupsReport:
|
||||
"""Build domains + per-IC satellite groups from the design graph."""
|
||||
cmap = constraints_map or {}
|
||||
ic_refs = [
|
||||
r for r, c in graph.components.items()
|
||||
if c.component_type == ComponentType.IC
|
||||
]
|
||||
groups: list[PlacementIcGroup] = []
|
||||
for ref in sorted(ic_refs, key=lambda r: (_ic_rank(graph.components[r]), r)):
|
||||
groups.append(_group_for_ic(graph, ref, cmap))
|
||||
|
||||
domains = _build_domains(graph, ic_refs)
|
||||
by_ref = {g.ref: g for g in groups}
|
||||
for dom in domains:
|
||||
order: list[str] = []
|
||||
ranked = sorted(
|
||||
dom.ic_refs,
|
||||
key=lambda r: (by_ref[r].rank if r in by_ref else 99, r),
|
||||
)
|
||||
for iref in ranked:
|
||||
order.append(iref)
|
||||
g = by_ref.get(iref)
|
||||
if g:
|
||||
for sat in g.satellites:
|
||||
if sat.ref not in order:
|
||||
order.append(sat.ref)
|
||||
dom.assemble_order = order
|
||||
|
||||
return FunctionalGroupsReport(objective="routing", domains=domains, groups=groups)
|
||||
|
||||
|
||||
# Alias used by the dedicated Placement pipeline (same topology artifact).
|
||||
build_placement_plan = build_functional_groups
|
||||
PlacementPlan = FunctionalGroupsReport
|
||||
|
||||
|
||||
|
||||
def load_capacitance_farads(comp: Component) -> float | None:
|
||||
"""Crystal CL from SimpleComponentSpecs.values, if present."""
|
||||
specs = comp.specs
|
||||
if not isinstance(specs, SimpleComponentSpecs):
|
||||
return None
|
||||
raw = specs.values.get("load_capacitance_f")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if v > 0 else None
|
||||
|
||||
|
||||
def _match_constraints(
|
||||
mpn: str | None,
|
||||
datasheets: dict[str, ComponentConstraints],
|
||||
) -> ComponentConstraints | None:
|
||||
if not mpn:
|
||||
return None
|
||||
if mpn in datasheets:
|
||||
return datasheets[mpn]
|
||||
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
|
||||
for ds_mpn, constraints in datasheets.items():
|
||||
if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm:
|
||||
return constraints
|
||||
return None
|
||||
|
||||
|
||||
def _ic_rank(comp: Component) -> int:
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
for prefix, rank in _RANK_PREFIXES:
|
||||
if sub == prefix.rstrip(".") or sub.startswith(prefix):
|
||||
return rank
|
||||
return 9
|
||||
|
||||
|
||||
def _group_for_ic(
|
||||
graph: DesignGraph,
|
||||
ref: str,
|
||||
cmap: dict[str, ComponentConstraints],
|
||||
) -> PlacementIcGroup:
|
||||
comp = graph.components[ref]
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
nets = [n for n in graph.nets_of_component(ref) if not _is_ground_net(graph, n)]
|
||||
power_nets = {n for n in nets if _is_power_net(graph, n)}
|
||||
primary = _primary_supply_net(comp, power_nets)
|
||||
sat_map: dict[str, PlacementSatellite] = {}
|
||||
|
||||
for net_name, others in graph.neighbors(ref).items():
|
||||
if _is_ground_net(graph, net_name):
|
||||
continue
|
||||
for oref in others:
|
||||
if oref == ref or oref in sat_map:
|
||||
continue
|
||||
other = graph.components.get(oref)
|
||||
if not other or other.component_type == ComponentType.IC:
|
||||
continue
|
||||
role = _role_hint(graph, comp, cons, other, net_name)
|
||||
sat_nets = {n for n in other.pins.values() if n}
|
||||
# Drop power-role parts that sit on a *different* named power rail
|
||||
# (LDO must not inherit VSYS input caps). Strap/bias caps with no
|
||||
# typed POWER net still attach.
|
||||
if role in _POWER_SAT_ROLES and primary:
|
||||
sat_power = {n for n in sat_nets if _is_power_net(graph, n)}
|
||||
if sat_power and primary not in sat_power:
|
||||
continue
|
||||
if role == "other" and other.component_type in _SKIP_OTHER_TYPES:
|
||||
continue
|
||||
if role not in _ASSEMBLE_ROLES:
|
||||
continue
|
||||
sat_map[oref] = PlacementSatellite(
|
||||
ref=oref,
|
||||
component_type=other.component_type.value,
|
||||
component_subtype=other.component_subtype,
|
||||
nets=sorted(sat_nets),
|
||||
hop=1,
|
||||
role_hint=role,
|
||||
)
|
||||
|
||||
# Cap enhancement: only on the primary supply rail (when known).
|
||||
supply_nets = [primary] if primary else []
|
||||
if not supply_nets:
|
||||
supply_nets = [
|
||||
n for pin_num, n in comp.pins.items()
|
||||
if n and not _is_ground_net(graph, n)
|
||||
and _is_ic_supply_pin(graph, cons, pin_num, n)
|
||||
]
|
||||
for net_name in supply_nets:
|
||||
if not net_name or _is_ground_net(graph, net_name):
|
||||
continue
|
||||
for cref in graph.capacitors_on_net(net_name):
|
||||
if cref == ref:
|
||||
continue
|
||||
cap = graph.components.get(cref)
|
||||
if not cap:
|
||||
continue
|
||||
others = {n for n in cap.pins.values() if n != net_name}
|
||||
if not any(_is_ground_net(graph, n) for n in others):
|
||||
continue
|
||||
farads = _cap_farads(cap)
|
||||
role: RoleHint = "bulk" if farads is not None and farads >= _BULK_F else "decoupling"
|
||||
existing = sat_map.get(cref)
|
||||
if existing is None or existing.role_hint in ("other", "series"):
|
||||
sat_map[cref] = PlacementSatellite(
|
||||
ref=cref,
|
||||
component_type=cap.component_type.value,
|
||||
component_subtype=cap.component_subtype,
|
||||
nets=sorted({n for n in cap.pins.values() if n}),
|
||||
hop=1,
|
||||
role_hint=role,
|
||||
)
|
||||
|
||||
satellites = sorted(sat_map.values(), key=lambda s: (_role_sort(s.role_hint), s.ref))
|
||||
assemble = [ref] + [s.ref for s in satellites]
|
||||
rules: list[dict[str, Any]] = list(cons.layout_rules) if cons and cons.layout_rules else []
|
||||
|
||||
return PlacementIcGroup(
|
||||
ref=ref,
|
||||
mpn=comp.mpn,
|
||||
component_subtype=comp.component_subtype or (cons.component_subtype if cons else None),
|
||||
rank=_ic_rank(comp),
|
||||
nets=sorted(nets),
|
||||
satellites=satellites,
|
||||
layout_rules=rules,
|
||||
assemble_order=assemble,
|
||||
)
|
||||
|
||||
|
||||
def _role_sort(role: RoleHint) -> int:
|
||||
order = [
|
||||
"decoupling", "bulk", "load_cap", "crystal", "filter",
|
||||
"pullup", "divider", "series", "bridge", "other",
|
||||
]
|
||||
try:
|
||||
return order.index(role)
|
||||
except ValueError:
|
||||
return 99
|
||||
|
||||
|
||||
def _role_hint(
|
||||
graph: DesignGraph,
|
||||
ic: Component,
|
||||
cons: ComponentConstraints | None,
|
||||
other: Component,
|
||||
via_net: str,
|
||||
) -> RoleHint:
|
||||
if other.component_type == ComponentType.CRYSTAL:
|
||||
return "crystal"
|
||||
|
||||
if other.component_type == ComponentType.CAPACITOR:
|
||||
if _looks_xtal_net(via_net) or _ic_pin_is_xtal(cons, via_net, ic):
|
||||
return "load_cap"
|
||||
|
||||
pin_nets = {n for n in other.pins.values() if n}
|
||||
gnd_nets = {n for n in pin_nets if _is_ground_net(graph, n)}
|
||||
live = [n for n in pin_nets if n not in gnd_nets]
|
||||
ic_nets = {n for n in ic.pins.values() if n}
|
||||
|
||||
# Bootstrap / flying cap between two pins of this IC.
|
||||
if len(live) == 2 and all(n in ic_nets for n in live):
|
||||
return "bridge"
|
||||
|
||||
# Cap to GND on an IC pin / bias / strap / power net → local bypass.
|
||||
if gnd_nets and len(live) == 1:
|
||||
net = live[0]
|
||||
if (
|
||||
net in ic_nets
|
||||
or _is_power_net(graph, net)
|
||||
or _net_is_ic_supply(graph, ic, cons, net)
|
||||
or _BIAS_NET_RE.search(net or "")
|
||||
or _STRAP_NET_RE.search(net or "")
|
||||
):
|
||||
farads = _cap_farads(other)
|
||||
return "bulk" if farads is not None and farads >= _BULK_F else "decoupling"
|
||||
return "other"
|
||||
|
||||
if other.component_type == ComponentType.INDUCTOR:
|
||||
return "filter"
|
||||
|
||||
if other.component_type == ComponentType.RESISTOR:
|
||||
nets = list(dict.fromkeys(n for n in other.pins.values() if n))
|
||||
if len(nets) == 2:
|
||||
a, b = nets
|
||||
if _is_power_net(graph, a) or _is_power_net(graph, b) or (
|
||||
_BIAS_NET_RE.search(a or "") or _BIAS_NET_RE.search(b or "")
|
||||
):
|
||||
if _is_ground_net(graph, a) or _is_ground_net(graph, b):
|
||||
return "divider"
|
||||
return "pullup"
|
||||
ic_nets = set(ic.pins.values())
|
||||
if a in ic_nets and b in ic_nets:
|
||||
return "bridge"
|
||||
if a in ic_nets or b in ic_nets:
|
||||
# Set resistor / NTC leg to GND stays series (placement-local).
|
||||
return "series"
|
||||
return "other"
|
||||
|
||||
return "other"
|
||||
|
||||
def _looks_xtal_net(name: str) -> bool:
|
||||
return bool(_XTAL_RE.search(name or ""))
|
||||
|
||||
|
||||
def _ic_pin_is_xtal(
|
||||
cons: ComponentConstraints | None,
|
||||
net_name: str,
|
||||
ic: Component,
|
||||
) -> bool:
|
||||
for pin_num, n in ic.pins.items():
|
||||
if n != net_name:
|
||||
continue
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
if any(_XTAL_RE.search(t) for t in tokens):
|
||||
return True
|
||||
return _looks_xtal_net(net_name)
|
||||
|
||||
|
||||
def _net_is_ic_supply(
|
||||
graph: DesignGraph,
|
||||
ic: Component,
|
||||
cons: ComponentConstraints | None,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
for pin_num, n in ic.pins.items():
|
||||
if n == net_name and _is_ic_supply_pin(graph, cons, pin_num, net_name):
|
||||
return True
|
||||
return _is_power_net(graph, net_name)
|
||||
|
||||
|
||||
_UPSTREAM_BUS_RE = re.compile(
|
||||
r"(?:^|[_/\-])(VBUS|VBAT|VIN|VCHG|VAC|VPH)(?:$|[_/\-\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OUTPUT_BUS_RE = re.compile(
|
||||
r"(?:^|[_/\-])(VSYS|VOUT|VREG)(?:$|[_/\-\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REGULATED_RAIL_RE = re.compile(r"^\+?\d+V\d*", re.IGNORECASE)
|
||||
|
||||
|
||||
def _primary_supply_net(comp: Component, power_nets: set[str]) -> str | None:
|
||||
"""Pick one supply rail per IC so converters do not merge the whole board.
|
||||
|
||||
Consumers prefer regulated digital rails (3V3 / VDD). Power ICs prefer
|
||||
output-ish nets (VSYS / VOUT / regulated) over upstream buses (VBUS / VIN).
|
||||
"""
|
||||
if not power_nets:
|
||||
return None
|
||||
|
||||
sub = (comp.component_subtype or "").lower()
|
||||
is_power_ic = sub.startswith("ic.power")
|
||||
|
||||
def score(name: str) -> tuple[int, str]:
|
||||
u = name.upper()
|
||||
s = 0
|
||||
if _UPSTREAM_BUS_RE.search(u):
|
||||
s -= 100
|
||||
if _OUTPUT_BUS_RE.search(u):
|
||||
s += 50
|
||||
if _REGULATED_RAIL_RE.match(u):
|
||||
s += 40
|
||||
if "3V3" in u or "3.3V" in u:
|
||||
s += 25
|
||||
elif re.search(r"1V\d+|1\.?\d+V", u):
|
||||
s += 10 # core rails still regulated, but secondary to I/O
|
||||
if "VDD" in u or "VCC" in u:
|
||||
s += 15
|
||||
if is_power_ic:
|
||||
if _UPSTREAM_BUS_RE.search(u):
|
||||
s -= 40
|
||||
if _OUTPUT_BUS_RE.search(u) or _REGULATED_RAIL_RE.match(u):
|
||||
s += 30
|
||||
return (s, name)
|
||||
|
||||
return max(power_nets, key=score)
|
||||
|
||||
|
||||
def _domain_id_for_rail(rail: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9]+", "_", rail or "").strip("_")
|
||||
return f"domain_{safe}" if safe else "domain_unknown"
|
||||
|
||||
|
||||
def _build_domains(graph: DesignGraph, ic_refs: list[str]) -> list[PlacementDomain]:
|
||||
"""Cluster ICs by primary supply rail (not union-find across converters)."""
|
||||
power_by_ic: dict[str, set[str]] = {}
|
||||
for ref in ic_refs:
|
||||
nets: set[str] = set()
|
||||
for n in graph.nets_of_component(ref):
|
||||
if _is_power_net(graph, n) and not _is_ground_net(graph, n):
|
||||
nets.add(n)
|
||||
power_by_ic[ref] = nets
|
||||
|
||||
by_rail: dict[str, list[str]] = {}
|
||||
no_rail: list[str] = []
|
||||
for ref in ic_refs:
|
||||
primary = _primary_supply_net(graph.components[ref], power_by_ic[ref])
|
||||
if primary is None:
|
||||
no_rail.append(ref)
|
||||
else:
|
||||
by_rail.setdefault(primary, []).append(ref)
|
||||
|
||||
domains: list[PlacementDomain] = []
|
||||
for rail, members in sorted(by_rail.items(), key=lambda x: x[0].upper()):
|
||||
domains.append(PlacementDomain(
|
||||
domain_id=_domain_id_for_rail(rail),
|
||||
power_nets=[rail],
|
||||
ic_refs=sorted(members),
|
||||
))
|
||||
if no_rail:
|
||||
domains.append(PlacementDomain(
|
||||
domain_id="domain_unpowered",
|
||||
power_nets=[],
|
||||
ic_refs=sorted(no_rail),
|
||||
))
|
||||
return domains
|
||||
|
||||
|
||||
def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]:
|
||||
if not cons:
|
||||
return []
|
||||
pin = cons.pin_by_number(pin_num)
|
||||
if not pin or not pin.name:
|
||||
return []
|
||||
return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()]
|
||||
|
||||
|
||||
def _looks_like_supply(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t):
|
||||
return False
|
||||
return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t))
|
||||
|
||||
|
||||
def _is_ic_supply_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
if tokens:
|
||||
return any(_looks_like_supply(t) for t in tokens)
|
||||
if _looks_like_supply(net_name or ""):
|
||||
return True
|
||||
net = graph.nets.get(net_name)
|
||||
return bool(net and net.net_type == NetType.POWER)
|
||||
|
||||
|
||||
def _is_ground_net(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
if net and net.net_type == NetType.GROUND:
|
||||
return True
|
||||
u = name.upper().replace("-", "_")
|
||||
return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or (
|
||||
u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS")
|
||||
)
|
||||
|
||||
|
||||
def _is_power_net(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
if net and net.net_type == NetType.POWER:
|
||||
return True
|
||||
return bool(re.match(r"^\+?\d+V\d*", (name or "").upper()))
|
||||
|
||||
|
||||
def _cap_farads(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0:
|
||||
return float(specs.value_farads)
|
||||
raw = (comp.value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
v = _parse_spice_value(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return v if v > 0 else None
|
||||
@@ -6,8 +6,9 @@ import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.periscopex.models import (
|
||||
CadIndexEntry,
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentModel,
|
||||
@@ -22,8 +23,8 @@ from backend.pinscopex.models import (
|
||||
|
||||
# Datasheets are loaded here for pin-name enrichment during graph build,
|
||||
# but NOT embedded into the graph. The validator loads them separately.
|
||||
from backend.pinscopex.parsers import parse_bom, parse_netlist_any
|
||||
from backend.pinscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
|
||||
from backend.periscopex.parsers import parse_bom, parse_netlist_any
|
||||
from backend.periscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component type classification
|
||||
@@ -31,8 +32,10 @@ from backend.pinscopex.resolve_passives import SkippedItem, resolve_bom, resolve
|
||||
|
||||
_PREFIX_TYPE: dict[str, ComponentType] = {
|
||||
"R": ComponentType.RESISTOR,
|
||||
"RN": ComponentType.RESISTOR,
|
||||
"C": ComponentType.CAPACITOR,
|
||||
"L": ComponentType.INDUCTOR,
|
||||
"FB": ComponentType.INDUCTOR,
|
||||
"U": ComponentType.IC,
|
||||
"IC": ComponentType.IC,
|
||||
"J": ComponentType.CONNECTOR,
|
||||
@@ -154,6 +157,11 @@ def _infer_net_properties(name: str) -> tuple[NetType, float | None]:
|
||||
voltage = _parse_rail_voltage(name)
|
||||
return NetType.POWER, voltage
|
||||
|
||||
# KiCad-style rails: 3V3_DIGITAL, 1V8_SI4684, 5V_USB (not I2C1-SCL-3V3).
|
||||
if re.match(r"^\d+V\d*", upper):
|
||||
voltage = _parse_rail_voltage(name)
|
||||
return NetType.POWER, voltage
|
||||
|
||||
# Everything else is a signal
|
||||
return NetType.SIGNAL, None
|
||||
|
||||
@@ -246,6 +254,7 @@ def build_graph(
|
||||
mpn_col: str = "Manufacturer Part Number",
|
||||
skipped: list[SkippedItem] | None = None,
|
||||
include_subdesigns: set[str] | None = None,
|
||||
pcb_path: str | Path | None = None,
|
||||
) -> DesignGraph:
|
||||
"""Build a DesignGraph deterministically from project files.
|
||||
|
||||
@@ -256,17 +265,63 @@ def build_graph(
|
||||
4. Resolve passive specs from patterns + cached component models
|
||||
5. Assemble components with classified type, linked constraints, and specs
|
||||
6. Assemble nets with inferred type/voltage and enriched pin names
|
||||
|
||||
When ``pcb_path`` points at a ``.kicad_pcb``, pad nets from the board replace
|
||||
schematic-derived connectivity (KiCad board nets are authoritative).
|
||||
"""
|
||||
# Parse BOM first so we can feed known refs into the netlist parser —
|
||||
# PADS-PCB netlists allow multi-word designators (e.g. "CV GND"), which
|
||||
# only tokenise correctly with the BOM's ref list as a lookup. EDIF
|
||||
# netlists ignore known_refs (designators are unambiguous tokens).
|
||||
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
|
||||
parts, raw_nets, _ = parse_netlist_any(
|
||||
bom_fields = {}
|
||||
for ref, entry in bom.items():
|
||||
row = {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
|
||||
if "dnp" in entry:
|
||||
row["dnp"] = entry.get("dnp")
|
||||
if entry.get("variant") is not None:
|
||||
row["variant"] = entry.get("variant")
|
||||
bom_fields[ref] = row
|
||||
schematic_fields: dict[str, dict] = {}
|
||||
parts, raw_nets, fmt = parse_netlist_any(
|
||||
netlist_path,
|
||||
known_refs=set(bom.keys()),
|
||||
include_subdesigns=include_subdesigns,
|
||||
)
|
||||
if pcb_path is not None:
|
||||
pcb = Path(pcb_path)
|
||||
if pcb.is_file():
|
||||
from backend.periscopex.parsers_kicad_pcb import nets_from_pcb, parse_kicad_pcb
|
||||
|
||||
layout = parse_kicad_pcb(pcb)
|
||||
pcb_nets = nets_from_pcb(layout)
|
||||
if pcb_nets:
|
||||
raw_nets = pcb_nets
|
||||
for ref, fp in layout.footprints.items():
|
||||
parts.setdefault(ref, fp.footprint or "")
|
||||
if fmt.startswith("kicad"):
|
||||
from backend.periscopex.parsers_kicad import kicad_part_fields
|
||||
for ref, extra in kicad_part_fields(netlist_path).items():
|
||||
schematic_fields[ref] = {
|
||||
"mpn": extra.get("mpn"),
|
||||
"value": extra.get("value", ""),
|
||||
"cad_uuid": extra.get("cad_uuid") or "",
|
||||
"cad_sheet": extra.get("cad_sheet") or "",
|
||||
}
|
||||
entry = bom.setdefault(
|
||||
ref,
|
||||
{"value": "", "footprint": "", "mpn": None, "lcsc": None, "datasheet_url": None},
|
||||
)
|
||||
if extra.get("mpn") and (
|
||||
not entry.get("mpn") or entry.get("mpn") == entry.get("value")
|
||||
):
|
||||
entry["mpn"] = extra["mpn"]
|
||||
if extra.get("lcsc") and not entry.get("lcsc"):
|
||||
entry["lcsc"] = extra["lcsc"]
|
||||
if extra.get("value") and not entry.get("value"):
|
||||
entry["value"] = extra["value"]
|
||||
if extra.get("footprint") and not entry.get("footprint"):
|
||||
entry["footprint"] = extra["footprint"]
|
||||
datasheets = _load_datasheets(datasheets_dir)
|
||||
|
||||
# --- Resolve passive specs ------------------------------------------------
|
||||
@@ -301,7 +356,9 @@ def build_graph(
|
||||
for ref, footprint in parts.items():
|
||||
bom_entry = bom.get(ref, {})
|
||||
value = bom_entry.get("value", "")
|
||||
mpn = bom_entry.get("mpn")
|
||||
mpn = bom_entry.get("mpn") or None
|
||||
if not mpn and _classify_component(ref, footprint) == ComponentType.IC:
|
||||
mpn = (value or "").strip() or None
|
||||
|
||||
components[ref] = Component(
|
||||
reference=ref,
|
||||
@@ -371,4 +428,17 @@ def build_graph(
|
||||
pins=pin_connections,
|
||||
)
|
||||
|
||||
return DesignGraph(components=components, nets=nets)
|
||||
cad_index: dict[str, CadIndexEntry] = {}
|
||||
for ref, extra in schematic_fields.items():
|
||||
uuid = extra.get("cad_uuid") or ""
|
||||
sheet = extra.get("cad_sheet") or ""
|
||||
if uuid or sheet:
|
||||
cad_index[ref] = CadIndexEntry(uuid=uuid, sheet=sheet)
|
||||
|
||||
return DesignGraph(
|
||||
components=components,
|
||||
nets=nets,
|
||||
bom_fields=bom_fields,
|
||||
schematic_fields=schematic_fields,
|
||||
cad_index=cad_index,
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""HF decoupling coverage — bulk without a small ceramic.
|
||||
|
||||
Without a switching frequency this does not invent a Z(f) target.
|
||||
INFO only: HF coverage depends on a ~100 nF close to the pin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, NetType
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
_cap_farads,
|
||||
_is_ground_net,
|
||||
_is_ic_supply_pin,
|
||||
_is_nc_net,
|
||||
_is_regulator_output_pin,
|
||||
_pin_label,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_BULK_MIN_F = 1e-6
|
||||
_HF_MAX_F = 1e-6
|
||||
_HF_MIN_F = 1e-9
|
||||
|
||||
|
||||
def _esl_hint(footprint: str) -> str:
|
||||
fp = (footprint or "").upper()
|
||||
if "0402" in fp:
|
||||
return "typical ESL ~0.4 nH (0402 stima)"
|
||||
if "0603" in fp:
|
||||
return "typical ESL ~0.6 nH (0603 stima)"
|
||||
if "0805" in fp:
|
||||
return "typical ESL ~0.8 nH (0805 stima)"
|
||||
return "ESL depends on package (stima)"
|
||||
|
||||
|
||||
def _valued_gnd_caps(graph: DesignGraph, net_name: str) -> list[tuple[str, float]]:
|
||||
out: list[tuple[str, float]] = []
|
||||
unknown = False
|
||||
for ref in graph.capacitors_on_net(net_name):
|
||||
cap = graph.components[ref]
|
||||
others = {n for n in cap.pins.values() if n != net_name}
|
||||
if not any(_is_ground_net(graph, n) for n in others):
|
||||
continue
|
||||
farads = _cap_farads(cap)
|
||||
if farads is None:
|
||||
unknown = True
|
||||
continue
|
||||
out.append((ref, farads))
|
||||
if unknown:
|
||||
return []
|
||||
return out
|
||||
|
||||
|
||||
def check_hf_decoupling_coverage(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
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)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen or _is_nc_net(net_name):
|
||||
continue
|
||||
is_rail = _is_ic_supply_pin(graph, cons, pin_num, net_name) or (
|
||||
_is_regulator_output_pin(cons, pin_num)
|
||||
)
|
||||
if not is_rail:
|
||||
continue
|
||||
net = graph.nets.get(net_name)
|
||||
if net and net.net_type == NetType.GROUND:
|
||||
continue
|
||||
seen.add(net_name)
|
||||
caps = _valued_gnd_caps(graph, net_name)
|
||||
if not caps:
|
||||
continue
|
||||
has_bulk = any(c >= _BULK_MIN_F for _, c in caps)
|
||||
has_hf = any(_HF_MIN_F <= c < _HF_MAX_F for _, c in caps)
|
||||
if not (has_bulk and not has_hf):
|
||||
continue
|
||||
bulk_ref = next(r for r, c in caps if c >= _BULK_MIN_F)
|
||||
fp = graph.components[bulk_ref].footprint
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="decoupling",
|
||||
source="hf_coverage_check",
|
||||
status="INFO",
|
||||
finding=(
|
||||
f"{ref} net '{net_name}' ({pin_label}) has bulk capacitance "
|
||||
f"but no ~100 nF ceramic for HF."
|
||||
),
|
||||
why=(
|
||||
f"Parallel Z(f) of large C is inductive above a few hundred "
|
||||
f"kHz ({_esl_hint(fp)}). Without f_sw this is not an Ω target."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a 10–100 nF ceramic from '{net_name}' to ground near "
|
||||
f"{ref}, in parallel with the bulk cap."
|
||||
),
|
||||
reference="netlist topology (stima)",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-ESR-001",
|
||||
))
|
||||
return findings
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Periscope facade over ImpedanceFinder's closed-form Z0 solver.
|
||||
|
||||
All Z0 numbers come from ImpedenceFinder (`vendor/impedancefinder`,
|
||||
Hammerstad-Jensen / Cohn as in KiCad pcb_calculator). This module only
|
||||
validates geometry, inverts width for a target Z, and exports KiCad
|
||||
custom-rule advice. It never emits Findings. CPWG is not implemented
|
||||
upstream — we raise instead of inventing a number.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
ensure_impedancefinder()
|
||||
from impedancefinder import zsolver
|
||||
|
||||
|
||||
class GeometryError(ValueError):
|
||||
"""Trace geometry is missing, non-physical, or unsupported."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceGeometry:
|
||||
h: float
|
||||
er: float
|
||||
t: float
|
||||
w: float | None = None
|
||||
s: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImpedanceResult:
|
||||
kind: str
|
||||
w_mm: float | None = None
|
||||
s_mm: float | None = None
|
||||
z0: float | None = None
|
||||
zodd: float | None = None
|
||||
zeven: float | None = None
|
||||
zdiff: float | None = None
|
||||
formula: str = "impedancefinder"
|
||||
|
||||
|
||||
def _require_positive(name: str, value: float | None) -> float:
|
||||
if value is None or value <= 0:
|
||||
raise GeometryError(f"{name} must be > 0")
|
||||
return float(value)
|
||||
|
||||
|
||||
def microstrip_z0(geo: TraceGeometry) -> float:
|
||||
h = _require_positive("h", geo.h)
|
||||
er = _require_positive("er", geo.er)
|
||||
w = _require_positive("w", geo.w)
|
||||
t = geo.t
|
||||
if t < 0:
|
||||
raise GeometryError("t must be >= 0")
|
||||
return zsolver.microstrip_z0(w, h, er, t)
|
||||
|
||||
|
||||
def stripline_z0(geo: TraceGeometry) -> float:
|
||||
h = _require_positive("h", geo.h)
|
||||
er = _require_positive("er", geo.er)
|
||||
w = _require_positive("w", geo.w)
|
||||
t = _require_positive("t", geo.t)
|
||||
try:
|
||||
return zsolver.stripline_z0(w, h, er, t)
|
||||
except ValueError as exc:
|
||||
raise GeometryError(str(exc)) from exc
|
||||
|
||||
|
||||
def coupled_diff_z(geo: TraceGeometry) -> tuple[float, float, float]:
|
||||
"""Return (Zodd, Zeven, Zdiff) via ImpedanceFinder IPC-2141A odd-mode."""
|
||||
s = _require_positive("s", geo.s)
|
||||
h = _require_positive("h", geo.h)
|
||||
z0 = microstrip_z0(geo)
|
||||
zdiff = zsolver.diff_microstrip_z0(
|
||||
_require_positive("w", geo.w), h, s, geo.er, geo.t
|
||||
)
|
||||
zodd = zdiff / 2.0
|
||||
zeven = 2.0 * z0 - zodd
|
||||
return (zodd, zeven, zdiff)
|
||||
|
||||
|
||||
def cpw_z0(geo: TraceGeometry) -> float:
|
||||
_require_positive("h", geo.h)
|
||||
_require_positive("er", geo.er)
|
||||
_require_positive("w", geo.w)
|
||||
_require_positive("s", geo.s)
|
||||
try:
|
||||
return zsolver.cpwg_z0(geo.w, geo.h, geo.s, geo.er, geo.t)
|
||||
except NotImplementedError as exc:
|
||||
raise GeometryError(str(exc)) from exc
|
||||
|
||||
|
||||
def solve_width(
|
||||
kind: str,
|
||||
target_z: float,
|
||||
h: float,
|
||||
er: float,
|
||||
t: float,
|
||||
s: float | None = None,
|
||||
) -> float:
|
||||
_require_positive("target_z", target_z)
|
||||
_require_positive("h", h)
|
||||
_require_positive("er", er)
|
||||
if kind == "stripline":
|
||||
_require_positive("t", t)
|
||||
elif t < 0:
|
||||
raise GeometryError("t must be >= 0")
|
||||
|
||||
def z_of(w: float) -> float:
|
||||
geo = TraceGeometry(h=h, er=er, t=t, w=w, s=s)
|
||||
if kind == "microstrip":
|
||||
return microstrip_z0(geo)
|
||||
if kind == "stripline":
|
||||
return stripline_z0(geo)
|
||||
if kind == "diff":
|
||||
return coupled_diff_z(geo)[2]
|
||||
if kind == "cpw":
|
||||
return cpw_z0(geo)
|
||||
raise GeometryError(f"unknown kind {kind}")
|
||||
|
||||
lo, hi = 0.01 * h, 40.0 * h
|
||||
z_lo, z_hi = z_of(lo), z_of(hi)
|
||||
if not (min(z_lo, z_hi) <= target_z <= max(z_lo, z_hi)):
|
||||
raise GeometryError("target_z is outside the solvable width range")
|
||||
for _ in range(48):
|
||||
mid = 0.5 * (lo + hi)
|
||||
zm = z_of(mid)
|
||||
if zm > target_z:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return 0.5 * (lo + hi)
|
||||
|
||||
|
||||
def stackup_targets(
|
||||
h: float,
|
||||
er: float,
|
||||
t: float,
|
||||
s: float,
|
||||
) -> dict[str, ImpedanceResult]:
|
||||
w50 = solve_width("microstrip", 50.0, h, er, t)
|
||||
w90 = solve_width("diff", 90.0, h, er, t, s=s)
|
||||
w100 = solve_width("diff", 100.0, h, er, t, s=s)
|
||||
z50 = microstrip_z0(TraceGeometry(h=h, er=er, t=t, w=w50))
|
||||
_, _, zd90 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w90, s=s))
|
||||
_, _, zd100 = coupled_diff_z(TraceGeometry(h=h, er=er, t=t, w=w100, s=s))
|
||||
return {
|
||||
"microstrip_50": ImpedanceResult(kind="microstrip", w_mm=w50, z0=z50),
|
||||
"diff_90": ImpedanceResult(kind="diff", w_mm=w90, s_mm=s, zdiff=zd90),
|
||||
"diff_100": ImpedanceResult(kind="diff", w_mm=w100, s_mm=s, zdiff=zd100),
|
||||
}
|
||||
|
||||
|
||||
def export_kicad_dru(targets: dict[str, ImpedanceResult]) -> str:
|
||||
"""KiCad custom-rule advice. The user applies it; Periscope does not DRC the PCB."""
|
||||
lines = [
|
||||
"(version 1)",
|
||||
"# Periscope impedance advice (ImpedanceFinder solver) — apply in pcbnew.",
|
||||
]
|
||||
mapping = (
|
||||
("microstrip_50", "PERISCOPE_50OHM", "50Ohm"),
|
||||
("diff_90", "PERISCOPE_90OHM_USB", "90Ohm"),
|
||||
("diff_100", "PERISCOPE_100OHM_DIFF", "100Ohm"),
|
||||
)
|
||||
for key, rule, netclass in mapping:
|
||||
r = targets[key]
|
||||
w = r.w_mm
|
||||
if w is None:
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"(rule {rule}")
|
||||
lines.append(f' (constraint track_width (min {w:.4f}mm) (opt {w:.4f}mm) (max {w:.4f}mm))')
|
||||
if r.s_mm:
|
||||
lines.append(
|
||||
f" (constraint diff_pair_gap (min {r.s_mm:.4f}mm) "
|
||||
f"(opt {r.s_mm:.4f}mm) (max {r.s_mm:.4f}mm))"
|
||||
)
|
||||
lines.append(f' (condition "A.NetClass == \'{netclass}\'"))')
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,156 @@
|
||||
"""ImpedenceFinder net analysis on specified PCB traces.
|
||||
|
||||
Walks sampled points on named nets (net_walk + planes + zsolver).
|
||||
Stackup and widths come from the board (or explicit LayoutStackup).
|
||||
No invented εr/h; missing stackup or empty net list skips.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from backend.vendor_path import ensure_impedancefinder
|
||||
|
||||
ensure_impedancefinder()
|
||||
from impedancefinder import net_analysis, report
|
||||
from impedancefinder.model import (
|
||||
BoardData,
|
||||
DielectricLayer,
|
||||
Point2D,
|
||||
Stackup,
|
||||
TraceSegment,
|
||||
ViaSpan,
|
||||
ZonePolygon,
|
||||
)
|
||||
|
||||
from backend.periscopex.impedance import GeometryError
|
||||
from backend.periscopex.models import DesignGraph, LayoutGraph, NetType
|
||||
|
||||
|
||||
def _stackup(layout: LayoutGraph) -> Stackup:
|
||||
raw = layout.stackup
|
||||
if raw is None:
|
||||
raise GeometryError("PCB has no stackup (copper + dielectric εr/h)")
|
||||
t = raw.copper_thickness_mm
|
||||
if t is None or t <= 0:
|
||||
raise GeometryError("PCB stackup has no copper thickness")
|
||||
return Stackup(
|
||||
copper_layer_names=tuple(raw.copper_layers),
|
||||
dielectrics=tuple(
|
||||
DielectricLayer(name=d.name, er=d.er, height_mm=d.height_mm)
|
||||
for d in raw.dielectrics
|
||||
),
|
||||
copper_thickness_mm=t,
|
||||
)
|
||||
|
||||
|
||||
def layout_to_board_data(layout: LayoutGraph) -> BoardData:
|
||||
stackup = _stackup(layout)
|
||||
segments: list[TraceSegment] = []
|
||||
for s in layout.segments:
|
||||
if not s.net or s.width <= 0:
|
||||
continue
|
||||
segments.append(TraceSegment(
|
||||
net=s.net,
|
||||
layer=s.layer,
|
||||
start=Point2D(s.start[0], s.start[1]),
|
||||
end=Point2D(s.end[0], s.end[1]),
|
||||
width_mm=s.width,
|
||||
))
|
||||
vias: list[ViaSpan] = []
|
||||
layers = stackup.copper_layer_names
|
||||
if len(layers) >= 2:
|
||||
top, bot = layers[0], layers[-1]
|
||||
for v in layout.vias:
|
||||
if not v.net or v.drill is None or v.drill <= 0:
|
||||
continue
|
||||
vias.append(ViaSpan(
|
||||
net=v.net,
|
||||
position=Point2D(v.x, v.y),
|
||||
top_layer=top,
|
||||
bottom_layer=bot,
|
||||
drill_mm=v.drill,
|
||||
))
|
||||
zones: list[ZonePolygon] = []
|
||||
for z in layout.zones:
|
||||
rings = tuple(
|
||||
tuple(Point2D(x, y) for x, y in ring)
|
||||
for ring in z.outlines
|
||||
if len(ring) >= 3
|
||||
)
|
||||
if rings:
|
||||
zones.append(ZonePolygon(net=z.net, layer=z.layer, outlines_mm=rings))
|
||||
return BoardData(
|
||||
segments=tuple(segments),
|
||||
vias=tuple(vias),
|
||||
zone_polygons=tuple(zones),
|
||||
copper_layer_names=stackup.copper_layer_names,
|
||||
stackup=stackup,
|
||||
outline=None,
|
||||
)
|
||||
|
||||
|
||||
def analyze_specified_nets(
|
||||
layout: LayoutGraph,
|
||||
net_names: list[str],
|
||||
pitch_mm: float,
|
||||
) -> list[dict]:
|
||||
"""Analyze only the named nets. Empty names → []. Missing net → error row."""
|
||||
if pitch_mm <= 0:
|
||||
raise GeometryError("pitch_mm must be > 0")
|
||||
wanted = [n.strip() for n in net_names if n and n.strip()]
|
||||
if not wanted:
|
||||
return []
|
||||
board = layout_to_board_data(layout)
|
||||
stackup = board.stackup
|
||||
assert stackup is not None
|
||||
rows: list[dict] = []
|
||||
for name in wanted:
|
||||
segs = net_analysis.segments_for(board, name)
|
||||
if not segs:
|
||||
rows.append({"net_name": name, "error": "no segments on this net"})
|
||||
continue
|
||||
result = net_analysis.analyze_net(board, stackup, name, pitch_mm)
|
||||
summary = report.summarize_net(name, board, result)
|
||||
row = asdict(summary)
|
||||
row["sample_count"] = len(result.samples)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
# ImpedenceFinder net_walk sample interval (mm). Same as vendor
|
||||
# tests/test_net_walk.py pitch_mm=1.0 — not a Z0 target.
|
||||
NET_WALK_PITCH_MM = 1.0
|
||||
|
||||
|
||||
def nets_needed(layout: LayoutGraph, graph: DesignGraph | None) -> list[str]:
|
||||
"""Routed copper that is not a power/ground net in the schematic."""
|
||||
routed = {s.net for s in layout.segments if s.net and s.width > 0}
|
||||
needed: list[str] = []
|
||||
for name in sorted(routed):
|
||||
if graph is not None:
|
||||
net = graph.nets.get(name)
|
||||
if net is not None and net.net_type in (NetType.POWER, NetType.GROUND):
|
||||
continue
|
||||
needed.append(name)
|
||||
return needed
|
||||
|
||||
|
||||
def analyze_where_needed(
|
||||
layout: LayoutGraph,
|
||||
graph: DesignGraph | None = None,
|
||||
pitch_mm: float = NET_WALK_PITCH_MM,
|
||||
) -> dict:
|
||||
"""Pipeline entry: skip without stackup or without routed signal nets."""
|
||||
if pitch_mm <= 0:
|
||||
raise GeometryError("pitch_mm must be > 0")
|
||||
if layout.stackup is None:
|
||||
return {"pitch_mm": pitch_mm, "nets": [], "skipped": "no stackup"}
|
||||
names = nets_needed(layout, graph)
|
||||
if not names:
|
||||
return {"pitch_mm": pitch_mm, "nets": [], "skipped": "no routed signal nets"}
|
||||
return {
|
||||
"pitch_mm": pitch_mm,
|
||||
"nets": analyze_specified_nets(layout, names, pitch_mm),
|
||||
"skipped": None,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Open-drain / on-die pull-up pins from extracted internal_features."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, Finding
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
_pin_name_tokens,
|
||||
_resistor_to_power,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
|
||||
def check_internal_features(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
cmap = constraints_map or {}
|
||||
findings: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
feats = cons.internal_features if cons else None
|
||||
if not feats or not feats.pullup_pins:
|
||||
continue
|
||||
for pin_name in feats.pullup_pins:
|
||||
net = None
|
||||
for pin_num, n in comp.pins.items():
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
names = tokens or [n or "", str(pin_num)]
|
||||
if any(
|
||||
t.upper() == pin_name.upper() or (n or "").upper() == pin_name.upper()
|
||||
for t in names
|
||||
):
|
||||
net = n
|
||||
break
|
||||
if not net:
|
||||
continue
|
||||
if _resistor_to_power(graph, net):
|
||||
continue
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="internal_features",
|
||||
source="internal_features_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} {pin_name} is listed as needing an external pull-up "
|
||||
f"and net '{net}' has none."
|
||||
),
|
||||
why="internal_features.pullup_pins from the datasheet block diagram.",
|
||||
recommendation="Add a pull-up to the I/O rail, or confirm an on-die pull is enabled.",
|
||||
reference="internal_features",
|
||||
net=net,
|
||||
pins=[f"{ref}.{pin_name}"],
|
||||
rule_id="PE-INT-001",
|
||||
))
|
||||
return findings
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Validate datasheet layout_rules. Distances stay null unless numeric."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"})
|
||||
|
||||
|
||||
def _num(v: Any) -> float | None:
|
||||
if v is None or v is False:
|
||||
return None
|
||||
if isinstance(v, bool):
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def has_any_layout_rule(raw: object) -> bool:
|
||||
"""True when extraction already produced at least one structured rule."""
|
||||
if not isinstance(raw, list):
|
||||
return False
|
||||
for row in raw:
|
||||
if isinstance(row, dict) and str(row.get("kind") or "").strip() in KNOWN_KINDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def needs_layout_rules_refresh(
|
||||
data: dict,
|
||||
*,
|
||||
min_scan_version: str,
|
||||
) -> bool:
|
||||
"""True when layout_rules are empty and the extract predates the scan version.
|
||||
|
||||
After a successful extract at ``min_scan_version`` or newer, an empty
|
||||
``layout_rules`` list means the datasheet had no guidance — do not loop.
|
||||
"""
|
||||
if has_any_layout_rule(data.get("layout_rules")):
|
||||
return False
|
||||
ver = str(data.get("model_version") or "0.0.0")
|
||||
if not min_scan_version or min_scan_version == "0.0.0":
|
||||
return False
|
||||
try:
|
||||
return Version(ver) < Version(min_scan_version)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
|
||||
"""Return (normalized rows, errors). Empty list is a valid skip."""
|
||||
if not raw:
|
||||
return [], []
|
||||
if not isinstance(raw, list):
|
||||
return [], ["layout_rules must be an array"]
|
||||
ok: list[dict] = []
|
||||
errors: list[str] = []
|
||||
for i, row in enumerate(raw):
|
||||
if not isinstance(row, dict):
|
||||
errors.append(f"layout_rules[{i}] must be an object")
|
||||
continue
|
||||
kind = str(row.get("kind") or "").strip()
|
||||
if kind not in KNOWN_KINDS:
|
||||
errors.append(f"layout_rules[{i}] unknown kind {kind!r}")
|
||||
continue
|
||||
dist = _num(row.get("max_distance_mm"))
|
||||
via = row.get("min_via_count")
|
||||
via_i = None
|
||||
if isinstance(via, int) and not isinstance(via, bool):
|
||||
via_i = via
|
||||
elif via is not None:
|
||||
n = _num(via)
|
||||
via_i = int(n) if n is not None else None
|
||||
page = row.get("source_page")
|
||||
page_i = int(page) if isinstance(page, int) else None
|
||||
ok.append({
|
||||
"kind": kind,
|
||||
"pin": row.get("pin"),
|
||||
"cap_value_hint": row.get("cap_value_hint"),
|
||||
"max_distance_mm": dist,
|
||||
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
|
||||
"min_via_count": via_i,
|
||||
"net_class": row.get("net_class"),
|
||||
"note": row.get("note"),
|
||||
"source_page": page_i,
|
||||
})
|
||||
return ok, errors
|
||||
@@ -17,8 +17,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.pinscopex.models import ComponentType, DesignGraph, Finding, NetType
|
||||
from backend.pinscopex.resolve_passives import _parse_spice_value
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding, NetType
|
||||
from backend.periscopex.resolve_passives import _parse_spice_value
|
||||
|
||||
_COLOR_TOKENS = {
|
||||
"R": "red", "RED": "red",
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Shared-library promotion gates for extracted IC JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def pintable_checksum(pintable: list[Any]) -> str:
|
||||
"""Stable hash of pin number+name pairs (order-independent)."""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for pin in pintable or []:
|
||||
if isinstance(pin, dict):
|
||||
num = str(pin.get("number") or "").strip()
|
||||
name = str(pin.get("name") or "").strip()
|
||||
else:
|
||||
num = str(getattr(pin, "number", "") or "").strip()
|
||||
name = str(getattr(pin, "name", "") or "").strip()
|
||||
if num:
|
||||
rows.append((num, name))
|
||||
payload = json.dumps(sorted(rows), separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def should_promote_extraction(data: dict) -> tuple[bool, str]:
|
||||
"""Return (ok, reason). Reject empty / tiny pintables from shared library."""
|
||||
pins = data.get("pintable") or []
|
||||
if not isinstance(pins, list) or len(pins) == 0:
|
||||
return False, "empty pintable"
|
||||
if len(pins) < 2:
|
||||
return False, "pintable has fewer than 2 pins"
|
||||
# Require at least one named pin so a number-only stub cannot poison the library.
|
||||
named = 0
|
||||
for pin in pins:
|
||||
name = pin.get("name") if isinstance(pin, dict) else getattr(pin, "name", None)
|
||||
if name and str(name).strip() and str(name).strip() != "~":
|
||||
named += 1
|
||||
if named == 0:
|
||||
return False, "pintable has no named pins"
|
||||
return True, pintable_checksum(pins)
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Distributor lifecycle / RoHS — cached records only, never a guessed equivalent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.models import ComponentType, DesignGraph, Finding
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
_EOL = re.compile(
|
||||
r"\b(obsolete|eol|end\s*of\s*life|discontinued|last\s*time\s*buy|ltb)\b",
|
||||
re.I,
|
||||
)
|
||||
_NRND = re.compile(
|
||||
r"\b(nrnd|not\s+for\s+new\s+designs|not\s+recommended)\b",
|
||||
re.I,
|
||||
)
|
||||
_ACTIVE = re.compile(r"\b(active|production|recommended)\b", re.I)
|
||||
_ROHS_NO = re.compile(r"\b(non[-\s]?compliant|not\s+compliant|no)\b", re.I)
|
||||
_ROHS_YES = re.compile(r"\b(rohs\s*\d*\s*compliant|compliant|yes|true)\b", re.I)
|
||||
_ROHS_NA = re.compile(r"\b(not\s+applicable|n/?a|exempt)\b", re.I)
|
||||
|
||||
|
||||
class LifecycleRecord(BaseModel):
|
||||
mpn: str
|
||||
source: str = ""
|
||||
lifecycle: str | None = None # active | nrnd | eol | unknown
|
||||
rohs_compliant: bool | None = None
|
||||
stock: int | None = None
|
||||
lead_time: str | None = None
|
||||
replacement: str | None = None
|
||||
product_status_raw: str = ""
|
||||
|
||||
|
||||
def _status_lifecycle(raw: str) -> str | None:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if _EOL.search(s):
|
||||
return "eol"
|
||||
if _NRND.search(s):
|
||||
return "nrnd"
|
||||
if _ACTIVE.search(s):
|
||||
return "active"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _rohs(raw: str) -> bool | None:
|
||||
s = (raw or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if _ROHS_NA.search(s):
|
||||
return None
|
||||
if _ROHS_NO.search(s):
|
||||
return False
|
||||
if _ROHS_YES.search(s):
|
||||
return True
|
||||
return None
|
||||
|
||||
|
||||
def _replacement(product: dict) -> str | None:
|
||||
for key in ("ProductSubstitutions", "Substitutes", "replacement", "Replacement"):
|
||||
val = product.get(key)
|
||||
if not val:
|
||||
continue
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
if isinstance(val, list) and val:
|
||||
first = val[0]
|
||||
if isinstance(first, str) and first.strip():
|
||||
return first.strip()
|
||||
if isinstance(first, dict):
|
||||
for k in ("ManufacturerProductNumber", "ManufacturerPartNumber", "mpn"):
|
||||
if first.get(k):
|
||||
return str(first[k]).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_distributor_product(mpn: str, product: dict, *, source: str = "digikey") -> LifecycleRecord:
|
||||
"""Map a DigiKey/Mouser/LCSC product dict. Unknown fields stay None."""
|
||||
status = (
|
||||
product.get("ProductStatus")
|
||||
or product.get("productStatus")
|
||||
or product.get("partLifeCycle")
|
||||
or product.get("LifecycleStatus")
|
||||
or ""
|
||||
)
|
||||
rohs_raw = (
|
||||
product.get("RoHSStatus")
|
||||
or product.get("rohsStatus")
|
||||
or product.get("rohs")
|
||||
or ""
|
||||
)
|
||||
if isinstance(rohs_raw, bool):
|
||||
rohs = rohs_raw
|
||||
rohs_raw = "true" if rohs_raw else "false"
|
||||
else:
|
||||
rohs = _rohs(str(rohs_raw))
|
||||
stock = product.get("QuantityAvailable")
|
||||
if stock is None:
|
||||
stock = product.get("stock")
|
||||
try:
|
||||
stock_i = int(stock) if stock is not None else None
|
||||
except (TypeError, ValueError):
|
||||
stock_i = None
|
||||
lead = product.get("ManufacturerLeadWeeks") or product.get("lead_time") or product.get("LeadTime")
|
||||
return LifecycleRecord(
|
||||
mpn=mpn,
|
||||
source=source,
|
||||
lifecycle=_status_lifecycle(str(status)),
|
||||
rohs_compliant=rohs,
|
||||
stock=stock_i,
|
||||
lead_time=str(lead) if lead not in (None, "") else None,
|
||||
replacement=_replacement(product),
|
||||
product_status_raw=str(status),
|
||||
)
|
||||
|
||||
|
||||
def load_lifecycle_dir(directory: str | Path) -> dict[str, LifecycleRecord]:
|
||||
out: dict[str, LifecycleRecord] = {}
|
||||
path = Path(directory)
|
||||
if not path.is_dir():
|
||||
return out
|
||||
for f in path.glob("*.json"):
|
||||
raw = json.loads(f.read_text())
|
||||
rec = LifecycleRecord.model_validate(raw)
|
||||
out[rec.mpn] = rec
|
||||
return out
|
||||
|
||||
|
||||
def write_lifecycle_record(directory: str | Path, rec: LifecycleRecord) -> Path:
|
||||
path = Path(directory)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
dest = path / f"{safe_mpn(rec.mpn)}.json"
|
||||
dest.write_text(rec.model_dump_json(indent=2) + "\n")
|
||||
return dest
|
||||
|
||||
|
||||
def _match_record(mpn: str | None, records: dict[str, LifecycleRecord]) -> LifecycleRecord | None:
|
||||
if not mpn:
|
||||
return None
|
||||
if mpn in records:
|
||||
return records[mpn]
|
||||
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
|
||||
for key, rec in records.items():
|
||||
if re.sub(r"[/_\-\s]", "", key).upper() == norm:
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
def check_lifecycle(
|
||||
graph: DesignGraph,
|
||||
records: dict[str, LifecycleRecord] | None,
|
||||
) -> list[Finding]:
|
||||
recs = records or {}
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type in (
|
||||
ComponentType.MECHANICAL, ComponentType.FIDUCIAL, ComponentType.TEST_POINT,
|
||||
):
|
||||
continue
|
||||
mpn = (comp.mpn or "").strip()
|
||||
rec = _match_record(mpn, recs)
|
||||
if rec is None:
|
||||
continue
|
||||
if mpn in seen:
|
||||
continue
|
||||
seen.add(mpn)
|
||||
if rec.lifecycle == "eol":
|
||||
rec_txt = (
|
||||
f"Distributor replacement: {rec.replacement}."
|
||||
if rec.replacement else
|
||||
"No distributor replacement was listed."
|
||||
)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=mpn,
|
||||
aspect="lifecycle",
|
||||
source="lifecycle_check",
|
||||
status="WARNING",
|
||||
finding=f"{mpn} is EOL/obsolete ({rec.product_status_raw or 'eol'}).",
|
||||
why="Distributor ProductStatus, not an LLM equivalent search.",
|
||||
recommendation=rec_txt,
|
||||
reference=rec.source or "distributor",
|
||||
pins=[ref],
|
||||
rule_id="PE-LF-001",
|
||||
))
|
||||
elif rec.lifecycle == "nrnd":
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=mpn,
|
||||
aspect="lifecycle",
|
||||
source="lifecycle_check",
|
||||
status="INFO",
|
||||
finding=f"{mpn} is NRND ({rec.product_status_raw or 'nrnd'}).",
|
||||
why="Distributor ProductStatus.",
|
||||
recommendation="Prefer an Active orderable if the design is new.",
|
||||
reference=rec.source or "distributor",
|
||||
pins=[ref],
|
||||
rule_id="PE-LF-002",
|
||||
))
|
||||
if rec.rohs_compliant is False:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=mpn,
|
||||
aspect="lifecycle",
|
||||
source="lifecycle_check",
|
||||
status="WARNING",
|
||||
finding=f"{mpn} is marked RoHS non-compliant.",
|
||||
why="RoHS fail only when the distributor flag is explicit.",
|
||||
recommendation="Choose a RoHS-compliant orderable of the same MPN family.",
|
||||
reference=rec.source or "distributor",
|
||||
pins=[ref],
|
||||
rule_id="PE-LF-003",
|
||||
))
|
||||
return findings
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Pydantic models for PinscopeX: datasheet constraints and design graph."""
|
||||
"""Pydantic models for PeriscopeX: datasheet constraints and design graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator, model_validator
|
||||
|
||||
|
||||
class Pin(BaseModel):
|
||||
@@ -40,10 +40,17 @@ def _check_subtype(v: object) -> str | None:
|
||||
"""Shared pre-validator for component_subtype fields."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
from backend.pinscopex.taxonomy import validate_subtype
|
||||
from backend.periscopex.taxonomy import validate_subtype
|
||||
return validate_subtype(str(v))
|
||||
|
||||
|
||||
class InternalFeatures(BaseModel):
|
||||
"""Block-diagram extras: ESD clamps, on-die pull-ups, analog switches."""
|
||||
esd_clamp_pins: list[str] = []
|
||||
pullup_pins: list[str] = []
|
||||
analog_switch: list[str] = []
|
||||
|
||||
|
||||
class ComponentConstraints(BaseModel):
|
||||
mpn: str
|
||||
model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
|
||||
@@ -52,6 +59,8 @@ class ComponentConstraints(BaseModel):
|
||||
pintable: list[Pin]
|
||||
absolute_maximum_ratings: list[AbsMaxRating]
|
||||
rules: list[Rule]
|
||||
internal_features: InternalFeatures | None = None
|
||||
layout_rules: list[dict] = []
|
||||
|
||||
_validate_subtype = field_validator("component_subtype", mode="before")(
|
||||
staticmethod(_check_subtype)
|
||||
@@ -131,20 +140,31 @@ class CapacitorSpecs(BaseModel):
|
||||
|
||||
|
||||
class InductorSpecs(BaseModel):
|
||||
"""Standardised inductor parameters. Value always in henries."""
|
||||
"""Standardised inductor / ferrite-bead parameters."""
|
||||
specs_type: Literal["inductor"] = "inductor"
|
||||
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
|
||||
value_henries: float
|
||||
value_henries: float | None = None
|
||||
value_formatted: str
|
||||
tolerance: str | None = None # "±5%" or "±0.1uH"
|
||||
package: str | None = None
|
||||
current_rating_a: str | None = None
|
||||
dcr_ohms: float | None = None
|
||||
impedance_ohm: float | None = None # ferrite beads: Z at test frequency
|
||||
|
||||
_validate_subtype = field_validator("component_subtype", mode="before")(
|
||||
staticmethod(_check_subtype)
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_primary_value(self) -> InductorSpecs:
|
||||
if self.component_subtype == "passive.ferrite_bead":
|
||||
if self.impedance_ohm is None:
|
||||
raise ValueError("ferrite bead requires impedance_ohm")
|
||||
return self
|
||||
if self.value_henries is None:
|
||||
raise ValueError("inductor requires value_henries")
|
||||
return self
|
||||
|
||||
|
||||
class SimpleComponentSpecs(BaseModel):
|
||||
"""Specs for discrete/simple components. Schema defined in taxonomy JSON."""
|
||||
@@ -223,6 +243,12 @@ class Component(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CadIndexEntry(BaseModel):
|
||||
"""KiCad symbol identity for plugin pan-and-zoom."""
|
||||
uuid: str = ""
|
||||
sheet: str = ""
|
||||
|
||||
|
||||
class DesignGraph(BaseModel):
|
||||
"""
|
||||
Bipartite design graph: Components <-> Nets.
|
||||
@@ -233,6 +259,10 @@ class DesignGraph(BaseModel):
|
||||
"""
|
||||
components: dict[str, Component] = {}
|
||||
nets: dict[str, Net] = {}
|
||||
# KiCad property table vs uploaded BOM (empty on PADS/EDIF).
|
||||
bom_fields: dict[str, dict] = {}
|
||||
schematic_fields: dict[str, dict] = {}
|
||||
cad_index: dict[str, CadIndexEntry] = {}
|
||||
|
||||
# -- Traversal helpers --------------------------------------------------
|
||||
|
||||
@@ -271,7 +301,8 @@ class DesignGraph(BaseModel):
|
||||
"""Capacitor refs connected to a net (useful for decoupling checks)."""
|
||||
return [
|
||||
r for r in self.components_on_net(net_name)
|
||||
if self.components[r].component_type == ComponentType.CAPACITOR
|
||||
if (c := self.components.get(r)) is not None
|
||||
and c.component_type == ComponentType.CAPACITOR
|
||||
]
|
||||
|
||||
def components_by_subtype(self, prefix: str) -> list[str]:
|
||||
@@ -318,7 +349,13 @@ class Finding(BaseModel):
|
||||
status: Literal["ERROR", "WARNING", "INFO"]
|
||||
recommendation: str = ""
|
||||
reference: str = ""
|
||||
source: str | None = None # None/"review" = LLM datasheet review; "pin_mux_check"/"led_current_check" = deterministic
|
||||
source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic
|
||||
net: str | None = None # net name for CAD telemetry / SI filters
|
||||
pins: list[str] = [] # e.g. ["U3.54"] for pan-and-zoom
|
||||
rule_id: str | None = None # deterministic id, e.g. PE-MUX-001
|
||||
cad_sheet: str | None = None # schematic sheet filename for plugin sync
|
||||
cad_uuid: str | None = None # KiCad symbol/pin uuid
|
||||
variant: str | None = None # DNP / ECO / assembly variant
|
||||
|
||||
|
||||
class ValidationReport(BaseModel):
|
||||
@@ -409,3 +446,64 @@ class ResolvedPassive(BaseModel):
|
||||
power_rating: str | None = None
|
||||
dielectric: str | None = None
|
||||
raw_fields: dict[str, str] = {}
|
||||
|
||||
|
||||
class LayoutPad(BaseModel):
|
||||
number: str
|
||||
x: float
|
||||
y: float
|
||||
net: str = ""
|
||||
|
||||
|
||||
class LayoutFootprint(BaseModel):
|
||||
reference: str
|
||||
footprint: str = ""
|
||||
x: float
|
||||
y: float
|
||||
layer: str = ""
|
||||
pads: list[LayoutPad] = []
|
||||
courtyard: list[tuple[float, float]] = []
|
||||
|
||||
|
||||
class LayoutSegment(BaseModel):
|
||||
start: tuple[float, float]
|
||||
end: tuple[float, float]
|
||||
width: float = 0.0
|
||||
layer: str = ""
|
||||
net: str = ""
|
||||
|
||||
|
||||
class LayoutVia(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
net: str = ""
|
||||
drill: float | None = None
|
||||
|
||||
|
||||
class LayoutDielectric(BaseModel):
|
||||
name: str
|
||||
er: float
|
||||
height_mm: float
|
||||
|
||||
|
||||
class LayoutStackup(BaseModel):
|
||||
copper_layers: list[str]
|
||||
dielectrics: list[LayoutDielectric]
|
||||
copper_thickness_mm: float | None = None
|
||||
|
||||
|
||||
class LayoutZone(BaseModel):
|
||||
net: str
|
||||
layer: str
|
||||
outlines: list[list[tuple[float, float]]] = []
|
||||
|
||||
|
||||
class LayoutGraph(BaseModel):
|
||||
"""Parsed `.kicad_pcb` geometry. Optional; schema validation does not require it."""
|
||||
nets: dict[str, int] = {}
|
||||
footprints: dict[str, LayoutFootprint] = {}
|
||||
segments: list[LayoutSegment] = []
|
||||
vias: list[LayoutVia] = []
|
||||
stackup: LayoutStackup | None = None
|
||||
zones: list[LayoutZone] = []
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""NC pintable pins must not sit on an active net with other parts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
)
|
||||
|
||||
_NC_NAME_RE = re.compile(
|
||||
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NC_NET_RE = re.compile(
|
||||
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def check_nc_pins(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match(comp.mpn or comp.value, constraints_map)
|
||||
if not cons or not cons.pintable:
|
||||
continue
|
||||
for pin in cons.pintable:
|
||||
if not _is_nc_pin_name(pin.name or ""):
|
||||
continue
|
||||
net_name = comp.pins.get(str(pin.number))
|
||||
if not net_name:
|
||||
continue
|
||||
if _NC_NET_RE.match(net_name.strip()):
|
||||
continue
|
||||
others = [
|
||||
r for r in graph.components_on_net(net_name)
|
||||
if r != ref
|
||||
]
|
||||
if not others:
|
||||
# Lone net named oddly but empty of other parts — still flag if
|
||||
# the net name looks like a real signal (not floating placeholder).
|
||||
if _looks_active_net(net_name):
|
||||
findings.append(_finding(ref, comp.mpn or "", pin.number, pin.name, net_name, []))
|
||||
continue
|
||||
findings.append(_finding(ref, comp.mpn or "", pin.number, pin.name, net_name, others))
|
||||
return findings
|
||||
|
||||
|
||||
def _finding(ref, mpn, pin_num, pin_name, net, others) -> Finding:
|
||||
other_s = ", ".join(others[:6]) if others else "(no other refs)"
|
||||
return Finding(
|
||||
designator=ref,
|
||||
mpn=mpn,
|
||||
aspect="connectivity",
|
||||
source="nc_pin_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} pin {pin_num} ({pin_name or 'NC'}) is marked NC in the "
|
||||
f"pintable but connects to net '{net}'"
|
||||
+ (f" with {other_s}." if others else ".")
|
||||
),
|
||||
why="No-connect pins should remain unconnected or on an explicit NC net.",
|
||||
recommendation="Leave the NC pin floating or disconnect the net.",
|
||||
reference="pintable",
|
||||
rule_id="PE-NC-001",
|
||||
net=net,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
)
|
||||
|
||||
|
||||
def _is_nc_pin_name(name: str) -> bool:
|
||||
t = (name or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
if _NC_NAME_RE.match(t):
|
||||
return True
|
||||
# Slash-separated alts: "NC/GPIO" still counts as NC-capable; only pure NC.
|
||||
parts = [p.strip() for p in re.split(r"[/,]", t) if p.strip()]
|
||||
return bool(parts) and all(_NC_NAME_RE.match(p) or p.upper() == "NC" for p in parts)
|
||||
|
||||
|
||||
def _looks_active_net(name: str) -> bool:
|
||||
u = (name or "").strip()
|
||||
if not u or u.startswith("unconnected"):
|
||||
return False
|
||||
return not _NC_NET_RE.match(u)
|
||||
|
||||
|
||||
def _match(
|
||||
mpn: str | None,
|
||||
datasheets: dict[str, ComponentConstraints],
|
||||
) -> ComponentConstraints | None:
|
||||
if not mpn:
|
||||
return None
|
||||
if mpn in datasheets:
|
||||
return datasheets[mpn]
|
||||
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
|
||||
for ds_mpn, constraints in datasheets.items():
|
||||
if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm:
|
||||
return constraints
|
||||
return None
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Unpack a netlist upload: one file, several KiCad sheets, or a zip.
|
||||
|
||||
The hierarchical ``.kicad_sch`` parser needs sibling files on disk. A single
|
||||
temp file named ``tmpXXXX.kicad_sch`` cannot see ``Sheetfile`` children.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.parsers import detect_netlist_format
|
||||
|
||||
MAX_BUNDLE_BYTES = 30 * 1024 * 1024
|
||||
_MAX_ZIP_MEMBERS = 400
|
||||
|
||||
_KIND = str # pads | edif | kicad_* | zip | kicad_pcb | unknown
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetlistUpload:
|
||||
root: Path
|
||||
work_dir: Path
|
||||
pcb: Path | None
|
||||
extra_sch: list[Path]
|
||||
bom: Path | None = None
|
||||
|
||||
|
||||
def sniff_netlist_kind(content: bytes) -> str:
|
||||
if content[:2] == b"PK":
|
||||
return "zip"
|
||||
head = content[:2048].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
|
||||
low = head[:40].lower()
|
||||
if low.startswith("(kicad_pcb"):
|
||||
return "kicad_pcb"
|
||||
if low.startswith("(kicad_sch"):
|
||||
return "kicad_sch"
|
||||
if low.startswith("(edif"):
|
||||
return "edif"
|
||||
if low.startswith("(export"):
|
||||
return "kicad_sexp"
|
||||
if low.startswith("<?xml") or low.startswith("<export"):
|
||||
return "kicad_xml"
|
||||
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
|
||||
return "pads"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _safe_rel(name: str) -> str:
|
||||
rel = name.replace("\\", "/").strip()
|
||||
if not rel or rel.startswith("/") or rel.startswith("\\"):
|
||||
raise ValueError(f"Rejected path: {name}")
|
||||
parts = Path(rel).parts
|
||||
if ".." in parts or (parts and parts[0] == ".."):
|
||||
raise ValueError(f"Rejected path: {name}")
|
||||
return rel
|
||||
|
||||
|
||||
_KEEP_SUFFIX = {
|
||||
".kicad_sch",
|
||||
".kicad_pcb",
|
||||
".kicad_pro",
|
||||
".kicad_net",
|
||||
".xml",
|
||||
".edn",
|
||||
".edif",
|
||||
".edf",
|
||||
".asc",
|
||||
".net",
|
||||
".csv",
|
||||
".xlsx",
|
||||
}
|
||||
|
||||
|
||||
def _keep_zip_member(rel: str) -> bool:
|
||||
parts = Path(rel).parts
|
||||
if any(
|
||||
p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"}
|
||||
for p in parts
|
||||
):
|
||||
return False
|
||||
return Path(rel).suffix.lower() in _KEEP_SUFFIX
|
||||
|
||||
|
||||
def _extract_zip(data: bytes, dest: Path) -> None:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
total = 0
|
||||
kept = 0
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
rel = _safe_rel(info.filename)
|
||||
if not _keep_zip_member(rel):
|
||||
continue
|
||||
kept += 1
|
||||
if kept > _MAX_ZIP_MEMBERS:
|
||||
raise ValueError("Zip has too many schematic files")
|
||||
total += max(info.file_size, 0)
|
||||
if total > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("Zip is too large")
|
||||
out = dest / rel
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info) as src:
|
||||
payload = src.read()
|
||||
if len(payload) > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("Zip member is too large")
|
||||
out.write_bytes(payload)
|
||||
|
||||
|
||||
def _write_named(name: str, data: bytes, dest: Path) -> None:
|
||||
rel = _safe_rel(name)
|
||||
kind = sniff_netlist_kind(data)
|
||||
if kind == "zip":
|
||||
_extract_zip(data, dest)
|
||||
return
|
||||
out = dest / Path(rel).name
|
||||
# Keep a single subdirectory when the client sent webkitRelativePath.
|
||||
if "/" in rel:
|
||||
out = dest / rel
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(data)
|
||||
|
||||
|
||||
def find_kicad_pcb(work: Path) -> Path | None:
|
||||
hits = sorted(p for p in work.rglob("*.kicad_pcb") if p.is_file())
|
||||
return hits[0] if hits else None
|
||||
|
||||
|
||||
def find_bom(work: Path) -> Path | None:
|
||||
"""Prefer a shallow BOM path (KiCad project root over nested copies)."""
|
||||
hits = [
|
||||
p for p in work.rglob("*")
|
||||
if p.is_file() and p.suffix.lower() in {".csv", ".xlsx"}
|
||||
]
|
||||
if not hits:
|
||||
return None
|
||||
hits.sort(key=lambda p: (len(p.relative_to(work).parts), p.name.lower()))
|
||||
return hits[0]
|
||||
|
||||
|
||||
def _sheetfiles_of(path: Path) -> list[str]:
|
||||
from backend.periscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag
|
||||
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
tree = _parse_sexp(text)
|
||||
if _tag(tree) != "kicad_sch":
|
||||
return []
|
||||
return _sheetfiles(tree)
|
||||
|
||||
|
||||
def _pick_root(work: Path) -> Path:
|
||||
schs: list[Path] = []
|
||||
exported: list[Path] = []
|
||||
for p in work.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
kind = sniff_netlist_kind(p.read_bytes()[:2048])
|
||||
if kind == "kicad_sch":
|
||||
schs.append(p)
|
||||
elif kind in ("kicad_xml", "kicad_sexp", "edif", "pads"):
|
||||
exported.append(p)
|
||||
|
||||
if exported:
|
||||
pref = [
|
||||
p for p in exported
|
||||
if sniff_netlist_kind(p.read_bytes()[:2048]) in (
|
||||
"kicad_xml", "kicad_sexp", "edif",
|
||||
)
|
||||
]
|
||||
return (pref or exported)[0]
|
||||
|
||||
if not schs:
|
||||
raise ValueError(
|
||||
"No schematic found. Drop the KiCad project folder or a netlist."
|
||||
)
|
||||
|
||||
referenced: set[Path] = set()
|
||||
for p in schs:
|
||||
for rel in _sheetfiles_of(p):
|
||||
try:
|
||||
child = (p.parent / rel.replace("\\", "/")).resolve()
|
||||
except ValueError:
|
||||
continue
|
||||
referenced.add(child)
|
||||
roots = [p for p in schs if p.resolve() not in referenced]
|
||||
if not roots:
|
||||
raise ValueError("Cyclic sheet includes — upload an exported KiCad netlist instead.")
|
||||
|
||||
pro = list(work.rglob("*.kicad_pro"))
|
||||
if len(roots) > 1 and pro:
|
||||
stems = {p.stem for p in pro}
|
||||
matched = [r for r in roots if r.stem in stems]
|
||||
if len(matched) == 1:
|
||||
return matched[0]
|
||||
if len(roots) > 1:
|
||||
names = ", ".join(sorted(r.name for r in roots))
|
||||
raise ValueError(
|
||||
f"Multiple root sheets ({names}). Upload a zip of the project, "
|
||||
"or the top-level .kicad_sch together with every Sheetfile child."
|
||||
)
|
||||
return roots[0]
|
||||
|
||||
|
||||
def materialize_netlist_upload(
|
||||
files: list[tuple[str, bytes]],
|
||||
dest: Path,
|
||||
) -> NetlistUpload:
|
||||
"""Write uploaded bytes into ``dest`` and return the file to parse."""
|
||||
if not files:
|
||||
raise ValueError("No netlist file uploaded")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
total = sum(len(b) for _n, b in files)
|
||||
if total > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("Upload is too large")
|
||||
|
||||
if len(files) == 1:
|
||||
name, data = files[0]
|
||||
kind = sniff_netlist_kind(data)
|
||||
if kind == "kicad_pcb":
|
||||
raise ValueError(
|
||||
"This is a board file. Drop the KiCad project folder, or put "
|
||||
"the .kicad_pcb on the optional board step."
|
||||
)
|
||||
if kind == "unknown" and not name.lower().endswith(".zip"):
|
||||
raise ValueError(
|
||||
"Not a netlist. Drop the KiCad project folder, a zip, or a "
|
||||
"PADS / EDIF / KiCad netlist."
|
||||
)
|
||||
|
||||
for name, data in files:
|
||||
_write_named(name, data, dest)
|
||||
|
||||
root = _pick_root(dest)
|
||||
pcb = find_kicad_pcb(dest)
|
||||
bom = find_bom(dest)
|
||||
extras = [
|
||||
p for p in work_sch_files(dest)
|
||||
if p.resolve() != root.resolve()
|
||||
]
|
||||
return NetlistUpload(
|
||||
root=root, work_dir=dest, pcb=pcb, extra_sch=extras, bom=bom,
|
||||
)
|
||||
|
||||
|
||||
def work_sch_files(dest: Path) -> list[Path]:
|
||||
return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file())
|
||||
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
NetlistFormat = Literal["pads", "edif"]
|
||||
NetlistFormat = Literal["pads", "edif", "kicad_xml", "kicad_sexp", "kicad_sch"]
|
||||
|
||||
|
||||
def parse_netlist(
|
||||
@@ -157,25 +158,26 @@ def _parse_pin_tokens(
|
||||
|
||||
|
||||
def detect_netlist_format(content: bytes | str) -> NetlistFormat:
|
||||
"""Sniff the first chunk of a netlist to decide whether it's PADS or EDIF.
|
||||
"""Sniff the first chunk of a netlist to decide the format.
|
||||
|
||||
EDIF s-expressions start with ``(edif …`` (with possible leading whitespace
|
||||
or BOM); PADS-PCB ASCII files start with ``*PADS-PCB*``. The "pads" branch
|
||||
is the default when no clear marker is found — preserves the old behavior
|
||||
where the parser raises a friendly error on unrecognised input.
|
||||
EDIF starts with ``(edif``; KiCad XML with ``<export`` / ``<?xml``;
|
||||
KiCad s-expr netlist with ``(export``; schematic with ``(kicad_sch``.
|
||||
PADS-PCB ASCII (``*PADS-PCB*``) is the default when no marker is found.
|
||||
"""
|
||||
if isinstance(content, bytes):
|
||||
try:
|
||||
text = content[:1024].decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
text = ""
|
||||
text = content[:2048].decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = content[:1024]
|
||||
head = text.lstrip("").lstrip()
|
||||
# Case-insensitive match — EDIF spec allows different capitalisations
|
||||
# (KiCad emits lowercase; xDX Designer emits lowercase too).
|
||||
if head[:5].lower() == "(edif":
|
||||
text = content[:2048]
|
||||
head = text.lstrip("\ufeff").lstrip()
|
||||
low = head[:40].lower()
|
||||
if low.startswith("(edif"):
|
||||
return "edif"
|
||||
if low.startswith("(kicad_sch"):
|
||||
return "kicad_sch"
|
||||
if low.startswith("(export"):
|
||||
return "kicad_sexp"
|
||||
if low.startswith("<?xml") or low.startswith("<export"):
|
||||
return "kicad_xml"
|
||||
return "pads"
|
||||
|
||||
|
||||
@@ -195,11 +197,14 @@ def parse_netlist_any(
|
||||
their nets land in the output (PADS netlists have no sub-design concept).
|
||||
"""
|
||||
p = Path(path)
|
||||
sample = p.read_bytes()[:1024]
|
||||
sample = p.read_bytes()[:2048]
|
||||
fmt = detect_netlist_format(sample)
|
||||
if fmt == "edif":
|
||||
from backend.pinscopex.parsers_edif import parse_edif_netlist
|
||||
from backend.periscopex.parsers_edif import parse_edif_netlist
|
||||
parts, nets = parse_edif_netlist(p, include_subdesigns=include_subdesigns)
|
||||
elif fmt.startswith("kicad"):
|
||||
from backend.periscopex.parsers_kicad import parse_kicad
|
||||
parts, nets, _ = parse_kicad(p)
|
||||
else:
|
||||
parts, nets = parse_netlist(p, known_refs=known_refs)
|
||||
return parts, nets, fmt
|
||||
@@ -210,7 +215,10 @@ def validate_netlist(parts: dict, nets: dict) -> list[str]:
|
||||
errors: list[str] = []
|
||||
|
||||
if not parts:
|
||||
errors.append("No components found — is this a PADS-PCB (.asc) or EDIF (.edn) netlist?")
|
||||
errors.append(
|
||||
"No components found — is this a PADS-PCB (.asc), EDIF (.edn), "
|
||||
"or KiCad netlist / .kicad_sch?"
|
||||
)
|
||||
return errors # further checks are meaningless without parts
|
||||
|
||||
if not nets:
|
||||
@@ -259,22 +267,47 @@ def parse_bom(
|
||||
result: dict[str, dict] = {}
|
||||
text = Path(path).read_text()
|
||||
reader = csv.DictReader(text.splitlines())
|
||||
colnames = {n.lower() for n in (reader.fieldnames or []) if n}
|
||||
has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
|
||||
has_variant_col = bool(colnames & {"variant"})
|
||||
|
||||
for row in reader:
|
||||
refs_raw = row.get(reference_col, "")
|
||||
value = row.get("Value", "") or row.get("Comment", "")
|
||||
footprint = row.get("Footprint", "")
|
||||
mpn = row.get(mpn_col, "") or None
|
||||
mpn = (row.get(mpn_col, "") or "").strip() or None
|
||||
lcsc = row.get("LCSC", "") or None
|
||||
datasheet_url = (row.get("Datasheet", "") or "").strip() or None
|
||||
|
||||
# Expand grouped references: "C1,C2,C5" -> ["C1", "C2", "C5"]
|
||||
for ref in (r.strip() for r in refs_raw.split(",")):
|
||||
if ref:
|
||||
result[ref] = {
|
||||
"value": value,
|
||||
"footprint": footprint,
|
||||
"mpn": mpn,
|
||||
"lcsc": lcsc,
|
||||
}
|
||||
refs = [r.strip() for r in refs_raw.split(",") if r.strip()]
|
||||
# KiCad exports often leave Manufacturer Part Number empty and put
|
||||
# the orderable code in Value (or PNM). Without this, U* never
|
||||
# enter ic_mpns and review reports "no datasheet PDF".
|
||||
if not mpn:
|
||||
mpn = (row.get("PNM", "") or "").strip() or None
|
||||
if not mpn and any(re.match(r"^U\d", r, re.I) for r in refs):
|
||||
mpn = (value or "").strip() or None
|
||||
|
||||
dnp_raw = (row.get("DNP") or row.get("DNI") or "").strip().lower()
|
||||
fitted_raw = (row.get("Fitted") or row.get("Populate") or "").strip().lower()
|
||||
variant = (row.get("Variant") or row.get("variant") or "").strip() or None
|
||||
is_dnp = dnp_raw in {"1", "y", "yes", "true", "dnp", "dni", "x"}
|
||||
if not is_dnp and fitted_raw in {"0", "n", "no", "false"}:
|
||||
is_dnp = True
|
||||
|
||||
for ref in refs:
|
||||
entry = {
|
||||
"value": value,
|
||||
"footprint": footprint,
|
||||
"mpn": mpn,
|
||||
"lcsc": lcsc,
|
||||
"datasheet_url": datasheet_url,
|
||||
}
|
||||
if has_dnp_col:
|
||||
entry["dnp"] = is_dnp
|
||||
if has_variant_col:
|
||||
entry["variant"] = variant
|
||||
result[ref] = entry
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,725 @@
|
||||
"""KiCad netlist (XML / s-expression) and ``.kicad_sch`` parser.
|
||||
|
||||
Yields the same ``(parts, nets)`` shape as PADS/EDIF so graph build is format-agnostic.
|
||||
``.kicad_sch`` uses embedded ``lib_symbols`` plus wires/labels. Hierarchical
|
||||
``(sheet …)`` entries are followed from the root file (path-jailed under the
|
||||
project directory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
_MPN_FIELD_NAMES = {
|
||||
"mpn", "manufacturer part number", "manufacturer_part_number",
|
||||
"manf#", "part number", "partnumber", "p/n",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S-expression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tokenize(text: str) -> Iterator[str]:
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c.isspace():
|
||||
i += 1
|
||||
continue
|
||||
if c == "(" or c == ")":
|
||||
yield c
|
||||
i += 1
|
||||
continue
|
||||
if c == '"':
|
||||
j = i + 1
|
||||
buf: list[str] = []
|
||||
while j < n and text[j] != '"':
|
||||
if text[j] == "\\" and j + 1 < n:
|
||||
buf.append(text[j + 1])
|
||||
j += 2
|
||||
else:
|
||||
buf.append(text[j])
|
||||
j += 1
|
||||
yield '"' + "".join(buf)
|
||||
i = j + 1
|
||||
continue
|
||||
j = i
|
||||
while j < n and not text[j].isspace() and text[j] not in "()":
|
||||
j += 1
|
||||
yield text[i:j]
|
||||
i = j
|
||||
|
||||
|
||||
def _parse_sexp(text: str) -> Any:
|
||||
tokens = list(_tokenize(text))
|
||||
it = iter(tokens)
|
||||
|
||||
def form() -> Any:
|
||||
out: list[Any] = []
|
||||
for tok in it:
|
||||
if tok == "(":
|
||||
out.append(form())
|
||||
elif tok == ")":
|
||||
return out
|
||||
elif tok.startswith('"'):
|
||||
out.append(tok[1:])
|
||||
else:
|
||||
out.append(tok)
|
||||
return out
|
||||
|
||||
first = next(it, None)
|
||||
if first != "(":
|
||||
raise ValueError("KiCad file is not an s-expression")
|
||||
return form()
|
||||
|
||||
|
||||
def _tag(node: Any) -> str:
|
||||
if isinstance(node, list) and node:
|
||||
return str(node[0])
|
||||
return ""
|
||||
|
||||
|
||||
def _kids(node: Any, name: str) -> list[list]:
|
||||
if not isinstance(node, list):
|
||||
return []
|
||||
return [x for x in node[1:] if isinstance(x, list) and x and x[0] == name]
|
||||
|
||||
|
||||
def _kid(node: Any, name: str) -> list | None:
|
||||
found = _kids(node, name)
|
||||
return found[0] if found else None
|
||||
|
||||
|
||||
def _val(node: Any, name: str) -> str:
|
||||
k = _kid(node, name)
|
||||
if not k or len(k) < 2:
|
||||
return ""
|
||||
return str(k[1])
|
||||
|
||||
|
||||
def _unquote_attr(node: ET.Element, key: str) -> str:
|
||||
return (node.get(key) or "").strip()
|
||||
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XML netlist (File → Export → Netlist)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _iter_xml(root: ET.Element, name: str) -> Iterator[ET.Element]:
|
||||
for el in root.iter():
|
||||
if _local(el.tag) == name:
|
||||
yield el
|
||||
|
||||
|
||||
def parse_kicad_xml_netlist(path: str | Path) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
|
||||
tree = ET.parse(path)
|
||||
root = tree.getroot()
|
||||
parts: dict[str, str] = {}
|
||||
fields: dict[str, dict] = {}
|
||||
for comp in _iter_xml(root, "comp"):
|
||||
ref = _unquote_attr(comp, "ref")
|
||||
if not ref:
|
||||
continue
|
||||
value = ""
|
||||
footprint = ""
|
||||
mpn = None
|
||||
lcsc = None
|
||||
for child in list(comp):
|
||||
loc = _local(child.tag)
|
||||
if loc == "value":
|
||||
value = (child.text or "").strip()
|
||||
elif loc == "footprint":
|
||||
footprint = (child.text or "").strip()
|
||||
elif loc == "fields":
|
||||
for field in child:
|
||||
if _local(field.tag) != "field":
|
||||
continue
|
||||
fname = (field.get("name") or "").strip().lower()
|
||||
fval = (field.text or "").strip()
|
||||
if fname in _MPN_FIELD_NAMES and fval:
|
||||
mpn = fval
|
||||
elif fname == "lcsc" and fval:
|
||||
lcsc = fval
|
||||
elif loc == "property":
|
||||
pname = (child.get("name") or "").strip().lower()
|
||||
pval = (child.get("value") or child.text or "").strip()
|
||||
if pname in _MPN_FIELD_NAMES and pval:
|
||||
mpn = pval
|
||||
elif pname == "lcsc" and pval:
|
||||
lcsc = pval
|
||||
parts[ref] = footprint
|
||||
fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc}
|
||||
nets: dict[str, list[tuple[str, str]]] = {}
|
||||
for net in _iter_xml(root, "net"):
|
||||
name = _unquote_attr(net, "name") or f"Net-{_unquote_attr(net, 'code')}"
|
||||
pins: list[tuple[str, str]] = []
|
||||
for node in net:
|
||||
if _local(node.tag) != "node":
|
||||
continue
|
||||
ref = _unquote_attr(node, "ref")
|
||||
pin = _unquote_attr(node, "pin")
|
||||
if ref and pin:
|
||||
pins.append((ref, pin))
|
||||
if name:
|
||||
nets[name] = pins
|
||||
return parts, nets, fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S-expression netlist (kicad-cli sch export netlist)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_kicad_sexp_netlist(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
|
||||
parts: dict[str, str] = {}
|
||||
fields: dict[str, dict] = {}
|
||||
comps = _kid(tree, "components") or []
|
||||
for comp in comps[1:]:
|
||||
if _tag(comp) != "comp":
|
||||
continue
|
||||
ref = _val(comp, "ref")
|
||||
if not ref:
|
||||
continue
|
||||
value = _val(comp, "value")
|
||||
footprint = _val(comp, "footprint")
|
||||
mpn = None
|
||||
lcsc = None
|
||||
for field in _kids(_kid(comp, "fields") or [], "field"):
|
||||
fname = ""
|
||||
fval = ""
|
||||
name_el = _kid(field, "name")
|
||||
if name_el and len(name_el) >= 2:
|
||||
fname = str(name_el[1]).lower()
|
||||
strs = [str(x) for x in field[1:] if not isinstance(x, list)]
|
||||
if strs:
|
||||
fval = strs[-1]
|
||||
if fname in _MPN_FIELD_NAMES and fval:
|
||||
mpn = fval
|
||||
elif fname == "lcsc" and fval:
|
||||
lcsc = fval
|
||||
parts[ref] = footprint
|
||||
fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc}
|
||||
|
||||
nets: dict[str, list[tuple[str, str]]] = {}
|
||||
nets_el = _kid(tree, "nets") or []
|
||||
for net in nets_el[1:]:
|
||||
if _tag(net) != "net":
|
||||
continue
|
||||
name = _val(net, "name") or f"Net-{_val(net, 'code')}"
|
||||
pins: list[tuple[str, str]] = []
|
||||
for node in _kids(net, "node"):
|
||||
ref = _val(node, "ref")
|
||||
pin = _val(node, "pin")
|
||||
if ref and pin:
|
||||
pins.append((ref, pin))
|
||||
if name:
|
||||
nets[name] = pins
|
||||
return parts, nets, fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-sheet .kicad_sch (embedded lib_symbols + wires)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fnum(v: Any) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _at(node: Any) -> tuple[float, float, float]:
|
||||
k = _kid(node, "at")
|
||||
if not k or len(k) < 3:
|
||||
return 0.0, 0.0, 0.0
|
||||
rot = _fnum(k[3]) if len(k) > 3 else 0.0
|
||||
return _fnum(k[1]), _fnum(k[2]), rot
|
||||
|
||||
|
||||
def _snap(x: float, y: float) -> tuple[int, int]:
|
||||
return round(x * 1000), round(y * 1000)
|
||||
|
||||
|
||||
def _rotate(px: float, py: float, deg: float) -> tuple[float, float]:
|
||||
r = deg % 360.0
|
||||
rad = math.radians(r)
|
||||
c, s = math.cos(rad), math.sin(rad)
|
||||
return px * c + py * s, -px * s + py * c
|
||||
|
||||
|
||||
def _mirror_axes(sym: Any) -> tuple[bool, bool]:
|
||||
"""KiCad ``(mirror x)`` / ``(mirror y)`` — flip symbol-local axes."""
|
||||
m = _kid(sym, "mirror")
|
||||
if not m:
|
||||
return False, False
|
||||
axes = {str(item) for item in m[1:]}
|
||||
if not axes:
|
||||
# Legacy bare ``(mirror)`` — treat as X flip (historical eeschema).
|
||||
return True, False
|
||||
return ("x" in axes), ("y" in axes)
|
||||
|
||||
|
||||
def _point_on_segment(
|
||||
p: tuple[int, int],
|
||||
a: tuple[int, int],
|
||||
b: tuple[int, int],
|
||||
tol: int = 2,
|
||||
) -> bool:
|
||||
"""True if snapped point ``p`` lies on segment ``ab`` (inclusive)."""
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
px, py = p
|
||||
if px < min(ax, bx) - tol or px > max(ax, bx) + tol:
|
||||
return False
|
||||
if py < min(ay, by) - tol or py > max(ay, by) + tol:
|
||||
return False
|
||||
dx, dy = bx - ax, by - ay
|
||||
len2 = dx * dx + dy * dy
|
||||
if len2 == 0:
|
||||
return abs(px - ax) <= tol and abs(py - ay) <= tol
|
||||
# Distance from p to infinite line, then clamp to segment.
|
||||
t = ((px - ax) * dx + (py - ay) * dy) / len2
|
||||
if t < -0.01 or t > 1.01:
|
||||
return False
|
||||
qx = ax + t * dx
|
||||
qy = ay + t * dy
|
||||
return (px - qx) ** 2 + (py - qy) ** 2 <= tol * tol
|
||||
|
||||
|
||||
def _lib_pins(sym: Any) -> dict[tuple[int, str], tuple[float, float]]:
|
||||
"""(unit, pin_number) -> (x, y) in symbol space. unit 0 = common."""
|
||||
out: dict[tuple[int, str], tuple[float, float]] = {}
|
||||
|
||||
def walk(node: Any, unit: int) -> None:
|
||||
if not isinstance(node, list) or not node:
|
||||
return
|
||||
if node[0] == "symbol" and len(node) > 1 and isinstance(node[1], str):
|
||||
# nested unit symbol Device:R_1_1 → unit 1
|
||||
m = re.search(r"_(\d+)_(\d+)$", str(node[1]))
|
||||
u = int(m.group(1)) if m else unit
|
||||
for ch in node[1:]:
|
||||
walk(ch, u)
|
||||
return
|
||||
if node[0] == "pin":
|
||||
ax, ay, _ = _at(node)
|
||||
num = _val(node, "number") or ""
|
||||
if not num and len(node) > 1:
|
||||
num = str(node[1])
|
||||
if num:
|
||||
out[(unit, num)] = (ax, ay)
|
||||
out[(0, num)] = (ax, ay)
|
||||
return
|
||||
for ch in node[1:]:
|
||||
if isinstance(ch, list):
|
||||
walk(ch, unit)
|
||||
|
||||
walk(sym, 0)
|
||||
return out
|
||||
|
||||
|
||||
class _DSU:
|
||||
def __init__(self) -> None:
|
||||
self.p: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
|
||||
def add(self, pt: tuple[int, int]) -> None:
|
||||
self.p.setdefault(pt, pt)
|
||||
|
||||
def find(self, a: tuple[int, int]) -> tuple[int, int]:
|
||||
self.add(a)
|
||||
if self.p[a] != a:
|
||||
self.p[a] = self.find(self.p[a])
|
||||
return self.p[a]
|
||||
|
||||
def union(self, a: tuple[int, int], b: tuple[int, int]) -> None:
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra != rb:
|
||||
self.p[rb] = ra
|
||||
|
||||
|
||||
_KIND_RANK = {"unnamed": 0, "local": 1, "hier": 2, "global": 3}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SchSheet:
|
||||
parts: dict[str, str]
|
||||
nets: dict[str, list[tuple[str, str]]]
|
||||
fields: dict[str, dict]
|
||||
net_scope: dict[str, str]
|
||||
sheetfiles: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _sheetfiles(tree: Any) -> list[str]:
|
||||
out: list[str] = []
|
||||
for sheet in _kids(tree, "sheet"):
|
||||
for p in _kids(sheet, "property"):
|
||||
if len(p) >= 3 and str(p[1]) == "Sheetfile":
|
||||
rel = str(p[2]).strip()
|
||||
if rel:
|
||||
out.append(rel)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
|
||||
lib_pins: dict[str, dict[tuple[int, str], tuple[float, float]]] = {}
|
||||
for sym in _kids(_kid(tree, "lib_symbols") or [], "symbol"):
|
||||
lid = str(sym[1]) if len(sym) > 1 else ""
|
||||
if lid:
|
||||
lib_pins[lid] = _lib_pins(sym)
|
||||
|
||||
parts: dict[str, str] = {}
|
||||
fields: dict[str, dict] = {}
|
||||
pin_at: dict[tuple[str, str], tuple[int, int]] = {}
|
||||
dsu = _DSU()
|
||||
labels: dict[tuple[int, int], tuple[str, str]] = {}
|
||||
power_pts: list[tuple[tuple[int, int], str]] = []
|
||||
wire_segs: list[tuple[tuple[int, int], tuple[int, int]]] = []
|
||||
|
||||
def prop(sym: Any, key: str) -> str:
|
||||
for p in _kids(sym, "property"):
|
||||
if len(p) >= 3 and str(p[1]) == key:
|
||||
return str(p[2])
|
||||
return ""
|
||||
|
||||
def set_label(pt: tuple[int, int], name: str, kind: str) -> None:
|
||||
if not name:
|
||||
return
|
||||
prev = labels.get(pt)
|
||||
if prev is None or _KIND_RANK[kind] >= _KIND_RANK[prev[1]]:
|
||||
labels[pt] = (name, kind)
|
||||
|
||||
def apply_sym_xy(px: float, py: float, rot: float, mx: bool, my: bool) -> tuple[float, float]:
|
||||
rx, ry = _rotate(px, py, rot)
|
||||
if mx:
|
||||
rx = -rx
|
||||
if my:
|
||||
ry = -ry
|
||||
return rx, ry
|
||||
|
||||
for sym in _kids(tree, "symbol"):
|
||||
lib_id = _val(sym, "lib_id")
|
||||
ix, iy, rot = _at(sym)
|
||||
unit = int(_fnum(_val(sym, "unit") or "1") or 1)
|
||||
mx, my = _mirror_axes(sym)
|
||||
ref = prop(sym, "Reference")
|
||||
if ref.startswith("#"):
|
||||
# power flag / graphic
|
||||
val = prop(sym, "Value") or lib_id.rsplit(":", 1)[-1]
|
||||
lp = lib_pins.get(lib_id, {})
|
||||
xy = lp.get((unit, "1")) or lp.get((0, "1")) or (0.0, 0.0)
|
||||
px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my)
|
||||
pt = _snap(ix + px, iy + py)
|
||||
dsu.add(pt)
|
||||
if val:
|
||||
power_pts.append((pt, val))
|
||||
continue
|
||||
if not ref:
|
||||
continue
|
||||
value = prop(sym, "Value")
|
||||
footprint = prop(sym, "Footprint")
|
||||
mpn = None
|
||||
lcsc = None
|
||||
for p in _kids(sym, "property"):
|
||||
if len(p) < 3:
|
||||
continue
|
||||
n = str(p[1]).strip().lower()
|
||||
v = str(p[2]).strip()
|
||||
if n in _MPN_FIELD_NAMES and v:
|
||||
mpn = v
|
||||
elif n == "lcsc" and v:
|
||||
lcsc = v
|
||||
parts[ref] = footprint
|
||||
fields[ref] = {
|
||||
"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc,
|
||||
"cad_uuid": _val(sym, "uuid"),
|
||||
}
|
||||
lp = lib_pins.get(lib_id, {})
|
||||
for pin_el in _kids(sym, "pin"):
|
||||
num = str(pin_el[1]) if len(pin_el) > 1 else ""
|
||||
if not num:
|
||||
continue
|
||||
xy = lp.get((unit, num)) or lp.get((0, num)) or (0.0, 0.0)
|
||||
px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my)
|
||||
pt = _snap(ix + px, iy + py)
|
||||
pin_at[(ref, num)] = pt
|
||||
dsu.add(pt)
|
||||
|
||||
def collect_pts(node: Any) -> None:
|
||||
if not isinstance(node, list) or not node:
|
||||
return
|
||||
tag = node[0]
|
||||
if tag == "wire":
|
||||
pts = _kid(node, "pts")
|
||||
coords: list[tuple[int, int]] = []
|
||||
if pts:
|
||||
for xy in _kids(pts, "xy"):
|
||||
if len(xy) >= 3:
|
||||
pt = _snap(_fnum(xy[1]), _fnum(xy[2]))
|
||||
dsu.add(pt)
|
||||
coords.append(pt)
|
||||
for a, b in zip(coords, coords[1:]):
|
||||
dsu.union(a, b)
|
||||
wire_segs.append((a, b))
|
||||
return
|
||||
if tag == "label":
|
||||
name = str(node[1]) if len(node) > 1 else ""
|
||||
x, y, _ = _at(node)
|
||||
pt = _snap(x, y)
|
||||
dsu.add(pt)
|
||||
set_label(pt, name, "local")
|
||||
return
|
||||
if tag == "global_label":
|
||||
name = str(node[1]) if len(node) > 1 else ""
|
||||
x, y, _ = _at(node)
|
||||
pt = _snap(x, y)
|
||||
dsu.add(pt)
|
||||
set_label(pt, name, "global")
|
||||
return
|
||||
if tag == "hierarchical_label":
|
||||
name = str(node[1]) if len(node) > 1 else ""
|
||||
x, y, _ = _at(node)
|
||||
pt = _snap(x, y)
|
||||
dsu.add(pt)
|
||||
set_label(pt, name, "hier")
|
||||
return
|
||||
if tag == "sheet":
|
||||
for pin in _kids(node, "pin"):
|
||||
name = str(pin[1]) if len(pin) > 1 else ""
|
||||
x, y, _ = _at(pin)
|
||||
pt = _snap(x, y)
|
||||
dsu.add(pt)
|
||||
set_label(pt, name, "hier")
|
||||
return
|
||||
if tag == "junction":
|
||||
x, y, _ = _at(node)
|
||||
dsu.add(_snap(x, y))
|
||||
return
|
||||
for ch in node[1:]:
|
||||
if isinstance(ch, list):
|
||||
collect_pts(ch)
|
||||
|
||||
collect_pts(tree)
|
||||
|
||||
for pt in labels:
|
||||
dsu.add(pt)
|
||||
for pt, _name in power_pts:
|
||||
dsu.add(pt)
|
||||
|
||||
# Pins / labels / power on the middle of a wire share that net.
|
||||
attach_pts = list(pin_at.values()) + list(labels.keys()) + [pt for pt, _ in power_pts]
|
||||
for pt in attach_pts:
|
||||
for a, b in wire_segs:
|
||||
if _point_on_segment(pt, a, b):
|
||||
dsu.union(pt, a)
|
||||
dsu.union(pt, b)
|
||||
|
||||
# KiCad semantics: same-name global labels and power symbols are one net
|
||||
# even when not geometrically connected. Same-name local labels merge
|
||||
# within a single sheet.
|
||||
by_name: dict[tuple[str, str], list[tuple[int, int]]] = {}
|
||||
for pt, (name, kind) in labels.items():
|
||||
if kind in ("global", "local", "hier"):
|
||||
by_name.setdefault((kind, name), []).append(pt)
|
||||
for pt, name in power_pts:
|
||||
by_name.setdefault(("global", name), []).append(pt)
|
||||
for pts in by_name.values():
|
||||
if len(pts) < 2:
|
||||
continue
|
||||
head = pts[0]
|
||||
for p in pts[1:]:
|
||||
dsu.union(head, p)
|
||||
|
||||
root_name: dict[tuple[int, int], str] = {}
|
||||
root_kind: dict[tuple[int, int], str] = {}
|
||||
for pt, (name, kind) in labels.items():
|
||||
r = dsu.find(pt)
|
||||
prev = root_kind.get(r, "unnamed")
|
||||
if _KIND_RANK[kind] >= _KIND_RANK[prev]:
|
||||
root_name[r] = name
|
||||
root_kind[r] = kind
|
||||
for pt, name in power_pts:
|
||||
r = dsu.find(pt)
|
||||
prev = root_kind.get(r, "unnamed")
|
||||
if _KIND_RANK["global"] >= _KIND_RANK[prev]:
|
||||
root_name[r] = name
|
||||
root_kind[r] = "global"
|
||||
|
||||
grouped: dict[tuple[int, int], list[tuple[str, str]]] = {}
|
||||
for (ref, pin), pt in pin_at.items():
|
||||
grouped.setdefault(dsu.find(pt), []).append((ref, pin))
|
||||
|
||||
nets: dict[str, list[tuple[str, str]]] = {}
|
||||
net_scope: dict[str, str] = {}
|
||||
used_names: set[str] = set()
|
||||
for root, pins in grouped.items():
|
||||
name = root_name.get(root)
|
||||
kind = root_kind.get(root, "unnamed")
|
||||
if not name:
|
||||
ref0, pin0 = pins[0]
|
||||
name = f"Net-({ref0}-Pad{pin0})"
|
||||
kind = "unnamed"
|
||||
while name in used_names:
|
||||
name = name + "_"
|
||||
used_names.add(name)
|
||||
nets[name] = pins
|
||||
net_scope[name] = kind
|
||||
|
||||
return _SchSheet(
|
||||
parts=parts,
|
||||
nets=nets,
|
||||
fields=fields,
|
||||
net_scope=net_scope,
|
||||
sheetfiles=_sheetfiles(tree),
|
||||
)
|
||||
|
||||
|
||||
def parse_kicad_sch(tree: Any) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
|
||||
sheet = _parse_kicad_sch_sheet(tree)
|
||||
return sheet.parts, sheet.nets, sheet.fields
|
||||
|
||||
|
||||
def _uniq_pins(pins: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[tuple[str, str]] = []
|
||||
for p in pins:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _safe_sheetfile(parent: Path, rel: str, project_root: Path) -> Path:
|
||||
rel_norm = rel.replace("\\", "/").strip()
|
||||
if not rel_norm or rel_norm.startswith("/") or ".." in Path(rel_norm).parts:
|
||||
raise ValueError(f"Sheetfile path rejected: {rel}")
|
||||
child = (parent.parent / rel_norm).resolve()
|
||||
root = project_root.resolve()
|
||||
try:
|
||||
child.relative_to(root)
|
||||
except ValueError:
|
||||
raise ValueError(f"Sheetfile path rejected: {rel}") from None
|
||||
return child
|
||||
|
||||
|
||||
def parse_kicad_sch_project(
|
||||
root_path: str | Path,
|
||||
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
|
||||
root = Path(root_path).resolve()
|
||||
project_root = root.parent
|
||||
seen: set[Path] = set()
|
||||
loaded: list[tuple[Path, _SchSheet]] = []
|
||||
|
||||
def visit(path: Path) -> None:
|
||||
path = path.resolve()
|
||||
if path in seen:
|
||||
raise ValueError(f"Cyclic sheet include: {path.name}")
|
||||
if not path.is_file():
|
||||
raise ValueError(
|
||||
f"Missing sheet file: {path.name}. Drop the whole KiCad "
|
||||
"project folder, not a single sheet."
|
||||
)
|
||||
seen.add(path)
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
tree = _parse_sexp(text)
|
||||
if _tag(tree) != "kicad_sch":
|
||||
raise ValueError(f"Expected kicad_sch in {path.name}, got {_tag(tree)!r}")
|
||||
sheet = _parse_kicad_sch_sheet(tree)
|
||||
loaded.append((path, sheet))
|
||||
for rel in sheet.sheetfiles:
|
||||
child = _safe_sheetfile(path, rel, project_root)
|
||||
visit(child)
|
||||
|
||||
visit(root)
|
||||
|
||||
parts: dict[str, str] = {}
|
||||
fields: dict[str, dict] = {}
|
||||
global_nets: dict[str, list[tuple[str, str]]] = {}
|
||||
hier_nets: dict[str, list[tuple[str, str]]] = {}
|
||||
local_nets: dict[str, list[tuple[str, str]]] = {}
|
||||
multi = len(loaded) > 1
|
||||
|
||||
for path, sheet in loaded:
|
||||
for ref, fp in sheet.parts.items():
|
||||
if ref in parts:
|
||||
raise ValueError(f"Duplicate reference {ref} in {path.name}")
|
||||
parts[ref] = fp
|
||||
fields[ref] = {
|
||||
**sheet.fields.get(ref, {}),
|
||||
"cad_sheet": path.name,
|
||||
}
|
||||
for name, pins in sheet.nets.items():
|
||||
scope = sheet.net_scope.get(name, "unnamed")
|
||||
if scope == "global":
|
||||
global_nets[name] = _uniq_pins(global_nets.get(name, []) + pins)
|
||||
elif scope == "hier":
|
||||
hier_nets[name] = _uniq_pins(hier_nets.get(name, []) + pins)
|
||||
else:
|
||||
out_name = f"{path.stem}/{name}" if multi else name
|
||||
local_nets[out_name] = _uniq_pins(local_nets.get(out_name, []) + pins)
|
||||
|
||||
nets: dict[str, list[tuple[str, str]]] = {}
|
||||
for name, pins in global_nets.items():
|
||||
nets[name] = pins
|
||||
for name, pins in hier_nets.items():
|
||||
nets[name] = _uniq_pins(nets.get(name, []) + pins)
|
||||
for name, pins in local_nets.items():
|
||||
out = name
|
||||
while out in nets:
|
||||
out = out + "_"
|
||||
nets[out] = pins
|
||||
|
||||
return parts, nets, fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fields_cache: dict[str, dict[str, dict]] = {}
|
||||
|
||||
|
||||
def parse_kicad(
|
||||
path: str | Path,
|
||||
) -> tuple[dict[str, str], dict[str, list[tuple[str, str]]], dict[str, dict]]:
|
||||
p = Path(path)
|
||||
raw = p.read_bytes()
|
||||
head = raw[:256].decode("utf-8", errors="replace").lstrip("\ufeff").lstrip()
|
||||
if head.startswith("<") or head.startswith("<?xml"):
|
||||
parts, nets, fields = parse_kicad_xml_netlist(p)
|
||||
else:
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
tree = _parse_sexp(text)
|
||||
tag = _tag(tree)
|
||||
if tag == "kicad_sch":
|
||||
parts, nets, fields = parse_kicad_sch_project(p)
|
||||
elif tag == "export":
|
||||
parts, nets, fields = parse_kicad_sexp_netlist(tree)
|
||||
else:
|
||||
raise ValueError(f"Unsupported KiCad s-expression root {tag!r}")
|
||||
if not parts:
|
||||
raise ValueError("No components found in KiCad file")
|
||||
if not nets:
|
||||
raise ValueError(
|
||||
"No nets found. For a multi-sheet schematic, export a netlist "
|
||||
"(File → Export → Netlist) instead of uploading .kicad_sch."
|
||||
)
|
||||
_fields_cache[str(p.resolve())] = fields
|
||||
return parts, nets, fields
|
||||
|
||||
|
||||
def kicad_part_fields(path: str | Path) -> dict[str, dict]:
|
||||
key = str(Path(path).resolve())
|
||||
if key not in _fields_cache:
|
||||
parse_kicad(path)
|
||||
return _fields_cache.get(key, {})
|
||||
@@ -0,0 +1,311 @@
|
||||
"""KiCad `.kicad_pcb` ingest — footprints, pads, nets, segments, vias.
|
||||
|
||||
No SI/DRC. Schema validation stays complete without this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.models import (
|
||||
LayoutDielectric,
|
||||
LayoutFootprint,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
LayoutSegment,
|
||||
LayoutStackup,
|
||||
LayoutVia,
|
||||
LayoutZone,
|
||||
)
|
||||
from backend.periscopex.parsers_kicad import (
|
||||
_at,
|
||||
_fnum,
|
||||
_kid,
|
||||
_kids,
|
||||
_parse_sexp,
|
||||
_rotate,
|
||||
_tag,
|
||||
_val,
|
||||
)
|
||||
|
||||
|
||||
def _xy(node: object, name: str) -> tuple[float, float]:
|
||||
k = _kid(node, name)
|
||||
if not k or len(k) < 3:
|
||||
return 0.0, 0.0
|
||||
return _fnum(k[1]), _fnum(k[2])
|
||||
|
||||
|
||||
def _prop(node: object, key: str) -> str:
|
||||
for p in _kids(node, "property"):
|
||||
if len(p) >= 3 and str(p[1]) == key:
|
||||
return str(p[2])
|
||||
return ""
|
||||
|
||||
|
||||
def _pad_net(pad: object) -> str:
|
||||
n = _kid(pad, "net")
|
||||
if not n or len(n) < 2:
|
||||
return ""
|
||||
# KiCad 9/10 often stores ``(net "GND")`` without a numeric code.
|
||||
if len(n) == 2 and not isinstance(n[1], list):
|
||||
return str(n[1])
|
||||
# Legacy ``(net 3 "GND")``.
|
||||
if len(n) >= 3:
|
||||
return str(n[2])
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_pcb_net_name(name: str) -> str:
|
||||
"""Strip KiCad root-sheet ``/`` prefixes; keep empty / unconnected as-is."""
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return ""
|
||||
while n.startswith("/"):
|
||||
n = n[1:]
|
||||
return n
|
||||
|
||||
|
||||
def nets_from_pcb(layout: LayoutGraph) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Pad connectivity from a parsed board — authoritative when sch geometry fails."""
|
||||
nets: dict[str, list[tuple[str, str]]] = {}
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
for ref, fp in layout.footprints.items():
|
||||
for pad in fp.pads:
|
||||
raw = pad.net or ""
|
||||
if not raw:
|
||||
continue
|
||||
name = _normalize_pcb_net_name(raw)
|
||||
if not name:
|
||||
continue
|
||||
key = (name, ref, pad.number)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
nets.setdefault(name, []).append((ref, pad.number))
|
||||
return nets
|
||||
|
||||
|
||||
def _net_name(node: object, nets: dict[str, int]) -> str:
|
||||
n = _kid(node, "net")
|
||||
if not n or len(n) < 2:
|
||||
named = _val(node, "net_name")
|
||||
return named
|
||||
# ``(net "GND")`` or ``(net 1)`` or ``(net 1 "GND")``
|
||||
if len(n) == 2 and not isinstance(n[1], list):
|
||||
token = n[1]
|
||||
if isinstance(token, str) and not str(token).replace(".", "", 1).isdigit():
|
||||
return str(token)
|
||||
try:
|
||||
code = int(_fnum(token))
|
||||
except (TypeError, ValueError):
|
||||
return str(token)
|
||||
return next((name for name, c in nets.items() if c == code), str(code))
|
||||
if len(n) >= 3:
|
||||
return str(n[2])
|
||||
try:
|
||||
code = int(_fnum(n[1]))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
return next((name for name, c in nets.items() if c == code), str(code))
|
||||
|
||||
|
||||
def _layer_type(node: object) -> str:
|
||||
return str(_val(node, "type") or "").lower()
|
||||
|
||||
|
||||
def _parse_stackup(tree: object) -> LayoutStackup | None:
|
||||
setup = _kid(tree, "setup")
|
||||
if not setup:
|
||||
return None
|
||||
stack = _kid(setup, "stackup")
|
||||
if not stack:
|
||||
return None
|
||||
copper: list[str] = []
|
||||
dielectrics: list[LayoutDielectric] = []
|
||||
thicknesses: list[float] = []
|
||||
for layer in _kids(stack, "layer"):
|
||||
name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else ""
|
||||
kind = _layer_type(layer)
|
||||
thick = _kid(layer, "thickness")
|
||||
height = _fnum(thick[1]) if thick and len(thick) > 1 else None
|
||||
if kind == "copper" or name.endswith(".Cu"):
|
||||
if name:
|
||||
copper.append(name)
|
||||
if height is not None and height > 0:
|
||||
thicknesses.append(height)
|
||||
continue
|
||||
if kind in {"core", "prepreg", "dielectric"} or name.lower().startswith("dielectric"):
|
||||
er_el = _kid(layer, "epsilon_r")
|
||||
if er_el is None:
|
||||
er_el = _kid(layer, "epsilonr")
|
||||
er = _fnum(er_el[1]) if er_el and len(er_el) > 1 else None
|
||||
if er is None or height is None or er <= 0 or height <= 0:
|
||||
continue
|
||||
dielectrics.append(LayoutDielectric(
|
||||
name=name or f"dielectric_{len(dielectrics)}",
|
||||
er=er,
|
||||
height_mm=height,
|
||||
))
|
||||
if len(copper) < 2 or len(dielectrics) != len(copper) - 1:
|
||||
return None
|
||||
t = thicknesses[0] if thicknesses else None
|
||||
return LayoutStackup(
|
||||
copper_layers=copper,
|
||||
dielectrics=dielectrics,
|
||||
copper_thickness_mm=t,
|
||||
)
|
||||
|
||||
|
||||
def _pts_xy(node: object) -> list[tuple[float, float]]:
|
||||
pts_el = _kid(node, "pts")
|
||||
if not pts_el:
|
||||
return []
|
||||
out: list[tuple[float, float]] = []
|
||||
for xy in pts_el[1:]:
|
||||
if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3:
|
||||
out.append((_fnum(xy[1]), _fnum(xy[2])))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_zone(node: object, nets: dict[str, int]) -> list[LayoutZone]:
|
||||
net = str(_val(node, "net_name") or "") or _net_name(node, nets)
|
||||
zones: list[LayoutZone] = []
|
||||
for poly in _kids(node, "filled_polygon"):
|
||||
layer = _val(poly, "layer")
|
||||
pts = _pts_xy(poly)
|
||||
if layer and len(pts) >= 3:
|
||||
zones.append(LayoutZone(net=net, layer=layer, outlines=[pts]))
|
||||
return zones
|
||||
|
||||
|
||||
def _is_crtyd(layer: str) -> bool:
|
||||
return str(layer).endswith("CrtYd")
|
||||
|
||||
|
||||
def _abs(fx: float, fy: float, frot: float, lx: float, ly: float) -> tuple[float, float]:
|
||||
rx, ry = _rotate(lx, ly, frot)
|
||||
return fx + rx, fy + ry
|
||||
|
||||
|
||||
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
|
||||
"""Courtyard vertices from the PCB file. Empty if KiCad has no CrtYd."""
|
||||
pts: list[tuple[float, float]] = []
|
||||
for poly in _kids(node, "fp_poly"):
|
||||
if not _is_crtyd(_val(poly, "layer")):
|
||||
continue
|
||||
pts_el = _kid(poly, "pts")
|
||||
if not pts_el:
|
||||
continue
|
||||
for xy in pts_el[1:]:
|
||||
if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3:
|
||||
pts.append(_abs(fx, fy, frot, _fnum(xy[1]), _fnum(xy[2])))
|
||||
if pts:
|
||||
return pts
|
||||
for rect in _kids(node, "fp_rect"):
|
||||
if not _is_crtyd(_val(rect, "layer")):
|
||||
continue
|
||||
sx, sy = _xy(rect, "start")
|
||||
ex, ey = _xy(rect, "end")
|
||||
return [
|
||||
_abs(fx, fy, frot, sx, sy),
|
||||
_abs(fx, fy, frot, ex, sy),
|
||||
_abs(fx, fy, frot, ex, ey),
|
||||
_abs(fx, fy, frot, sx, ey),
|
||||
]
|
||||
for line in _kids(node, "fp_line"):
|
||||
if not _is_crtyd(_val(line, "layer")):
|
||||
continue
|
||||
sx, sy = _xy(line, "start")
|
||||
ex, ey = _xy(line, "end")
|
||||
a = _abs(fx, fy, frot, sx, sy)
|
||||
b = _abs(fx, fy, frot, ex, ey)
|
||||
if not pts or pts[-1] != a:
|
||||
pts.append(a)
|
||||
if pts[-1] != b:
|
||||
pts.append(b)
|
||||
return pts
|
||||
|
||||
|
||||
def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
|
||||
p = Path(path)
|
||||
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
|
||||
if _tag(tree) != "kicad_pcb":
|
||||
raise ValueError(f"Expected kicad_pcb, got {_tag(tree)!r}")
|
||||
|
||||
nets: dict[str, int] = {}
|
||||
footprints: dict[str, LayoutFootprint] = {}
|
||||
segments: list[LayoutSegment] = []
|
||||
vias: list[LayoutVia] = []
|
||||
zones: list[LayoutZone] = []
|
||||
|
||||
for node in tree[1:]:
|
||||
if not isinstance(node, list) or not node:
|
||||
continue
|
||||
tag = _tag(node)
|
||||
if tag == "net" and len(node) >= 3 and not any(isinstance(x, list) and x and x[0] == "node" for x in node[1:]):
|
||||
try:
|
||||
code = int(_fnum(node[1]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
name = str(node[2])
|
||||
if name:
|
||||
nets[name] = code
|
||||
continue
|
||||
if tag in {"footprint", "module"}:
|
||||
fp_name = str(node[1]) if len(node) > 1 and not isinstance(node[1], list) else ""
|
||||
fx, fy, frot = _at(node)
|
||||
layer = _val(node, "layer")
|
||||
ref = _prop(node, "Reference")
|
||||
if not ref or ref.startswith("#"):
|
||||
continue
|
||||
pads: list[LayoutPad] = []
|
||||
for pad in _kids(node, "pad"):
|
||||
num = str(pad[1]) if len(pad) > 1 else ""
|
||||
if not num:
|
||||
continue
|
||||
px, py, _ = _at(pad)
|
||||
rx, ry = _rotate(px, py, frot)
|
||||
pads.append(LayoutPad(
|
||||
number=num,
|
||||
x=fx + rx,
|
||||
y=fy + ry,
|
||||
net=_pad_net(pad),
|
||||
))
|
||||
footprints[ref] = LayoutFootprint(
|
||||
reference=ref,
|
||||
footprint=fp_name,
|
||||
x=fx,
|
||||
y=fy,
|
||||
layer=layer,
|
||||
pads=pads,
|
||||
courtyard=_courtyard_pts(node, fx, fy, frot),
|
||||
)
|
||||
continue
|
||||
if tag == "segment":
|
||||
segments.append(LayoutSegment(
|
||||
start=_xy(node, "start"),
|
||||
end=_xy(node, "end"),
|
||||
width=_fnum(_val(node, "width") or 0),
|
||||
layer=_val(node, "layer"),
|
||||
net=_net_name(node, nets),
|
||||
))
|
||||
continue
|
||||
if tag == "via":
|
||||
drill_el = _kid(node, "drill")
|
||||
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
|
||||
vx, vy, _ = _at(node)
|
||||
vias.append(LayoutVia(x=vx, y=vy, net=_net_name(node, nets), drill=drill))
|
||||
continue
|
||||
if tag == "zone":
|
||||
zones.extend(_parse_zone(node, nets))
|
||||
continue
|
||||
|
||||
return LayoutGraph(
|
||||
nets=nets,
|
||||
footprints=footprints,
|
||||
segments=segments,
|
||||
vias=vias,
|
||||
stackup=_parse_stackup(tree),
|
||||
zones=zones,
|
||||
)
|
||||
@@ -0,0 +1,524 @@
|
||||
"""Deterministic supply decoupling and I2C/reset pull-up checks.
|
||||
|
||||
These only fire when the graph already shows a pintable supply pin, an I2C
|
||||
net/pin name, or a reset pin — they do not guess capacitor values, mux
|
||||
alt-functions, or datasheet µF minima.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
CapacitorSpecs,
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
NetType,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
from backend.periscopex.led_current_check import _parse_resistance
|
||||
from backend.periscopex.resolve_passives import _parse_spice_value
|
||||
|
||||
_SUPPLY_PIN_RE = re.compile(
|
||||
r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|"
|
||||
r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\d*)$", re.IGNORECASE)
|
||||
_NOT_SUPPLY_RE = re.compile(
|
||||
r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_I2C_RE = re.compile(r"(?:^|[^A-Za-z0-9])(SDA|SCL)(\d+)?(?:$|[^A-Za-z0-9])", re.IGNORECASE)
|
||||
_SPI_NAME_RE = re.compile(r"(?i)\b(MISO|MOSI|SCLK|SCK)\b")
|
||||
_RESET_RE = re.compile(
|
||||
r"\b(N?RST(?:N|B)?|NRST|RESET(?:_?N|_?B)?|NRESET|CHIP_PU)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NC_NET_RE = re.compile(
|
||||
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OUT_PIN_RE = re.compile(
|
||||
r"(?:^|[_/])(VOUT|V_OUT|VO|VREG|SWOUT)(?:$|[_/\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_LOW_RESET_RE = re.compile(
|
||||
r"(?:N/?RST|NRST|NRESET|RESET[_-]?N|RSTN)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# NXP UM10204-style Rp window, widened so 2.2k–10k never false-positives.
|
||||
_RP_MIN_OHM = 1_000.0
|
||||
_RP_MAX_OHM = 22_000.0
|
||||
_VDD_MIN_FARADS = 50e-9
|
||||
_VOUT_MIN_FARADS = 0.47e-6
|
||||
|
||||
|
||||
def check_supply_decoupling(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
"""WARNING when an IC supply/VOUT net has no capacitor to ground, or
|
||||
only farads well below a typical Cin/Cout when every cap is valued."""
|
||||
findings: list[Finding] = []
|
||||
seen_nets: set[str] = set()
|
||||
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)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen_nets:
|
||||
continue
|
||||
if _is_nc_net(net_name):
|
||||
continue
|
||||
role = None
|
||||
if _is_ic_supply_pin(graph, cons, pin_num, net_name):
|
||||
role = "supply"
|
||||
elif _is_regulator_output_pin(cons, pin_num):
|
||||
role = "output"
|
||||
if role is None:
|
||||
continue
|
||||
seen_nets.add(net_name)
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
if not _capacitor_to_ground(graph, net_name):
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="decoupling",
|
||||
source="supply_decoupling_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} supply net '{net_name}' ({pin_label}) has no "
|
||||
f"capacitor to ground."
|
||||
if role == "supply"
|
||||
else (
|
||||
f"{ref} regulator output '{net_name}' ({pin_label}) "
|
||||
f"has no Cout capacitor to ground."
|
||||
)
|
||||
),
|
||||
why=(
|
||||
f"Pin {pin_label} sits on '{net_name}' and that net has no "
|
||||
f"capacitor whose other end is ground. Local decoupling "
|
||||
f"may be missing (or only present on a different island "
|
||||
f"behind a ferrite)."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a decoupling capacitor from '{net_name}' to ground "
|
||||
f"near {ref}."
|
||||
),
|
||||
reference="netlist topology",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-DEC-001",
|
||||
))
|
||||
continue
|
||||
min_f = _VOUT_MIN_FARADS if role == "output" else _VDD_MIN_FARADS
|
||||
max_c = _max_known_cap_farads(graph, net_name)
|
||||
if max_c is not None and max_c < min_f:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="decoupling",
|
||||
source="supply_decoupling_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} net '{net_name}' ({pin_label}) only has "
|
||||
f"{max_c * 1e6:.3g} µF to ground; typical "
|
||||
f"{'Cout' if role == 'output' else 'decoupling'} is larger."
|
||||
),
|
||||
why=(
|
||||
"Cap values are known on this net and the largest is "
|
||||
"below a wide typical minimum. This is not a datasheet "
|
||||
"µF requirement — treat as a sizing hint."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add bulk capacitance on '{net_name}' (often ≥1 µF on "
|
||||
f"LDO VOUT, ≥100 nF on MCU VDD) if the datasheet agrees."
|
||||
),
|
||||
reference="netlist topology",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-DEC-002",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def check_i2c_pullups(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
"""WARNING when an SDA/SCL net has no resistor to a power rail."""
|
||||
findings: list[Finding] = []
|
||||
seen_nets: set[str] = set()
|
||||
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)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen_nets:
|
||||
continue
|
||||
if _is_nc_net(net_name):
|
||||
continue
|
||||
if not _is_i2c_pin(graph, cons, pin_num, net_name):
|
||||
continue
|
||||
seen_nets.add(net_name)
|
||||
net = graph.nets.get(net_name)
|
||||
if net and net.net_type in (NetType.POWER, NetType.GROUND):
|
||||
continue
|
||||
if _resistor_to_power(graph, net_name):
|
||||
ohms = _parallel_pullup_ohms(graph, net_name)
|
||||
if ohms is not None and (
|
||||
ohms < _RP_MIN_OHM or ohms > _RP_MAX_OHM
|
||||
):
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="i2c_pullup",
|
||||
source="i2c_pullup_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"I2C net '{net_name}' ({ref} {pin_label}) pull-up "
|
||||
f"is {ohms:.3g} Ω (wide NXP-style band "
|
||||
f"{_RP_MIN_OHM:.0f}–{_RP_MAX_OHM:.0f} Ω)."
|
||||
),
|
||||
why=(
|
||||
"UM10204 Rp depends on Vdd, Iol and bus capacitance. "
|
||||
"This bound is wide on purpose; 2.2–10 kΩ at 3.3 V "
|
||||
"is typical. Unknown resistor values are not sized."
|
||||
),
|
||||
recommendation=(
|
||||
f"Use a pull-up on '{net_name}' inside "
|
||||
f"{_RP_MIN_OHM:.0f}–{_RP_MAX_OHM:.0f} Ω unless the "
|
||||
f"bus capacitance/Iol calculation says otherwise."
|
||||
),
|
||||
reference="NXP UM10204 (wide bound)",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-I2C-002",
|
||||
))
|
||||
continue
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="i2c_pullup",
|
||||
source="i2c_pullup_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"I2C net '{net_name}' ({ref} {pin_label}) has no pull-up "
|
||||
f"resistor to a power rail."
|
||||
),
|
||||
why=(
|
||||
f"SDA/SCL is open-drain. Without a resistor from "
|
||||
f"'{net_name}' to a supply, the bus cannot idle high."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a pull-up (typically 2.2–10 kΩ) from '{net_name}' "
|
||||
f"to the I2C I/O rail."
|
||||
),
|
||||
reference="netlist topology",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-I2C-001",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def check_reset_pullups(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
"""WARNING when a reset pin's net is only this IC and has no pull-up."""
|
||||
findings: list[Finding] = []
|
||||
seen_nets: set[str] = set()
|
||||
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)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen_nets:
|
||||
continue
|
||||
if _is_nc_net(net_name):
|
||||
continue
|
||||
if not _is_reset_pin(graph, cons, pin_num, net_name):
|
||||
continue
|
||||
seen_nets.add(net_name)
|
||||
net = graph.nets.get(net_name)
|
||||
if net and net.net_type in (NetType.POWER, NetType.GROUND):
|
||||
continue
|
||||
if _other_ic_on_net(graph, net_name, ref):
|
||||
continue
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
if _is_active_low_reset(cons, pin_num, net_name) and _resistor_to_ground(
|
||||
graph, net_name
|
||||
):
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="reset_pullup",
|
||||
source="reset_pullup_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} active-low reset '{net_name}' ({pin_label}) "
|
||||
f"has a pull-down to ground."
|
||||
),
|
||||
why=(
|
||||
"An active-low NRST/RESET_N pin held down by a resistor "
|
||||
"will sit in reset unless a stronger pull-up wins. "
|
||||
"Datasheets that omit an internal pull-up expect a pull-up, "
|
||||
"not a pull-down."
|
||||
),
|
||||
recommendation=(
|
||||
f"Remove the pull-down on '{net_name}' or replace it "
|
||||
f"with a pull-up to the I/O rail."
|
||||
),
|
||||
reference="netlist topology",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-RST-002",
|
||||
))
|
||||
if _resistor_to_power(graph, net_name):
|
||||
continue
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="reset_pullup",
|
||||
source="reset_pullup_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} reset pin {pin_label} on '{net_name}' has no "
|
||||
f"pull-up and no other IC driving the net."
|
||||
),
|
||||
why=(
|
||||
f"The net only lands on {ref} (plus passives). Without a "
|
||||
f"resistor to a supply, an active-low reset input can float."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a pull-up to the I/O rail, or drive '{net_name}' "
|
||||
f"from a reset supervisor / GPIO."
|
||||
),
|
||||
reference="netlist topology",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-RST-001",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _pin_label(cons: ComponentConstraints | None, pin_num: str, net_name: str) -> str:
|
||||
if cons:
|
||||
pin = cons.pin_by_number(pin_num)
|
||||
if pin and pin.name:
|
||||
return f"{pin_num} ({pin.name})"
|
||||
return str(pin_num)
|
||||
|
||||
|
||||
def _is_nc_net(name: str) -> bool:
|
||||
return bool(_NC_NET_RE.match((name or "").strip()))
|
||||
|
||||
|
||||
def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]:
|
||||
"""Slash-separated pin *name* tokens only — not the mux alt-function table."""
|
||||
if not cons:
|
||||
return []
|
||||
pin = cons.pin_by_number(pin_num)
|
||||
if not pin or not pin.name:
|
||||
return []
|
||||
return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()]
|
||||
|
||||
|
||||
def _looks_like_supply(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t):
|
||||
return False
|
||||
return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t))
|
||||
|
||||
|
||||
def _is_ic_supply_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
if tokens:
|
||||
return any(_looks_like_supply(t) for t in tokens)
|
||||
# No pintable row: fall back to net name / POWER type.
|
||||
if _looks_like_supply(net_name or ""):
|
||||
return True
|
||||
net = graph.nets.get(net_name)
|
||||
return bool(net and net.net_type == NetType.POWER)
|
||||
|
||||
|
||||
def _is_i2c_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
net = net_name or ""
|
||||
if re.match(r"(?i)SPI([_-]|$)", net) or re.search(
|
||||
r"(?i)\bSPI[_-]?(CLK|SCK|MOSI|MISO|CS|SS)\b", net,
|
||||
):
|
||||
return False
|
||||
tokens = _pin_name_tokens(cons, pin_num)
|
||||
if any(_SPI_NAME_RE.search(t) for t in tokens):
|
||||
return False
|
||||
if _I2C_RE.search(net):
|
||||
return True
|
||||
return any(_I2C_RE.search(t) for t in tokens)
|
||||
|
||||
|
||||
def _is_reset_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
if _RESET_RE.search(net_name or ""):
|
||||
return True
|
||||
return any(_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
|
||||
|
||||
|
||||
def _is_ground_net(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
if net and net.net_type == NetType.GROUND:
|
||||
return True
|
||||
u = name.upper().replace("-", "_")
|
||||
return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or (
|
||||
u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS")
|
||||
)
|
||||
|
||||
|
||||
def _is_power_net(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
if net and net.net_type == NetType.POWER:
|
||||
return True
|
||||
return bool(re.match(r"^\d+V\d*", (name or "").upper()))
|
||||
|
||||
|
||||
def _capacitor_to_ground(graph: DesignGraph, power_net: str) -> bool:
|
||||
for ref in graph.capacitors_on_net(power_net):
|
||||
cap = graph.components[ref]
|
||||
others = {n for n in cap.pins.values() if n != power_net}
|
||||
if any(_is_ground_net(graph, n) for n in others):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resistor_to_power(graph: DesignGraph, net_name: str) -> bool:
|
||||
for ref in graph.components_on_net(net_name):
|
||||
comp = graph.components[ref]
|
||||
if comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
others = {n for n in comp.pins.values() if n != net_name}
|
||||
if any(_is_power_net(graph, n) for n in others):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resistor_to_ground(graph: DesignGraph, net_name: str) -> bool:
|
||||
for ref in graph.components_on_net(net_name):
|
||||
comp = graph.components[ref]
|
||||
if comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
others = {n for n in comp.pins.values() if n != net_name}
|
||||
if any(_is_ground_net(graph, n) for n in others):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resistor_ohms(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
|
||||
return float(specs.value_ohms)
|
||||
return _parse_resistance(comp.value)
|
||||
|
||||
|
||||
def _parallel_pullup_ohms(graph: DesignGraph, net_name: str) -> float | None:
|
||||
acc = 0.0
|
||||
known = 0
|
||||
for ref in graph.components_on_net(net_name):
|
||||
comp = graph.components[ref]
|
||||
if comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
others = {n for n in comp.pins.values() if n != net_name}
|
||||
if not any(_is_power_net(graph, n) for n in others):
|
||||
continue
|
||||
ohms = _resistor_ohms(comp)
|
||||
if ohms is None or ohms <= 0:
|
||||
return None
|
||||
acc += 1.0 / ohms
|
||||
known += 1
|
||||
if not known or acc <= 0:
|
||||
return None
|
||||
return 1.0 / acc
|
||||
|
||||
|
||||
def _cap_farads(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0:
|
||||
return float(specs.value_farads)
|
||||
raw = (comp.value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
v = _parse_spice_value(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return v if v > 0 else None
|
||||
|
||||
|
||||
def _max_known_cap_farads(graph: DesignGraph, power_net: str) -> float | None:
|
||||
known: list[float] = []
|
||||
any_unknown = False
|
||||
for ref in graph.capacitors_on_net(power_net):
|
||||
cap = graph.components[ref]
|
||||
others = {n for n in cap.pins.values() if n != power_net}
|
||||
if not any(_is_ground_net(graph, n) for n in others):
|
||||
continue
|
||||
farads = _cap_farads(cap)
|
||||
if farads is None:
|
||||
any_unknown = True
|
||||
continue
|
||||
known.append(farads)
|
||||
if any_unknown or not known:
|
||||
return None
|
||||
return max(known)
|
||||
|
||||
|
||||
def _is_regulator_output_pin(
|
||||
cons: ComponentConstraints | None, pin_num: str,
|
||||
) -> bool:
|
||||
return any(_OUT_PIN_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
|
||||
|
||||
|
||||
def _is_active_low_reset(
|
||||
cons: ComponentConstraints | None, pin_num: str, net_name: str,
|
||||
) -> bool:
|
||||
if _ACTIVE_LOW_RESET_RE.search(net_name or ""):
|
||||
return True
|
||||
return any(_ACTIVE_LOW_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
|
||||
|
||||
|
||||
def _other_ic_on_net(graph: DesignGraph, net_name: str, self_ref: str) -> bool:
|
||||
for ref in graph.components_on_net(net_name):
|
||||
if ref == self_ref:
|
||||
continue
|
||||
other = graph.components.get(ref)
|
||||
if other and other.component_type == ComponentType.IC:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Stronger datasheet page text: reading-order blocks + table markdown.
|
||||
|
||||
Used by DeepSeek PDF ingest (review/extraction) and by quote verification
|
||||
so both see the same reconstructed page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SPARSE_CHARS = 80
|
||||
|
||||
|
||||
def pdf_page_texts(pdf_path: Path | str) -> list[str]:
|
||||
"""1-based page texts (index 0 unused). Empty list if the file cannot be read."""
|
||||
path = Path(pdf_path)
|
||||
blob = _pages_pymupdf(path)
|
||||
if blob is None:
|
||||
blob = _pages_pypdf(path)
|
||||
return blob
|
||||
|
||||
|
||||
def extract_pdf_document_text(pdf_path: Path | str, *, max_chars: int) -> str:
|
||||
"""Full datasheet dump with ``--- page N ---`` markers, truncated."""
|
||||
path = Path(pdf_path)
|
||||
pages = pdf_page_texts(path)
|
||||
n = max(0, len(pages) - 1)
|
||||
parts = [f"[PDF: {path.name}, {n} pages]"]
|
||||
for i in range(1, n + 1):
|
||||
body = (pages[i] or "").strip()
|
||||
parts.append(f"--- page {i} ---\n{body}")
|
||||
blob = "\n\n".join(parts)
|
||||
if len(blob) > max_chars:
|
||||
# Count how many page markers survive the cut for observability.
|
||||
kept = blob[:max_chars].count("--- page ")
|
||||
log.info(
|
||||
"PDF text truncated: %s full=%d chars cap=%d kept_pages≈%d/%d",
|
||||
path.name, len(blob), max_chars, kept, n,
|
||||
)
|
||||
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
|
||||
return blob
|
||||
|
||||
|
||||
def page_is_sparse(text: str) -> bool:
|
||||
compact = re.sub(r"\s+", "", text or "")
|
||||
return len(compact) < _SPARSE_CHARS
|
||||
|
||||
|
||||
def _pages_pymupdf(pdf_path: Path) -> list[str] | None:
|
||||
try:
|
||||
import fitz
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as exc:
|
||||
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
|
||||
return None
|
||||
try:
|
||||
pages = [""]
|
||||
for page in doc:
|
||||
pages.append(fitz_page_text(page))
|
||||
return pages
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
def _pages_pypdf(pdf_path: Path) -> list[str]:
|
||||
from pypdf import PdfReader
|
||||
|
||||
try:
|
||||
reader = PdfReader(str(pdf_path))
|
||||
except Exception as exc:
|
||||
log.warning("pypdf failed to open %s: %s", pdf_path, exc)
|
||||
return []
|
||||
pages = [""]
|
||||
for page in reader.pages:
|
||||
try:
|
||||
pages.append(page.extract_text() or "")
|
||||
except Exception:
|
||||
pages.append("")
|
||||
return pages
|
||||
|
||||
|
||||
def fitz_page_text(page) -> str:
|
||||
"""Reading-order text plus any reconstructed tables; flag sparse scans."""
|
||||
tables = _table_markdown(page)
|
||||
blocks = _blocks_text(page)
|
||||
chunks = [c for c in (blocks, tables) if c]
|
||||
text = "\n\n".join(chunks).strip()
|
||||
if page_is_sparse(text):
|
||||
note = "[low-text page: diagram or scan — use the page image]"
|
||||
text = f"{text}\n{note}".strip() if text else note
|
||||
return text
|
||||
|
||||
|
||||
def _blocks_text(page) -> str:
|
||||
try:
|
||||
blocks = page.get_text("blocks") or []
|
||||
except Exception:
|
||||
try:
|
||||
return (page.get_text("text") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
# (x0, y0, x1, y1, text, block_no, block_type, ...)
|
||||
textual = [b for b in blocks if len(b) >= 5 and str(b[4]).strip()]
|
||||
textual.sort(key=lambda b: (round(float(b[1]) / 6.0), float(b[0])))
|
||||
for b in textual:
|
||||
piece = str(b[4]).strip()
|
||||
if piece:
|
||||
lines.append(piece)
|
||||
if lines:
|
||||
return "\n".join(lines)
|
||||
try:
|
||||
return (page.get_text("text") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _table_markdown(page) -> str:
|
||||
try:
|
||||
finder = page.find_tables()
|
||||
except Exception:
|
||||
return ""
|
||||
tables = getattr(finder, "tables", None) or []
|
||||
chunks: list[str] = []
|
||||
for table in tables:
|
||||
md = _one_table_markdown(table)
|
||||
if md:
|
||||
chunks.append(md)
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
|
||||
def _one_table_markdown(table) -> str:
|
||||
try:
|
||||
md = table.to_markdown()
|
||||
if md and md.strip():
|
||||
return md.strip()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
rows = table.extract()
|
||||
except Exception:
|
||||
return ""
|
||||
if not rows:
|
||||
return ""
|
||||
out: list[str] = []
|
||||
for row in rows:
|
||||
cells = [re.sub(r"\s+", " ", str(c or "")).strip() for c in row]
|
||||
if any(cells):
|
||||
out.append("| " + " | ".join(cells) + " |")
|
||||
return "\n".join(out)
|
||||
@@ -16,19 +16,19 @@ perspective is ambiguous).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
)
|
||||
from backend.pinscopex.pin_function_tokens import (
|
||||
from backend.periscopex.pin_function_tokens import (
|
||||
complement,
|
||||
normalize_functions,
|
||||
parse_net_token,
|
||||
signals_for_peripheral,
|
||||
)
|
||||
from backend.pinscopex.validate import _match_constraints
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
|
||||
def check_pin_mux_feasibility(
|
||||
@@ -169,4 +169,7 @@ def _feasibility_finding(
|
||||
),
|
||||
recommendation=rec,
|
||||
reference=f"{mpn or ref} alternate-function table",
|
||||
net=net_name,
|
||||
pins=[f"{ref}.{pin_num}"],
|
||||
rule_id="PE-MUX-001",
|
||||
)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""G2: decoupling proximity on the PCB vs datasheet layout_rules.
|
||||
|
||||
Runs only when a LayoutGraph is present and a decoupling_proximity rule
|
||||
has a numeric max_distance_mm. Null millimetres skip — no 3 mm default.
|
||||
Thermal vias (`PE-PLC-002`) skip without courtyard vertices and without
|
||||
min_via_count — no invented pad radius. same_layer (`PE-PLC-003`) uses
|
||||
the boolean parameter plus footprint layers from the PCB. Crystals use
|
||||
the same decoupling_proximity rule. Track length is shortest path on
|
||||
segments vs max_distance_mm — no invented “much larger than euclidean”.
|
||||
Keepout (`PE-PLC-004`) is a foreign net endpoint inside the courtyard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import math
|
||||
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
LayoutPad,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
|
||||
def _pad_for(layout: LayoutGraph, ref: str, number: str) -> LayoutPad | None:
|
||||
fp = layout.footprints.get(ref)
|
||||
if not fp:
|
||||
return None
|
||||
for pad in fp.pads:
|
||||
if pad.number == str(number):
|
||||
return pad
|
||||
return None
|
||||
|
||||
|
||||
def _pin_number(cons: ComponentConstraints, token: str) -> str | None:
|
||||
want = str(token).strip()
|
||||
if not want:
|
||||
return None
|
||||
for pin in cons.pintable:
|
||||
if str(pin.number) == want or (pin.name or "").upper() == want.upper():
|
||||
return str(pin.number)
|
||||
return None
|
||||
|
||||
|
||||
def _dist(a: LayoutPad, b: LayoutPad) -> float:
|
||||
return math.hypot(a.x - b.x, a.y - b.y)
|
||||
|
||||
|
||||
def _xy_key(x: float, y: float) -> tuple[float, float]:
|
||||
return (round(x, 3), round(y, 3))
|
||||
|
||||
|
||||
def _path_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float | None:
|
||||
segs = [s for s in layout.segments if s.net == net]
|
||||
if not segs:
|
||||
return None
|
||||
adj: dict[tuple[float, float], list[tuple[tuple[float, float], float]]] = {}
|
||||
for s in segs:
|
||||
p = _xy_key(s.start[0], s.start[1])
|
||||
q = _xy_key(s.end[0], s.end[1])
|
||||
length = math.hypot(s.end[0] - s.start[0], s.end[1] - s.start[1])
|
||||
adj.setdefault(p, []).append((q, length))
|
||||
adj.setdefault(q, []).append((p, length))
|
||||
src = _xy_key(a.x, a.y)
|
||||
dst = _xy_key(b.x, b.y)
|
||||
if src not in adj or dst not in adj:
|
||||
return None
|
||||
dist = {src: 0.0}
|
||||
heap: list[tuple[float, tuple[float, float]]] = [(0.0, src)]
|
||||
while heap:
|
||||
d, node = heapq.heappop(heap)
|
||||
if d > dist.get(node, math.inf):
|
||||
continue
|
||||
if node == dst:
|
||||
return d
|
||||
for nxt, w in adj.get(node, []):
|
||||
nd = d + w
|
||||
if nd < dist.get(nxt, math.inf):
|
||||
dist[nxt] = nd
|
||||
heapq.heappush(heap, (nd, nxt))
|
||||
return None
|
||||
|
||||
|
||||
def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float:
|
||||
path = _path_mm(layout, net, a, b)
|
||||
if path is None:
|
||||
return _dist(a, b)
|
||||
return path
|
||||
|
||||
|
||||
def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None:
|
||||
for net in graph.nets.values():
|
||||
for pc in net.pins:
|
||||
if pc.component_ref == ref and str(pc.pin_number) == str(pin_no):
|
||||
return net.name
|
||||
return graph.pin_net(ref, pin_no)
|
||||
|
||||
|
||||
def check_placement(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or not layout.footprints:
|
||||
return []
|
||||
findings: list[Finding] = []
|
||||
for ref, comp in graph.components.items():
|
||||
if comp.component_type not in (ComponentType.IC, ComponentType.CRYSTAL):
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn, constraints_map)
|
||||
if not cons or not cons.layout_rules:
|
||||
continue
|
||||
for rule in cons.layout_rules:
|
||||
kind = rule.get("kind")
|
||||
if kind == "decoupling_proximity":
|
||||
findings.extend(
|
||||
_decoupling_finding(ref, comp, cons, rule, graph, layout)
|
||||
)
|
||||
findings.extend(
|
||||
_same_layer_finding(ref, comp, cons, rule, graph, layout)
|
||||
)
|
||||
elif kind == "thermal_via":
|
||||
findings.extend(_thermal_via_finding(ref, comp, cons, rule, layout))
|
||||
elif kind == "keepout":
|
||||
findings.extend(_keepout_finding(ref, comp, cons, rule, graph, layout))
|
||||
return findings
|
||||
|
||||
|
||||
def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]:
|
||||
pin_no = _pin_number(cons, str(rule.get("pin") or ""))
|
||||
if not pin_no:
|
||||
return []
|
||||
net = _net_for_pin(graph, ref, pin_no)
|
||||
if not net:
|
||||
return []
|
||||
ic_pad = _pad_for(layout, ref, pin_no)
|
||||
if not ic_pad:
|
||||
return []
|
||||
cap_pads: list[LayoutPad] = []
|
||||
for cref in graph.capacitors_on_net(net):
|
||||
fp = layout.footprints.get(cref)
|
||||
if not fp:
|
||||
continue
|
||||
for pad in fp.pads:
|
||||
if pad.net == net or pad.net == ic_pad.net:
|
||||
cap_pads.append(pad)
|
||||
if not cap_pads:
|
||||
return []
|
||||
nearest = min(_reach_mm(layout, net, ic_pad, p) for p in cap_pads)
|
||||
extracted = rule.get("max_distance_mm")
|
||||
if extracted is None:
|
||||
return []
|
||||
limit = float(extracted)
|
||||
if nearest <= limit:
|
||||
return []
|
||||
return [Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or cons.mpn,
|
||||
aspect="placement",
|
||||
finding=(
|
||||
f"Decoupling on {net} is {nearest:.1f} mm from {ref}.{pin_no} "
|
||||
f"(limit {limit:g} mm)."
|
||||
),
|
||||
why=f"layout_rules max_distance_mm={limit:g}.",
|
||||
status="ERROR",
|
||||
recommendation="Place the decoupling capacitor closer to the supply pin.",
|
||||
source="placement_check",
|
||||
rule_id="PE-PLC-001",
|
||||
net=net,
|
||||
pins=[pin_no],
|
||||
source_page=rule.get("source_page"),
|
||||
)]
|
||||
|
||||
|
||||
def _copper_side(layer: str) -> str | None:
|
||||
s = (layer or "").strip().upper()
|
||||
if s.startswith("F."):
|
||||
return "F"
|
||||
if s.startswith("B."):
|
||||
return "B"
|
||||
return None
|
||||
|
||||
|
||||
def _same_layer_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]:
|
||||
if rule.get("same_layer") is not True:
|
||||
return []
|
||||
pin_no = _pin_number(cons, str(rule.get("pin") or ""))
|
||||
if not pin_no:
|
||||
return []
|
||||
net = _net_for_pin(graph, ref, pin_no)
|
||||
if not net:
|
||||
return []
|
||||
ic_fp = layout.footprints.get(ref)
|
||||
if not ic_fp:
|
||||
return []
|
||||
ic_side = _copper_side(ic_fp.layer)
|
||||
if ic_side is None:
|
||||
return []
|
||||
placed = []
|
||||
for cref in graph.capacitors_on_net(net):
|
||||
fp = layout.footprints.get(cref)
|
||||
if not fp:
|
||||
continue
|
||||
side = _copper_side(fp.layer)
|
||||
if side is None:
|
||||
continue
|
||||
placed.append((cref, side, fp))
|
||||
if not placed:
|
||||
return []
|
||||
if any(side == ic_side for _, side, _ in placed):
|
||||
return []
|
||||
if len(ic_fp.courtyard) >= 3:
|
||||
for v in layout.vias:
|
||||
if v.net and v.net != net:
|
||||
continue
|
||||
if _in_poly(v.x, v.y, ic_fp.courtyard):
|
||||
return []
|
||||
return [Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or cons.mpn,
|
||||
aspect="placement",
|
||||
finding=(
|
||||
f"Decoupling on {net} is on the opposite copper from {ref} "
|
||||
f"(same_layer=true)."
|
||||
),
|
||||
why="layout_rules same_layer=true.",
|
||||
status="WARNING",
|
||||
recommendation="Place the decoupling capacitor on the same layer or add a via in the courtyard.",
|
||||
source="placement_check",
|
||||
rule_id="PE-PLC-003",
|
||||
net=net,
|
||||
pins=[pin_no],
|
||||
source_page=rule.get("source_page"),
|
||||
)]
|
||||
|
||||
|
||||
def _in_poly(x: float, y: float, poly: list[tuple[float, float]]) -> bool:
|
||||
n = len(poly)
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = poly[i]
|
||||
xj, yj = poly[j]
|
||||
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def _thermal_via_finding(ref, comp, cons, rule, layout: LayoutGraph) -> list[Finding]:
|
||||
min_n = rule.get("min_via_count")
|
||||
if min_n is None:
|
||||
return []
|
||||
fp = layout.footprints.get(ref)
|
||||
if not fp or len(fp.courtyard) < 3:
|
||||
return []
|
||||
n = sum(1 for v in layout.vias if _in_poly(v.x, v.y, fp.courtyard))
|
||||
if n >= int(min_n):
|
||||
return []
|
||||
pin = str(rule.get("pin") or "").strip()
|
||||
return [Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or cons.mpn,
|
||||
aspect="placement",
|
||||
finding=(
|
||||
f"{n} thermal vias in courtyard of {ref} "
|
||||
f"(min_via_count {int(min_n)})."
|
||||
),
|
||||
why=f"layout_rules min_via_count={int(min_n)}.",
|
||||
status="ERROR",
|
||||
recommendation="Add vias in the thermal pad courtyard.",
|
||||
source="placement_check",
|
||||
rule_id="PE-PLC-002",
|
||||
pins=[pin] if pin else [],
|
||||
source_page=rule.get("source_page"),
|
||||
)]
|
||||
|
||||
|
||||
def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]:
|
||||
fp = layout.footprints.get(ref)
|
||||
if not fp or len(fp.courtyard) < 3:
|
||||
return []
|
||||
pin_no = _pin_number(cons, str(rule.get("pin") or ""))
|
||||
own = _net_for_pin(graph, ref, pin_no) if pin_no else None
|
||||
if not own:
|
||||
return []
|
||||
foreign: list[str] = []
|
||||
for s in layout.segments:
|
||||
if not s.net or s.net == own:
|
||||
continue
|
||||
if _in_poly(s.start[0], s.start[1], fp.courtyard) or _in_poly(
|
||||
s.end[0], s.end[1], fp.courtyard
|
||||
):
|
||||
foreign.append(s.net)
|
||||
if not foreign:
|
||||
return []
|
||||
net = sorted(set(foreign))[0]
|
||||
return [Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or cons.mpn,
|
||||
aspect="placement",
|
||||
finding=f"Track on {net} enters courtyard of {ref} (keepout on {own}).",
|
||||
why="layout_rules kind=keepout.",
|
||||
status="WARNING",
|
||||
recommendation="Keep other nets out of the courtyard.",
|
||||
source="placement_check",
|
||||
rule_id="PE-PLC-004",
|
||||
net=net,
|
||||
pins=[pin_no],
|
||||
source_page=rule.get("source_page"),
|
||||
)]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Layout F2 skeleton — propose satellite xy from PCB anchors + numeric rules.
|
||||
|
||||
No millimetres are invented. Packing runs only when a LayoutGraph has
|
||||
footprints and at least one ``decoupling_proximity`` rule carries a numeric
|
||||
``max_distance_mm``. Otherwise the report is ``skipped`` with an explicit reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementIcGroup
|
||||
from backend.periscopex.models import DesignGraph, LayoutGraph, LayoutPad
|
||||
|
||||
SkipReason = Literal[
|
||||
"no_pcb_footprints",
|
||||
"no_numeric_layout_rules",
|
||||
"no_packable_satellites",
|
||||
]
|
||||
|
||||
|
||||
class PlacementProposal(BaseModel):
|
||||
ref: str
|
||||
anchor_ref: str
|
||||
rule_kind: str
|
||||
max_distance_mm: float
|
||||
proposed_x: float
|
||||
proposed_y: float
|
||||
layer: str = ""
|
||||
basis: str = "ic_pad+rule"
|
||||
|
||||
|
||||
class PlacementPackReport(BaseModel):
|
||||
objective: Literal["routing"] = "routing"
|
||||
status: Literal["packed", "skipped"] = "skipped"
|
||||
skip_reason: SkipReason | None = None
|
||||
placements: list[PlacementProposal] = []
|
||||
|
||||
|
||||
def build_placement_pack(
|
||||
plan: FunctionalGroupsReport,
|
||||
layout: LayoutGraph | None,
|
||||
graph: DesignGraph | None = None,
|
||||
) -> PlacementPackReport:
|
||||
"""Propose satellite positions within extracted proximity limits."""
|
||||
if layout is None or not layout.footprints:
|
||||
return PlacementPackReport(status="skipped", skip_reason="no_pcb_footprints")
|
||||
|
||||
if not _has_numeric_proximity(plan):
|
||||
return PlacementPackReport(
|
||||
status="skipped",
|
||||
skip_reason="no_numeric_layout_rules",
|
||||
)
|
||||
|
||||
placements: list[PlacementProposal] = []
|
||||
used_refs: set[str] = set()
|
||||
|
||||
for group in plan.groups:
|
||||
placements.extend(
|
||||
_pack_group(group, layout, graph, used_refs),
|
||||
)
|
||||
|
||||
if not placements:
|
||||
return PlacementPackReport(
|
||||
status="skipped",
|
||||
skip_reason="no_packable_satellites",
|
||||
)
|
||||
return PlacementPackReport(status="packed", placements=placements)
|
||||
|
||||
|
||||
def _has_numeric_proximity(plan: FunctionalGroupsReport) -> bool:
|
||||
for g in plan.groups:
|
||||
for rule in g.layout_rules:
|
||||
if rule.get("kind") != "decoupling_proximity":
|
||||
continue
|
||||
if _num(rule.get("max_distance_mm")) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _num(raw) -> float | None:
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if v > 0 else None
|
||||
|
||||
|
||||
def _pack_group(
|
||||
group: PlacementIcGroup,
|
||||
layout: LayoutGraph,
|
||||
graph: DesignGraph | None,
|
||||
used_refs: set[str],
|
||||
) -> list[PlacementProposal]:
|
||||
ic_fp = layout.footprints.get(group.ref)
|
||||
if not ic_fp:
|
||||
return []
|
||||
|
||||
candidates = [
|
||||
s for s in group.satellites
|
||||
if s.role_hint in ("decoupling", "bulk") and s.ref not in used_refs
|
||||
]
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
out: list[PlacementProposal] = []
|
||||
for rule in group.layout_rules:
|
||||
if rule.get("kind") != "decoupling_proximity":
|
||||
continue
|
||||
limit = _num(rule.get("max_distance_mm"))
|
||||
if limit is None:
|
||||
continue
|
||||
pad = _anchor_pad(group, layout, graph, str(rule.get("pin") or ""))
|
||||
if pad is None:
|
||||
# Fall back to footprint origin when pin is unknown but rule is numeric.
|
||||
pad = LayoutPad(number="", x=ic_fp.x, y=ic_fp.y, net="")
|
||||
basis = "ic_origin+rule"
|
||||
else:
|
||||
basis = "ic_pad+rule"
|
||||
|
||||
net = pad.net or None
|
||||
matched = [
|
||||
s for s in candidates
|
||||
if s.ref not in used_refs and (not net or net in (s.nets or []))
|
||||
]
|
||||
if not matched:
|
||||
matched = [s for s in candidates if s.ref not in used_refs]
|
||||
if not matched:
|
||||
continue
|
||||
|
||||
for i, sat in enumerate(matched):
|
||||
angle = (2.0 * math.pi * i) / max(len(matched), 8)
|
||||
radius = limit * 0.5
|
||||
px = pad.x + radius * math.cos(angle)
|
||||
py = pad.y + radius * math.sin(angle)
|
||||
layer = ic_fp.layer or ""
|
||||
out.append(PlacementProposal(
|
||||
ref=sat.ref,
|
||||
anchor_ref=group.ref,
|
||||
rule_kind="decoupling_proximity",
|
||||
max_distance_mm=limit,
|
||||
proposed_x=round(px, 4),
|
||||
proposed_y=round(py, 4),
|
||||
layer=layer,
|
||||
basis=basis,
|
||||
))
|
||||
used_refs.add(sat.ref)
|
||||
# One numeric rule per IC is enough for the skeleton.
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _anchor_pad(
|
||||
group: PlacementIcGroup,
|
||||
layout: LayoutGraph,
|
||||
graph: DesignGraph | None,
|
||||
pin_token: str,
|
||||
) -> LayoutPad | None:
|
||||
fp = layout.footprints.get(group.ref)
|
||||
if not fp or not fp.pads:
|
||||
return None
|
||||
want = (pin_token or "").strip()
|
||||
if want:
|
||||
for pad in fp.pads:
|
||||
if pad.number == want:
|
||||
return pad
|
||||
if graph is not None:
|
||||
comp = graph.components.get(group.ref)
|
||||
if comp:
|
||||
for pin_num, net in comp.pins.items():
|
||||
if str(pin_num) == want:
|
||||
for pad in fp.pads:
|
||||
if pad.number == str(pin_num):
|
||||
return pad
|
||||
# No matching pad number — pick any pad on that net.
|
||||
for pad in fp.pads:
|
||||
if pad.net and pad.net == net:
|
||||
return pad
|
||||
# Prefer a pad on a power-looking net shared with decoupling sats.
|
||||
for pad in fp.pads:
|
||||
if pad.net:
|
||||
return pad
|
||||
return fp.pads[0]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Regulator current margin and explicit series-R IR drop.
|
||||
|
||||
Iout_max is the rating, never the load. IQ/load are summed only when every
|
||||
IC on the rail has a spec. Trace resistance is never estimated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.periscopex.led_current_check import _net_voltage, _parse_resistance
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
InductorSpecs,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.passive_rail_check import _is_ground_net
|
||||
from backend.periscopex.thermal_check import (
|
||||
_IOUT_MAX_KEYS,
|
||||
_LOAD_KEYS,
|
||||
_VIN_PIN,
|
||||
_VOUT_PIN,
|
||||
_first,
|
||||
_is_ldo,
|
||||
_pin_net_by_role,
|
||||
_specs_values,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_IQ_KEYS = (
|
||||
"iq_a", "quiescent_current_a", "supply_current_a", "idd_a", "icc_a",
|
||||
)
|
||||
_IR_FRAC = 0.05 # 5% of the rail — wide, not a datasheet number
|
||||
|
||||
|
||||
def _two_nets(comp: Component) -> tuple[str, str] | None:
|
||||
nets = list(dict.fromkeys(comp.pins.values()))
|
||||
if len(nets) != 2:
|
||||
return None
|
||||
return nets[0], nets[1]
|
||||
|
||||
|
||||
def _series_ohms(comp: Component) -> float | None:
|
||||
if comp.component_type == ComponentType.RESISTOR:
|
||||
if isinstance(comp.specs, ResistorSpecs) and comp.specs.value_ohms >= 0:
|
||||
return float(comp.specs.value_ohms)
|
||||
return _parse_resistance(comp.value)
|
||||
if comp.component_type == ComponentType.INDUCTOR:
|
||||
if isinstance(comp.specs, InductorSpecs) and comp.specs.dcr_ohms is not None:
|
||||
return float(comp.specs.dcr_ohms)
|
||||
return None
|
||||
|
||||
|
||||
def _expand_rail(graph: DesignGraph, start: str) -> set[str]:
|
||||
"""Follow series R/L between nets; do not walk through ICs (VIN/VOUT)."""
|
||||
seen = {start}
|
||||
stack = [start]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
for ref in graph.components_on_net(n):
|
||||
c = graph.components.get(ref)
|
||||
if not c or c.component_type not in (
|
||||
ComponentType.RESISTOR, ComponentType.INDUCTOR,
|
||||
):
|
||||
continue
|
||||
pair = _two_nets(c)
|
||||
if not pair:
|
||||
continue
|
||||
other = pair[1] if pair[0] == n else pair[0]
|
||||
if other in seen or _is_ground_net(graph, other):
|
||||
continue
|
||||
seen.add(other)
|
||||
stack.append(other)
|
||||
return seen
|
||||
|
||||
|
||||
def _regulators(graph: DesignGraph, cmap: dict[str, ComponentConstraints]):
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||||
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||||
if not (vin and vout) and not _is_ldo(comp, cons):
|
||||
continue
|
||||
if not (vin and vout):
|
||||
continue
|
||||
yield ref, comp, cons, vin, vout
|
||||
|
||||
|
||||
def check_power_margin(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
cmap = constraints_map or {}
|
||||
findings: list[Finding] = []
|
||||
for ref, comp, cons, vin, vout in _regulators(graph, cmap):
|
||||
values = _specs_values(comp)
|
||||
iout_max = _first(values, _IOUT_MAX_KEYS)
|
||||
i_load = _first(values, _LOAD_KEYS)
|
||||
ics: list[Component] = []
|
||||
missing_iq = False
|
||||
iq_sum = 0.0
|
||||
for net in _expand_rail(graph, vout):
|
||||
for r in graph.components_on_net(net):
|
||||
c = graph.components.get(r)
|
||||
if not c or c.component_type != ComponentType.IC or r == ref:
|
||||
continue
|
||||
if c in ics:
|
||||
continue
|
||||
ics.append(c)
|
||||
iq = _first(_specs_values(c), _IQ_KEYS)
|
||||
if iq is None:
|
||||
missing_iq = True
|
||||
else:
|
||||
iq_sum += iq
|
||||
i_total = None
|
||||
if i_load is not None and not missing_iq:
|
||||
i_total = i_load + iq_sum
|
||||
elif i_load is not None and not ics:
|
||||
i_total = i_load
|
||||
elif not missing_iq and ics and i_load is None:
|
||||
i_total = iq_sum
|
||||
if iout_max is not None and i_total is not None and i_total > iout_max:
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="power",
|
||||
source="power_margin_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} load ≈ {i_total:.3g} A exceeds Iout_max {iout_max:.3g} A "
|
||||
f"on '{vout}'."
|
||||
),
|
||||
why="Sum of specified IQ on the rail plus I_load. Missing IQ was not guessed.",
|
||||
recommendation="Raise the regulator rating or cut the load.",
|
||||
reference="regulator Iout_max",
|
||||
net=vout,
|
||||
pins=[ref],
|
||||
rule_id="PE-PWR-001",
|
||||
))
|
||||
|
||||
# IR drop only through an explicit series R/ferrite on VIN or VOUT.
|
||||
if i_load is None:
|
||||
continue
|
||||
for r in graph.components_on_net(vin):
|
||||
c = graph.components.get(r)
|
||||
if not c or c.component_type not in (
|
||||
ComponentType.RESISTOR, ComponentType.INDUCTOR,
|
||||
):
|
||||
continue
|
||||
pair = _two_nets(c)
|
||||
if not pair:
|
||||
continue
|
||||
ohms = _series_ohms(c)
|
||||
if ohms is None or ohms <= 0:
|
||||
continue
|
||||
drop = i_load * ohms
|
||||
vrail = _net_voltage(graph, vin) or _net_voltage(graph, vout)
|
||||
if vrail is None or vrail <= 0:
|
||||
continue
|
||||
if drop <= _IR_FRAC * vrail:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
designator=r,
|
||||
mpn=c.mpn or "",
|
||||
aspect="power",
|
||||
source="power_margin_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{r} series drop ≈ {drop:.3g} V at I_load={i_load:.3g} A "
|
||||
f"into {ref} VIN '{vin}'."
|
||||
),
|
||||
why="IR from an explicit series R/ferrite DCR. Trace resistance was not estimated.",
|
||||
recommendation="Lower DCR or the load, or accept the drop if it is intended.",
|
||||
reference="netlist series R",
|
||||
net=vin,
|
||||
pins=[r],
|
||||
rule_id="PE-PWR-001",
|
||||
))
|
||||
return findings
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Deterministic check that a finding's datasheet quote is actually in the PDF.
|
||||
|
||||
The reviewer must cite verbatim text. This module extracts page text (PyMuPDF,
|
||||
then pypdf) and looks for a normalized match on the cited page ±1. Failures
|
||||
demote ERROR → WARNING and prefix ``why`` with ``Unverified:``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.periscopex.pdf_text import pdf_page_texts as extract_pdf_pages
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
_MIN_QUOTE_CHARS = 12
|
||||
_EMPTY_PAGE_ALNUM = 40
|
||||
_PAGE_WINDOW = 1
|
||||
|
||||
|
||||
def normalize_quote(s: str) -> str:
|
||||
"""Fold µ/μ, drop soft hyphens and linebreak hyphenation, squeeze space."""
|
||||
t = (s or "").replace("µ", "μ").replace("\u00ad", "")
|
||||
t = re.sub(r"-\s+", "", t)
|
||||
t = re.sub(r"\s+", " ", t).strip().lower()
|
||||
return t
|
||||
|
||||
|
||||
def _alnum(s: str) -> str:
|
||||
return re.sub(r"[^a-z0-9μ]+", "", normalize_quote(s))
|
||||
|
||||
|
||||
def quote_in_text(quote: str, text: str) -> bool:
|
||||
"""True if *quote* appears in *text* after the same folding the PDF viewer uses."""
|
||||
q = normalize_quote(quote)
|
||||
if len(q) < _MIN_QUOTE_CHARS:
|
||||
return False
|
||||
hay = normalize_quote(text)
|
||||
if q in hay:
|
||||
return True
|
||||
qa, ha = _alnum(quote), _alnum(text)
|
||||
return len(qa) >= _MIN_QUOTE_CHARS and qa in ha
|
||||
|
||||
|
||||
def pdf_page_texts(pdf_path: Path) -> list[str]:
|
||||
"""1-based page texts (index 0 unused). Empty list if the file cannot be read."""
|
||||
return extract_pdf_pages(pdf_path)
|
||||
|
||||
|
||||
def locate_quote(
|
||||
pdf_path: Path,
|
||||
page: int | None,
|
||||
quote: str,
|
||||
*,
|
||||
window: int = _PAGE_WINDOW,
|
||||
) -> tuple[str, int | None]:
|
||||
"""Return ``(ok|missing_quote|not_found|page_empty|no_pdf|bad_page, matched_page)``."""
|
||||
q = (quote or "").strip()
|
||||
if len(normalize_quote(q)) < _MIN_QUOTE_CHARS:
|
||||
return ("missing_quote", None)
|
||||
if not pdf_path.is_file():
|
||||
return ("no_pdf", None)
|
||||
pages = pdf_page_texts(pdf_path)
|
||||
n = len(pages) - 1
|
||||
if n < 1:
|
||||
return ("no_pdf", None)
|
||||
if page is None or not isinstance(page, int) or page < 1:
|
||||
# Search the whole file; keep the first hit.
|
||||
for i in range(1, n + 1):
|
||||
if quote_in_text(q, pages[i]):
|
||||
return ("ok", i)
|
||||
if max(len(_alnum(p)) for p in pages[1:]) < _EMPTY_PAGE_ALNUM:
|
||||
return ("page_empty", None)
|
||||
return ("not_found", None)
|
||||
|
||||
lo = max(1, page - window)
|
||||
hi = min(n, page + window)
|
||||
matched: int | None = None
|
||||
any_text = False
|
||||
for i in range(lo, hi + 1):
|
||||
if len(_alnum(pages[i])) >= _EMPTY_PAGE_ALNUM:
|
||||
any_text = True
|
||||
if quote_in_text(q, pages[i]):
|
||||
matched = i
|
||||
break
|
||||
if matched is not None:
|
||||
return ("ok", matched)
|
||||
if not any_text:
|
||||
return ("page_empty", None)
|
||||
if page > n:
|
||||
return ("bad_page", None)
|
||||
return ("not_found", None)
|
||||
|
||||
|
||||
_REASONS = {
|
||||
"missing_quote": "no verbatim datasheet quote.",
|
||||
"not_found": "cited text not found on the datasheet page.",
|
||||
"page_empty": "cited page has no extractable text (figure or scan).",
|
||||
"no_pdf": "datasheet PDF unavailable to check the quote.",
|
||||
"bad_page": "source_page missing or out of range.",
|
||||
}
|
||||
|
||||
|
||||
def _mark_unverified(finding: Finding, reason_key: str) -> None:
|
||||
if finding.status == "ERROR":
|
||||
finding.status = "WARNING"
|
||||
msg = _REASONS[reason_key]
|
||||
if not finding.why.startswith("Unverified:"):
|
||||
finding.why = f"Unverified: {msg} {finding.why}".strip()
|
||||
|
||||
|
||||
def verify_finding_citations(
|
||||
findings: list[Finding],
|
||||
*,
|
||||
default_pdf: Path,
|
||||
default_mpn: str,
|
||||
pdf_dir: Path | None = None,
|
||||
mpn_by_designator: dict[str, str] | None = None,
|
||||
pdf_for_mpn: Callable[[str], Path | None] | None = None,
|
||||
) -> None:
|
||||
"""Mutate *findings* in place: check each ``source_quote`` against the PDF."""
|
||||
mpn_by_designator = mpn_by_designator or {}
|
||||
pdf_dir = pdf_dir or default_pdf.parent
|
||||
cache: dict[str, Path | None] = {}
|
||||
|
||||
def resolve_pdf(finding: Finding) -> Path:
|
||||
mpn = default_mpn
|
||||
if finding.source_designator:
|
||||
mpn = mpn_by_designator.get(finding.source_designator) or default_mpn
|
||||
if pdf_for_mpn is not None:
|
||||
hit = pdf_for_mpn(mpn)
|
||||
if hit is not None:
|
||||
return hit
|
||||
key = mpn
|
||||
if key not in cache:
|
||||
p = pdf_dir / f"{safe_mpn(mpn)}.pdf"
|
||||
cache[key] = p if p.is_file() else None
|
||||
return cache[key] or default_pdf
|
||||
|
||||
for finding in findings:
|
||||
pdf = resolve_pdf(finding)
|
||||
reason, matched = locate_quote(pdf, finding.source_page, finding.source_quote)
|
||||
if reason == "ok":
|
||||
if matched is not None and finding.source_page != matched:
|
||||
finding.source_page = matched
|
||||
finding.reference = re.sub(
|
||||
r"p\.\S+$",
|
||||
f"p.{matched}",
|
||||
finding.reference or f"{default_mpn} datasheet p.{matched}",
|
||||
)
|
||||
continue
|
||||
_mark_unverified(finding, reason)
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
CapacitorSpecs,
|
||||
ComponentSpecs,
|
||||
ComponentType,
|
||||
@@ -19,7 +19,7 @@ from backend.pinscopex.models import (
|
||||
SimpleComponentSpecs,
|
||||
ValueDecoder,
|
||||
)
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
from backend.periscopex.parsers import parse_bom
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -304,7 +304,29 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
|
||||
dielectric=dielectric,
|
||||
)
|
||||
|
||||
if subtype.startswith("passive.inductor") or subtype == "passive.ferrite_bead":
|
||||
if subtype == "passive.ferrite_bead":
|
||||
raw = vals.get("impedance_ohm") or vals.get("value_ohms")
|
||||
if raw is None:
|
||||
raise ValueError("Missing impedance_ohm in auto-resolved ferrite bead specs")
|
||||
impedance_ohm = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
|
||||
current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None
|
||||
dcr_raw = vals.get("dcr_ohms")
|
||||
dcr_ohms: float | None = None
|
||||
if dcr_raw is not None:
|
||||
dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw)
|
||||
formatted = value_formatted or _format_value(impedance_ohm, "ohm")
|
||||
return InductorSpecs(
|
||||
component_subtype=subtype_for_specs,
|
||||
value_henries=None,
|
||||
value_formatted=formatted,
|
||||
tolerance=tolerance,
|
||||
package=package,
|
||||
current_rating_a=current_rating_a,
|
||||
dcr_ohms=dcr_ohms,
|
||||
impedance_ohm=impedance_ohm,
|
||||
)
|
||||
|
||||
if subtype.startswith("passive.inductor"):
|
||||
raw = vals.get("value_henries")
|
||||
if raw is None:
|
||||
raise ValueError(f"Missing value_henries in auto-resolved inductor specs")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Stable per-IC neighborhood hash so a second review can skip unchanged chips."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from backend.periscopex.models import ComponentType, DesignGraph
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
|
||||
def ic_neighborhood_fingerprint(
|
||||
graph: DesignGraph,
|
||||
ref: str,
|
||||
constraints_map: dict | None = None,
|
||||
) -> str | None:
|
||||
"""Hash MPN, pin→net, 1-hop neighbors, and extraction model_version.
|
||||
|
||||
Returns None if *ref* is not an IC. Neighbor changes (pull-up added on
|
||||
SDA, etc.) invalidate every IC on that net.
|
||||
"""
|
||||
comp = graph.components.get(ref)
|
||||
if not comp or comp.component_type != ComponentType.IC:
|
||||
return None
|
||||
pins = tuple(sorted((str(p), n) for p, n in comp.pins.items()))
|
||||
neighbors: list[tuple[str, str, str, str]] = []
|
||||
for _pin, net_name in pins:
|
||||
for other in graph.components_on_net(net_name):
|
||||
if other == ref:
|
||||
continue
|
||||
o = graph.components[other]
|
||||
o_pins_on_net = tuple(
|
||||
sorted(str(p) for p, n in o.pins.items() if n == net_name)
|
||||
)
|
||||
neighbors.append(
|
||||
(other, o.mpn or "", o.component_type.value, ",".join(o_pins_on_net))
|
||||
)
|
||||
model_version = ""
|
||||
cons = _match_constraints(comp.mpn or comp.value, constraints_map or {})
|
||||
if cons is not None:
|
||||
model_version = getattr(cons, "model_version", "") or ""
|
||||
payload = {
|
||||
"mpn": comp.mpn or "",
|
||||
"pins": pins,
|
||||
"neighbors": tuple(sorted(neighbors)),
|
||||
"model_version": model_version,
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, default=str).encode()
|
||||
return hashlib.sha256(blob).hexdigest()
|
||||
|
||||
|
||||
def graph_ic_fingerprints(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict | None = None,
|
||||
) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for ref, comp in graph.components.items():
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
fp = ic_neighborhood_fingerprint(graph, ref, constraints_map)
|
||||
if fp:
|
||||
out[ref] = fp
|
||||
return out
|
||||
|
||||
|
||||
def skip_unchanged_ics(
|
||||
completed_refs: set[str],
|
||||
previous: dict[str, str],
|
||||
current: dict[str, str],
|
||||
) -> set[str]:
|
||||
"""Keep skip only for completed ICs whose neighborhood hash is unchanged."""
|
||||
skip: set[str] = set()
|
||||
for ref in completed_refs:
|
||||
if ref in current and previous.get(ref) == current[ref]:
|
||||
skip.add(ref)
|
||||
return skip
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Finding review disposition, ECO export, and release signature.
|
||||
|
||||
Review state lives beside comments on the report JSON — it is not a
|
||||
Finding field, so a pipeline re-run can keep dispositions by finding_id.
|
||||
Empty reason is invalid. false_positive / wontfix / open are not ECO rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Literal
|
||||
|
||||
from backend.periscopex.models import Finding
|
||||
|
||||
ReviewState = Literal["open", "false_positive", "accepted", "wontfix"]
|
||||
VALID_STATES: frozenset[str] = frozenset({"open", "false_positive", "accepted", "wontfix"})
|
||||
|
||||
|
||||
class ReviewError(ValueError):
|
||||
"""Invalid review payload; do not store a silent default."""
|
||||
|
||||
|
||||
def apply_review_state(
|
||||
current: dict[str, dict[str, Any]],
|
||||
finding_id: str,
|
||||
*,
|
||||
state: str,
|
||||
reason: str,
|
||||
user_id: str,
|
||||
user_name: str = "",
|
||||
updated_at: str | None = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
if not finding_id:
|
||||
raise ReviewError("finding_id is required")
|
||||
if state not in VALID_STATES:
|
||||
raise ReviewError(f"invalid review state {state!r}")
|
||||
text = (reason or "").strip()
|
||||
if state != "open" and not text:
|
||||
raise ReviewError("reason is required")
|
||||
rec = {
|
||||
"state": state,
|
||||
"reason": text,
|
||||
"user_id": user_id,
|
||||
"user_name": user_name,
|
||||
"updated_at": updated_at or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
next_states = dict(current)
|
||||
if state == "open":
|
||||
next_states.pop(finding_id, None)
|
||||
return next_states
|
||||
next_states[finding_id] = rec
|
||||
return next_states
|
||||
|
||||
|
||||
def _state_of(states: dict[str, dict[str, Any]], finding_id: str | None) -> str:
|
||||
if not finding_id:
|
||||
return "open"
|
||||
rec = states.get(finding_id)
|
||||
if not rec:
|
||||
return "open"
|
||||
return rec.get("state") or "open"
|
||||
|
||||
|
||||
def build_eco(
|
||||
findings: Iterable[Finding],
|
||||
review_states: dict[str, dict[str, Any]],
|
||||
) -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
for f in findings:
|
||||
fid = f.finding_id
|
||||
if _state_of(review_states, fid) != "accepted":
|
||||
continue
|
||||
rec = review_states.get(fid or "", {})
|
||||
items.append({
|
||||
"finding_id": fid or "",
|
||||
"rule_id": f.rule_id or "",
|
||||
"ref": f.designator,
|
||||
"before": f.finding,
|
||||
"after": f.recommendation or "",
|
||||
"reason": rec.get("reason") or "",
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def eco_csv(items: list[dict[str, str]]) -> str:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(
|
||||
buf,
|
||||
fieldnames=["finding_id", "rule_id", "ref", "before", "after", "reason"],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(items)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def sign_report(report: dict[str, Any], *, user_id: str, timestamp: str | None = None) -> dict[str, str]:
|
||||
payload = json.dumps(report.get("findings") or [], sort_keys=True, default=str)
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return {
|
||||
"sha256": digest,
|
||||
"user_id": user_id,
|
||||
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Power-good → enable sequencing when the IC specs declare a sequence.
|
||||
|
||||
No RC time constants are invented. Missing power_sequence means skip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
)
|
||||
from backend.periscopex.thermal_check import (
|
||||
_VIN_PIN,
|
||||
_VOUT_PIN,
|
||||
_is_ldo,
|
||||
_pin_net_by_role,
|
||||
_specs_values,
|
||||
)
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_PG_RE = re.compile(r"(?:^|[_/])(PG|PGOOD|PWRGD|POWER_GOOD|POK)(?:$|[_/\d])", re.I)
|
||||
_EN_RE = re.compile(
|
||||
r"(?:^|[_/])(EN|ENA|ENABLE|n?SHDN|nEN|EN_N)(?:$|[_/\d])",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _has_sequence(comp) -> bool:
|
||||
values = _specs_values(comp)
|
||||
raw = values.get("power_sequence")
|
||||
if raw is None or raw == "" or raw is False:
|
||||
return False
|
||||
if isinstance(raw, (int, float)) and raw == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_power_sequencing(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
cmap = constraints_map or {}
|
||||
regs: list[tuple] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||||
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||||
if not (vin and vout) and not _is_ldo(comp, cons):
|
||||
continue
|
||||
pg = _pin_net_by_role(graph, comp, cons, _PG_RE, exclude_re=None)
|
||||
en = _pin_net_by_role(graph, comp, cons, _EN_RE, exclude_re=None)
|
||||
regs.append((ref, comp, cons, vin, vout, pg, en))
|
||||
|
||||
findings: list[Finding] = []
|
||||
for dref, dcomp, dcons, dvin, _dvout, _dpg, den in regs:
|
||||
if not _has_sequence(dcomp):
|
||||
continue
|
||||
if not den or not dvin:
|
||||
continue
|
||||
upstream = [
|
||||
row for row in regs
|
||||
if row[0] != dref and row[4] and row[4] == dvin
|
||||
]
|
||||
if not upstream:
|
||||
continue
|
||||
uref, ucomp, _ucons, _uvin, _uvout, upg, _uen = upstream[0]
|
||||
if not upg:
|
||||
findings.append(Finding(
|
||||
designator=dref,
|
||||
mpn=dcomp.mpn or "",
|
||||
aspect="sequencing",
|
||||
source="sequencing_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{dref} specs declare power_sequence but upstream {uref} "
|
||||
f"has no PG pin feeding {dref} EN '{den}'."
|
||||
),
|
||||
why="Sequence was listed in IC specs; delay milliseconds were not estimated.",
|
||||
recommendation="Tie the upstream power-good to this enable, or remove the sequence spec if unused.",
|
||||
reference="power_sequence",
|
||||
net=den,
|
||||
pins=[dref, uref],
|
||||
rule_id="PE-SEQ-001",
|
||||
))
|
||||
continue
|
||||
if upg != den:
|
||||
findings.append(Finding(
|
||||
designator=dref,
|
||||
mpn=dcomp.mpn or "",
|
||||
aspect="sequencing",
|
||||
source="sequencing_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{uref} PG '{upg}' does not connect to {dref} EN '{den}'."
|
||||
),
|
||||
why="Declared power_sequence expects PG to enable the next rail.",
|
||||
recommendation="Net the upstream PG to the downstream EN.",
|
||||
reference="power_sequence",
|
||||
net=den,
|
||||
pins=[f"{uref}", f"{dref}"],
|
||||
rule_id="PE-SEQ-001",
|
||||
))
|
||||
return findings
|
||||
@@ -0,0 +1,91 @@
|
||||
"""G1 SI: intra-pair skew only when the datasheet gives millimetres.
|
||||
|
||||
Pair names (_DP/_DM, _P/_N) only identify which nets to compare. The
|
||||
limit is never 3W, USB spec folklore, or a default millimetre.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph, LayoutSegment
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_PAIR_SUFFIXES = (("_DP", "_DM"), ("_P", "_N"), ("+", "-"))
|
||||
|
||||
|
||||
def _seg_len(seg: LayoutSegment) -> float:
|
||||
return math.hypot(seg.end[0] - seg.start[0], seg.end[1] - seg.start[1])
|
||||
|
||||
|
||||
def net_length_mm(layout: LayoutGraph, net: str) -> float:
|
||||
return sum(_seg_len(s) for s in layout.segments if s.net == net)
|
||||
|
||||
|
||||
def partner_net(name: str) -> str | None:
|
||||
for a, b in _PAIR_SUFFIXES:
|
||||
if name.endswith(a):
|
||||
return name[: -len(a)] + b
|
||||
if name.endswith(b):
|
||||
return name[: -len(b)] + a
|
||||
return None
|
||||
|
||||
|
||||
def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None:
|
||||
for comp in graph.components.values():
|
||||
cons = _match_constraints(comp.mpn, constraints_map)
|
||||
if not cons:
|
||||
continue
|
||||
for rule in cons.layout_rules or []:
|
||||
if rule.get("kind") != "length_match":
|
||||
continue
|
||||
mm = rule.get("max_distance_mm")
|
||||
if mm is None:
|
||||
continue
|
||||
return float(mm), rule.get("source_page")
|
||||
return None
|
||||
|
||||
|
||||
def check_si(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict,
|
||||
layout: LayoutGraph | None,
|
||||
) -> list[Finding]:
|
||||
if layout is None or not layout.segments:
|
||||
return []
|
||||
limit = _length_match_limit_mm(constraints_map, graph)
|
||||
if limit is None:
|
||||
return []
|
||||
max_mm, page = limit
|
||||
seen: set[tuple[str, str]] = set()
|
||||
findings: list[Finding] = []
|
||||
names = {s.net for s in layout.segments if s.net}
|
||||
for net in names:
|
||||
partner = partner_net(net)
|
||||
if not partner or partner not in names:
|
||||
continue
|
||||
key = tuple(sorted((net, partner)))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner))
|
||||
if skew <= max_mm:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
designator="layout",
|
||||
mpn="",
|
||||
aspect="si",
|
||||
finding=(
|
||||
f"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} "
|
||||
f"(datasheet max {max_mm:g} mm)."
|
||||
),
|
||||
why=f"length_match max_distance_mm={max_mm:g}.",
|
||||
status="ERROR",
|
||||
recommendation="Length-match the differential pair.",
|
||||
source="si_check",
|
||||
rule_id="PE-SI-001",
|
||||
net=net,
|
||||
pins=[],
|
||||
source_page=page,
|
||||
))
|
||||
return findings
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Schematic thermal estimates for LDOs and dissipating resistors.
|
||||
|
||||
I_load is never inferred from Iout_max. θJA is never invented: missing
|
||||
theta_ja after a known P is INFO only. Ta defaults to 25 °C.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.led_current_check import (
|
||||
_leg_color,
|
||||
_net_voltage,
|
||||
_parse_resistance,
|
||||
_series_resistor,
|
||||
_vf,
|
||||
)
|
||||
from backend.periscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.periscopex.passive_rail_check import _pin_name_tokens
|
||||
from backend.periscopex.resolve_passives import _parse_spice_value
|
||||
from backend.periscopex.validate import _match_constraints
|
||||
|
||||
_TA_C = 25.0
|
||||
_TJ_WARN_C = 125.0
|
||||
_LOAD_KEYS = (
|
||||
"i_load", "i_load_a", "load_current_a", "typical_load_a",
|
||||
"iout_typical_a", "typical_output_current_a",
|
||||
)
|
||||
_IOUT_MAX_KEYS = (
|
||||
"iout_max", "iout_max_a", "i_out_max", "max_output_current_a",
|
||||
"output_current_max_a",
|
||||
)
|
||||
_THETA_KEYS = ("theta_ja", "theta_ja_c_per_w", "thermal_resistance_ja", "rth_ja")
|
||||
_VIN_PIN = re.compile(r"(?:^|[_/])(VIN|IN)(?:$|[_/\d])", re.I)
|
||||
_VOUT_PIN = re.compile(r"(?:^|[_/])(VOUT|V_OUT|VO|OUT)(?:$|[_/\d])", re.I)
|
||||
_NOT_OUT = re.compile(r"\b(EN|FB|NC|GND|PG)\b", re.I)
|
||||
|
||||
|
||||
def _num(v: object) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip()
|
||||
try:
|
||||
return _parse_spice_value(s)
|
||||
except ValueError:
|
||||
m = re.match(r"^[-+]?\d*\.?\d+", s)
|
||||
if m:
|
||||
try:
|
||||
return float(m.group(0))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _specs_values(comp: Component) -> dict:
|
||||
specs = comp.specs
|
||||
values = getattr(specs, "values", None) if specs else None
|
||||
return values if isinstance(values, dict) else {}
|
||||
|
||||
|
||||
def _first(values: dict, keys: tuple[str, ...]) -> float | None:
|
||||
for k in keys:
|
||||
if k in values:
|
||||
n = _num(values[k])
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _power_rating_w(comp: Component) -> float | None:
|
||||
specs = comp.specs
|
||||
if isinstance(specs, ResistorSpecs) and specs.power_rating_w:
|
||||
raw = specs.power_rating_w
|
||||
s = str(raw).strip().upper().replace("W", "")
|
||||
if "/" in s:
|
||||
try:
|
||||
a, b = s.split("/", 1)
|
||||
return float(a) / float(b)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return _num(raw) or _num(s)
|
||||
return None
|
||||
|
||||
|
||||
def _is_ldo(comp: Component, cons: ComponentConstraints | None) -> bool:
|
||||
sub = (comp.component_subtype or "") + " " + ((cons.component_subtype if cons else "") or "")
|
||||
if "ldo" in sub.lower() or "linear_regulator" in sub.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pin_net_by_role(
|
||||
graph: DesignGraph,
|
||||
comp: Component,
|
||||
cons: ComponentConstraints | None,
|
||||
role_re: re.Pattern,
|
||||
exclude_re: re.Pattern | None = _NOT_OUT,
|
||||
) -> str | None:
|
||||
for pin_num, net in comp.pins.items():
|
||||
tokens = _pin_name_tokens(cons, pin_num) or [pin_num]
|
||||
if any(
|
||||
role_re.search(t) and not (exclude_re and exclude_re.search(t))
|
||||
for t in tokens
|
||||
):
|
||||
return net
|
||||
if role_re.search(net or "") and not (exclude_re and exclude_re.search(net or "")):
|
||||
return net
|
||||
return None
|
||||
|
||||
|
||||
def check_thermal(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints] | None = None,
|
||||
) -> list[Finding]:
|
||||
cmap = constraints_map or {}
|
||||
findings: list[Finding] = []
|
||||
findings.extend(_ldo_thermal(graph, cmap))
|
||||
findings.extend(_resistor_thermal(graph))
|
||||
return findings
|
||||
|
||||
|
||||
def _ldo_thermal(
|
||||
graph: DesignGraph,
|
||||
cmap: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||||
if not _is_ldo(comp, cons):
|
||||
# VIN+VOUT names still count as a regulator for this check.
|
||||
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||||
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||||
if not (vin_n and vout_n):
|
||||
continue
|
||||
else:
|
||||
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||||
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||||
values = _specs_values(comp)
|
||||
i_load = _first(values, _LOAD_KEYS)
|
||||
if i_load is None:
|
||||
# Explicitly ignore Iout_max — that is not a load.
|
||||
continue
|
||||
vin = _net_voltage(graph, vin_n) if vin_n else None
|
||||
vout = _net_voltage(graph, vout_n) if vout_n else None
|
||||
if vin is None or vout is None or vin <= vout:
|
||||
continue
|
||||
p = i_load * (vin - vout)
|
||||
theta = _first(values, _THETA_KEYS)
|
||||
net = vout_n or vin_n
|
||||
if theta is None:
|
||||
out.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="thermal",
|
||||
source="thermal_check",
|
||||
status="INFO",
|
||||
finding=(
|
||||
f"{ref} dissipation ≈ {p:.3g} W "
|
||||
f"(I_load={i_load:.3g} A, Vin-Vout={vin - vout:.3g} V); "
|
||||
f"manca theta_ja."
|
||||
),
|
||||
why="θJA is not in the IC specs; Tj is not estimated.",
|
||||
recommendation="Add theta_ja (or θJA) from the datasheet package table.",
|
||||
reference="thermal estimate",
|
||||
net=net,
|
||||
pins=[ref],
|
||||
rule_id="PE-TH-001",
|
||||
))
|
||||
continue
|
||||
tj = _TA_C + p * theta
|
||||
status = "WARNING" if tj >= _TJ_WARN_C else "INFO"
|
||||
rule = "PE-TH-002" if status == "WARNING" else "PE-TH-001"
|
||||
out.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="thermal",
|
||||
source="thermal_check",
|
||||
status=status,
|
||||
finding=(
|
||||
f"{ref} Tj ≈ {tj:.0f} °C at Ta={_TA_C:.0f} °C "
|
||||
f"(P≈{p:.3g} W, θJA={theta:.3g} °C/W)."
|
||||
),
|
||||
why="P = I_load × (Vin−Vout); Tj = Ta + P·θJA. Iout_max was not used as load.",
|
||||
recommendation="Lower I_load, drop, or θJA (better copper / package) if Tj is high.",
|
||||
reference="thermal estimate",
|
||||
net=net,
|
||||
pins=[ref],
|
||||
rule_id=rule,
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _resistor_thermal(graph: DesignGraph) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for ref in sorted(graph.components_by_subtype("discrete.led")):
|
||||
led = graph.components.get(ref)
|
||||
if not led or not led.specs:
|
||||
continue
|
||||
values = getattr(led.specs, "values", None) or {}
|
||||
for pid, net in led.pins.items():
|
||||
res = _series_resistor(graph, net, ref)
|
||||
if not res:
|
||||
continue
|
||||
rref, rval, far = res
|
||||
if rref in seen:
|
||||
continue
|
||||
rcomp = graph.components.get(rref)
|
||||
rating = _power_rating_w(rcomp) if rcomp else None
|
||||
if rating is None:
|
||||
continue
|
||||
color = _leg_color(pid, led)
|
||||
vf = _vf(values, color)
|
||||
vrail = _net_voltage(graph, far)
|
||||
if vrail is None:
|
||||
vrail = max(
|
||||
(v for v in (_net_voltage(graph, n) for n in led.pins.values()) if v is not None),
|
||||
default=None,
|
||||
)
|
||||
if vrail is None or vf is None or vrail <= vf or rval <= 0:
|
||||
continue
|
||||
i = (vrail - vf) / rval
|
||||
p = i * i * rval
|
||||
if p <= rating:
|
||||
continue
|
||||
seen.add(rref)
|
||||
out.append(Finding(
|
||||
designator=rref,
|
||||
mpn=(rcomp.mpn if rcomp else "") or "",
|
||||
aspect="thermal",
|
||||
source="thermal_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{rref} dissipates ≈ {p:.3g} W on the LED path, "
|
||||
f"above its {rating:.3g} W rating."
|
||||
),
|
||||
why="P = I²R with I from (Vrail−Vf)/R. Rating comes from power_rating_w.",
|
||||
recommendation="Use a higher-wattage resistor or raise R to cut current.",
|
||||
reference="resistor power rating",
|
||||
net=net,
|
||||
pins=[rref],
|
||||
rule_id="PE-TH-003",
|
||||
))
|
||||
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if ref in seen or comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
rating = _power_rating_w(comp)
|
||||
ohms = None
|
||||
if isinstance(comp.specs, ResistorSpecs):
|
||||
ohms = float(comp.specs.value_ohms)
|
||||
if ohms is None:
|
||||
ohms = _parse_resistance(comp.value)
|
||||
if rating is None or ohms is None or ohms <= 0:
|
||||
continue
|
||||
nets = list(dict.fromkeys(comp.pins.values()))
|
||||
if len(nets) != 2:
|
||||
continue
|
||||
v1, v2 = _net_voltage(graph, nets[0]), _net_voltage(graph, nets[1])
|
||||
if v1 is None or v2 is None:
|
||||
continue
|
||||
dv = abs(v1 - v2)
|
||||
if dv <= 0:
|
||||
continue
|
||||
i = dv / ohms
|
||||
p = i * i * ohms
|
||||
if p <= rating:
|
||||
continue
|
||||
out.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="thermal",
|
||||
source="thermal_check",
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} shunt dissipates ≈ {p:.3g} W "
|
||||
f"(ΔV={dv:.3g} V / {ohms:.3g} Ω), above its {rating:.3g} W rating."
|
||||
),
|
||||
why="P = I²R with I = ΔV/R from known net voltages. No guessed current.",
|
||||
recommendation="Raise the wattage rating or the resistance.",
|
||||
reference="resistor power rating",
|
||||
net=nets[0],
|
||||
pins=[ref],
|
||||
rule_id="PE-TH-003",
|
||||
))
|
||||
return out
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared utility functions for the pinscopex core library."""
|
||||
"""Shared utility functions for the periscopex core library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,7 +20,7 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
@@ -28,8 +28,9 @@ from backend.pinscopex.models import (
|
||||
NetType,
|
||||
ValidationReport,
|
||||
)
|
||||
from backend.pinscopex.pin_function_tokens import parse_net_token
|
||||
from backend.pinscopex.validation_tools import (
|
||||
from backend.periscopex.pin_function_tokens import parse_net_token
|
||||
from backend.periscopex.quote_verify import verify_finding_citations
|
||||
from backend.periscopex.validation_tools import (
|
||||
ALL_TOOLS,
|
||||
SUBMIT_REVIEW_SCHEMA,
|
||||
ConstraintsMap,
|
||||
@@ -54,13 +55,21 @@ how it's wired in the actual circuit.
|
||||
Treat this IC as a COVERAGE CHECKLIST, not a single investigation. Before \
|
||||
hunting for problems, enumerate every focus area this IC has — derive them \
|
||||
from its pins, nets, neighbors, and subtype. A typical checklist:
|
||||
- Power & decoupling on each supply pin.
|
||||
- Power & decoupling on each supply pin — recommended Cin/Cout values, \
|
||||
ESR, and placement notes, not just "a cap is present".
|
||||
- Each signal interface to each connected component — voltage \
|
||||
compatibility, direction, and correct cross-connection (e.g. TX↔RX).
|
||||
- Absolute-maximum ratings on each pin vs. the actual rail driving it.
|
||||
- Absolute-maximum ratings on each pin vs. the actual rail driving it. \
|
||||
Use the extracted abs-max table in the component context when present; \
|
||||
confirm against the datasheet page if a number is missing or ambiguous.
|
||||
- Recommended operating conditions and electrical characteristics \
|
||||
(VIH/VIL, VOL/VOH, input leakage, drive strength) where they change \
|
||||
whether the interface actually works.
|
||||
- Reset / enable / boot / mode-strap / configuration pins.
|
||||
- Clock or crystal circuit, if present.
|
||||
- Required external components named by the datasheet.
|
||||
- Clock or crystal circuit, if present — load capacitors and the \
|
||||
datasheet's recommended values.
|
||||
- Required external components named by the datasheet (bootstrap, \
|
||||
compensation, feedback divider, sense resistor).
|
||||
- Unused / no-connect pins.
|
||||
|
||||
Then work the areas one at a time. For EACH area, don't just confirm a \
|
||||
@@ -127,8 +136,12 @@ INFO (worth noting but unlikely to cause problems).
|
||||
- **source_quote**: The exact verbatim sentence or clause from the datasheet \
|
||||
that states the requirement. Copy it precisely, character-for-character (a \
|
||||
short span, ~200 chars max) so it can be located and highlighted in the PDF. \
|
||||
Omit this field when the requirement is shown only in a figure or a \
|
||||
rasterized table with no selectable text — do not paraphrase or invent a quote.
|
||||
ERROR and WARNING findings **must** include this field. Periscope checks the \
|
||||
quote against the extracted text of the cited page (±1); invented or \
|
||||
paraphrased quotes are demoted to Unverified WARNING. Omit the field only \
|
||||
when the requirement is shown solely in a figure or a rasterized table with \
|
||||
no selectable text — then status is WARNING at most and `why` must start \
|
||||
with `Unverified:`.
|
||||
- **source_designator**: Leave unset when `source_page`/`source_quote` come \
|
||||
from THIS component's datasheet (the default). Set it to a connected \
|
||||
component's designator (e.g. `U3`) only when the page/quote come from that \
|
||||
@@ -231,10 +244,10 @@ whose purpose you have not identified.
|
||||
|
||||
### Budget per concern: cap ONE concern, not the whole review
|
||||
A single concern (one potential finding under investigation) gets at \
|
||||
most two follow-up tool calls beyond what was already in your initial \
|
||||
most three follow-up tool calls beyond what was already in your initial \
|
||||
context. If the concern is not resolved within that budget, submit it \
|
||||
as WARNING with `why` starting `Unverified: <what you could not \
|
||||
establish in two queries>` and move on to the next area. This per-concern \
|
||||
establish in three queries>` and move on to the next area. This per-concern \
|
||||
cap exists so one concern cannot swallow the whole review — NOT so you \
|
||||
finish early. Your total budget across all concerns is generous: spend it \
|
||||
on breadth. The failure mode to avoid is leaving focus areas of this IC \
|
||||
@@ -346,6 +359,27 @@ alternate-function list shown for peripheral-named-net pins is taken \
|
||||
verbatim from the datasheet pin table and is reliable even when the short \
|
||||
`(NAME)` label is not — prefer it when judging what a pin can be muxed to.
|
||||
|
||||
### ESD / TVS arrays — do not invent the diode topology
|
||||
An IO pin whose neighbor is GND (or whose pin name is IO/I/O) does NOT \
|
||||
mean a single steering diode from IO to GND that conducts at ~0.7 V. \
|
||||
Many 2-channel ESD arrays (audio, RS-232, RS-485) are *bidirectional \
|
||||
back-to-back* with a signed working voltage (Vrwm, often ±12 V or \
|
||||
±13 V). In that topology a 1 Vrms AC-coupled audio swing is inside the \
|
||||
standoff range and is not clipped.
|
||||
|
||||
Before claiming clipping, forward conduction, or "unidirectional clamp":
|
||||
1. Quote the datasheet topology (block diagram or "bidirectional" / \
|
||||
"unidirectional" / "back-to-back" wording) in `source_quote`.
|
||||
2. Quote Vrwm (or equivalent working-voltage row) with sign. Use that \
|
||||
number as the standoff, not a generic silicon Vf.
|
||||
3. If the block diagram or electrical table is unreadable, status is \
|
||||
WARNING at most and `why` must start with `Unverified:` — never ERROR \
|
||||
from "typical for this part" or from pin names alone.
|
||||
|
||||
A replacement recommendation must name a part whose topology matches \
|
||||
the signal (do not suggest a unidirectional array for a bipolar \
|
||||
AC-coupled audio net).
|
||||
|
||||
### Direction-control and transceiver function tables
|
||||
Bidirectional transceivers, level shifters, mux/demux, bus switches, and \
|
||||
analog switches (74xx245, 74xx125, 74xx157, TS3A-family, etc.) often \
|
||||
@@ -414,7 +448,7 @@ context if needed.
|
||||
|
||||
|
||||
# Maximum turns for the review agentic loop
|
||||
_MAX_REVIEW_TURNS = 10
|
||||
_MAX_REVIEW_TURNS = 16
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -449,6 +483,18 @@ def build_component_context(
|
||||
if constraints and constraints.package_info:
|
||||
pi = constraints.package_info
|
||||
lines.append(f"Package: {pi.package}, {pi.pin_count} pins")
|
||||
if constraints and constraints.absolute_maximum_ratings:
|
||||
lines.append("Extracted ratings (abs-max, plus Vrwm/polarity for ESD):")
|
||||
for r in constraints.absolute_maximum_ratings:
|
||||
bits = []
|
||||
if r.min is not None:
|
||||
bits.append(f"min {r.min:g}")
|
||||
if r.max is not None:
|
||||
bits.append(f"max {r.max:g}")
|
||||
span = " ".join(bits) if bits else "?"
|
||||
lines.append(
|
||||
f" {r.parameter}: {span} {r.unit} (datasheet p.{r.source_page})"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Build pin list — prefer extracted pintable order, fall back to netlist.
|
||||
@@ -765,7 +811,13 @@ def review_component(
|
||||
# Check for submit_review
|
||||
for block in response.content:
|
||||
if block.type == "tool_use" and block.name == "submit_review":
|
||||
return _parse_review(block.input, ic_ref, mpn)
|
||||
result = _parse_review(block.input, ic_ref, mpn)
|
||||
verify_finding_citations(
|
||||
result.findings,
|
||||
default_pdf=Path(pdf_path),
|
||||
default_mpn=mpn,
|
||||
)
|
||||
return result
|
||||
|
||||
# Process graph tool calls
|
||||
tool_results = []
|
||||
@@ -854,13 +906,24 @@ def _parse_review(
|
||||
else:
|
||||
src_designator = None
|
||||
src_mpn = mpn
|
||||
status = item["status"]
|
||||
why = str(item.get("why") or "")
|
||||
quote = str(item.get("source_quote") or "").strip()
|
||||
# ERROR/WARNING with no verbatim quote: demote before PDF check.
|
||||
if status in ("ERROR", "WARNING") and not quote:
|
||||
if status == "ERROR":
|
||||
status = "WARNING"
|
||||
if not why.startswith("Unverified:"):
|
||||
why = (
|
||||
"Unverified: no verbatim datasheet quote. " + why
|
||||
).strip()
|
||||
findings.append(Finding(
|
||||
designator=ic_ref,
|
||||
mpn=mpn,
|
||||
source_designator=src_designator,
|
||||
finding=item["finding"],
|
||||
why=item.get("why", ""),
|
||||
status=item["status"],
|
||||
why=why,
|
||||
status=status,
|
||||
source_page=page,
|
||||
source_quote=item.get("source_quote", ""),
|
||||
recommendation=item.get("recommendation", ""),
|
||||
@@ -933,7 +996,7 @@ def validate_design(
|
||||
model: str = "claude-sonnet-4-6",
|
||||
) -> ValidationReport:
|
||||
"""Load graph, review every IC against its datasheet, write report."""
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
raw = json.loads(Path(graph_path).read_text())
|
||||
graph = DesignGraph.model_validate(raw)
|
||||
@@ -13,11 +13,11 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
DesignGraph,
|
||||
)
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -256,6 +256,104 @@ def get_net_for_pin(
|
||||
return f"Pin {pin}{pin_name} on {designator} -> {net_name} [{net.net_type.value}{voltage_str}]"
|
||||
|
||||
|
||||
def shortest_path(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
designator_a: str,
|
||||
pin_a: str,
|
||||
designator_b: str,
|
||||
pin_b: str,
|
||||
*,
|
||||
max_hops: int = 12,
|
||||
) -> str:
|
||||
"""BFS through the bipartite graph from A.pin to B.pin.
|
||||
|
||||
Hops alternate component→net→component. Returns the hop list or a
|
||||
clear miss message. Caps depth so the reviewer cannot explode memory
|
||||
on dense power nets.
|
||||
"""
|
||||
a = graph.components.get(designator_a)
|
||||
b = graph.components.get(designator_b)
|
||||
if not a:
|
||||
return f"Component '{designator_a}' not found."
|
||||
if not b:
|
||||
return f"Component '{designator_b}' not found."
|
||||
|
||||
net_a = a.pins.get(str(pin_a))
|
||||
net_b = b.pins.get(str(pin_b))
|
||||
if not net_a:
|
||||
return f"Pin {pin_a} on {designator_a} is not connected in the netlist."
|
||||
if not net_b:
|
||||
return f"Pin {pin_b} on {designator_b} is not connected in the netlist."
|
||||
|
||||
if designator_a == designator_b and str(pin_a) == str(pin_b):
|
||||
return f"Same endpoint: {designator_a}.{pin_a} on {net_a}."
|
||||
|
||||
if net_a == net_b:
|
||||
return (
|
||||
f"Direct (same net): {designator_a}.{pin_a} —[{net_a}]— "
|
||||
f"{designator_b}.{pin_b}"
|
||||
)
|
||||
|
||||
# BFS on component nodes; edges are nets shared between components.
|
||||
from collections import deque
|
||||
|
||||
start = designator_a
|
||||
goal = designator_b
|
||||
queue: deque[str] = deque([start])
|
||||
# prev[ref] = (previous_ref, via_net)
|
||||
prev: dict[str, tuple[str, str] | None] = {start: None}
|
||||
hops = 0
|
||||
found = False
|
||||
while queue and hops < max_hops:
|
||||
hops += 1
|
||||
for _ in range(len(queue)):
|
||||
cur = queue.popleft()
|
||||
for net_name, others in graph.neighbors(cur).items():
|
||||
for other in others:
|
||||
if other in prev:
|
||||
continue
|
||||
prev[other] = (cur, net_name)
|
||||
if other == goal:
|
||||
found = True
|
||||
queue.clear()
|
||||
break
|
||||
queue.append(other)
|
||||
if found:
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found or goal not in prev:
|
||||
return (
|
||||
f"No path within {max_hops} hops from "
|
||||
f"{designator_a}.{pin_a} ({net_a}) to "
|
||||
f"{designator_b}.{pin_b} ({net_b})."
|
||||
)
|
||||
|
||||
# Reconstruct component chain, then decorate endpoints with pins.
|
||||
chain_refs: list[str] = []
|
||||
via_nets: list[str] = []
|
||||
node = goal
|
||||
while node != start:
|
||||
chain_refs.append(node)
|
||||
parent, via = prev[node] # type: ignore[misc]
|
||||
via_nets.append(via)
|
||||
node = parent
|
||||
chain_refs.append(start)
|
||||
chain_refs.reverse()
|
||||
via_nets.reverse()
|
||||
|
||||
parts: list[str] = [f"{designator_a}.{pin_a}"]
|
||||
for i, via in enumerate(via_nets):
|
||||
nxt = chain_refs[i + 1]
|
||||
if nxt == designator_b:
|
||||
parts.append(f"—[{via}]— {designator_b}.{pin_b}")
|
||||
else:
|
||||
parts.append(f"—[{via}]— {nxt}")
|
||||
return f"Path ({len(via_nets)} hop(s)): " + " ".join(parts)
|
||||
|
||||
|
||||
def get_pintable(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
@@ -317,18 +415,24 @@ def _resolve_neighbor_pdf(
|
||||
Mirrors validation._find_pdf's local-then-library lookup so neighbor
|
||||
datasheets follow the same resolution rules as the IC under review.
|
||||
"""
|
||||
safe = safe_mpn(mpn)
|
||||
local = state.pdf_dir / f"{safe}.pdf"
|
||||
if local.is_file():
|
||||
from backend.services.datasheet_finder import find_local_pdf
|
||||
|
||||
local = find_local_pdf(state.pdf_dir, mpn)
|
||||
if local is not None and local.is_file():
|
||||
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
|
||||
if local.resolve() != wanted.resolve() and not wanted.is_file():
|
||||
wanted.write_bytes(local.read_bytes())
|
||||
return wanted
|
||||
return local
|
||||
if state.storage is not None:
|
||||
try:
|
||||
from backend.services import projects as proj_svc
|
||||
lib_key = proj_svc.library_has_datasheet(state.storage, mpn)
|
||||
if lib_key:
|
||||
state.storage.download_to_local(lib_key, local)
|
||||
if local.is_file():
|
||||
return local
|
||||
wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
|
||||
state.storage.download_to_local(lib_key, wanted)
|
||||
if wanted.is_file():
|
||||
return wanted
|
||||
except Exception:
|
||||
log.exception("excerpt: library lookup failed for %s", mpn)
|
||||
return None
|
||||
@@ -405,7 +509,7 @@ def get_datasheet_excerpt(
|
||||
return ("get_datasheet_excerpt called without per-review state — "
|
||||
"this is a bug, no excerpt returned.", None)
|
||||
|
||||
# Lazy import to avoid backend↔pinscopex circular dependency at module load.
|
||||
# Lazy import to avoid backend↔periscopex circular dependency at module load.
|
||||
from backend.services.llm import PdfBlock
|
||||
|
||||
designator = (designator or "").strip()
|
||||
@@ -584,6 +688,38 @@ GET_NET_FOR_PIN_SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
SHORTEST_PATH_SCHEMA = {
|
||||
"name": "shortest_path",
|
||||
"description": (
|
||||
"Find the shortest hop path through the netlist between two pins "
|
||||
"(component.pin → nets → components). Use to verify whether two "
|
||||
"pins share a rail path, or how a signal reaches another IC, "
|
||||
"instead of guessing from neighborhood context."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"designator_a": {
|
||||
"type": "string",
|
||||
"description": "Start component reference, e.g. 'U1'",
|
||||
},
|
||||
"pin_a": {
|
||||
"type": "string",
|
||||
"description": "Start pin number, e.g. '12'",
|
||||
},
|
||||
"designator_b": {
|
||||
"type": "string",
|
||||
"description": "End component reference, e.g. 'U3'",
|
||||
},
|
||||
"pin_b": {
|
||||
"type": "string",
|
||||
"description": "End pin number, e.g. '5'",
|
||||
},
|
||||
},
|
||||
"required": ["designator_a", "pin_a", "designator_b", "pin_b"],
|
||||
},
|
||||
}
|
||||
|
||||
GET_PINTABLE_SCHEMA = {
|
||||
"name": "get_pintable",
|
||||
"description": (
|
||||
@@ -639,11 +775,10 @@ SUBMIT_REVIEW_SCHEMA = {
|
||||
"source_quote": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The exact verbatim text from the datasheet that "
|
||||
"states this requirement — copy it "
|
||||
"character-for-character (max ~200 chars). Omit "
|
||||
"if the evidence is only in a figure or a "
|
||||
"rasterized table with no selectable text."
|
||||
"Required for ERROR and WARNING. Exact verbatim "
|
||||
"datasheet text (max ~200 chars). Periscope "
|
||||
"checks it against the PDF page. Omit only if "
|
||||
"the evidence is a figure/scan with no text."
|
||||
),
|
||||
},
|
||||
"source_designator": {
|
||||
@@ -722,6 +857,7 @@ GET_DATASHEET_EXCERPT_SCHEMA = {
|
||||
GRAPH_TOOLS = [
|
||||
FIND_CONNECTED_COMPONENTS_SCHEMA,
|
||||
GET_NET_FOR_PIN_SCHEMA,
|
||||
SHORTEST_PATH_SCHEMA,
|
||||
GET_PINTABLE_SCHEMA,
|
||||
GET_DATASHEET_EXCERPT_SCHEMA,
|
||||
]
|
||||
@@ -765,6 +901,17 @@ def execute_tool(
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tool_name == "shortest_path":
|
||||
return (
|
||||
shortest_path(
|
||||
graph, constraints_map,
|
||||
tool_input["designator_a"],
|
||||
tool_input["pin_a"],
|
||||
tool_input["designator_b"],
|
||||
tool_input["pin_b"],
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tool_name == "get_pintable":
|
||||
return (
|
||||
get_pintable(
|
||||
@@ -81,11 +81,13 @@ async def _run() -> None:
|
||||
# are visible to any API instance tailing the event log.
|
||||
pipeline_svc.set_broker(event_bridge.GCSEventBroker(storage, user_id))
|
||||
|
||||
# Fresh runs wipe the prior event log so the SSE consumer doesn't
|
||||
# mix old events into the new run. Resume keeps the prior log so
|
||||
# users see the full history.
|
||||
if not resume:
|
||||
pipeline_svc.broker.clear_history(project_id)
|
||||
# Always wipe the prior event log. Reprocess uses resume=True, and
|
||||
# the old log still contains ``pipeline_complete``; the SSE tail
|
||||
# would stop there and the UI would show a finished run with no live
|
||||
# log while the worker is still reviewing. Pause-resume also hits a
|
||||
# terminal ``pipeline_paused``. A fresh seq from 0 is the only safe
|
||||
# option — completed_review_refs still skip paid ICs.
|
||||
pipeline_svc.broker.clear_history(project_id)
|
||||
|
||||
if mode == "run":
|
||||
await pipeline_svc.run_pipeline(
|
||||
@@ -99,8 +101,11 @@ async def _run() -> None:
|
||||
await pipeline_svc.run_regen_pipeline(
|
||||
storage, user_id, project_id, stages,
|
||||
)
|
||||
elif mode == "placement":
|
||||
from backend.services import placement_pipeline as placement_svc
|
||||
await placement_svc.run_placement_pipeline(storage, user_id, project_id)
|
||||
else:
|
||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run' or 'regen'")
|
||||
raise SystemExit(f"unknown MODE={mode!r}; expected 'run', 'regen', or 'placement'")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]
|
||||
openai>=1.60
|
||||
anthropic>=0.83
|
||||
google-genai>=1.59
|
||||
pydantic[email]>=2.0
|
||||
@@ -12,6 +13,10 @@ google-cloud-storage>=2.14
|
||||
google-cloud-run>=0.10
|
||||
google-api-python-client>=2.100
|
||||
pypdf>=4.0
|
||||
pymupdf>=1.24
|
||||
PyJWT[crypto]>=2.8
|
||||
cryptography>=42.0
|
||||
packaging>=23.0
|
||||
httpx>=0.27
|
||||
packaging>=24.0
|
||||
shapely>=2.0
|
||||
PyYAML>=6.0
|
||||
|
||||
+29
-84
@@ -15,7 +15,7 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.routers.deps import get_storage
|
||||
from backend.services import admin_settings as settings_svc
|
||||
from backend.services.billing_hook import get_billing
|
||||
@@ -41,6 +41,14 @@ async def is_admin(request: Request) -> bool:
|
||||
request.state._is_admin = True
|
||||
return True
|
||||
|
||||
if settings.use_local_auth:
|
||||
from backend.services import local_users
|
||||
|
||||
user = local_users.get_user(user_id)
|
||||
result = bool(user and user.is_admin)
|
||||
request.state._is_admin = result
|
||||
return result
|
||||
|
||||
# Fetch user from Clerk Backend API and check public_metadata.role
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -77,86 +85,13 @@ async def list_components(request: Request):
|
||||
"""List all extracted IC components and passive patterns in the library."""
|
||||
await _require_admin(request)
|
||||
storage = get_storage(request)
|
||||
|
||||
# IC extractions (deduplicate by MPN)
|
||||
ic_keys = [
|
||||
k for k in storage.list_prefix("library/extracted/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
ics = []
|
||||
seen_ic_mpns: set[str] = set()
|
||||
for key in ic_keys:
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ic_mpns:
|
||||
continue
|
||||
seen_ic_mpns.add(mpn)
|
||||
ics.append({
|
||||
"mpn": mpn,
|
||||
"type": "ic",
|
||||
"subtype": data.get("component_subtype", ""),
|
||||
"pin_count": len(data.get("pintable", [])),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Passive patterns
|
||||
pattern_keys = [
|
||||
k for k in storage.list_prefix("library/patterns/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
passives = []
|
||||
seen_passive_names: set[str] = set()
|
||||
for key in pattern_keys:
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if name in seen_passive_names:
|
||||
continue
|
||||
seen_passive_names.add(name)
|
||||
passives.append({
|
||||
"mpn": name,
|
||||
"type": "passive",
|
||||
"subtype": data.get("component_type", ""),
|
||||
"description": data.get("description", ""),
|
||||
"regex": data.get("regex", ""),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Simple component models (library/models/) + passive models (library/passives/)
|
||||
model_keys = [
|
||||
k for k in storage.list_prefix("library/models/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
passive_model_keys = [
|
||||
k for k in storage.list_prefix("library/passives/")
|
||||
if k.endswith(".json")
|
||||
]
|
||||
simple_models = []
|
||||
seen_model_mpns: set[str] = set()
|
||||
for key in model_keys + passive_model_keys:
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn", "")
|
||||
if mpn in seen_model_mpns:
|
||||
continue
|
||||
seen_model_mpns.add(mpn)
|
||||
specs = data.get("specs", {})
|
||||
simple_models.append({
|
||||
"mpn": mpn,
|
||||
"type": "simple",
|
||||
"specs_type": specs.get("specs_type", ""),
|
||||
"subtype": specs.get("component_subtype", ""),
|
||||
"param_count": len(specs.get("values", {})),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
catalog = proj_svc.list_library_catalog(storage)
|
||||
return JSONResponse(
|
||||
content={"ics": ics, "passives": passives, "simple": simple_models},
|
||||
content={
|
||||
"ics": catalog["ics"],
|
||||
"passives": catalog["passives"],
|
||||
"simple": catalog["simple"],
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@@ -562,13 +497,23 @@ async def list_running_pipelines(request: Request):
|
||||
if meta.status not in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING):
|
||||
continue
|
||||
|
||||
# Sweeper: if the execution is in a terminal Cloud Run state,
|
||||
# the worker is already gone. Flip status → error so the UI
|
||||
# stops lying. Skip the sweep when execution_name is missing
|
||||
# (worker may still be enqueueing).
|
||||
# Sweeper: if the execution is in a terminal Cloud Run / local
|
||||
# state, the worker is already gone. Flip status → error so the
|
||||
# UI stops lying. Also heal projects whose event log already
|
||||
# ends with pipeline_complete (finished, meta never flipped).
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, uid, meta.id)
|
||||
if healed is not None:
|
||||
continue
|
||||
|
||||
exec_state = "unknown"
|
||||
if meta.execution_name:
|
||||
exec_state = job_runner.get_execution_state(meta.execution_name)
|
||||
elif not job_runner.use_cloud_run_jobs():
|
||||
# Local zombie: no execution_name but a dead pid file, or
|
||||
# no live proc — treat as failed after the stale window.
|
||||
exec_state = job_runner.get_execution_state(
|
||||
f"local/projects/{meta.id}"
|
||||
)
|
||||
if exec_state in ("succeeded", "failed", "cancelled"):
|
||||
# Allow a short grace period so we don't race the worker
|
||||
# writing its own terminal status. updated may be stale
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Local Periscope auth endpoints (register / login / me)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services import local_jwt, local_users
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str = Field(min_length=8)
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
def _require_local_auth() -> None:
|
||||
if not settings.use_local_auth:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Local auth is not enabled. Set AUTH_JWT_SECRET on the server.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mode")
|
||||
async def auth_mode():
|
||||
"""Public: how the frontend should authenticate."""
|
||||
if settings.use_clerk:
|
||||
return {"mode": "clerk", "auth_enabled": True}
|
||||
if settings.use_local_auth:
|
||||
return {"mode": "local", "auth_enabled": True}
|
||||
return {"mode": "off", "auth_enabled": False}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(body: RegisterRequest):
|
||||
_require_local_auth()
|
||||
try:
|
||||
user = local_users.create_user(body.email, body.password, body.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
token = local_jwt.issue_token(user.user_id, user.email)
|
||||
return {"token": token, "user": user.public()}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginRequest):
|
||||
_require_local_auth()
|
||||
user = local_users.authenticate(body.email, body.password)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid email or password")
|
||||
token = local_jwt.issue_token(user.user_id, user.email)
|
||||
return {"token": token, "user": user.public()}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request):
|
||||
_require_local_auth()
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
if not user_id or user_id == "local" or user_id == "anonymous":
|
||||
raise HTTPException(401, "Authentication required")
|
||||
user = local_users.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(401, "User not found")
|
||||
return user.public()
|
||||
@@ -41,10 +41,10 @@ class ContactResponse(BaseModel):
|
||||
def _build_contact_message(data: ContactRequest) -> MIMEMultipart:
|
||||
"""Build the contact form email."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = settings.contact_recipient
|
||||
msg["Reply-To"] = data.email
|
||||
msg["Subject"] = f"[Pinscope Contact] {data.subject or 'New message'} from {data.name}"
|
||||
msg["Subject"] = f"[Periscope Contact] {data.subject or 'New message'} from {data.name}"
|
||||
|
||||
# Plain text
|
||||
lines = [
|
||||
@@ -55,7 +55,7 @@ def _build_contact_message(data: ContactRequest) -> MIMEMultipart:
|
||||
lines.append(f"Company: {data.company}")
|
||||
if data.subject:
|
||||
lines.append(f"Subject: {data.subject}")
|
||||
lines += ["", data.message, "", "— Sent from the Pinscope contact form"]
|
||||
lines += ["", data.message, "", "— Sent from the Periscope contact form"]
|
||||
msg.attach(MIMEText("\n".join(lines), "plain"))
|
||||
|
||||
# HTML
|
||||
@@ -94,7 +94,7 @@ def _build_contact_message(data: ContactRequest) -> MIMEMultipart:
|
||||
{rows}
|
||||
</table>
|
||||
<div style="margin-top: 16px; padding: 16px; background: #f9fafb; border-radius: 8px; font-size: 14px; line-height: 1.6; white-space: pre-wrap;">{message}</div>
|
||||
<p style="margin-top: 24px; font-size: 12px; color: #888;">Sent from the Pinscope contact form</p>
|
||||
<p style="margin-top: 24px; font-size: 12px; color: #888;">Sent from the Periscope contact form</p>
|
||||
</div>"""
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Impedance calculator and ImpedenceFinder analysis of PCB nets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import asdict
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.periscopex.impedance import (
|
||||
GeometryError,
|
||||
TraceGeometry,
|
||||
coupled_diff_z,
|
||||
cpw_z0,
|
||||
export_kicad_dru,
|
||||
microstrip_z0,
|
||||
solve_width,
|
||||
stackup_targets,
|
||||
stripline_z0,
|
||||
)
|
||||
from backend.periscopex.impedance_traces import (
|
||||
NET_WALK_PITCH_MM,
|
||||
analyze_specified_nets,
|
||||
)
|
||||
from backend.periscopex.antenna_rf import build_antenna_report, build_design_recipe
|
||||
from backend.periscopex.models import DesignGraph, LayoutGraph
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
from backend.routers.deps import get_storage, resolve_or_404
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
router = APIRouter(tags=["impedance"])
|
||||
|
||||
|
||||
class ImpedanceRequest(BaseModel):
|
||||
mode: Literal["trace", "stackup"] = "trace"
|
||||
kind: Literal["microstrip", "stripline", "cpw", "diff"] | None = None
|
||||
h: float
|
||||
er: float
|
||||
t: float = 0.035
|
||||
w: float | None = None
|
||||
s: float | None = None
|
||||
target_z: float | None = None
|
||||
|
||||
|
||||
def _z_for_kind(kind: str, geo: TraceGeometry) -> dict:
|
||||
if kind == "microstrip":
|
||||
return {"z0": microstrip_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
|
||||
if kind == "stripline":
|
||||
return {"z0": stripline_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
|
||||
if kind == "cpw":
|
||||
return {"z0": cpw_z0(geo), "w_mm": geo.w, "s_mm": geo.s, "kind": kind}
|
||||
if kind == "diff":
|
||||
zodd, zeven, zdiff = coupled_diff_z(geo)
|
||||
return {
|
||||
"z0": None,
|
||||
"zodd": zodd,
|
||||
"zeven": zeven,
|
||||
"zdiff": zdiff,
|
||||
"w_mm": geo.w,
|
||||
"s_mm": geo.s,
|
||||
"kind": kind,
|
||||
}
|
||||
raise GeometryError(f"unknown kind {kind}")
|
||||
|
||||
|
||||
@router.post("/impedance")
|
||||
def compute_impedance(body: ImpedanceRequest):
|
||||
try:
|
||||
if body.mode == "stackup":
|
||||
s = body.s if body.s is not None else 0.2
|
||||
targets = stackup_targets(h=body.h, er=body.er, t=body.t, s=s)
|
||||
return {
|
||||
"targets": {k: asdict(v) for k, v in targets.items()},
|
||||
"kicad_dru": export_kicad_dru(targets),
|
||||
}
|
||||
kind = body.kind or "microstrip"
|
||||
w = body.w
|
||||
if body.target_z is not None:
|
||||
w = solve_width(kind, body.target_z, body.h, body.er, body.t, s=body.s)
|
||||
geo = TraceGeometry(h=body.h, er=body.er, t=body.t, w=w, s=body.s)
|
||||
return _z_for_kind(kind, geo)
|
||||
except GeometryError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
class NetsRequest(BaseModel):
|
||||
nets: list[str]
|
||||
pitch_mm: float | None = None
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/impedance/nets")
|
||||
async def get_project_impedance_nets(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner, project_id)
|
||||
key = f"{prefix}/impedance_nets.json"
|
||||
if not storage.exists(key):
|
||||
return {"pitch_mm": NET_WALK_PITCH_MM, "nets": [], "skipped": "not run"}
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/impedance/nets")
|
||||
async def analyze_project_impedance_nets(
|
||||
project_id: str, body: NetsRequest, request: Request,
|
||||
):
|
||||
storage = get_storage(request)
|
||||
owner, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner, project_id)
|
||||
pcb_key = f"{prefix}/uploads/pcb.kicad_pcb"
|
||||
if not storage.exists(pcb_key):
|
||||
raise HTTPException(400, "No .kicad_pcb on this project")
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
|
||||
try:
|
||||
tmp.write(storage.read_bytes(pcb_key))
|
||||
tmp.close()
|
||||
layout = parse_kicad_pcb(tmp.name)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
pitch = body.pitch_mm if body.pitch_mm is not None else NET_WALK_PITCH_MM
|
||||
try:
|
||||
rows = analyze_specified_nets(layout, body.nets, pitch)
|
||||
except GeometryError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"pitch_mm": pitch, "nets": rows, "skipped": None}
|
||||
|
||||
|
||||
class AntennaDesignRequest(BaseModel):
|
||||
f0_mhz: float | None = None
|
||||
target_z_ohm: float = 50.0
|
||||
h: float | None = None
|
||||
er: float | None = None
|
||||
t: float | None = None
|
||||
template: Literal["ifa", "meander", "stub"] = "ifa"
|
||||
|
||||
|
||||
def _load_graph_layout(storage, prefix: str) -> tuple[DesignGraph | None, LayoutGraph | None, dict | None]:
|
||||
graph = None
|
||||
layout = None
|
||||
znets = None
|
||||
gk = f"{prefix}/design_graph.json"
|
||||
if storage.exists(gk):
|
||||
graph = DesignGraph.model_validate(storage.read_json(gk))
|
||||
lk = f"{prefix}/layout_graph.json"
|
||||
if storage.exists(lk):
|
||||
layout = LayoutGraph.model_validate(storage.read_json(lk))
|
||||
elif storage.exists(f"{prefix}/uploads/pcb.kicad_pcb"):
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
|
||||
try:
|
||||
tmp.write(storage.read_bytes(f"{prefix}/uploads/pcb.kicad_pcb"))
|
||||
tmp.close()
|
||||
layout = parse_kicad_pcb(tmp.name)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
zk = f"{prefix}/impedance_nets.json"
|
||||
if storage.exists(zk):
|
||||
znets = storage.read_json(zk)
|
||||
return graph, layout, znets
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/antenna")
|
||||
async def get_project_antenna(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner, project_id)
|
||||
graph, layout, znets = _load_graph_layout(storage, prefix)
|
||||
if graph is None:
|
||||
raise HTTPException(404, "design_graph.json not found — run analysis first")
|
||||
report = build_antenna_report(graph, layout, impedance_nets=znets)
|
||||
return report.model_dump(mode="json")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/antenna/design")
|
||||
async def post_project_antenna_design(
|
||||
project_id: str, body: AntennaDesignRequest, request: Request,
|
||||
):
|
||||
storage = get_storage(request)
|
||||
owner, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner, project_id)
|
||||
graph, layout, znets = _load_graph_layout(storage, prefix)
|
||||
if graph is None:
|
||||
raise HTTPException(404, "design_graph.json not found — run analysis first")
|
||||
report = build_antenna_report(
|
||||
graph,
|
||||
layout,
|
||||
impedance_nets=znets,
|
||||
f0_mhz=body.f0_mhz,
|
||||
target_z_ohm=body.target_z_ohm,
|
||||
h_mm=body.h,
|
||||
er=body.er,
|
||||
t_mm=body.t,
|
||||
template=body.template,
|
||||
)
|
||||
# Recompute design with explicit params (same as report.design but ensure POST body wins)
|
||||
report.design = build_design_recipe(
|
||||
graph,
|
||||
layout,
|
||||
f0_mhz=body.f0_mhz,
|
||||
target_z_ohm=body.target_z_ohm,
|
||||
h_mm=body.h,
|
||||
er=body.er,
|
||||
t_mm=body.t,
|
||||
template=body.template,
|
||||
)
|
||||
return report.model_dump(mode="json")
|
||||
+385
-16
@@ -16,6 +16,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from backend.routers.deps import get_storage, resolve_or_404
|
||||
from backend.services import event_bridge, job_runner
|
||||
@@ -24,12 +25,23 @@ from backend.services import projects as proj_svc
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_REGEN_STAGES = {"derating"}
|
||||
_REPROCESS_OK_FROM = frozenset({
|
||||
proj_svc.STATUS_COMPLETE,
|
||||
proj_svc.STATUS_ERROR,
|
||||
proj_svc.STATUS_CANCELLED,
|
||||
})
|
||||
|
||||
|
||||
class RegenRequest(BaseModel):
|
||||
stages: list[str]
|
||||
|
||||
|
||||
class ReprocessRequest(BaseModel):
|
||||
"""``failed`` retries skipped/errored reviews and ICs whose circuit
|
||||
neighborhood changed; ``all`` re-reviews every IC."""
|
||||
mode: Literal["failed", "all"] = "failed"
|
||||
|
||||
|
||||
router = APIRouter(tags=["pipeline"])
|
||||
|
||||
|
||||
@@ -72,7 +84,7 @@ async def start(project_id: str, request: Request):
|
||||
|
||||
# Idempotent enqueue: only one ``draft|complete|error|cancelled`` ->
|
||||
# ``queued`` transition can win. Concurrent /start clicks => 409.
|
||||
from backend._version import PINSCOPE_VERSION
|
||||
from backend._version import PERISCOPE_VERSION
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
@@ -80,7 +92,7 @@ async def start(project_id: str, request: Request):
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
pinscope_version=PINSCOPE_VERSION,
|
||||
periscope_version=PERISCOPE_VERSION,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline already running or queued")
|
||||
@@ -179,6 +191,86 @@ async def resume(project_id: str, request: Request):
|
||||
return {"status": "resumed", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/reprocess", status_code=202)
|
||||
async def reprocess(project_id: str, request: Request, req: ReprocessRequest | None = None):
|
||||
"""Re-run a finished project without the create wizard.
|
||||
|
||||
Keeps BOM, netlist, datasheets, and library extractions. ``failed``
|
||||
(default) skips ICs that already produced a review; ``all`` re-reviews
|
||||
every IC.
|
||||
"""
|
||||
from backend._version import PERISCOPE_VERSION
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before reprocessing")
|
||||
|
||||
if _project_active(meta):
|
||||
await _interrupt_active_pipeline(storage, owner_user_id, project_id)
|
||||
meta = proj_svc.get_project(storage, owner_user_id, project_id) or meta
|
||||
|
||||
if meta.status == proj_svc.STATUS_PAUSED:
|
||||
allowed = _REPROCESS_OK_FROM | {proj_svc.STATUS_PAUSED}
|
||||
else:
|
||||
allowed = _REPROCESS_OK_FROM
|
||||
|
||||
if meta.status not in allowed and not _project_active(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot reprocess from status={meta.status}.",
|
||||
)
|
||||
|
||||
body = req or ReprocessRequest()
|
||||
retry_failed = body.mode == "failed"
|
||||
keep_refs = (
|
||||
proj_svc.completed_review_refs_for_retry(storage, owner_user_id, project_id)
|
||||
if retry_failed else []
|
||||
)
|
||||
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, owner_user_id, project_id,
|
||||
from_status=allowed | {
|
||||
proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING,
|
||||
},
|
||||
to_status=proj_svc.STATUS_QUEUED,
|
||||
cancel_requested=False,
|
||||
execution_name=None,
|
||||
pipeline_state=None,
|
||||
pause_checkpoint=None,
|
||||
pause_reason=None,
|
||||
completed_review_refs=keep_refs,
|
||||
periscope_version=PERISCOPE_VERSION,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
raise HTTPException(409, "Pipeline already running or queued")
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_pipeline(
|
||||
project_id, owner_user_id, resume=retry_failed, free=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_pipeline (reprocess) failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
status=proj_svc.STATUS_ERROR,
|
||||
pipeline_state={"error": "Failed to enqueue worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue pipeline worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id, execution_name=execution_name,
|
||||
)
|
||||
return {
|
||||
"status": "reprocess_started",
|
||||
"project_id": project_id,
|
||||
"mode": body.mode,
|
||||
"resume": retry_failed,
|
||||
"kept_review_refs": keep_refs,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/restart", status_code=202)
|
||||
async def restart(project_id: str, request: Request):
|
||||
"""Admin-only: wipe per-project extractions and run the pipeline free."""
|
||||
@@ -293,6 +385,12 @@ async def regen(project_id: str, req: RegenRequest, request: Request):
|
||||
|
||||
|
||||
_EXEC_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
||||
_ANALYSIS_SSE_TERMINAL = frozenset({
|
||||
"pipeline_complete",
|
||||
"pipeline_error",
|
||||
"pipeline_cancelled",
|
||||
"pipeline_paused",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/events")
|
||||
@@ -300,20 +398,28 @@ async def events(project_id: str, request: Request):
|
||||
"""SSE stream of pipeline progress events.
|
||||
|
||||
Tails the GCS-backed event log written by the worker. Stops on
|
||||
terminal events as today, but also has two hard-crash escape
|
||||
hatches: the project's status reaching a terminal value, and the
|
||||
Cloud Run execution reaching a terminal state. Either of those
|
||||
triggers a synthetic ``pipeline_error`` so the SSE doesn't hang
|
||||
forever when the worker dies without writing its terminal event.
|
||||
analysis terminal events, but also has two hard-crash escape
|
||||
hatches: the project's status reaching a terminal value *after*
|
||||
having been active, and the Cloud Run execution reaching a
|
||||
terminal state. Either of those triggers a synthetic
|
||||
``pipeline_error`` so the SSE doesn't hang forever when the worker
|
||||
dies without writing its terminal event.
|
||||
"""
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.execution_name
|
||||
# Drive the GCS tail and the escape-hatch poll concurrently. The
|
||||
# tail yields events; the escape hatch flips a flag.
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
# Only treat a terminal status as a crash if we observed the
|
||||
# project as queued/running first — otherwise a finished project
|
||||
# reconnecting to /events would immediately synthesize an error
|
||||
# (or race with a historical pipeline_complete replay).
|
||||
active_state = {
|
||||
"saw": meta.status in (
|
||||
proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING,
|
||||
),
|
||||
}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
@@ -325,13 +431,18 @@ async def events(project_id: str, request: Request):
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if cur.status in proj_svc.TERMINAL_STATUSES:
|
||||
if cur.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING):
|
||||
active_state["saw"] = True
|
||||
elif active_state["saw"] and cur.status in proj_svc.TERMINAL_STATUSES:
|
||||
crash_detected["reason"] = (
|
||||
f"project status={cur.status} (terminal)"
|
||||
)
|
||||
return
|
||||
# Cloud Run hard-crash detection
|
||||
if execution_name:
|
||||
if execution_name and (
|
||||
active_state["saw"]
|
||||
or cur.status in (proj_svc.STATUS_QUEUED, proj_svc.STATUS_RUNNING)
|
||||
):
|
||||
try:
|
||||
state = job_runner.get_execution_state(execution_name)
|
||||
except Exception:
|
||||
@@ -346,14 +457,19 @@ async def events(project_id: str, request: Request):
|
||||
try:
|
||||
async for msg in event_bridge.tail_events(
|
||||
storage, owner_user_id, project_id,
|
||||
terminal_events=_ANALYSIS_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip placement events in the shared log.
|
||||
if ev.startswith("placement_"):
|
||||
continue
|
||||
yield {
|
||||
"event": msg["event"],
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if msg["event"] in event_bridge.TERMINAL_EVENTS:
|
||||
if ev in _ANALYSIS_SSE_TERMINAL:
|
||||
return
|
||||
|
||||
# tail_events exited without a terminal event — escape hatch
|
||||
@@ -380,26 +496,279 @@ async def events(project_id: str, request: Request):
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return EventSourceResponse(event_generator())
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
ping=15,
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/status")
|
||||
async def status(project_id: str, request: Request):
|
||||
"""Polling fallback — returns current project state."""
|
||||
_, meta = await resolve_or_404(request, project_id)
|
||||
"""Polling fallback — returns current project state.
|
||||
|
||||
Also heals zombie ``running``/``queued`` projects whose event log
|
||||
already ends with ``pipeline_complete`` (worker died after finishing).
|
||||
"""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id)
|
||||
if healed is not None:
|
||||
meta = healed
|
||||
return {
|
||||
"status": meta.status,
|
||||
"summary": meta.summary,
|
||||
"pipeline_state": meta.pipeline_state,
|
||||
"running": meta.status in (proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED),
|
||||
"placement_status": meta.placement_status,
|
||||
"placement_state": meta.placement_state,
|
||||
"placement_running": (meta.placement_status or "draft") in ("queued", "running"),
|
||||
"healed": healed is not None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placement pipeline (parallel — topology only, no LLM / no credits)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_PLACEMENT_START_OK = frozenset({"draft", "complete", "error", "cancelled"})
|
||||
_PLACEMENT_SSE_TERMINAL = frozenset({
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/placement/start", status_code=202)
|
||||
async def start_placement(project_id: str, request: Request):
|
||||
"""Enqueue the Placement topology pipeline (free, no analysis status change)."""
|
||||
from backend.services.placement_pipeline import analysis_busy, placement_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not meta.has_bom or not meta.has_netlist:
|
||||
raise HTTPException(400, "Upload BOM and netlist before starting placement")
|
||||
if analysis_busy(meta):
|
||||
raise HTTPException(409, "Analysis pipeline is running; wait or cancel it first")
|
||||
if placement_busy(meta):
|
||||
raise HTTPException(409, "Placement pipeline already running or queued")
|
||||
if (meta.placement_status or "draft") not in _PLACEMENT_START_OK:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot start placement from placement_status={meta.placement_status}",
|
||||
)
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_status="queued",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=None,
|
||||
placement_execution_name=None,
|
||||
)
|
||||
# Clear before enqueue so the placement SSE client never stops on a
|
||||
# leftover analysis ``pipeline_complete`` in the shared event log.
|
||||
try:
|
||||
event_bridge.GCSEventBroker(storage, owner_user_id).clear_history(project_id)
|
||||
except Exception:
|
||||
logger.exception("failed to clear events before placement start for %s", project_id)
|
||||
|
||||
try:
|
||||
execution_name = job_runner.enqueue_placement_pipeline(
|
||||
project_id, owner_user_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("enqueue_placement_pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_state={"error": "Failed to enqueue placement worker"},
|
||||
)
|
||||
raise HTTPException(503, "Failed to enqueue placement worker; please retry")
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_execution_name=execution_name,
|
||||
)
|
||||
return {"status": "started", "project_id": project_id}
|
||||
|
||||
|
||||
@router.post("/pipeline/{project_id}/placement/cancel")
|
||||
async def cancel_placement(project_id: str, request: Request):
|
||||
"""Soft-cancel the Placement pipeline via ``placement_cancel_requested``."""
|
||||
from backend.services.placement_pipeline import placement_busy
|
||||
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
if not placement_busy(meta):
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Placement is not running (placement_status={meta.placement_status})",
|
||||
)
|
||||
proj_svc.update_project(
|
||||
storage, owner_user_id, project_id,
|
||||
placement_cancel_requested=True,
|
||||
)
|
||||
return {"status": "cancel_requested", "project_id": project_id}
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/plan")
|
||||
async def get_placement_plan(project_id: str, request: Request):
|
||||
"""Return ``placement_plan.json`` (F1 topology — no coordinates)."""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/placement_plan.json"
|
||||
if not storage.exists(key):
|
||||
# Fallback for plans written only as functional_groups during analysis.
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/functional_groups.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Placement plan not found — run placement first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/pack")
|
||||
async def get_placement_pack(project_id: str, request: Request):
|
||||
"""Return ``placement_pack.json`` (F2 — skipped without PCB + numeric rules)."""
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/placement_pack.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Placement pack not found — run placement first")
|
||||
return storage.read_json(key)
|
||||
|
||||
|
||||
@router.get("/pipeline/{project_id}/placement/events")
|
||||
async def placement_events(project_id: str, request: Request):
|
||||
"""SSE stream for Placement pipeline progress (watches placement_* only)."""
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
|
||||
async def event_generator():
|
||||
execution_name = meta.placement_execution_name
|
||||
crash_detected: dict[str, str | None] = {"reason": None}
|
||||
|
||||
async def watch_status() -> None:
|
||||
poll_interval = 2.0
|
||||
saw_active = (meta.placement_status or "draft") in ("queued", "running")
|
||||
while True:
|
||||
await asyncio.sleep(poll_interval)
|
||||
try:
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
except Exception:
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
pst = cur.placement_status or "draft"
|
||||
if pst in ("queued", "running"):
|
||||
saw_active = True
|
||||
elif saw_active and pst in ("complete", "error", "cancelled"):
|
||||
# Worker wrote terminal status; if SSE missed the event,
|
||||
# surface a synthetic terminal after a short grace.
|
||||
crash_detected["reason"] = f"placement_status={pst} (terminal)"
|
||||
return
|
||||
if execution_name:
|
||||
try:
|
||||
state = job_runner.get_execution_state(execution_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
if state in _EXEC_TERMINAL and (
|
||||
saw_active or pst in ("queued", "running")
|
||||
):
|
||||
crash_detected["reason"] = f"execution state={state}"
|
||||
return
|
||||
|
||||
watcher = asyncio.create_task(watch_status())
|
||||
try:
|
||||
async for msg in event_bridge.tail_events(
|
||||
storage, owner_user_id, project_id,
|
||||
terminal_events=_PLACEMENT_SSE_TERMINAL,
|
||||
):
|
||||
if crash_detected["reason"] is not None:
|
||||
break
|
||||
ev = msg["event"]
|
||||
# Skip leftover analysis events if the log was not cleared yet.
|
||||
if not (
|
||||
ev.startswith("placement_")
|
||||
or ev == "heartbeat"
|
||||
):
|
||||
continue
|
||||
yield {
|
||||
"event": ev,
|
||||
"data": json.dumps(msg.get("data", {})),
|
||||
}
|
||||
if ev in _PLACEMENT_SSE_TERMINAL:
|
||||
return
|
||||
|
||||
if crash_detected["reason"] is not None:
|
||||
cur = proj_svc.get_project(storage, owner_user_id, project_id)
|
||||
err = None
|
||||
if cur and cur.placement_state:
|
||||
err = cur.placement_state.get("error")
|
||||
yield {
|
||||
"event": "placement_error",
|
||||
"data": json.dumps({
|
||||
"error": err or crash_detected["reason"]
|
||||
or "placement worker terminated without a terminal event",
|
||||
"synthetic": True,
|
||||
}),
|
||||
}
|
||||
finally:
|
||||
watcher.cancel()
|
||||
try:
|
||||
await watcher
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
ping=15,
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _interrupt_active_pipeline(
|
||||
storage, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
"""Cancel a queued/running worker so a new run can be enqueued.
|
||||
|
||||
If the worker is already dead (docker rebuild, OOM) the status can
|
||||
stay ``running``; force it to cancelled after a short wait.
|
||||
"""
|
||||
proj_svc.request_cancel(storage, user_id, project_id)
|
||||
await _await_terminal(storage, user_id, project_id, timeout_s=10.0)
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
if meta is None:
|
||||
return
|
||||
if _project_active(meta) and meta.execution_name:
|
||||
job_runner.cancel_execution(meta.execution_name)
|
||||
await _await_terminal(storage, user_id, project_id, timeout_s=5.0)
|
||||
meta = proj_svc.get_project(storage, user_id, project_id) or meta
|
||||
if _project_active(meta):
|
||||
try:
|
||||
proj_svc.transition_status(
|
||||
storage, user_id, project_id,
|
||||
from_status={proj_svc.STATUS_RUNNING, proj_svc.STATUS_QUEUED},
|
||||
to_status=proj_svc.STATUS_CANCELLED,
|
||||
pipeline_state={"error": "Superseded by reprocess"},
|
||||
cancel_requested=False,
|
||||
)
|
||||
except proj_svc.StatusConflict:
|
||||
pass
|
||||
|
||||
|
||||
async def _await_terminal(
|
||||
storage, user_id: str, project_id: str, *, timeout_s: float,
|
||||
) -> None:
|
||||
|
||||
+256
-106
@@ -1,20 +1,49 @@
|
||||
"""Project CRUD and file upload endpoints."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 # 30 MB
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
router = APIRouter(tags=["projects"])
|
||||
|
||||
|
||||
def _bom_file_to_csv_bytes(path: Path) -> bytes | None:
|
||||
"""Normalize a BOM found inside a KiCad zip/folder to CSV bytes."""
|
||||
suffix = path.suffix.lower()
|
||||
raw = path.read_bytes()
|
||||
if suffix == ".csv":
|
||||
return raw
|
||||
if suffix == ".xlsx":
|
||||
try:
|
||||
import csv as csv_mod
|
||||
import io
|
||||
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
out = io.StringIO()
|
||||
writer = csv_mod.writer(out)
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
writer.writerow([("" if c is None else str(c)) for c in row])
|
||||
wb.close()
|
||||
return out.getvalue().encode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# --- Library check ---
|
||||
|
||||
|
||||
@@ -34,7 +63,7 @@ async def check_library(req: LibraryCheckRequest, request: Request):
|
||||
|
||||
passive_resolved: list[str] = []
|
||||
if req.passive_mpns:
|
||||
from backend.pinscopex.resolve_passives import resolve_mpn
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
|
||||
passive_resolved = [
|
||||
mpn for mpn in req.passive_mpns
|
||||
@@ -59,6 +88,31 @@ async def check_library(req: LibraryCheckRequest, request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/library")
|
||||
async def get_library(request: Request):
|
||||
"""List chips, passives, discrete specs, and datasheets in the shared library."""
|
||||
storage = get_storage(request)
|
||||
return JSONResponse(
|
||||
content=proj_svc.list_library_catalog(storage),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/library/datasheet/{mpn:path}")
|
||||
async def get_library_datasheet(mpn: str, request: Request):
|
||||
"""Stream a datasheet PDF from the shared library."""
|
||||
storage = get_storage(request)
|
||||
key = proj_svc.library_has_datasheet(storage, mpn)
|
||||
if not key:
|
||||
raise HTTPException(404, f"Datasheet not in library: {mpn}")
|
||||
data = storage.read_bytes(key)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
@@ -86,7 +140,14 @@ async def list_projects(request: Request):
|
||||
|
||||
@router.get("/projects/{project_id}")
|
||||
async def get_project(project_id: str, request: Request):
|
||||
_, meta = await resolve_or_404(request, project_id)
|
||||
storage = get_storage(request)
|
||||
owner_user_id, meta = await resolve_or_404(request, project_id)
|
||||
healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id)
|
||||
if healed is not None:
|
||||
meta = healed
|
||||
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
|
||||
if healed_pl is not None:
|
||||
meta = healed_pl
|
||||
return meta.model_dump()
|
||||
|
||||
|
||||
@@ -195,7 +256,7 @@ async def get_netlist_subdesigns(project_id: str, request: Request):
|
||||
``selected`` list (None = "include everything") so the wizard can render
|
||||
the picker pre-populated.
|
||||
"""
|
||||
from backend.pinscopex.parsers_edif import list_edif_subdesigns
|
||||
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
||||
import tempfile, os
|
||||
|
||||
storage = get_storage(request)
|
||||
@@ -299,7 +360,7 @@ async def upload_bom(
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
from backend.periscopex.parsers import parse_bom
|
||||
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
||||
@@ -359,7 +420,7 @@ async def upload_bom(
|
||||
# simple → datasheet upload). Mirrors the bucket logic in
|
||||
# services/pipeline.py:_stage_bom_parse so the field is correct after
|
||||
# either path runs.
|
||||
from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
from backend.periscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
|
||||
ic_mpns: list[str] = []
|
||||
passive_mpns: list[str] = []
|
||||
@@ -437,48 +498,99 @@ async def upload_bom(
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/upload/netlist")
|
||||
async def upload_netlist(project_id: str, file: UploadFile, request: Request):
|
||||
async def upload_netlist(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
file: UploadFile | None = File(default=None),
|
||||
files: list[UploadFile] | None = File(default=None),
|
||||
paths: str | None = Form(default=None),
|
||||
):
|
||||
storage = get_storage(request)
|
||||
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
||||
if not result:
|
||||
raise HTTPException(404, "Project not found")
|
||||
user_id = result[0] # owner_user_id for storage paths
|
||||
data = await file.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
|
||||
|
||||
# Auto-detect PADS vs EDIF from the file's first bytes — users don't pick
|
||||
# a format, the wizard accepts either.
|
||||
from backend.pinscopex.parsers import (
|
||||
detect_netlist_format, parse_netlist_any, validate_netlist,
|
||||
)
|
||||
from backend.pinscopex.parsers_edif import list_edif_subdesigns
|
||||
import tempfile, os
|
||||
from backend.periscopex.netlist_bundle import materialize_netlist_upload
|
||||
from backend.periscopex.parsers import parse_netlist_any, validate_netlist
|
||||
from backend.periscopex.parsers_edif import list_edif_subdesigns
|
||||
import tempfile
|
||||
|
||||
blobs: list[tuple[str, bytes]] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
uploads = list(files or []) if files else ([file] if file is not None else [])
|
||||
rels: list[str] | None = None
|
||||
if paths:
|
||||
try:
|
||||
parsed_paths = json.loads(paths)
|
||||
except json.JSONDecodeError:
|
||||
parsed_paths = None
|
||||
if isinstance(parsed_paths, list) and all(isinstance(x, str) for x in parsed_paths):
|
||||
rels = parsed_paths
|
||||
for i, uf in enumerate(uploads):
|
||||
data = await uf.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
413,
|
||||
f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)",
|
||||
)
|
||||
name = (
|
||||
rels[i]
|
||||
if rels is not None and i < len(rels)
|
||||
else (uf.filename or "netlist")
|
||||
)
|
||||
mark = (name, len(data))
|
||||
if mark in seen:
|
||||
continue
|
||||
seen.add(mark)
|
||||
blobs.append((name, data))
|
||||
if not blobs:
|
||||
raise HTTPException(400, "No netlist file uploaded")
|
||||
|
||||
fmt = detect_netlist_format(data)
|
||||
suffix = ".edn" if fmt == "edif" else ".asc"
|
||||
sub_designs: list[dict] = []
|
||||
bom_saved = False
|
||||
pcb_saved = False
|
||||
sheets = 1
|
||||
try:
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
parts, nets, _ = parse_netlist_any(tmp.name)
|
||||
# For EDIF, also surface the sub-design layout so the wizard can
|
||||
# decide whether to prompt the user. Cheap second parse — same file.
|
||||
if fmt == "edif":
|
||||
sub_designs = list_edif_subdesigns(tmp.name)
|
||||
os.unlink(tmp.name)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
|
||||
parts, nets, fmt = parse_netlist_any(parsed.root)
|
||||
if fmt == "edif":
|
||||
sub_designs = list_edif_subdesigns(parsed.root)
|
||||
issues = validate_netlist(parts, nets)
|
||||
if issues:
|
||||
raise ValueError("; ".join(issues))
|
||||
root_bytes = parsed.root.read_bytes()
|
||||
key = proj_svc.save_netlist(storage, user_id, project_id, root_bytes, fmt=fmt)
|
||||
if fmt == "kicad_sch":
|
||||
proj_svc.save_companion_sheets(
|
||||
storage, user_id, project_id, parsed.root, parsed.extra_sch,
|
||||
)
|
||||
sheets = 1 + len(parsed.extra_sch)
|
||||
else:
|
||||
proj_svc.clear_companion_sheets(storage, user_id, project_id)
|
||||
if parsed.pcb is not None:
|
||||
proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes())
|
||||
pcb_saved = True
|
||||
if parsed.bom is not None:
|
||||
bom_bytes = _bom_file_to_csv_bytes(parsed.bom)
|
||||
if bom_bytes:
|
||||
proj_svc.save_bom(storage, user_id, project_id, bom_bytes)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
bom_columns={
|
||||
"reference": "Reference",
|
||||
"mpn": "Manufacturer Part Number",
|
||||
},
|
||||
)
|
||||
bom_saved = True
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid netlist: {e}")
|
||||
issues = validate_netlist(parts, nets)
|
||||
if issues:
|
||||
raise HTTPException(400, f"Netlist failed sanity check: {'; '.join(issues)}")
|
||||
key = proj_svc.save_netlist(storage, user_id, project_id, data, fmt=fmt)
|
||||
# EDIF: emit a designator→pins preview matching the PADS browser-side
|
||||
# shape, so the wizard's power-sources step can render its dropdowns
|
||||
# without re-parsing the (s-expression-heavy) file in the browser.
|
||||
raise HTTPException(400, f"Netlist failed sanity check: {e}") from e
|
||||
|
||||
designator_pins: list[dict] = []
|
||||
if fmt == "edif":
|
||||
if fmt != "pads":
|
||||
designator_pins = _build_designator_pins(parts, nets)
|
||||
return {
|
||||
"path": key,
|
||||
@@ -487,6 +599,40 @@ async def upload_netlist(project_id: str, file: UploadFile, request: Request):
|
||||
"format": fmt,
|
||||
"sub_designs": sub_designs,
|
||||
"designator_pins": designator_pins,
|
||||
"pcb_saved": pcb_saved,
|
||||
"bom_saved": bom_saved,
|
||||
"sheets": sheets,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/upload/pcb")
|
||||
async def upload_pcb(project_id: str, file: UploadFile, request: Request):
|
||||
storage = get_storage(request)
|
||||
result = proj_svc.resolve_project_access(storage, get_user_id(request), project_id)
|
||||
if not result:
|
||||
raise HTTPException(404, "Project not found")
|
||||
user_id = result[0]
|
||||
data = await file.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, f"File too large (max {MAX_UPLOAD_BYTES // 1024 // 1024} MB)")
|
||||
import tempfile, os
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kicad_pcb")
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
layout = parse_kicad_pcb(tmp.name)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid KiCad PCB: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
key = proj_svc.save_pcb(storage, user_id, project_id, data)
|
||||
return {
|
||||
"path": key,
|
||||
"footprints": len(layout.footprints),
|
||||
"nets": len(layout.nets),
|
||||
"segments": len(layout.segments),
|
||||
}
|
||||
|
||||
|
||||
@@ -501,7 +647,7 @@ def _build_designator_pins(
|
||||
(natural sort on refs and on pin numbers) so the wizard's dropdowns
|
||||
look identical regardless of netlist format.
|
||||
"""
|
||||
from backend.pinscopex.utils import natural_sort_key
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
|
||||
by_ref: dict[str, dict[str, str]] = {ref: {} for ref in parts}
|
||||
for net_name, pins in nets.items():
|
||||
@@ -577,27 +723,17 @@ async def list_collaborators(project_id: str, request: Request):
|
||||
all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id]
|
||||
collaborators = []
|
||||
if settings.use_auth:
|
||||
async with httpx.AsyncClient() as client:
|
||||
for uid in all_user_ids:
|
||||
entry: dict = {"user_id": uid, "name": None, "email": None, "image_url": None,
|
||||
"role": "owner" if uid == owner_user_id else "collaborator"}
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"https://api.clerk.com/v1/users/{uid}",
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
clerk = resp.json()
|
||||
first = clerk.get("first_name") or ""
|
||||
last = clerk.get("last_name") or ""
|
||||
entry["name"] = f"{first} {last}".strip() or None
|
||||
emails = clerk.get("email_addresses", [])
|
||||
if emails:
|
||||
entry["email"] = emails[0].get("email_address")
|
||||
entry["image_url"] = clerk.get("image_url")
|
||||
except Exception:
|
||||
pass
|
||||
collaborators.append(entry)
|
||||
from backend.services.user_directory import get_user_profile
|
||||
|
||||
for uid in all_user_ids:
|
||||
profile = await get_user_profile(uid)
|
||||
collaborators.append({
|
||||
"user_id": uid,
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"image_url": profile.get("image_url"),
|
||||
"role": "owner" if uid == owner_user_id else "collaborator",
|
||||
})
|
||||
else:
|
||||
# Local dev — just return user_ids without enrichment
|
||||
collaborators = [
|
||||
@@ -623,22 +759,9 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
|
||||
if not settings.use_auth:
|
||||
raise HTTPException(400, "Collaboration requires authentication to be enabled")
|
||||
|
||||
# Look up user by email via Clerk Backend API
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://api.clerk.com/v1/users",
|
||||
params={"email_address": [req.email]},
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, "Failed to look up user")
|
||||
from backend.services.user_directory import find_user_id_by_email, get_user_profile
|
||||
|
||||
users = resp.json()
|
||||
if not users:
|
||||
raise HTTPException(404, "No user found with that email")
|
||||
|
||||
clerk_user = users[0]
|
||||
collab_user_id = clerk_user.get("id")
|
||||
collab_user_id = await find_user_id_by_email(req.email)
|
||||
if not collab_user_id:
|
||||
raise HTTPException(404, "No user found with that email")
|
||||
|
||||
@@ -652,15 +775,12 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
|
||||
|
||||
proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id)
|
||||
|
||||
# Return the collaborator info
|
||||
first = clerk_user.get("first_name") or ""
|
||||
last = clerk_user.get("last_name") or ""
|
||||
emails = clerk_user.get("email_addresses", [])
|
||||
profile = await get_user_profile(collab_user_id)
|
||||
return {
|
||||
"user_id": collab_user_id,
|
||||
"name": f"{first} {last}".strip() or None,
|
||||
"email": emails[0].get("email_address") if emails else None,
|
||||
"image_url": clerk_user.get("image_url"),
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"image_url": profile.get("image_url"),
|
||||
}
|
||||
|
||||
|
||||
@@ -722,31 +842,41 @@ async def make_collaborator_owner(
|
||||
return {"ok": True, "owner_user_id": collaborator_user_id}
|
||||
|
||||
|
||||
# --- DigiKey auto-fetch ---
|
||||
# --- Datasheet auto-fetch ---
|
||||
|
||||
|
||||
@router.get("/digikey/datasheet")
|
||||
async def fetch_digikey_datasheet(mpn: str, request: Request):
|
||||
"""Fetch a datasheet PDF from DigiKey for the given MPN.
|
||||
@router.get("/datasheets/fetch")
|
||||
async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = None):
|
||||
"""Fetch a datasheet PDF for the given MPN.
|
||||
|
||||
Returns the PDF bytes on success, or a JSON error on failure.
|
||||
Tries LCSC (no API key), manufacturer PDF URLs, optional Mouser, then DigiKey
|
||||
if configured. ``/api/digikey/datasheet`` is kept as an alias.
|
||||
"""
|
||||
from backend.services.digikey import fetch_datasheet
|
||||
from backend.services.datasheet_finder import find_datasheet
|
||||
|
||||
result = await fetch_datasheet(mpn)
|
||||
result = await find_datasheet(mpn, lcsc_id=lcsc)
|
||||
if not result.ok:
|
||||
# 404, not 502: "DigiKey has no exact match" / "the manufacturer CDN
|
||||
# blocked the download" is an expected per-MPN miss the wizard handles
|
||||
# (it shows a "fetch failed — upload manually" row), not a broken
|
||||
# gateway. 502 made a board full of exotic parts read as a server
|
||||
# meltdown in the browser console.
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"detail": result.error or "Failed to fetch datasheet", "url": result.url},
|
||||
content={
|
||||
"detail": result.error or "Failed to fetch datasheet",
|
||||
"url": result.url,
|
||||
"urls": result.suggested_urls or ([result.url] if result.url else []),
|
||||
"source": result.source,
|
||||
},
|
||||
)
|
||||
headers = {"Content-Disposition": f'attachment; filename="{mpn}.pdf"'}
|
||||
if result.url:
|
||||
headers["X-Datasheet-Url"] = result.url
|
||||
if result.source:
|
||||
headers["X-Datasheet-Source"] = result.source
|
||||
try:
|
||||
proj_svc.remember_datasheet(
|
||||
get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers)
|
||||
|
||||
|
||||
@@ -773,12 +903,10 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
import asyncio
|
||||
|
||||
from backend.services.digikey import fetch_params
|
||||
from backend.services.extraction import auto_resolve_specs
|
||||
from backend.services.extraction import CatalogResolveMiss, auto_resolve_specs
|
||||
|
||||
if not settings.use_digikey:
|
||||
raise HTTPException(400, "DigiKey API not configured")
|
||||
if not settings.anthropic_api_key:
|
||||
raise HTTPException(400, "Anthropic API key not configured")
|
||||
|
||||
storage = get_storage(request)
|
||||
sem = asyncio.Semaphore(10)
|
||||
@@ -805,14 +933,18 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
|
||||
if not result.ok or not result.params:
|
||||
return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"}
|
||||
|
||||
# Map params to taxonomy via Haiku
|
||||
model = await auto_resolve_specs(
|
||||
mpn=item.mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
digikey_description=result.params.description,
|
||||
component_type=item.component_type,
|
||||
)
|
||||
# Map params: catalog parse first, LLM only if needed.
|
||||
try:
|
||||
model = await auto_resolve_specs(
|
||||
mpn=item.mpn,
|
||||
digikey_params=result.params.parameters,
|
||||
digikey_category=result.params.category,
|
||||
digikey_description=result.params.description,
|
||||
component_type=item.component_type,
|
||||
use_llm=settings.has_llm_credentials(),
|
||||
)
|
||||
except CatalogResolveMiss as e:
|
||||
return {"mpn": item.mpn, "status": "failed", "error": str(e)}
|
||||
|
||||
# Save to library
|
||||
storage.write_json(lib_key, model.model_dump())
|
||||
@@ -928,8 +1060,26 @@ async def lcsc_resolve_passive(
|
||||
"cannot auto-resolve",
|
||||
)
|
||||
|
||||
from backend.services.passive_from_distributor import specs_from_lcsc_payload
|
||||
|
||||
catalog_model = specs_from_lcsc_payload(mpn, payload)
|
||||
if catalog_model is not None:
|
||||
storage.write_json(project_model_key, catalog_model.model_dump())
|
||||
proj_svc.save_to_library(
|
||||
storage, project_model_key, "passives", f"{safe}.json",
|
||||
)
|
||||
return {
|
||||
"mpn": mpn,
|
||||
"safe_mpn": safe,
|
||||
"model": catalog_model.model_dump(),
|
||||
"cached": False,
|
||||
"lcsc_id": req.lcsc_id,
|
||||
}
|
||||
|
||||
# Catalog miss (ferrite, odd text): LLM path, charged if the logger has tokens.
|
||||
|
||||
# Download taxonomy to a temp dir so auto_resolve_specs can read/write it.
|
||||
# Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths.
|
||||
# Mirrors the PipelineWorkspace pattern: periscopex operates on local paths.
|
||||
api_logger = ApiLogger()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tax_dir = Path(tmpdir) / "taxonomy"
|
||||
|
||||
+115
-4
@@ -8,10 +8,18 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.periscopex.review_workflow import (
|
||||
ReviewError,
|
||||
apply_review_state,
|
||||
build_eco,
|
||||
eco_csv,
|
||||
sign_report,
|
||||
)
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
|
||||
from backend.services import projects as proj_svc
|
||||
|
||||
@@ -38,6 +46,17 @@ async def get_report(project_id: str, request: Request):
|
||||
return JSONResponse(storage.read_json(key))
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/cad-bridge")
|
||||
async def get_cad_bridge(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
key = f"{prefix}/periscope-findings.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "CAD bridge not found — run the pipeline first")
|
||||
return JSONResponse(storage.read_json(key))
|
||||
|
||||
|
||||
class AddCommentBody(BaseModel):
|
||||
finding_id: str
|
||||
text: str
|
||||
@@ -89,8 +108,87 @@ async def delete_comment(project_id: str, comment_id: str, request: Request):
|
||||
comment_list.pop(i)
|
||||
if not comment_list:
|
||||
del comments[finding_id]
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse({"ok": True})
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
class ReviewBody(BaseModel):
|
||||
state: str
|
||||
reason: str = ""
|
||||
user_name: str = ""
|
||||
|
||||
|
||||
def _load_report(storage, owner_user_id: str, project_id: str) -> tuple[str, dict]:
|
||||
prefix = proj_svc.project_prefix(owner_user_id, project_id)
|
||||
key = f"{prefix}/report.json"
|
||||
if not storage.exists(key):
|
||||
raise HTTPException(404, "Report not found")
|
||||
return key, storage.read_json(key)
|
||||
|
||||
|
||||
def _findings_from_report(report_data: dict) -> list[Finding]:
|
||||
out: list[Finding] = []
|
||||
for raw in report_data.get("findings") or []:
|
||||
try:
|
||||
out.append(Finding.model_validate(raw))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/report/{project_id}/findings/{finding_id}/review")
|
||||
async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
user_id = get_user_id(request)
|
||||
key, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
|
||||
if finding_id not in ids:
|
||||
raise HTTPException(404, "Finding not found")
|
||||
try:
|
||||
states = apply_review_state(
|
||||
report_data.get("review_states") or {},
|
||||
finding_id,
|
||||
state=body.state,
|
||||
reason=body.reason,
|
||||
user_id=user_id,
|
||||
user_name=body.user_name,
|
||||
)
|
||||
except ReviewError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
report_data["review_states"] = states
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/eco.json")
|
||||
async def get_eco_json(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
_, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
|
||||
return JSONResponse({"items": items})
|
||||
|
||||
|
||||
@router.get("/report/{project_id}/eco.csv")
|
||||
async def get_eco_csv(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
_, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
|
||||
return Response(eco_csv(items), media_type="text/csv")
|
||||
|
||||
|
||||
@router.post("/report/{project_id}/sign")
|
||||
async def post_sign_report(project_id: str, request: Request):
|
||||
storage = get_storage(request)
|
||||
owner_user_id, _ = await resolve_or_404(request, project_id)
|
||||
user_id = get_user_id(request)
|
||||
key, report_data = _load_report(storage, owner_user_id, project_id)
|
||||
release = sign_report(report_data, user_id=user_id)
|
||||
report_data["release"] = release
|
||||
storage.write_json(key, report_data)
|
||||
return JSONResponse(release)
|
||||
raise HTTPException(404, "Comment not found")
|
||||
|
||||
|
||||
@@ -138,6 +236,19 @@ async def get_project_logs(project_id: str, request: Request):
|
||||
return JSONResponse([])
|
||||
text = storage.read_text(key)
|
||||
entries = [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
||||
from backend.services.llm.pricing import cost_for_entry
|
||||
|
||||
for entry in entries:
|
||||
if any(
|
||||
entry.get(k)
|
||||
for k in (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
)
|
||||
):
|
||||
entry["cost_usd"] = round(cost_for_entry(entry), 6)
|
||||
return JSONResponse(entries)
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ApiLogEntry(BaseModel):
|
||||
stage: str # pintable | rules | pattern | validation | ...
|
||||
identifier: str # MPN or component designator
|
||||
model: str
|
||||
provider: str = "anthropic" # anthropic | gemini
|
||||
provider: str = "deepseek" # deepseek | anthropic | gemini
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int = 0
|
||||
@@ -42,7 +42,7 @@ class ApiLogEntry(BaseModel):
|
||||
|
||||
@dataclass
|
||||
class CallMeta:
|
||||
"""Metadata returned alongside every Claude API call result."""
|
||||
"""Metadata returned alongside every LLM API call result."""
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cache_creation_input_tokens: int
|
||||
@@ -104,3 +104,27 @@ class ApiLogger:
|
||||
|
||||
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
|
||||
storage.write_text(key, text)
|
||||
|
||||
|
||||
def cache_stats_by_stage(entries: list[dict]) -> dict[str, dict]:
|
||||
"""Roll up prompt-cache hit rate per pipeline stage.
|
||||
|
||||
Returns ``{stage: {calls, input_tokens, cache_read_tokens, hit_ratio}}``.
|
||||
``hit_ratio`` is cache_read / input when input > 0, else 0.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for e in entries:
|
||||
stage = str(e.get("stage") or "unknown")
|
||||
bucket = out.setdefault(
|
||||
stage,
|
||||
{"calls": 0, "input_tokens": 0, "cache_read_tokens": 0, "hit_ratio": 0.0},
|
||||
)
|
||||
bucket["calls"] += 1
|
||||
bucket["input_tokens"] += int(e.get("input_tokens") or 0)
|
||||
bucket["cache_read_tokens"] += int(e.get("cache_read_input_tokens") or 0)
|
||||
for bucket in out.values():
|
||||
inp = bucket["input_tokens"]
|
||||
bucket["hit_ratio"] = (
|
||||
round(bucket["cache_read_tokens"] / inp, 4) if inp else 0.0
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -27,10 +27,10 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
from backend.pinscopex.resolve_passives import resolve_mpn
|
||||
from backend.pinscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.parsers import parse_bom
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
from backend.periscopex.taxonomy import SIMPLE_TYPES, type_for_ref
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.billing_hook import get_billing
|
||||
from backend.services.llm.pricing import CACHE_RATES, PRICING
|
||||
@@ -125,9 +125,9 @@ def estimate_stage_cost_usd(stage: str) -> float:
|
||||
settings_stage = str(base["settings_stage"])
|
||||
provider = settings.provider_for_stage(settings_stage)
|
||||
model = settings.model_for_stage(settings_stage)
|
||||
table = PRICING.get(provider) or PRICING["anthropic"]
|
||||
table = PRICING.get(provider) or PRICING["deepseek"]
|
||||
rates = table.get(model, table["default"])
|
||||
cache = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
|
||||
cache = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
|
||||
return (
|
||||
int(base["input"]) * rates["input"]
|
||||
+ int(base["output"]) * rates["output"]
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
"""Automatic datasheet lookup — LCSC, manufacturer URLs, optional DigiKey.
|
||||
|
||||
DeepSeek-adapted Periscope still needs the actual PDF. The original wizard
|
||||
only auto-fetched via DigiKey, which requires paid API keys and often
|
||||
fails when the manufacturer CDN blocks the download.
|
||||
|
||||
This module tries, in order:
|
||||
|
||||
1. Explicit BOM datasheet URL (``url_hint``).
|
||||
2. LCSC product search (no API key) — exact MPN match, then packing-suffix
|
||||
variants (``/TR``, ``SPTR``, …).
|
||||
3. Direct manufacturer URLs (TI, Espressif, ST, Analog, NXP, onsemi,
|
||||
Microchip, Murata, Silicon Labs) with HTML-interstitial follow when the
|
||||
CDN returns a page instead of a PDF.
|
||||
4. Mouser, if ``MOUSER_API_KEY`` is configured.
|
||||
5. DigiKey, if ``DIGIKEY_CLIENT_ID`` / ``SECRET`` are configured.
|
||||
|
||||
On a miss, ``suggested_urls`` lists every catalog/vendor link we found so
|
||||
the wizard can open them in the user's browser (datacenter IPs are often
|
||||
blocked).
|
||||
|
||||
Never raises: every failure is captured on :class:`DatasheetHit`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_PDF_MAGIC = b"%PDF-"
|
||||
_MIN_PDF_SIZE = 5_000
|
||||
_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Periscope/2.8"
|
||||
)
|
||||
_LCSC_BASE = "https://wmsc.lcsc.com/ftps/wm"
|
||||
|
||||
# Remainder after a common prefix that we treat as packing / orderable-code,
|
||||
# not a different die (CH340 vs CH340E is a different part — rejected).
|
||||
_PACKING_REMAINDER = re.compile(
|
||||
r"^(S?P?TR|TR|T|R|MTR|PBF|CT|AT|XT|G4|EVM|ND)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_TI_PREFIXES = (
|
||||
"mspm", "msp430", "tms", "tlv", "tps", "sn74", "sn54", "iso", "tmp1",
|
||||
"tmp2", "tmp3", "ina", "ads1", "ads8", "ads9", "tcan", "tmux", "opa",
|
||||
"ths", "ref3", "ref5", "ref6", "ucc", "bq2", "bq3", "csd", "drv",
|
||||
"tpd", "txb", "txs", "am26", "lm3", "lm2", "lm7", "lmx", "sitara",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatasheetHit:
|
||||
mpn: str
|
||||
pdf_bytes: bytes | None = None
|
||||
error: str | None = None
|
||||
url: str | None = None
|
||||
source: str | None = None # "lcsc" | "ti" | "mouser" | "digikey" | ...
|
||||
catalog_mpn: str | None = None # orderable code that actually matched
|
||||
suggested_urls: list[str] | None = None # browser-open links on a miss
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.pdf_bytes is not None
|
||||
|
||||
@property
|
||||
def alias_mpns(self) -> list[str]:
|
||||
extra = (self.catalog_mpn or "").strip()
|
||||
if extra and extra.upper() != (self.mpn or "").upper():
|
||||
return [extra]
|
||||
return []
|
||||
|
||||
|
||||
def _alnum(mpn: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9]", "", mpn.upper())
|
||||
|
||||
|
||||
def mpn_matches(query: str, candidate: str) -> bool:
|
||||
"""True when ``candidate`` is the same part as ``query``, allowing
|
||||
packing / tape-reel suffixes but not variant letters (CH340 vs CH340E)."""
|
||||
q = _alnum(query)
|
||||
c = _alnum(candidate)
|
||||
if not q or not c:
|
||||
return False
|
||||
if q == c:
|
||||
return True
|
||||
longer, shorter = (q, c) if len(q) >= len(c) else (c, q)
|
||||
if not longer.startswith(shorter):
|
||||
return False
|
||||
return bool(_PACKING_REMAINDER.match(longer[len(shorter):]))
|
||||
|
||||
|
||||
# Base MPNs this long are treated as a manufacturer family code: DigiKey/LCSC
|
||||
# orderable strings may add package/temp (24AA025E64 vs 24AA025E64-I/SN).
|
||||
# Keep this above short tokens like "10uF" / "CH340" so we do not steal a
|
||||
# sibling die's datasheet.
|
||||
_MIN_FAMILY_LEN = 7
|
||||
|
||||
|
||||
def mpn_query_variants(mpn: str) -> list[str]:
|
||||
"""Search strings to try when catalogs spell the same MPN differently."""
|
||||
raw = (mpn or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
variants: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(value: str) -> None:
|
||||
value = value.strip()
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
variants.append(value)
|
||||
|
||||
add(raw)
|
||||
add(raw.replace("_", "/"))
|
||||
add(raw.replace("/", "_"))
|
||||
for sep in (" — ", " – ", " - "):
|
||||
if sep in raw:
|
||||
add(raw.split(sep, 1)[0])
|
||||
break
|
||||
if "," in raw:
|
||||
add(raw.split(",", 1)[0])
|
||||
if len(_alnum(raw)) > 8 and raw[-1] in "Rr" and raw[-2].isalnum():
|
||||
add(raw[:-1])
|
||||
stripped = _strip_packing_alnum(raw)
|
||||
if stripped:
|
||||
add(stripped)
|
||||
return variants
|
||||
|
||||
|
||||
def mpn_catalog_match(query: str, candidate: str) -> bool:
|
||||
"""Same part for datasheet lookup: punctuation, packing, or orderable suffix."""
|
||||
if mpn_matches(query, candidate):
|
||||
return True
|
||||
q = _alnum(query)
|
||||
c = _alnum(candidate)
|
||||
if len(q) < _MIN_FAMILY_LEN or not c:
|
||||
return False
|
||||
return c.startswith(q) and len(c) > len(q)
|
||||
|
||||
|
||||
def find_local_pdf(pdf_dir: Path, mpn: str) -> Path | None:
|
||||
"""Find a datasheet PDF whose filename is this MPN or a catalog alias.
|
||||
|
||||
``ESP32-S31-WROOM-3`` matches ``ESP32-S31-WROOM-3-N16R16V.pdf`` and the
|
||||
reverse — packing / flash-size suffixes, not sibling dies (CH340 vs CH340E).
|
||||
"""
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
if not mpn or not pdf_dir.is_dir():
|
||||
return None
|
||||
for name in mpn_query_variants(mpn) or [mpn]:
|
||||
hit = pdf_dir / f"{safe_mpn(name)}.pdf"
|
||||
if hit.is_file():
|
||||
return hit
|
||||
want = _alnum(mpn)
|
||||
if len(want) < _MIN_FAMILY_LEN:
|
||||
return None
|
||||
family_hit: Path | None = None
|
||||
for hit in pdf_dir.glob("*.pdf"):
|
||||
stem = hit.stem
|
||||
if mpn_matches(mpn, stem) or mpn_catalog_match(mpn, stem) or mpn_catalog_match(stem, mpn):
|
||||
got = _alnum(stem)
|
||||
if got == want or mpn_matches(mpn, stem):
|
||||
return hit
|
||||
family_hit = family_hit or hit
|
||||
return family_hit
|
||||
|
||||
|
||||
def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
exact: dict | None = None
|
||||
loose: dict | None = None
|
||||
family: dict | None = None
|
||||
want = _alnum(mpn)
|
||||
for p in products:
|
||||
model = (
|
||||
p.get("productModel")
|
||||
or p.get("productName")
|
||||
or p.get("productIntroEn")
|
||||
or ""
|
||||
)
|
||||
if not model:
|
||||
continue
|
||||
got = _alnum(model)
|
||||
if got == want:
|
||||
exact = p
|
||||
break
|
||||
if loose is None and mpn_matches(mpn, model):
|
||||
loose = p
|
||||
elif family is None and mpn_catalog_match(mpn, model):
|
||||
family = p
|
||||
return exact or loose or family
|
||||
|
||||
|
||||
def _referer_for(url: str) -> str:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
p = urlparse(url)
|
||||
if not p.scheme or not p.netloc:
|
||||
return "https://www.google.com/"
|
||||
return f"{p.scheme}://{p.netloc}/"
|
||||
|
||||
|
||||
def _looks_like_html(data: bytes) -> bool:
|
||||
head = data.lstrip()[:400].lower()
|
||||
return (
|
||||
head.startswith(b"<!doctype html")
|
||||
or head.startswith(b"<html")
|
||||
or b"<head" in head
|
||||
or b"<title" in head
|
||||
)
|
||||
|
||||
|
||||
_PDF_HREF_RE = re.compile(
|
||||
r"""(?:href|src|content)\s*=\s*["']([^"']+\.pdf(?:\?[^"']*)?)["']""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _pdf_links_in_html(html: str, base_url: str, mpn: str) -> list[str]:
|
||||
"""PDF hrefs on a landing page that still look like this MPN's datasheet."""
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
want = _alnum(mpn)
|
||||
if len(want) < 5:
|
||||
return []
|
||||
stem_prefix = want[: min(6, len(want))]
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for match in _PDF_HREF_RE.finditer(html):
|
||||
href = match.group(1).strip()
|
||||
abs_url = urljoin(base_url, href)
|
||||
key = abs_url.split("#", 1)[0]
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
stem = PurePosixPath(urlparse(abs_url).path).stem
|
||||
blob = _alnum(stem) + _alnum(abs_url)
|
||||
if mpn_catalog_match(mpn, stem) or mpn_matches(mpn, stem) or stem_prefix in blob:
|
||||
out.append(key)
|
||||
if len(out) >= 3:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
async def _http_get(url: str) -> bytes:
|
||||
headers = {
|
||||
"User-Agent": _UA,
|
||||
"Accept": "application/pdf,application/octet-stream;q=0.9,text/html;q=0.8,*/*;q=0.7",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": _referer_for(url),
|
||||
}
|
||||
async with httpx.AsyncClient(
|
||||
timeout=25, follow_redirects=True, headers=headers,
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def _as_pdf_bytes(data: bytes) -> bytes:
|
||||
if not data.startswith(_PDF_MAGIC):
|
||||
raise ValueError("Downloaded file is not a valid PDF")
|
||||
if len(data) < _MIN_PDF_SIZE:
|
||||
raise ValueError(f"PDF too small ({len(data)} bytes)")
|
||||
return data
|
||||
|
||||
|
||||
async def _download_pdf(url: str, *, mpn: str | None = None, _hops: int = 0) -> bytes:
|
||||
"""GET a URL and return PDF bytes.
|
||||
|
||||
Vendor CDNs often 200 an HTML interstitial (captcha, cookie wall). If the
|
||||
body is HTML, follow at most one in-page ``.pdf`` link that still matches
|
||||
``mpn``. ``http://`` is retried as ``https://``.
|
||||
"""
|
||||
candidates = [url]
|
||||
if url.startswith("http://"):
|
||||
candidates.append("https://" + url[len("http://"):])
|
||||
last_err: Exception | None = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
data = await _http_get(candidate)
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
continue
|
||||
try:
|
||||
return _as_pdf_bytes(data)
|
||||
except ValueError as exc:
|
||||
last_err = exc
|
||||
if _hops >= 1 or not mpn or not _looks_like_html(data):
|
||||
continue
|
||||
try:
|
||||
html = data.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
for href in _pdf_links_in_html(html, candidate, mpn):
|
||||
try:
|
||||
return await _download_pdf(href, mpn=mpn, _hops=_hops + 1)
|
||||
except Exception as hop_exc:
|
||||
last_err = hop_exc
|
||||
continue
|
||||
raise last_err or ValueError("Download failed")
|
||||
|
||||
|
||||
async def _lcsc_search(keyword: str) -> list[dict]:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20,
|
||||
headers={"User-Agent": _UA, "Content-Type": "application/json", "Accept": "application/json"},
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
f"{_LCSC_BASE}/product/query/list",
|
||||
json={"keyword": keyword, "currentPage": 1, "pageSize": 30},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("result") or {}
|
||||
return result.get("dataList") or []
|
||||
|
||||
|
||||
async def _lcsc_detail(product_code: str) -> dict | None:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20,
|
||||
headers={"User-Agent": _UA, "Accept": "application/json"},
|
||||
) as client:
|
||||
resp = await client.get(
|
||||
f"{_LCSC_BASE}/product/detail",
|
||||
params={"productCode": product_code},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("result")
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
|
||||
def _pdf_url_from_product(product: dict) -> str | None:
|
||||
url = product.get("pdfUrl") or product.get("pdfURL") or product.get("pdfLinkUrl")
|
||||
if url and isinstance(url, str) and url.startswith("http"):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
async def _from_lcsc(mpn: str, lcsc_id: str | None) -> DatasheetHit | None:
|
||||
product: dict | None = None
|
||||
if lcsc_id:
|
||||
code = lcsc_id.strip().upper()
|
||||
if not code.startswith("C"):
|
||||
code = "C" + code
|
||||
try:
|
||||
product = await _lcsc_detail(code)
|
||||
except Exception as exc:
|
||||
log.info("LCSC detail %s failed: %s", code, exc)
|
||||
|
||||
if product is not None and not _pdf_url_from_product(product):
|
||||
product = None
|
||||
|
||||
if product is None:
|
||||
keywords = mpn_query_variants(mpn)
|
||||
for keyword in keywords:
|
||||
try:
|
||||
products = await _lcsc_search(keyword)
|
||||
except Exception as exc:
|
||||
log.info("LCSC search %s failed: %s", keyword, exc)
|
||||
continue
|
||||
product = _pick_lcsc_product(mpn, products)
|
||||
if product:
|
||||
break
|
||||
|
||||
if not product:
|
||||
return None
|
||||
url = _pdf_url_from_product(product)
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
pdf = await _download_pdf(url, mpn=mpn)
|
||||
except Exception as exc:
|
||||
log.info("LCSC PDF download failed for %s (%s): %s", mpn, url, exc)
|
||||
return DatasheetHit(mpn, error=f"LCSC download failed: {exc}", url=url, source="lcsc")
|
||||
catalog = (
|
||||
product.get("productModel")
|
||||
or product.get("productName")
|
||||
or ""
|
||||
)
|
||||
log.info("Fetched datasheet for %s via LCSC (%d KB)", mpn, len(pdf) // 1024)
|
||||
return DatasheetHit(
|
||||
mpn, pdf_bytes=pdf, url=url, source="lcsc",
|
||||
catalog_mpn=str(catalog) or None,
|
||||
)
|
||||
|
||||
|
||||
def _strip_packing_alnum(mpn: str) -> str | None:
|
||||
"""Return the alnum MPN with a trailing packing code removed, if any."""
|
||||
compact = _alnum(mpn)
|
||||
for suf in ("SPTR", "PTR", "MTR", "TR"):
|
||||
if compact.endswith(suf) and len(compact) > len(suf) + 3:
|
||||
return compact[: -len(suf)]
|
||||
return None
|
||||
|
||||
|
||||
_TI_PACKAGE_SUFFIXES = (
|
||||
"dbvr", "dbvt", "dbv", "pwr", "pwt", "pw", "rger", "rget", "rge",
|
||||
"dgsr", "dgsk", "dgs", "ydtr", "ydt", "dcnr", "dcnt", "dcn",
|
||||
"rgtr", "rgtt", "rgt", "runr", "runt", "dckr", "dckt", "dck",
|
||||
)
|
||||
|
||||
|
||||
def _ti_slugs(mpn: str) -> list[str]:
|
||||
"""Candidate TI datasheet slugs, most specific first."""
|
||||
raw = mpn.lower().replace("/", "-").strip("-")
|
||||
slugs: list[str] = []
|
||||
|
||||
def add(value: str) -> None:
|
||||
value = value.strip("-")
|
||||
if value and value not in slugs:
|
||||
slugs.append(value)
|
||||
|
||||
add(raw)
|
||||
for suffix in ("-t/r", "/tr", "-tr", "-reel", "sptr", "ptr", "mtr", "tr"):
|
||||
if raw.endswith(suffix) and len(raw) > len(suffix) + 3:
|
||||
add(raw[: -len(suffix)].rstrip("-"))
|
||||
break
|
||||
for pkg in _TI_PACKAGE_SUFFIXES:
|
||||
if raw.endswith(pkg) and len(raw) > len(pkg) + 4:
|
||||
add(raw[: -len(pkg)])
|
||||
break
|
||||
# Orderable INA228AQDGSRQ1 → datasheet ina228-q1 / ina228
|
||||
prefixes = sorted(_TI_PREFIXES, key=len, reverse=True)
|
||||
for prefix in prefixes:
|
||||
if not raw.startswith(prefix):
|
||||
continue
|
||||
rest = raw[len(prefix):]
|
||||
m = re.match(r"(\d{2,})", rest)
|
||||
if not m:
|
||||
break
|
||||
family = f"{prefix}{m.group(1)}"
|
||||
add(family)
|
||||
if "q1" in raw:
|
||||
add(f"{family}-q1")
|
||||
break
|
||||
return slugs
|
||||
|
||||
|
||||
def _looks_like_ti(mpn: str) -> bool:
|
||||
s = mpn.lower()
|
||||
return any(s.startswith(p) for p in _TI_PREFIXES)
|
||||
|
||||
|
||||
def _ti_urls(mpn: str) -> list[str]:
|
||||
if not _looks_like_ti(mpn):
|
||||
return []
|
||||
urls: list[str] = []
|
||||
for slug in _ti_slugs(mpn):
|
||||
urls.append(f"https://www.ti.com/lit/ds/symlink/{slug}.pdf")
|
||||
urls.append(f"https://www.ti.com/lit/gpn/{slug}.pdf")
|
||||
return urls
|
||||
|
||||
|
||||
def _espressif_urls(mpn: str) -> list[str]:
|
||||
s = mpn.strip().lower().replace("_", "-")
|
||||
if not s.startswith("esp"):
|
||||
return []
|
||||
s = re.sub(r"-n\d+r\d+v?$", "", s)
|
||||
slugs: list[str] = []
|
||||
for val in (s, re.split(r"-wroom|-wrover|-pico", s)[0]):
|
||||
val = val.strip("-")
|
||||
if val and val not in slugs:
|
||||
slugs.append(val)
|
||||
base = "https://www.espressif.com/sites/default/files/documentation"
|
||||
return [f"{base}/{slug}_datasheet_en.pdf" for slug in slugs]
|
||||
|
||||
|
||||
_ST_PREFIXES = (
|
||||
"stm32", "stm8", "stusb", "stspin", "usblc", "esda", "sm6t", "stth",
|
||||
"l78", "ld1117", "m24c", "vl53", "lsm6",
|
||||
)
|
||||
|
||||
|
||||
def _st_urls(mpn: str) -> list[str]:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _ST_PREFIXES):
|
||||
return []
|
||||
compact = re.sub(r"[^a-z0-9-]", "", s.replace("/", "-"))
|
||||
return [f"https://www.st.com/resource/en/datasheet/{compact}.pdf"]
|
||||
|
||||
|
||||
def _adi_family(mpn: str) -> str | None:
|
||||
s = re.sub(r"[^A-Z0-9]", "", mpn.upper())
|
||||
m = re.match(r"^((?:AD|LT|OP|ADP|LTC)[A-Z]*\d+)", s)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _adi_urls(mpn: str) -> list[str]:
|
||||
fam = _adi_family(mpn)
|
||||
if not fam:
|
||||
return []
|
||||
root = "https://www.analog.com/media/en/technical-documentation/data-sheets"
|
||||
return [f"{root}/{fam}.pdf", f"{root}/{fam.lower()}.pdf"]
|
||||
|
||||
|
||||
_NXP_PREFIXES = ("pca", "pcf", "lpc", "imx", "tja", "pn5", "pn7", "kw4")
|
||||
|
||||
|
||||
def _nxp_urls(mpn: str) -> list[str]:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _NXP_PREFIXES):
|
||||
return []
|
||||
token = re.sub(r"[^A-Z0-9-]", "", mpn.split("/")[0].split(",")[0].strip().upper())
|
||||
if not token:
|
||||
return []
|
||||
return [f"https://www.nxp.com/docs/en/data-sheet/{token}.pdf"]
|
||||
|
||||
|
||||
_ONSEMI_PREFIXES = ("ncp", "ncv", "ntd", "fdc", "cat24", "fusb")
|
||||
|
||||
|
||||
def _onsemi_urls(mpn: str) -> list[str]:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _ONSEMI_PREFIXES):
|
||||
return []
|
||||
slug = re.sub(r"[^a-z0-9]", "", s)
|
||||
return [f"https://www.onsemi.com/pdf/datasheet/{slug}.pdf"]
|
||||
|
||||
|
||||
_MICROCHIP_PREFIXES = (
|
||||
"mcp", "pic16", "pic18", "pic24", "pic32", "dspic", "atsam", "atmega",
|
||||
"attiny", "24aa", "24lc", "24fc", "25aa", "25lc", "lan87", "lan74",
|
||||
"ksz", "usb25", "enc28",
|
||||
)
|
||||
|
||||
|
||||
def _microchip_family(mpn: str) -> str | None:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _MICROCHIP_PREFIXES):
|
||||
return None
|
||||
token = re.split(r"[-/,]", s)[0]
|
||||
token = re.sub(r"[^a-z0-9]", "", token)
|
||||
return token or None
|
||||
|
||||
|
||||
def _microchip_urls(mpn: str) -> list[str]:
|
||||
fam = _microchip_family(mpn)
|
||||
if not fam:
|
||||
return []
|
||||
compact = re.sub(r"[^A-Za-z0-9]", "", mpn.split("/")[0].split(",")[0])
|
||||
return [
|
||||
f"https://www.microchip.com/en-us/product/{fam}",
|
||||
f"https://ww1.microchip.com/downloads/en/DeviceDoc/{compact}.pdf",
|
||||
f"https://ww1.microchip.com/downloads/en/DeviceDoc/{fam.upper()}.pdf",
|
||||
]
|
||||
|
||||
|
||||
_MURATA_PREFIXES = ("grm", "gqm", "gjm", "lqw", "lqm", "lqg", "blm", "nfm", "dlw", "nfe")
|
||||
|
||||
|
||||
def _murata_stem(mpn: str) -> str | None:
|
||||
s = mpn.strip().upper()
|
||||
if not any(s.startswith(p.upper()) for p in _MURATA_PREFIXES):
|
||||
return None
|
||||
stem = re.split(r"[/,]", s)[0]
|
||||
if stem.endswith("D") and len(stem) > 8:
|
||||
stem = stem[:-1]
|
||||
return stem
|
||||
|
||||
|
||||
def _murata_urls(mpn: str) -> list[str]:
|
||||
stem = _murata_stem(mpn)
|
||||
if not stem:
|
||||
return []
|
||||
raw = mpn.split("/")[0].split(",")[0].strip()
|
||||
return [
|
||||
f"https://search.murata.co.jp/Ceramy/image/img/A01X/G101/ENG/{stem}-01.pdf",
|
||||
f"https://www.murata.com/en-us/products/productdetail?partno={raw}",
|
||||
]
|
||||
|
||||
|
||||
_SILABS_PREFIXES = ("si4", "si5", "efr32", "cp21", "bgm", "wgm", "efm32")
|
||||
|
||||
|
||||
def _silabs_urls(mpn: str) -> list[str]:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _SILABS_PREFIXES):
|
||||
return []
|
||||
compact = re.sub(r"[^A-Za-z0-9-]", "", mpn.split("/")[0].split(",")[0])
|
||||
parts = compact.split("-")
|
||||
family = parts[0]
|
||||
urls = [
|
||||
f"https://www.silabs.com/documents/public/data-sheets/{compact}.pdf",
|
||||
f"https://www.silabs.com/documents/public/data-sheets/{family}.pdf",
|
||||
]
|
||||
if len(parts) >= 2:
|
||||
urls.insert(
|
||||
1,
|
||||
f"https://www.silabs.com/documents/public/data-sheets/{parts[0]}-{parts[1]}.pdf",
|
||||
)
|
||||
return urls
|
||||
|
||||
|
||||
def manufacturer_pdf_candidates(mpn: str) -> list[tuple[str, str]]:
|
||||
"""Stable vendor PDF URLs for this MPN (source, url), first match wins."""
|
||||
out: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(source: str, urls: list[str]) -> None:
|
||||
for url in urls:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
out.append((source, url))
|
||||
|
||||
add("ti", _ti_urls(mpn))
|
||||
add("espressif", _espressif_urls(mpn))
|
||||
add("st", _st_urls(mpn))
|
||||
add("analog", _adi_urls(mpn))
|
||||
add("nxp", _nxp_urls(mpn))
|
||||
add("onsemi", _onsemi_urls(mpn))
|
||||
add("microchip", _microchip_urls(mpn))
|
||||
add("murata", _murata_urls(mpn))
|
||||
add("silabs", _silabs_urls(mpn))
|
||||
return out
|
||||
|
||||
|
||||
def suggested_pdf_urls(mpn: str, *extras: str | None) -> list[str]:
|
||||
"""Deduped links the wizard can open in the user's browser."""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(url: str | None) -> None:
|
||||
url = (url or "").strip()
|
||||
if url.startswith("http") and url not in seen:
|
||||
seen.add(url)
|
||||
out.append(url)
|
||||
|
||||
for extra in extras:
|
||||
add(extra)
|
||||
for _source, url in manufacturer_pdf_candidates(mpn):
|
||||
add(url)
|
||||
return out
|
||||
|
||||
|
||||
async def _from_manufacturer(mpn: str) -> DatasheetHit | None:
|
||||
"""Direct manufacturer datasheet URLs (TI, ST, ADI, Espressif, Microchip, …)."""
|
||||
last_err = None
|
||||
last_url = None
|
||||
last_source = None
|
||||
for source, url in manufacturer_pdf_candidates(mpn):
|
||||
last_url = url
|
||||
last_source = source
|
||||
try:
|
||||
pdf = await _download_pdf(url, mpn=mpn)
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
continue
|
||||
log.info(
|
||||
"Fetched datasheet for %s via %s (%s, %d KB)",
|
||||
mpn, source, url, len(pdf) // 1024,
|
||||
)
|
||||
return DatasheetHit(mpn, pdf_bytes=pdf, url=url, source=source)
|
||||
if last_err:
|
||||
log.info("Manufacturer lookup missed %s: %s", mpn, last_err)
|
||||
return DatasheetHit(
|
||||
mpn, error=f"{last_source} download failed: {last_err}",
|
||||
url=last_url, source=last_source,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _from_ti(mpn: str) -> DatasheetHit | None:
|
||||
"""Historical name — manufacturer URL table (TI plus other stable vendors)."""
|
||||
return await _from_manufacturer(mpn)
|
||||
|
||||
|
||||
async def _from_digikey(mpn: str) -> DatasheetHit | None:
|
||||
if not settings.use_digikey:
|
||||
return None
|
||||
from backend.services.digikey import fetch_datasheet
|
||||
result = await fetch_datasheet(mpn)
|
||||
if result.ok:
|
||||
return DatasheetHit(
|
||||
mpn, pdf_bytes=result.pdf_bytes, url=result.url, source="digikey",
|
||||
catalog_mpn=getattr(result, "catalog_mpn", None),
|
||||
)
|
||||
return DatasheetHit(mpn, error=result.error, url=result.url, source="digikey")
|
||||
|
||||
|
||||
async def _from_mouser(mpn: str) -> DatasheetHit | None:
|
||||
if not settings.use_mouser:
|
||||
return None
|
||||
from backend.services.mouser import fetch_datasheet
|
||||
result = await fetch_datasheet(mpn)
|
||||
if result.ok:
|
||||
return DatasheetHit(
|
||||
mpn, pdf_bytes=result.pdf_bytes, url=result.url, source="mouser",
|
||||
catalog_mpn=getattr(result, "catalog_mpn", None),
|
||||
)
|
||||
if result.error or result.url:
|
||||
return DatasheetHit(mpn, error=result.error, url=result.url, source="mouser")
|
||||
return None
|
||||
|
||||
|
||||
async def find_datasheet(
|
||||
mpn: str, lcsc_id: str | None = None, url_hint: str | None = None,
|
||||
) -> DatasheetHit:
|
||||
"""Find and download a datasheet PDF for ``mpn``.
|
||||
|
||||
Tries an explicit BOM URL first, then LCSC, then manufacturer PDF
|
||||
URLs, then Mouser, then DigiKey. A miss always includes
|
||||
``suggested_urls`` for a browser download.
|
||||
"""
|
||||
mpn = (mpn or "").strip()
|
||||
if not mpn:
|
||||
return DatasheetHit(mpn, error="Empty MPN")
|
||||
|
||||
errors: list[str] = []
|
||||
found_urls: list[str] = []
|
||||
|
||||
hint = (url_hint or "").strip()
|
||||
if hint.startswith("http"):
|
||||
try:
|
||||
pdf = await _download_pdf(hint, mpn=mpn)
|
||||
return DatasheetHit(mpn, pdf_bytes=pdf, url=hint, source="bom")
|
||||
except Exception as exc:
|
||||
log.info("BOM datasheet URL missed %s: %s", mpn, exc)
|
||||
errors.append(f"bom: {exc}")
|
||||
found_urls.append(hint)
|
||||
|
||||
for source_fn in (_from_lcsc, _from_ti, _from_mouser, _from_digikey):
|
||||
try:
|
||||
if source_fn is _from_lcsc:
|
||||
hit = await _from_lcsc(mpn, lcsc_id)
|
||||
else:
|
||||
hit = await source_fn(mpn) # type: ignore[misc]
|
||||
except Exception as exc:
|
||||
log.info("Datasheet source %s raised for %s: %s", source_fn.__name__, mpn, exc)
|
||||
errors.append(f"{source_fn.__name__}: {exc}")
|
||||
continue
|
||||
if hit is None:
|
||||
continue
|
||||
if hit.ok:
|
||||
return hit
|
||||
if hit.error:
|
||||
errors.append(f"{hit.source or source_fn.__name__}: {hit.error}")
|
||||
if hit.url:
|
||||
found_urls.append(hit.url)
|
||||
|
||||
urls = suggested_pdf_urls(mpn, *found_urls)
|
||||
detail = "; ".join(errors) if errors else "No datasheet found"
|
||||
return DatasheetHit(
|
||||
mpn, error=detail, url=urls[0] if urls else None,
|
||||
suggested_urls=urls,
|
||||
)
|
||||
@@ -16,11 +16,12 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.services.storage import StorageBackend
|
||||
|
||||
BLOB_PREFIX = "library/datasheets/blobs/"
|
||||
REF_PREFIX = "library/datasheets/refs/"
|
||||
ALIAS_KEY = "library/datasheets/aliases.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -73,7 +74,7 @@ def store_datasheet(
|
||||
bk = blob_key(md5)
|
||||
if not storage.exists(bk):
|
||||
storage.upload_from_local(local_path, bk)
|
||||
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk})
|
||||
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk, "mpn": mpn})
|
||||
return bk
|
||||
|
||||
|
||||
@@ -81,28 +82,96 @@ def store_datasheet_bytes(
|
||||
storage: StorageBackend,
|
||||
data: bytes,
|
||||
mpn: str,
|
||||
extra_mpns: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Same as :func:`store_datasheet` but from in-memory bytes."""
|
||||
"""Same as :func:`store_datasheet` but from in-memory bytes.
|
||||
|
||||
``extra_mpns`` are additional catalog/orderable codes that should point
|
||||
at the same blob (family MPN vs ``…-N16R16V``).
|
||||
"""
|
||||
md5 = compute_md5_from_bytes(data)
|
||||
bk = blob_key(md5)
|
||||
if not storage.exists(bk):
|
||||
storage.write_bytes(bk, data)
|
||||
storage.write_json(ref_key(mpn), {"hash": md5, "blob_key": bk})
|
||||
names = [mpn, *(extra_mpns or [])]
|
||||
seen: set[str] = set()
|
||||
for name in names:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
key = name.upper()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
storage.write_json(ref_key(name), {"hash": md5, "blob_key": bk, "mpn": name})
|
||||
_record_aliases(storage, mpn, extra_mpns or [])
|
||||
return bk
|
||||
|
||||
|
||||
def _record_aliases(storage: StorageBackend, mpn: str, extra_mpns: list[str]) -> None:
|
||||
from backend.services.datasheet_finder import _alnum, _MIN_FAMILY_LEN
|
||||
|
||||
names = [mpn, *extra_mpns]
|
||||
compact = {n: _alnum(n) for n in names if n and n.strip()}
|
||||
if len(set(compact.values())) < 2 and not extra_mpns:
|
||||
return
|
||||
table: dict[str, str] = {}
|
||||
if storage.exists(ALIAS_KEY):
|
||||
raw = storage.read_json(ALIAS_KEY)
|
||||
table = dict(raw.get("aliases") or {})
|
||||
canonical = extra_mpns[0].strip() if extra_mpns else mpn
|
||||
for name, key in compact.items():
|
||||
if len(key) >= _MIN_FAMILY_LEN:
|
||||
table[key] = canonical
|
||||
storage.write_json(ALIAS_KEY, {"aliases": table})
|
||||
|
||||
|
||||
def resolve_datasheet(storage: StorageBackend, mpn: str) -> str | None:
|
||||
"""Look up the blob key for an MPN via its ref file.
|
||||
|
||||
Returns the blob key if the ref exists *and* the blob exists, else None.
|
||||
Tries spelling variants, then the shared alias table (family MPN →
|
||||
orderable code stored in the library).
|
||||
"""
|
||||
rk = ref_key(mpn)
|
||||
if not storage.exists(rk):
|
||||
from backend.services.datasheet_finder import (
|
||||
_MIN_FAMILY_LEN,
|
||||
_alnum,
|
||||
mpn_query_variants,
|
||||
)
|
||||
|
||||
def _from_ref(name: str) -> str | None:
|
||||
rk = ref_key(name)
|
||||
if not storage.exists(rk):
|
||||
return None
|
||||
ref = storage.read_json(rk)
|
||||
bk = ref.get("blob_key")
|
||||
if bk and storage.exists(bk):
|
||||
return bk
|
||||
return None
|
||||
ref = storage.read_json(rk)
|
||||
bk = ref.get("blob_key")
|
||||
if bk and storage.exists(bk):
|
||||
return bk
|
||||
|
||||
for name in mpn_query_variants(mpn) or [mpn]:
|
||||
hit = _from_ref(name)
|
||||
if hit:
|
||||
return hit
|
||||
|
||||
if not storage.exists(ALIAS_KEY):
|
||||
return None
|
||||
table = (storage.read_json(ALIAS_KEY) or {}).get("aliases") or {}
|
||||
want = _alnum(mpn)
|
||||
if not want:
|
||||
return None
|
||||
target = table.get(want)
|
||||
if target:
|
||||
hit = _from_ref(target)
|
||||
if hit:
|
||||
return hit
|
||||
if len(want) >= _MIN_FAMILY_LEN:
|
||||
for key, target in table.items():
|
||||
if key.startswith(want) or (
|
||||
want.startswith(key) and len(key) >= _MIN_FAMILY_LEN
|
||||
):
|
||||
hit = _from_ref(target)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.llm import Message, TextBlock
|
||||
from backend.services.llm.factory import call_with_fallback
|
||||
@@ -247,23 +247,16 @@ def _build_deduped(
|
||||
new_why = "Unverified: " + new_why
|
||||
|
||||
try:
|
||||
result.append(Finding(
|
||||
finding_id=canon.finding_id,
|
||||
designator=canon.designator,
|
||||
mpn=canon.mpn,
|
||||
aspect=canon.aspect,
|
||||
finding=str(group.get("finding") or canon.finding),
|
||||
why=new_why,
|
||||
source_page=group.get("source_page", canon.source_page),
|
||||
source_quote=canon.source_quote,
|
||||
source_designator=canon.source_designator,
|
||||
status=final_status,
|
||||
recommendation=str(
|
||||
result.append(canon.model_copy(update={
|
||||
"finding": str(group.get("finding") or canon.finding),
|
||||
"why": new_why,
|
||||
"source_page": group.get("source_page", canon.source_page),
|
||||
"status": final_status,
|
||||
"recommendation": str(
|
||||
group.get("recommendation") or canon.recommendation
|
||||
),
|
||||
reference=str(group.get("reference") or canon.reference),
|
||||
source=canon.source,
|
||||
))
|
||||
"reference": str(group.get("reference") or canon.reference),
|
||||
}))
|
||||
except Exception:
|
||||
log.exception("dedupe: failed to build merged Finding")
|
||||
return None
|
||||
|
||||
+77
-26
@@ -12,6 +12,12 @@ from dataclasses import dataclass, field
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.datasheet_finder import (
|
||||
_alnum,
|
||||
mpn_catalog_match,
|
||||
mpn_matches,
|
||||
mpn_query_variants,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,31 +109,58 @@ async def _keyword_search(mpn: str) -> list[dict]:
|
||||
|
||||
|
||||
def _find_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
"""Find the product whose MPN exactly matches ``mpn`` (case/space-insensitive).
|
||||
"""Pick a DigiKey product for ``mpn``.
|
||||
|
||||
Returns None when no result has a matching MPN. We intentionally do NOT
|
||||
fall back to ``products[0]`` — keyword-search hits without an MPN match
|
||||
are usually for a different part, and silently returning them has
|
||||
polluted the library with wrong specs for non-MPN tokens like ``10uF``.
|
||||
Prefers punctuation-insensitive equality, then packing suffixes, then a
|
||||
longer orderable code that starts with the BOM MPN. Does not fall back
|
||||
to ``products[0]``. Tries BOM spelling variants (underscore, reel, extra
|
||||
description after an em dash).
|
||||
"""
|
||||
if not products:
|
||||
return None
|
||||
|
||||
mpn_upper = mpn.upper().replace(" ", "")
|
||||
for product in products:
|
||||
if _get_mpn(product).upper().replace(" ", "") == mpn_upper:
|
||||
return product
|
||||
for query in mpn_query_variants(mpn):
|
||||
hit = _find_product_one(query, products)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
async def _search_mpn(mpn: str) -> str | None:
|
||||
"""Search DigiKey for an MPN and return the primary datasheet URL, or None."""
|
||||
products = await _keyword_search(mpn)
|
||||
product = _find_product(mpn, products)
|
||||
if not product:
|
||||
return None
|
||||
url = _get_ds_url(product)
|
||||
return url or None
|
||||
def _find_product_one(mpn: str, products: list[dict]) -> dict | None:
|
||||
exact = None
|
||||
loose = None
|
||||
family = None
|
||||
want = _alnum(mpn)
|
||||
for product in products:
|
||||
cand = _get_mpn(product)
|
||||
if not cand:
|
||||
continue
|
||||
got = _alnum(cand)
|
||||
if got == want:
|
||||
exact = product
|
||||
break
|
||||
if loose is None and mpn_matches(mpn, cand):
|
||||
loose = product
|
||||
elif family is None and mpn_catalog_match(mpn, cand):
|
||||
family = product
|
||||
return exact or loose or family
|
||||
|
||||
|
||||
async def _search_mpn(mpn: str) -> tuple[str | None, str | None]:
|
||||
"""Search DigiKey; return (datasheet_url, catalog_mpn)."""
|
||||
tried: set[str] = set()
|
||||
for keyword in mpn_query_variants(mpn):
|
||||
key = keyword.upper()
|
||||
if key in tried:
|
||||
continue
|
||||
tried.add(key)
|
||||
products = await _keyword_search(keyword)
|
||||
product = _find_product(mpn, products)
|
||||
if not product:
|
||||
continue
|
||||
url = _get_ds_url(product)
|
||||
if url:
|
||||
return url, _get_mpn(product) or None
|
||||
return None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,11 +205,13 @@ class DatasheetFetchResult:
|
||||
pdf_bytes: bytes | None = None,
|
||||
error: str | None = None,
|
||||
url: str | None = None,
|
||||
catalog_mpn: str | None = None,
|
||||
):
|
||||
self.mpn = mpn
|
||||
self.pdf_bytes = pdf_bytes
|
||||
self.error = error
|
||||
self.url = url # DigiKey datasheet URL (present even when PDF download fails)
|
||||
self.url = url
|
||||
self.catalog_mpn = catalog_mpn
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -195,7 +230,7 @@ async def fetch_datasheet(mpn: str) -> DatasheetFetchResult:
|
||||
return DatasheetFetchResult(mpn, error="DigiKey API not configured")
|
||||
|
||||
try:
|
||||
url = await _search_mpn(mpn)
|
||||
url, catalog_mpn = await _search_mpn(mpn)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("DigiKey search failed for %s: %s", mpn, e)
|
||||
return DatasheetFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
|
||||
@@ -211,20 +246,27 @@ async def fetch_datasheet(mpn: str) -> DatasheetFetchResult:
|
||||
pdf_bytes = await _download_pdf(url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("Datasheet download blocked for %s (%s): %s", mpn, url, e)
|
||||
return DatasheetFetchResult(mpn, error=f"Download blocked ({e.response.status_code})", url=url)
|
||||
return DatasheetFetchResult(
|
||||
mpn, error=f"Download blocked ({e.response.status_code})", url=url,
|
||||
catalog_mpn=catalog_mpn,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid PDF for %s (%s): %s", mpn, url, e)
|
||||
return DatasheetFetchResult(mpn, error=str(e), url=url)
|
||||
return DatasheetFetchResult(mpn, error=str(e), url=url, catalog_mpn=catalog_mpn)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("Datasheet download timed out for %s (%s)", mpn, url)
|
||||
return DatasheetFetchResult(mpn, error="Download timed out", url=url)
|
||||
return DatasheetFetchResult(mpn, error="Download timed out", url=url, catalog_mpn=catalog_mpn)
|
||||
except Exception as e:
|
||||
msg = str(e) or type(e).__name__
|
||||
logger.warning("Datasheet download failed for %s (%s): %s", mpn, url, msg)
|
||||
return DatasheetFetchResult(mpn, error=f"Download failed: {msg}", url=url)
|
||||
return DatasheetFetchResult(
|
||||
mpn, error=f"Download failed: {msg}", url=url, catalog_mpn=catalog_mpn,
|
||||
)
|
||||
|
||||
logger.info("Fetched datasheet for %s (%d KB)", mpn, len(pdf_bytes) // 1024)
|
||||
return DatasheetFetchResult(mpn, pdf_bytes=pdf_bytes, url=url)
|
||||
return DatasheetFetchResult(
|
||||
mpn, pdf_bytes=pdf_bytes, url=url, catalog_mpn=catalog_mpn,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -300,7 +342,16 @@ async def fetch_params(mpn: str) -> ParamsFetchResult:
|
||||
return ParamsFetchResult(mpn, error="DigiKey API not configured")
|
||||
|
||||
try:
|
||||
products = await _keyword_search(mpn)
|
||||
products: list[dict] = []
|
||||
tried: set[str] = set()
|
||||
for keyword in mpn_query_variants(mpn):
|
||||
key = keyword.upper()
|
||||
if key in tried:
|
||||
continue
|
||||
tried.add(key)
|
||||
products = await _keyword_search(keyword)
|
||||
if _find_product(mpn, products):
|
||||
break
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning("DigiKey search failed for %s: %s", mpn, e)
|
||||
return ParamsFetchResult(mpn, error=f"DigiKey search failed ({e.response.status_code})")
|
||||
|
||||
+34
-34
@@ -174,7 +174,7 @@ def _render_report_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Report Ready
|
||||
@@ -247,7 +247,7 @@ def _render_report_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -292,7 +292,7 @@ def _render_pipeline_started_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Pipeline Started
|
||||
@@ -398,7 +398,7 @@ def _render_pipeline_started_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -448,7 +448,7 @@ def _build_report_message(
|
||||
) -> MIMEMultipart:
|
||||
"""Build the report-ready email message."""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Report ready: {project_name}"
|
||||
|
||||
@@ -460,7 +460,7 @@ def _build_report_message(
|
||||
infos = summary.get("INFO", 0)
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"Your Pinscope validation report for \"{project_name}\" is ready.\n\n"
|
||||
f"Your Periscope validation report for \"{project_name}\" is ready.\n\n"
|
||||
f"Summary: {total} findings — {errors} errors, {warnings} warnings, {infos} info\n\n"
|
||||
f"View the report: {report_url}\n"
|
||||
)
|
||||
@@ -485,7 +485,7 @@ def _build_paused_message(
|
||||
credits_needed_low: float,
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Paused: {project_name} is waiting for credits"
|
||||
|
||||
@@ -495,7 +495,7 @@ def _build_paused_message(
|
||||
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"Your Pinscope run for \"{project_name}\" paused because you're low on credits.\n\n"
|
||||
f"Your Periscope run for \"{project_name}\" paused because you're low on credits.\n\n"
|
||||
f"{last_line}\n{stage_line}\n\n"
|
||||
f"Current balance: {balance:.2f} credits\n"
|
||||
f"Credits needed to finish (est): {credits_needed_low:.2f}+\n\n"
|
||||
@@ -510,13 +510,13 @@ def _build_topup_failed_message(
|
||||
amount_usd: float, reason: str,
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Pinscope: auto top-up failed"
|
||||
msg["Subject"] = "Periscope: auto top-up failed"
|
||||
manage_url = f"{settings.email_frontend_url}/credits"
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"We tried to auto top-up your Pinscope balance with "
|
||||
f"We tried to auto top-up your Periscope balance with "
|
||||
f"${amount_usd:.2f} but the charge failed.\n\n"
|
||||
f"Reason: {reason}\n\n"
|
||||
f"Auto top-up has been disabled until you update your payment method. "
|
||||
@@ -549,13 +549,13 @@ def _build_low_balance_message(
|
||||
to_email: str, recipient_name: str, balance: float, threshold: float,
|
||||
) -> MIMEMultipart:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Pinscope: low credit balance"
|
||||
msg["Subject"] = "Periscope: low credit balance"
|
||||
credits_url = f"{settings.email_frontend_url}/credits"
|
||||
text_body = (
|
||||
f"Hi {recipient_name},\n\n"
|
||||
f"Your Pinscope credit balance has dropped to "
|
||||
f"Your Periscope credit balance has dropped to "
|
||||
f"{balance:.2f} credits (below your threshold of {threshold:.2f}).\n\n"
|
||||
f"Top up here so your pipelines don't pause mid-run: {credits_url}\n"
|
||||
)
|
||||
@@ -691,10 +691,10 @@ async def send_test_email(to_email: str) -> dict:
|
||||
|
||||
result["step"] = "send"
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "Pinscope email test"
|
||||
msg.attach(MIMEText(f"Test email from Pinscope. Sender: {settings.email_sender}. Creds: {cred_type}", "plain"))
|
||||
msg["Subject"] = "Periscope email test"
|
||||
msg.attach(MIMEText(f"Test email from Periscope. Sender: {settings.email_sender}. Creds: {cred_type}", "plain"))
|
||||
|
||||
import asyncio as _asyncio
|
||||
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii")
|
||||
@@ -741,7 +741,7 @@ async def send_pipeline_started_email(
|
||||
|
||||
# Build message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Pipeline started: {project_name} ({num_components} components)"
|
||||
|
||||
@@ -866,7 +866,7 @@ def _render_feedback_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
Feedback Received
|
||||
@@ -953,7 +953,7 @@ def _render_feedback_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -1006,7 +1006,7 @@ async def send_feedback_received_email(
|
||||
to_email = settings.email_admin_notify
|
||||
subject_ctx = project_name or "general"
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Feedback ({type_label}): {subject_ctx}"
|
||||
|
||||
@@ -1095,7 +1095,7 @@ def _render_feedback_reply_email(
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 20px; font-weight: 700; color: #ffffff; letter-spacing: -0.025em;">
|
||||
Pinscope
|
||||
Periscope
|
||||
</td>
|
||||
<td align="right" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||
New Reply
|
||||
@@ -1113,7 +1113,7 @@ def _render_feedback_reply_email(
|
||||
Hi {_esc(recipient_first_name)},
|
||||
</td></tr>
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; color: #374151; padding-bottom: 18px;">
|
||||
The Pinscope team just replied to your feedback.
|
||||
The Periscope team just replied to your feedback.
|
||||
</td></tr>
|
||||
|
||||
{context_line}
|
||||
@@ -1124,7 +1124,7 @@ def _render_feedback_reply_email(
|
||||
<tr><td style="padding: 18px 22px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11px; font-weight: 600; color: #047857; text-transform: uppercase; letter-spacing: 0.05em; padding-bottom: 10px;">
|
||||
Pinscope team
|
||||
Periscope team
|
||||
</td></tr>
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; color: #064e3b; line-height: 1.55; white-space: pre-wrap;">
|
||||
{_esc(reply_text)}
|
||||
@@ -1155,12 +1155,12 @@ def _render_feedback_reply_email(
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="{feedback_url}" style="height:48px;v-text-anchor:middle;width:240px;" arcsize="14%" fillcolor="#3b82f6" stroke="f">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:sans-serif;font-size:15px;font-weight:bold;">View in Pinscope →</center>
|
||||
<center style="color:#ffffff;font-family:sans-serif;font-size:15px;font-weight:bold;">View in Periscope →</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="{feedback_url}" target="_blank" style="display: inline-block; background-color: #3b82f6; color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 600; text-decoration: none; padding: 12px 32px; border-radius: 8px; letter-spacing: -0.01em;">
|
||||
View in Pinscope →
|
||||
View in Periscope →
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td></tr>
|
||||
@@ -1170,7 +1170,7 @@ def _render_feedback_reply_email(
|
||||
Thank you so much for taking the time to share your feedback — we truly value it.
|
||||
</td></tr>
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; color: #374151; padding-top: 6px;">
|
||||
— The Pinscope team
|
||||
— The Periscope team
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
@@ -1180,7 +1180,7 @@ def _render_feedback_reply_email(
|
||||
<tr><td style="background-color: #f9fafb; padding: 20px 32px; border-top: 1px solid #e5e7eb;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 12px; color: #9ca3af;">
|
||||
Pinscope · Agentic schematic validation
|
||||
Periscope · Agentic schematic validation
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -1203,7 +1203,7 @@ async def send_feedback_reply_email(
|
||||
finding_designator: str | None = None,
|
||||
finding_mpn: str | None = None,
|
||||
) -> None:
|
||||
"""Notify the original submitter that the Pinscope team replied. Fire-and-forget."""
|
||||
"""Notify the original submitter that the Periscope team replied. Fire-and-forget."""
|
||||
if not settings.use_email:
|
||||
return
|
||||
|
||||
@@ -1229,15 +1229,15 @@ async def send_feedback_reply_email(
|
||||
first_name = full_name.split()[0] if full_name else "there"
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"Pinscope <{settings.email_sender}>"
|
||||
msg["From"] = f"Periscope <{settings.email_sender}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = "The Pinscope team replied to your feedback"
|
||||
msg["Subject"] = "The Periscope team replied to your feedback"
|
||||
|
||||
# Plain text fallback
|
||||
text_lines = [
|
||||
f"Hi {first_name},",
|
||||
"",
|
||||
"The Pinscope team just replied to your feedback.",
|
||||
"The Periscope team just replied to your feedback.",
|
||||
"",
|
||||
"— Reply —",
|
||||
reply_text,
|
||||
@@ -1245,10 +1245,10 @@ async def send_feedback_reply_email(
|
||||
"— Your original message —",
|
||||
original_message,
|
||||
"",
|
||||
f"View in Pinscope: {settings.email_frontend_url}/feedback",
|
||||
f"View in Periscope: {settings.email_frontend_url}/feedback",
|
||||
"",
|
||||
"Thank you so much for taking the time to share your feedback — we truly value it.",
|
||||
"— The Pinscope team",
|
||||
"— The Periscope team",
|
||||
]
|
||||
msg.attach(MIMEText("\n".join(text_lines), "plain"))
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ TERMINAL_EVENTS = frozenset({
|
||||
"pipeline_error",
|
||||
"pipeline_cancelled",
|
||||
"pipeline_paused",
|
||||
"placement_complete",
|
||||
"placement_error",
|
||||
"placement_cancelled",
|
||||
})
|
||||
|
||||
|
||||
@@ -131,19 +134,19 @@ async def tail_events(
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
heartbeat_interval: float = 15.0,
|
||||
terminal_events: frozenset[str] | None = None,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Yield events from the GCS-backed event log in order.
|
||||
|
||||
Stops yielding after a terminal event (``pipeline_complete``,
|
||||
``pipeline_error``, ``pipeline_cancelled``). Emits a
|
||||
``{"event": "heartbeat", "data": {}}`` synthetic event roughly every
|
||||
``heartbeat_interval`` seconds when no real events arrive, matching
|
||||
the behaviour of the in-memory broker's SSE loop.
|
||||
Stops yielding after a terminal event (default ``TERMINAL_EVENTS``).
|
||||
Emits a ``{"event": "heartbeat", "data": {}}`` synthetic event roughly
|
||||
every ``heartbeat_interval`` seconds when no real events arrive.
|
||||
|
||||
The caller is expected to handle disconnects/cancellations and
|
||||
secondary terminal-detection (``meta.status``, Cloud Run execution
|
||||
state) on top of this iterator.
|
||||
"""
|
||||
stop_on = terminal_events if terminal_events is not None else TERMINAL_EVENTS
|
||||
prefix = _events_prefix(user_id, project_id)
|
||||
last_seen_key: str | None = None
|
||||
last_emit_ts = 0.0
|
||||
@@ -166,7 +169,7 @@ async def tail_events(
|
||||
emitted_any = True
|
||||
last_seen_key = key
|
||||
last_emit_ts = asyncio.get_event_loop().time()
|
||||
if msg.get("event") in TERMINAL_EVENTS:
|
||||
if msg.get("event") in stop_on:
|
||||
return
|
||||
|
||||
now = asyncio.get_event_loop().time()
|
||||
|
||||
+206
-19
@@ -1,9 +1,11 @@
|
||||
"""Async datasheet extraction using Claude API.
|
||||
"""Async datasheet extraction using the configured LLM provider.
|
||||
|
||||
Ports the extraction steps from run_pipeline.py to async:
|
||||
- extract_pintable: Pin table + package info + taxonomy assignment
|
||||
- extract_pattern: Passive MPN pattern
|
||||
- extract_specs: Component specs (discrete, connectors, crystals, etc.)
|
||||
|
||||
Skills (SKILL.md + validate.py) run locally against DeepSeek. Do not use Anthropic Console Skills.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,8 +17,8 @@ import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.periscopex.models import (
|
||||
CapacitorSpecs,
|
||||
ComponentConstraints,
|
||||
ComponentModel,
|
||||
@@ -25,7 +27,7 @@ from backend.pinscopex.models import (
|
||||
NetType,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
from backend.pinscopex.taxonomy import (
|
||||
from backend.periscopex.taxonomy import (
|
||||
TAXONOMY_DIR,
|
||||
add_subtype,
|
||||
format_for_prompt,
|
||||
@@ -55,7 +57,7 @@ from backend.services.llm import (
|
||||
|
||||
PINTABLE_TOOL = {
|
||||
"name": "save_pintable",
|
||||
"description": "Save the extracted pin table, package info, and component subtype.",
|
||||
"description": "Save the extracted pin table, package info, absolute-maximum ratings, and component subtype.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -94,6 +96,76 @@ PINTABLE_TOOL = {
|
||||
"required": ["number", "name"],
|
||||
},
|
||||
},
|
||||
"absolute_maximum_ratings": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Rows from the Absolute Maximum Ratings table: supplies, "
|
||||
"pin voltages, current, temperature. For ESD/TVS ICs also "
|
||||
"include Electrical Characteristics Vrwm (signed min/max) "
|
||||
"and a polarity/topology row (bidirectional vs "
|
||||
"unidirectional / back-to-back). Skip IEC/HBM kV rows. "
|
||||
"Empty array if the table is unreadable."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "As printed, e.g. 'VCC', 'VIN', 'Storage temperature'",
|
||||
},
|
||||
"min": {"type": ["number", "null"]},
|
||||
"max": {"type": ["number", "null"]},
|
||||
"unit": {"type": "string", "description": "V, mA, °C, …"},
|
||||
"source_page": {
|
||||
"type": "integer",
|
||||
"description": "1-based datasheet page of this row",
|
||||
},
|
||||
},
|
||||
"required": ["parameter", "unit", "source_page"],
|
||||
},
|
||||
},
|
||||
"internal_features": {
|
||||
"type": "object",
|
||||
"description": "Optional block-diagram extras. Omit or empty if not shown.",
|
||||
"properties": {
|
||||
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
|
||||
"pullup_pins": {"type": "array", "items": {"type": "string"}},
|
||||
"analog_switch": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
"layout_rules": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"PCB layout constraints from typical-application / PCB layout pages. "
|
||||
"kind: decoupling_proximity | thermal_via | keepout | length_match. "
|
||||
"Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a "
|
||||
"number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, "
|
||||
"net_class, note, source_page. Empty array if the PDF has no layout guidance."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"decoupling_proximity",
|
||||
"thermal_via",
|
||||
"keepout",
|
||||
"length_match",
|
||||
],
|
||||
},
|
||||
"pin": {"type": ["string", "null"]},
|
||||
"cap_value_hint": {"type": ["string", "null"]},
|
||||
"max_distance_mm": {"type": ["number", "null"]},
|
||||
"same_layer": {"type": ["boolean", "null"]},
|
||||
"min_via_count": {"type": ["integer", "null"]},
|
||||
"net_class": {"type": ["string", "null"]},
|
||||
"note": {"type": ["string", "null"]},
|
||||
"source_page": {"type": ["integer", "null"]},
|
||||
},
|
||||
"required": ["kind"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable"],
|
||||
},
|
||||
@@ -208,14 +280,22 @@ SPECS_TOOL = {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_MAX_PDF_PAGES = 90
|
||||
_MAX_PDF_PAGES = 120
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Keywords used to find relevant pages for each extraction stage.
|
||||
# Include PCB / typical-application pages so layout_rules can be extracted
|
||||
# when large datasheets are trimmed to ≤_MAX_PDF_PAGES.
|
||||
_PINTABLE_KEYWORDS = re.compile(
|
||||
r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)"
|
||||
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description",
|
||||
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description"
|
||||
r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
|
||||
r"|ordering\s+information|device\s+information"
|
||||
r"|pcb\s+layout|layout\s+(guideline|recommendation|consideration|hint)"
|
||||
r"|typical\s+application|application\s+(circuit|schematic|information|note)"
|
||||
r"|reference\s+design|decoupling|bypass\s+capacitor|thermal\s+via"
|
||||
r"|land\s+pattern|keep[\s\-]?out|place\s+(close|near|within)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -280,6 +360,63 @@ def _to_tool(d: dict) -> ToolSchema:
|
||||
)
|
||||
|
||||
|
||||
def _coerce_abs_max(raw: object) -> list[dict]:
|
||||
"""Keep well-formed abs-max rows; drop garbage rather than failing extraction."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
parameter = str(row.get("parameter") or "").strip()
|
||||
unit = str(row.get("unit") or "").strip()
|
||||
page = row.get("source_page")
|
||||
if not parameter or not unit:
|
||||
continue
|
||||
try:
|
||||
source_page = int(page)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if source_page < 1:
|
||||
continue
|
||||
|
||||
def _num(v: object) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
out.append({
|
||||
"parameter": parameter,
|
||||
"min": _num(row.get("min")),
|
||||
"max": _num(row.get("max")),
|
||||
"unit": unit,
|
||||
"source_page": source_page,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_layout_rules(raw: object) -> list[dict]:
|
||||
from backend.periscopex.layout_rules import validate_layout_rules
|
||||
rows, _errors = validate_layout_rules(raw if isinstance(raw, list) else [])
|
||||
return rows
|
||||
|
||||
|
||||
def _coerce_internal_features(raw: object):
|
||||
from backend.periscopex.models import InternalFeatures
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
feat = InternalFeatures.model_validate(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if not feat.esd_clamp_pins and not feat.pullup_pins and not feat.analog_switch:
|
||||
return None
|
||||
return feat
|
||||
|
||||
|
||||
_GENERATE_SPECS_TOOL = {
|
||||
"name": "save_specs_schema",
|
||||
"description": "Save the standardized parameter schema for a component type.",
|
||||
@@ -479,7 +616,7 @@ async def extract_pintable(
|
||||
taxonomy = format_for_prompt("ic", tax_dir)
|
||||
|
||||
trimmed = _select_pages(pdf_path, _PINTABLE_KEYWORDS)
|
||||
skill_id, version = settings.get_skill("extract-pintable")
|
||||
skill_id, version = settings.get_skill_or_none("extract-pintable")
|
||||
system = (
|
||||
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n"
|
||||
f"MPN: {mpn}\n\n"
|
||||
@@ -493,7 +630,12 @@ async def extract_pintable(
|
||||
skill_name="extract-pintable",
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=f"Extract pin table and package info for MPN: {mpn}",
|
||||
user_text=(
|
||||
f"Extract pin table, package info, absolute maximum ratings, "
|
||||
f"and layout_rules (scan PCB layout / typical application / "
|
||||
f"thermal pages; max_distance_mm only if the PDF states mm) "
|
||||
f"for MPN: {mpn}"
|
||||
),
|
||||
pdf_path=trimmed,
|
||||
output_tool=_to_tool(PINTABLE_TOOL),
|
||||
)
|
||||
@@ -548,8 +690,12 @@ async def extract_pintable(
|
||||
component_subtype=subtype,
|
||||
package_info=result["package_info"],
|
||||
pintable=result["pintable"],
|
||||
absolute_maximum_ratings=[],
|
||||
absolute_maximum_ratings=_coerce_abs_max(
|
||||
result.get("absolute_maximum_ratings") or [],
|
||||
),
|
||||
rules=[],
|
||||
internal_features=_coerce_internal_features(result.get("internal_features")),
|
||||
layout_rules=_coerce_layout_rules(result.get("layout_rules")),
|
||||
)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -576,7 +722,7 @@ async def extract_pattern(
|
||||
tax_dir = taxonomy_dir or settings.taxonomy_dir
|
||||
taxonomy = format_for_prompt("passive", tax_dir)
|
||||
|
||||
skill_id, version = settings.get_skill("extract-pattern")
|
||||
skill_id, version = settings.get_skill_or_none("extract-pattern")
|
||||
system = (
|
||||
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n\n"
|
||||
f"EXISTING PASSIVE TAXONOMY SUBTYPES:\n{taxonomy}\n\n"
|
||||
@@ -665,7 +811,7 @@ async def extract_specs(
|
||||
subtypes_text = format_for_prompt(component_type, tax_dir)
|
||||
specs_text = format_specs_for_prompt(component_type, tax_dir)
|
||||
|
||||
skill_id, version = settings.get_skill("extract-specs")
|
||||
skill_id, version = settings.get_skill_or_none("extract-specs")
|
||||
system = (
|
||||
f"DYNAMIC CONTEXT FOR THIS EXTRACTION:\n"
|
||||
f"MPN: {mpn}\n"
|
||||
@@ -798,6 +944,10 @@ Call save_resolved_specs with the mapped values.\
|
||||
"""
|
||||
|
||||
|
||||
class CatalogResolveMiss(RuntimeError):
|
||||
"""Distributor params did not parse and ``use_llm`` was false."""
|
||||
|
||||
|
||||
async def auto_resolve_specs(
|
||||
mpn: str,
|
||||
digikey_params: list[dict[str, str]],
|
||||
@@ -806,13 +956,35 @@ async def auto_resolve_specs(
|
||||
component_type: str,
|
||||
taxonomy_dir: Path | None = None,
|
||||
api_logger: ApiLogger | None = None,
|
||||
*,
|
||||
use_llm: bool = True,
|
||||
) -> ComponentModel:
|
||||
"""Map DigiKey product parameters to taxonomy specs using a lightweight model.
|
||||
"""Map DigiKey/LCSC product parameters to taxonomy specs.
|
||||
|
||||
Returns a ComponentModel ready to persist. Raises on failure.
|
||||
Passives with a parseable value skip the model. ``use_llm=False`` returns
|
||||
only that catalog parse or raises :class:`CatalogResolveMiss`.
|
||||
"""
|
||||
tax_dir = taxonomy_dir or settings.taxonomy_dir
|
||||
|
||||
from backend.services.passive_from_distributor import specs_from_distributor
|
||||
|
||||
if component_type == "passive":
|
||||
direct = specs_from_distributor(
|
||||
mpn=mpn,
|
||||
params=digikey_params,
|
||||
category=digikey_category,
|
||||
description=digikey_description,
|
||||
)
|
||||
if direct is not None:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).info(
|
||||
"Auto-resolved %s from distributor params (no LLM)", mpn,
|
||||
)
|
||||
return direct
|
||||
|
||||
if not use_llm:
|
||||
raise CatalogResolveMiss(f"No catalog specs for {mpn}")
|
||||
|
||||
# Auto-generate type-level specs if none exist
|
||||
if not has_specs(component_type, tax_dir):
|
||||
try:
|
||||
@@ -916,7 +1088,7 @@ async def auto_resolve_specs(
|
||||
|
||||
# Convert passive SimpleComponentSpecs to typed models
|
||||
if component_type == "passive":
|
||||
from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
@@ -931,7 +1103,7 @@ _PASSIVE_PREFIX_HINT: dict[str, str] = {
|
||||
"C": "capacitor — populate value_farads",
|
||||
"R": "resistor — populate value_ohms",
|
||||
"L": "inductor — populate value_henries",
|
||||
"FB": "ferrite bead — populate value_ohms (impedance)",
|
||||
"FB": "ferrite bead — populate impedance_ohm (Z at test frequency, not henries)",
|
||||
}
|
||||
|
||||
_VALUE_RESOLVE_SYSTEM = """\
|
||||
@@ -952,8 +1124,9 @@ CRITICAL RULES:
|
||||
dielectric, package, or power rating. Never invent these.
|
||||
- Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable
|
||||
string) and the matching primary numeric field
|
||||
(``value_farads`` / ``value_ohms`` / ``value_henries``). Leave every other
|
||||
parameter out (do not include a null entry — omit the key entirely).
|
||||
(``value_farads`` / ``value_ohms`` / ``value_henries`` / ``impedance_ohm``
|
||||
for ferrite beads). Leave every other parameter out (do not include a null
|
||||
entry — omit the key entirely). Never invent henries for a ferrite bead.
|
||||
- Express numeric values with SPICE multiplier prefixes and units
|
||||
(u=1e-6, n=1e-9, p=1e-12, k=1e3, M=1e6). Examples: ``10uF``, ``4.7kohm``, ``100nH``.
|
||||
- Pick the GENERIC parent subtype — e.g. ``passive.capacitor``, ``passive.resistor``,
|
||||
@@ -987,6 +1160,20 @@ async def resolve_from_value(
|
||||
"""
|
||||
tax_dir = taxonomy_dir or settings.taxonomy_dir
|
||||
|
||||
from backend.services.passive_from_value import (
|
||||
is_placeholder_value,
|
||||
specs_from_bom_value,
|
||||
)
|
||||
|
||||
parsed = specs_from_bom_value(mpn, value, ref_prefix)
|
||||
if parsed is not None:
|
||||
logging.getLogger(__name__).info(
|
||||
"Resolved from value %s=%r without LLM", mpn, value,
|
||||
)
|
||||
return parsed
|
||||
if is_placeholder_value(value):
|
||||
raise ValueError(f"Placeholder BOM value {value!r} for {mpn}")
|
||||
|
||||
if not has_specs(component_type, tax_dir):
|
||||
try:
|
||||
await _generate_type_specs(component_type, tax_dir, api_logger=api_logger)
|
||||
@@ -1073,7 +1260,7 @@ async def resolve_from_value(
|
||||
)
|
||||
|
||||
if component_type == "passive":
|
||||
from backend.pinscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from backend.config import settings
|
||||
@@ -57,8 +58,11 @@ def _spawn_local_subprocess(
|
||||
free: bool,
|
||||
mode: str = "run",
|
||||
regen_stages: list[str] | None = None,
|
||||
proc_key: str | None = None,
|
||||
execution_name: str | None = None,
|
||||
) -> str:
|
||||
name = _local_execution_name(project_id)
|
||||
key = proc_key or project_id
|
||||
name = execution_name or _local_execution_name(project_id)
|
||||
env = os.environ.copy()
|
||||
env["PROJECT_ID"] = project_id
|
||||
env["USER_ID"] = user_id
|
||||
@@ -71,36 +75,70 @@ def _spawn_local_subprocess(
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "backend.pipeline_worker"],
|
||||
env=env,
|
||||
# Inherit stdout/stderr so logs appear in the dev terminal
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
_write_pid(key, proc.pid)
|
||||
with _local_procs_lock:
|
||||
# Reap any old proc for the same project before tracking the new one.
|
||||
prior = _local_procs.pop(project_id, None)
|
||||
# Reap any old proc for the same key before tracking the new one.
|
||||
prior = _local_procs.pop(key, None)
|
||||
if prior is not None:
|
||||
try:
|
||||
prior.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
_local_procs[project_id] = proc
|
||||
logger.info("dev: spawned worker subprocess pid=%s for %s", proc.pid, project_id)
|
||||
_local_procs[key] = proc
|
||||
logger.info(
|
||||
"dev: spawned worker subprocess pid=%s for %s mode=%s",
|
||||
proc.pid, project_id, mode,
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _pid_path(project_id: str) -> Path:
|
||||
return settings.data_dir / "workers" / f"{project_id}.pid"
|
||||
|
||||
|
||||
def _write_pid(project_id: str, pid: int) -> None:
|
||||
path = _pid_path(project_id)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(str(pid))
|
||||
|
||||
|
||||
def _pid_alive(project_id: str) -> bool | None:
|
||||
"""True/False if a pid file exists; None if there is no file."""
|
||||
path = _pid_path(project_id)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
pid = int(path.read_text().strip())
|
||||
except ValueError:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _local_state(project_id: str) -> ExecutionState:
|
||||
with _local_procs_lock:
|
||||
proc = _local_procs.get(project_id)
|
||||
if proc is None:
|
||||
return "unknown"
|
||||
rc = proc.poll()
|
||||
if rc is None:
|
||||
if proc is not None:
|
||||
rc = proc.poll()
|
||||
if rc is None:
|
||||
return "running"
|
||||
if rc == 0:
|
||||
return "succeeded"
|
||||
if rc < 0:
|
||||
# Negative return = terminated by signal
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
alive = _pid_alive(project_id)
|
||||
if alive is True:
|
||||
return "running"
|
||||
if rc == 0:
|
||||
return "succeeded"
|
||||
if rc < 0:
|
||||
# Negative return = terminated by signal
|
||||
return "cancelled"
|
||||
return "failed"
|
||||
if alive is False:
|
||||
return "failed"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _local_cancel(project_id: str) -> None:
|
||||
@@ -309,6 +347,9 @@ def get_execution_state(execution_name: str | None) -> ExecutionState:
|
||||
if execution_name.startswith("local/projects/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(project_id)
|
||||
if execution_name.startswith("local/placement/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
return _local_state(f"placement:{project_id}")
|
||||
return _cloud_run_state(execution_name)
|
||||
|
||||
|
||||
@@ -324,4 +365,21 @@ def cancel_execution(execution_name: str | None) -> None:
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(project_id)
|
||||
return
|
||||
if execution_name.startswith("local/placement/"):
|
||||
project_id = execution_name.split("/", 2)[-1]
|
||||
_local_cancel(f"placement:{project_id}")
|
||||
return
|
||||
_cloud_run_cancel(execution_name)
|
||||
|
||||
|
||||
def enqueue_placement_pipeline(project_id: str, user_id: str) -> str:
|
||||
"""Dispatch the parallel Placement pipeline (topology plan, no LLM)."""
|
||||
if use_cloud_run_jobs():
|
||||
return _enqueue_cloud_run_job(
|
||||
project_id, user_id, resume=False, free=True, mode="placement",
|
||||
)
|
||||
return _spawn_local_subprocess(
|
||||
project_id, user_id, resume=False, free=True, mode="placement",
|
||||
proc_key=f"placement:{project_id}",
|
||||
execution_name=f"local/placement/{project_id}",
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Provider-agnostic LLM client layer.
|
||||
|
||||
All Claude API calls in the backend route through this package via the
|
||||
``LLMProvider`` interface. The default provider is Anthropic; per-stage
|
||||
All model calls in the backend route through this package via the
|
||||
``LLMProvider`` interface. The default provider is DeepSeek; per-stage
|
||||
overrides via ``Settings.provider_*`` env vars route specific stages to
|
||||
other providers (currently Anthropic + Gemini).
|
||||
Anthropic or Gemini if those keys are configured.
|
||||
"""
|
||||
|
||||
from backend.services.llm.factory import call_with_fallback, get_provider
|
||||
|
||||
@@ -269,7 +269,22 @@ class AnthropicProvider(LLMProvider):
|
||||
runs ``validate.py`` server-side via code_execution, and voluntarily
|
||||
calls ``output_tool`` once it has well-formed data.
|
||||
"""
|
||||
try:\n skill_id, version = settings.get_skill(skill_name)\n except Exception:\n skill_id, version = None, None
|
||||
try:
|
||||
skill_id, version = settings.get_skill(skill_name)
|
||||
except Exception:
|
||||
skill_id, version = None, None
|
||||
|
||||
if not skill_id:
|
||||
from backend.services.llm.local_skill import run_skill_locally
|
||||
return await run_skill_locally(
|
||||
self,
|
||||
skill_name=skill_name,
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=user_text,
|
||||
pdf_path=pdf_path,
|
||||
output_tool=output_tool,
|
||||
)
|
||||
|
||||
# Build initial user content
|
||||
user_content: list[dict] = []
|
||||
|
||||
@@ -92,9 +92,7 @@ class LLMProvider(Protocol):
|
||||
) -> tuple[dict, "Completion"]:
|
||||
"""Execute a managed Skill and return (forced-tool input, Completion).
|
||||
|
||||
Anthropic uses Console Skills (skill_id + container + code_execution
|
||||
beta). Gemini raises ``NotImplementedError`` — there is no
|
||||
Gemini-managed-Skill equivalent today; if you want a Gemini path for
|
||||
skill-style extraction, inline the SKILL.md content as ``system`` and
|
||||
run validation locally."""
|
||||
DeepSeek and Gemini inline ``skills/<name>/SKILL.md`` and run
|
||||
``validate.py`` locally. Anthropic uses Console Skills when a
|
||||
skill_id is configured, otherwise the same local path."""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""DeepSeek provider — OpenAI-compatible Chat Completions.
|
||||
|
||||
Translates the unified ``Message`` / ``Completion`` shapes into DeepSeek's
|
||||
OpenAI-style chat format. DeepSeek does not accept native PDF documents, so
|
||||
``PdfBlock`` is converted to extracted text (and page images when the
|
||||
session model is a vision model). Thinking-mode ``reasoning_content`` is
|
||||
round-tripped on subsequent turns.
|
||||
|
||||
Extraction skills run locally via :mod:`backend.services.llm.local_skill`
|
||||
(DeepSeek has no Anthropic Console Skills equivalent).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from openai import APIStatusError, AsyncOpenAI
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.llm.base import LLMProvider, LLMSession
|
||||
from backend.services.llm.local_skill import run_skill_locally
|
||||
from backend.services.llm.pdf_ingest import pdf_to_openai_content
|
||||
from backend.services.llm.types import (
|
||||
Completion,
|
||||
ContentBlock,
|
||||
Message,
|
||||
PdfBlock,
|
||||
TextBlock,
|
||||
ToolCall,
|
||||
ToolChoice,
|
||||
ToolResultBlock,
|
||||
ToolSchema,
|
||||
Usage,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# V4.1 Flash is natively multimodal. Legacy flash / vision-exp names
|
||||
# still route there. v4-pro does not accept images until it is retired
|
||||
# onto Flash (2026-09-14).
|
||||
_VISION_MODELS = {
|
||||
"deepseek-flash",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-flash-vision-exp",
|
||||
}
|
||||
|
||||
|
||||
def _is_vision_model(model: str) -> bool:
|
||||
name = (model or "").strip().lower()
|
||||
if name in _VISION_MODELS or "vision" in name:
|
||||
return True
|
||||
return name.startswith("deepseek-flash")
|
||||
|
||||
|
||||
def _to_openai_tool(t: ToolSchema) -> dict:
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.input_schema,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _to_openai_tool_choice(c: ToolChoice) -> dict | str:
|
||||
if c == "auto":
|
||||
return "auto"
|
||||
if c == "none":
|
||||
return "none"
|
||||
if isinstance(c, dict) and "name" in c:
|
||||
return {"type": "function", "function": {"name": c["name"]}}
|
||||
raise ValueError(f"Invalid tool_choice: {c!r}")
|
||||
|
||||
|
||||
def _reasoning_from_blocks(blocks: list[ContentBlock]) -> str | None:
|
||||
for b in blocks:
|
||||
rc = getattr(b, "reasoning_content", None)
|
||||
if rc:
|
||||
return rc
|
||||
return None
|
||||
|
||||
|
||||
def _pdf_parts(path, *, vision: bool) -> list[dict]:
|
||||
return pdf_to_openai_content(
|
||||
path,
|
||||
vision=vision,
|
||||
max_chars=settings.deepseek_pdf_max_chars,
|
||||
max_images=settings.deepseek_pdf_image_pages,
|
||||
)
|
||||
|
||||
|
||||
def _user_content_parts(blocks: list[ContentBlock], *, vision: bool) -> list[dict]:
|
||||
"""Flatten user-side blocks (text / pdf) into OpenAI content parts."""
|
||||
parts: list[dict] = []
|
||||
for b in blocks:
|
||||
if isinstance(b, TextBlock):
|
||||
parts.append({"type": "text", "text": b.text})
|
||||
elif isinstance(b, PdfBlock):
|
||||
parts.extend(_pdf_parts(b.path, vision=vision))
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Unexpected block in user content: {type(b).__name__}"
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def _repair_assistant_messages(messages: list[dict]) -> list[dict]:
|
||||
"""Ensure every assistant turn has a string ``content`` (DeepSeek 400).
|
||||
|
||||
``content: null`` / omitted content is rejected even when ``tool_calls``
|
||||
is present. Empty string is accepted.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
changed = False
|
||||
for m in messages:
|
||||
if m.get("role") != "assistant":
|
||||
out.append(m)
|
||||
continue
|
||||
content = m.get("content")
|
||||
if isinstance(content, str):
|
||||
out.append(m)
|
||||
continue
|
||||
fixed = dict(m)
|
||||
fixed["content"] = content if isinstance(content, str) else ""
|
||||
out.append(fixed)
|
||||
changed = True
|
||||
return out if changed else messages
|
||||
|
||||
|
||||
def messages_to_openai(messages: list[Message], *, vision: bool) -> list[dict]:
|
||||
"""Convert unified messages into DeepSeek/OpenAI chat messages.
|
||||
|
||||
Tool results become ``role=tool`` messages (OpenAI does not mix
|
||||
``tool_result`` with documents in one user turn). Any PdfBlocks that
|
||||
accompanied tool results are emitted as a following user message.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for m in messages:
|
||||
if m.role == "assistant":
|
||||
text_parts = [b.text for b in m.content if isinstance(b, TextBlock)]
|
||||
tool_calls = [b for b in m.content if isinstance(b, ToolCall)]
|
||||
msg: dict[str, Any] = {"role": "assistant"}
|
||||
text = "".join(text_parts)
|
||||
# DeepSeek rejects content=null and a missing content key, even
|
||||
# when tool_calls is set. Empty string is accepted.
|
||||
msg["content"] = text
|
||||
if tool_calls:
|
||||
msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": json.dumps(tc.input),
|
||||
},
|
||||
}
|
||||
for tc in tool_calls
|
||||
]
|
||||
reasoning = _reasoning_from_blocks(m.content)
|
||||
if reasoning:
|
||||
msg["reasoning_content"] = reasoning
|
||||
out.append(msg)
|
||||
continue
|
||||
|
||||
# user
|
||||
tool_results = [b for b in m.content if isinstance(b, ToolResultBlock)]
|
||||
other = [b for b in m.content if not isinstance(b, ToolResultBlock)]
|
||||
for tr in tool_results:
|
||||
out.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tr.tool_use_id,
|
||||
"content": tr.content,
|
||||
})
|
||||
if other:
|
||||
parts = _user_content_parts(other, vision=vision)
|
||||
if len(parts) == 1 and parts[0].get("type") == "text":
|
||||
out.append({"role": "user", "content": parts[0]["text"]})
|
||||
else:
|
||||
out.append({"role": "user", "content": parts})
|
||||
elif not tool_results:
|
||||
out.append({"role": "user", "content": ""})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_tool_arguments(raw: str | None) -> dict:
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
log.warning("DeepSeek tool arguments were not valid JSON: %s", raw[:200])
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _cache_hit_tokens(usage: Any) -> int:
|
||||
hit = getattr(usage, "prompt_cache_hit_tokens", None)
|
||||
if hit:
|
||||
return int(hit)
|
||||
details = getattr(usage, "prompt_tokens_details", None)
|
||||
if details is not None:
|
||||
cached = getattr(details, "cached_tokens", None)
|
||||
if cached:
|
||||
return int(cached)
|
||||
return 0
|
||||
|
||||
|
||||
def completion_from_openai(resp: Any) -> Completion:
|
||||
choice = resp.choices[0]
|
||||
msg = choice.message
|
||||
text = msg.content or ""
|
||||
reasoning = getattr(msg, "reasoning_content", None) or None
|
||||
|
||||
tool_calls: list[ToolCall] = []
|
||||
raw_blocks: list[ContentBlock] = []
|
||||
if text or reasoning:
|
||||
raw_blocks.append(TextBlock(text=text or "", reasoning_content=reasoning))
|
||||
for i, tc in enumerate(msg.tool_calls or []):
|
||||
fn = tc.function
|
||||
parsed = _parse_tool_arguments(getattr(fn, "arguments", None))
|
||||
call = ToolCall(
|
||||
id=tc.id or f"{fn.name}_{i}",
|
||||
name=fn.name,
|
||||
input=parsed,
|
||||
reasoning_content=reasoning if i == 0 and not text else None,
|
||||
)
|
||||
tool_calls.append(call)
|
||||
raw_blocks.append(call)
|
||||
|
||||
usage_md = getattr(resp, "usage", None)
|
||||
if usage_md is not None:
|
||||
prompt = int(usage_md.prompt_tokens or 0)
|
||||
cached = _cache_hit_tokens(usage_md)
|
||||
usage = Usage(
|
||||
input_tokens=max(0, prompt - cached),
|
||||
output_tokens=int(usage_md.completion_tokens or 0),
|
||||
cache_creation_tokens=0,
|
||||
cache_read_tokens=cached,
|
||||
)
|
||||
else:
|
||||
usage = Usage()
|
||||
|
||||
stop = choice.finish_reason or "unknown"
|
||||
return Completion(
|
||||
text=text,
|
||||
tool_calls=tool_calls,
|
||||
usage=usage,
|
||||
stop_reason=str(stop),
|
||||
raw_assistant_blocks=raw_blocks,
|
||||
)
|
||||
|
||||
|
||||
class DeepSeekSession(LLMSession):
|
||||
provider_name = "deepseek"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: AsyncOpenAI,
|
||||
model: str,
|
||||
system: str,
|
||||
max_tokens: int,
|
||||
temperature: float | None = None,
|
||||
thinking: bool = True,
|
||||
reasoning_effort: str = "medium",
|
||||
) -> None:
|
||||
self._client = client
|
||||
self.model = model
|
||||
self._system = system
|
||||
self._max_tokens = max_tokens
|
||||
self._temperature = temperature
|
||||
self._thinking = thinking
|
||||
self._reasoning_effort = reasoning_effort
|
||||
self._vision = _is_vision_model(model)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
*,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSchema] | None = None,
|
||||
tool_choice: ToolChoice = "auto",
|
||||
) -> Completion:
|
||||
oai_messages: list[dict] = [
|
||||
{"role": "system", "content": self._system},
|
||||
]
|
||||
oai_messages.extend(messages_to_openai(messages, vision=self._vision))
|
||||
|
||||
# DeepSeek rejects forced tool_choice while thinking is on
|
||||
# ("Thinking mode does not support this tool_choice"). Auto-resolve
|
||||
# and extraction always force a save_* tool, so drop thinking there.
|
||||
forced_tool = isinstance(tool_choice, dict) and "name" in tool_choice
|
||||
thinking = self._thinking and not forced_tool
|
||||
|
||||
extra_body: dict[str, Any] = {
|
||||
"thinking": {"type": "enabled" if thinking else "disabled"},
|
||||
}
|
||||
if thinking:
|
||||
extra_body["reasoning_effort"] = self._reasoning_effort
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": oai_messages,
|
||||
"max_tokens": self._max_tokens,
|
||||
"extra_body": extra_body,
|
||||
}
|
||||
if self._temperature is not None:
|
||||
kwargs["temperature"] = self._temperature
|
||||
if tools:
|
||||
kwargs["tools"] = [_to_openai_tool(t) for t in tools]
|
||||
kwargs["tool_choice"] = _to_openai_tool_choice(tool_choice)
|
||||
|
||||
resp = await self._create(kwargs)
|
||||
completion = completion_from_openai(resp)
|
||||
# Thinking-only (no visible text, no tools) cannot be replayed on the
|
||||
# next turn. Retry once with thinking off so the review loop gets a
|
||||
# real content/tool_calls message instead of a 400 on the follow-up.
|
||||
if (
|
||||
thinking
|
||||
and not (completion.text or "").strip()
|
||||
and not completion.tool_calls
|
||||
):
|
||||
log.warning(
|
||||
"DeepSeek thinking-only turn (no content/tool_calls); "
|
||||
"retrying with thinking disabled"
|
||||
)
|
||||
extra_body = {
|
||||
"thinking": {"type": "disabled"},
|
||||
}
|
||||
kwargs = {**kwargs, "extra_body": extra_body}
|
||||
resp = await self._create(kwargs)
|
||||
completion = completion_from_openai(resp)
|
||||
return completion
|
||||
|
||||
async def _create(self, kwargs: dict[str, Any]) -> Any:
|
||||
try:
|
||||
return await self._client.chat.completions.create(**kwargs)
|
||||
except APIStatusError as exc:
|
||||
body = str(exc)
|
||||
if exc.status_code != 400 or "content or tool_calls" not in body:
|
||||
raise
|
||||
repaired = _repair_assistant_messages(list(kwargs["messages"]))
|
||||
if repaired == kwargs["messages"]:
|
||||
raise
|
||||
log.warning(
|
||||
"DeepSeek 400 (empty assistant content); retrying with "
|
||||
"placeholder content"
|
||||
)
|
||||
kwargs = {**kwargs, "messages": repaired}
|
||||
return await self._client.chat.completions.create(**kwargs)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DeepSeekProvider(LLMProvider):
|
||||
name = "deepseek"
|
||||
|
||||
def __init__(self) -> None:
|
||||
api_key = settings.deepseek_api_key
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
"DEEPSEEK_API_KEY is not set. Copy backend/.env.example to "
|
||||
"backend/.env and add a key from https://platform.deepseek.com/"
|
||||
)
|
||||
self._client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=settings.deepseek_base_url,
|
||||
timeout=httpx.Timeout(420.0, connect=20.0),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
*,
|
||||
model: str,
|
||||
system: str,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float | None = None,
|
||||
) -> LLMSession:
|
||||
thinking = settings.deepseek_thinking.strip().lower() != "disabled"
|
||||
effort = settings.deepseek_reasoning_effort
|
||||
if max_tokens >= 16000:
|
||||
effort = "high"
|
||||
return DeepSeekSession(
|
||||
client=self._client,
|
||||
model=model,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
thinking=thinking,
|
||||
reasoning_effort=effort,
|
||||
)
|
||||
|
||||
async def run_skill(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
model: str,
|
||||
system: str,
|
||||
user_text: str,
|
||||
pdf_path: str | None,
|
||||
output_tool: ToolSchema,
|
||||
) -> tuple[dict, Completion]:
|
||||
return await run_skill_locally(
|
||||
self,
|
||||
skill_name=skill_name,
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=user_text,
|
||||
pdf_path=pdf_path,
|
||||
output_tool=output_tool,
|
||||
)
|
||||
@@ -15,13 +15,18 @@ log = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
@lru_cache(maxsize=8)
|
||||
def get_provider_by_name(name: str) -> LLMProvider:
|
||||
"""Return a singleton provider instance for ``name`` ("anthropic" |
|
||||
"gemini"). Used by :func:`get_provider` and :func:`call_with_fallback`."""
|
||||
"""Return a singleton provider instance for ``name`` ("deepseek" |
|
||||
"anthropic" | "gemini"). Used by :func:`get_provider` and
|
||||
:func:`call_with_fallback`."""
|
||||
if name == "deepseek":
|
||||
from backend.services.llm.deepseek_provider import DeepSeekProvider
|
||||
return DeepSeekProvider()
|
||||
if name == "anthropic":
|
||||
from backend.services.llm.anthropic_provider import AnthropicProvider
|
||||
return AnthropicProvider()
|
||||
log.warning("Anthropic is disabled — using DeepSeek instead")
|
||||
from backend.services.llm.deepseek_provider import DeepSeekProvider
|
||||
return DeepSeekProvider()
|
||||
if name == "gemini":
|
||||
from backend.services.llm.gemini_provider import GeminiProvider
|
||||
return GeminiProvider()
|
||||
|
||||
@@ -371,9 +371,13 @@ class GeminiProvider(LLMProvider):
|
||||
pdf_path: str | None,
|
||||
output_tool: ToolSchema,
|
||||
) -> tuple[dict, Completion]:
|
||||
raise NotImplementedError(
|
||||
f"GeminiProvider.run_skill() not implemented (skill={skill_name!r}). "
|
||||
f"Anthropic Console Skills have no Gemini equivalent. To migrate "
|
||||
f"this skill to Gemini, inline its SKILL.md as the system prompt "
|
||||
f"and run validate.py locally."
|
||||
from backend.services.llm.local_skill import run_skill_locally
|
||||
return await run_skill_locally(
|
||||
self,
|
||||
skill_name=skill_name,
|
||||
model=model,
|
||||
system=system,
|
||||
user_text=user_text,
|
||||
pdf_path=pdf_path,
|
||||
output_tool=output_tool,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Provider-agnostic local skill runner.
|
||||
|
||||
Anthropic Console Skills have no equivalent on DeepSeek (or Gemini). This
|
||||
module inlines ``skills/<name>/SKILL.md`` as the system prompt, drives a
|
||||
normal tool-calling session, and runs ``validate.py`` locally after each
|
||||
``output_tool`` call. Used by DeepSeek and Gemini; Anthropic falls back
|
||||
here when no Console skill id is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.llm.base import LLMProvider
|
||||
from backend.services.llm.types import (
|
||||
Completion,
|
||||
Message,
|
||||
PdfBlock,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolSchema,
|
||||
Usage,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SKILL_MAX_TURNS = 10
|
||||
_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
||||
|
||||
_LOCAL_SKILL_TAIL = """
|
||||
You cannot run shell commands or Python. Do not try to execute validate.py.
|
||||
After extracting the data, call the `{tool}` tool with the structured result.
|
||||
The server validates the payload. If validation fails you will receive the
|
||||
errors and must call `{tool}` again with a corrected payload.
|
||||
Do NOT write files to disk.
|
||||
"""
|
||||
|
||||
|
||||
def skills_dir() -> Path:
|
||||
return Path(settings.skills_dir)
|
||||
|
||||
|
||||
def load_skill_markdown(skill_name: str) -> str:
|
||||
path = skills_dir() / skill_name / "SKILL.md"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Skill {skill_name!r} not found at {path}. "
|
||||
f"Expected skills/{skill_name}/SKILL.md in the repo."
|
||||
)
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
return _FRONTMATTER.sub("", raw).strip()
|
||||
|
||||
|
||||
def load_skill_validator(skill_name: str):
|
||||
"""Import ``skills/<name>/validate.py`` and return its ``validate`` fn."""
|
||||
path = skills_dir() / skill_name / "validate.py"
|
||||
if not path.is_file():
|
||||
return None
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"periscope_skill_{skill_name.replace('-', '_')}_validate", path,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
fn = getattr(mod, "validate", None)
|
||||
return fn if callable(fn) else None
|
||||
|
||||
|
||||
def _sum_usage(total: Usage, piece: Usage) -> Usage:
|
||||
return Usage(
|
||||
input_tokens=total.input_tokens + piece.input_tokens,
|
||||
output_tokens=total.output_tokens + piece.output_tokens,
|
||||
cache_creation_tokens=total.cache_creation_tokens + piece.cache_creation_tokens,
|
||||
cache_read_tokens=total.cache_read_tokens + piece.cache_read_tokens,
|
||||
)
|
||||
|
||||
|
||||
async def run_skill_locally(
|
||||
provider: LLMProvider,
|
||||
*,
|
||||
skill_name: str,
|
||||
model: str,
|
||||
system: str,
|
||||
user_text: str,
|
||||
pdf_path: str | None,
|
||||
output_tool: ToolSchema,
|
||||
max_turns: int = _SKILL_MAX_TURNS,
|
||||
) -> tuple[dict, Completion]:
|
||||
"""Run ``skill_name`` as an in-process tool loop on ``provider``."""
|
||||
skill_md = load_skill_markdown(skill_name)
|
||||
validator = load_skill_validator(skill_name)
|
||||
full_system = (
|
||||
skill_md
|
||||
+ "\n\n"
|
||||
+ system.strip()
|
||||
+ "\n"
|
||||
+ _LOCAL_SKILL_TAIL.format(tool=output_tool.name)
|
||||
)
|
||||
|
||||
user_blocks: list = []
|
||||
if pdf_path:
|
||||
user_blocks.append(PdfBlock(path=Path(pdf_path), cacheable=True))
|
||||
user_blocks.append(TextBlock(text=user_text, cacheable=True))
|
||||
messages: list[Message] = [Message(role="user", content=user_blocks)]
|
||||
|
||||
total = Usage()
|
||||
t0 = time.monotonic()
|
||||
last_completion: Completion | None = None
|
||||
|
||||
session = await provider.create_session(
|
||||
model=model, system=full_system, max_tokens=16384, temperature=0.0,
|
||||
)
|
||||
try:
|
||||
for turn in range(max_turns):
|
||||
force = turn >= max_turns - 2
|
||||
completion = await session.complete(
|
||||
messages=messages,
|
||||
tools=[output_tool],
|
||||
tool_choice={"name": output_tool.name} if force else "auto",
|
||||
)
|
||||
last_completion = completion
|
||||
total = _sum_usage(total, completion.usage)
|
||||
|
||||
payload: dict | None = None
|
||||
for tc in completion.tool_calls:
|
||||
if tc.name == output_tool.name:
|
||||
payload = dict(tc.input)
|
||||
break
|
||||
|
||||
messages.append(Message(
|
||||
role="assistant", content=completion.raw_assistant_blocks,
|
||||
))
|
||||
|
||||
if payload is None:
|
||||
messages.append(Message(role="user", content=[TextBlock(
|
||||
text=(
|
||||
f"You did not call {output_tool.name}. "
|
||||
f"Call it now with the extracted data."
|
||||
),
|
||||
)]))
|
||||
continue
|
||||
|
||||
errors: list[str] = []
|
||||
if validator is not None:
|
||||
check = dict(payload)
|
||||
mpn_hint = re.search(r"MPN:\s*(\S+)", user_text or "", re.I)
|
||||
if mpn_hint and "mpn" not in check:
|
||||
check["mpn"] = mpn_hint.group(1).rstrip(".,;")
|
||||
try:
|
||||
errors = list(validator(check) or [])
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Skill %s validate.py raised: %s", skill_name, exc,
|
||||
)
|
||||
errors = [f"validator crashed: {exc}"]
|
||||
|
||||
if not errors:
|
||||
completion.usage = total
|
||||
completion.turns = turn + 1 # type: ignore[attr-defined]
|
||||
completion.duration_ms = int((time.monotonic() - t0) * 1000) # type: ignore[attr-defined]
|
||||
return payload, completion
|
||||
|
||||
messages.append(Message(
|
||||
role="user",
|
||||
content=[
|
||||
ToolResultBlock(
|
||||
tool_use_id=completion.tool_calls[0].id,
|
||||
name=output_tool.name,
|
||||
content="VALIDATION FAILED:\n" + "\n".join(
|
||||
f"- {e}" for e in errors
|
||||
),
|
||||
),
|
||||
TextBlock(
|
||||
text=(
|
||||
"Fix the payload and call "
|
||||
f"{output_tool.name} again."
|
||||
),
|
||||
),
|
||||
],
|
||||
))
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Skill {skill_name!r} did not produce a valid {output_tool.name} "
|
||||
f"in {max_turns} turns"
|
||||
+ (f" (last stop_reason={last_completion.stop_reason})"
|
||||
if last_completion else "")
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Convert datasheet PDFs into text (and optional page images).
|
||||
|
||||
DeepSeek's Chat Completions API does not accept native PDF documents.
|
||||
Anthropic/Gemini providers send the file bytes; DeepSeek instead extracts
|
||||
text with PyMuPDF (pypdf fallback) and, on a vision model, renders the
|
||||
pages that actually matter (pin tables, abs-max, electrical, application)
|
||||
rather than always the first N pages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.pdf_text import (
|
||||
extract_pdf_document_text,
|
||||
fitz_page_text,
|
||||
page_is_sparse,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_MAX_CHARS = 500_000
|
||||
_DEFAULT_MAX_IMAGES = 32
|
||||
_RENDER_ZOOM = 1.7
|
||||
|
||||
# Pages whose diagrams/tables the model must actually see.
|
||||
_PAGE_KEYWORDS = re.compile(
|
||||
r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)"
|
||||
r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description"
|
||||
r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics"
|
||||
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
|
||||
r"|decoupling|bypass\s+capacitor|typical\s+application"
|
||||
r"|application\s+(circuit|schematic|information|note)|reference\s+design"
|
||||
r"|block\s+diagram|functional\s+block|internal\s+block"
|
||||
r"|pcb\s+layout|layout\s+consideration|thermal\s+via"
|
||||
r"|ordering\s+information|device\s+information",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_pdf_text(path: Path | str, *, max_chars: int = _DEFAULT_MAX_CHARS) -> str:
|
||||
"""Return datasheet text with page markers, truncated to ``max_chars``.
|
||||
|
||||
Uses reading-order blocks and reconstructed tables (PyMuPDF), with pypdf
|
||||
as fallback. Sparse/scan pages are flagged so the vision pass can cover them.
|
||||
"""
|
||||
return extract_pdf_document_text(path, max_chars=max_chars)
|
||||
|
||||
|
||||
def relevant_page_indices(
|
||||
path: Path | str,
|
||||
*,
|
||||
max_pages: int,
|
||||
keywords: re.Pattern[str] = _PAGE_KEYWORDS,
|
||||
) -> list[int]:
|
||||
"""0-based page indices to send as images: front matter + keyword hits."""
|
||||
pdf_path = Path(path)
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception:
|
||||
return list(range(max_pages))
|
||||
try:
|
||||
total = len(doc)
|
||||
if total <= max_pages:
|
||||
return list(range(total))
|
||||
hits: set[int] = set()
|
||||
sparse: list[int] = []
|
||||
for i in range(total):
|
||||
try:
|
||||
text = fitz_page_text(doc[i])
|
||||
except Exception:
|
||||
text = ""
|
||||
if keywords.search(text):
|
||||
for neighbor in (i - 1, i, i + 1):
|
||||
if 0 <= neighbor < total:
|
||||
hits.add(neighbor)
|
||||
elif page_is_sparse(text) and _page_has_artwork(doc[i]):
|
||||
sparse.append(i)
|
||||
for i in sparse[:8]:
|
||||
hits.add(i)
|
||||
front = set(range(min(5, total)))
|
||||
ranked_hits = sorted(hits)
|
||||
if len(ranked_hits) >= max_pages:
|
||||
keep_front = [i for i in ranked_hits if i < 5][:2]
|
||||
rest = [i for i in ranked_hits if i not in keep_front]
|
||||
need = max_pages - len(keep_front)
|
||||
return sorted(keep_front + rest[-need:])
|
||||
chosen = set(hits)
|
||||
for i in sorted(front) + list(range(total)):
|
||||
if len(chosen) >= max_pages:
|
||||
break
|
||||
chosen.add(i)
|
||||
return sorted(chosen)
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
def render_pdf_page_jpegs(
|
||||
path: Path | str,
|
||||
*,
|
||||
max_pages: int = _DEFAULT_MAX_IMAGES,
|
||||
zoom: float = _RENDER_ZOOM,
|
||||
page_indices: list[int] | None = None,
|
||||
) -> list[tuple[int, bytes]]:
|
||||
"""Render selected pages as JPEG bytes.
|
||||
|
||||
``page_indices`` is 0-based. When omitted, keyword-relevant pages are
|
||||
chosen instead of always rendering the front of the PDF.
|
||||
|
||||
Returns a list of (1-based page number, jpeg bytes). Empty if PyMuPDF
|
||||
is not installed or rendering fails — callers should still send text.
|
||||
"""
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
log.info("PyMuPDF not installed — DeepSeek vision page images skipped")
|
||||
return []
|
||||
|
||||
pdf_path = Path(path)
|
||||
if page_indices is None:
|
||||
page_indices = relevant_page_indices(pdf_path, max_pages=max_pages)
|
||||
|
||||
out: list[tuple[int, bytes]] = []
|
||||
try:
|
||||
doc = fitz.open(str(pdf_path))
|
||||
except Exception as exc:
|
||||
log.warning("PyMuPDF failed to open %s: %s", pdf_path, exc)
|
||||
return []
|
||||
|
||||
try:
|
||||
total = len(doc)
|
||||
omitted = [i + 1 for i in range(total) if i not in set(page_indices)]
|
||||
if omitted:
|
||||
log.info(
|
||||
"PDF page images: %s rendering %d/%d pages; omitted e.g. %s",
|
||||
pdf_path.name,
|
||||
min(len(page_indices), max_pages),
|
||||
total,
|
||||
omitted[:12],
|
||||
)
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
for i in page_indices:
|
||||
if i < 0 or i >= len(doc):
|
||||
continue
|
||||
page = doc[i]
|
||||
pix = page.get_pixmap(matrix=matrix, alpha=False)
|
||||
jpeg = pix.tobytes("jpeg")
|
||||
out.append((i + 1, jpeg))
|
||||
if len(out) >= max_pages:
|
||||
break
|
||||
except Exception as exc:
|
||||
log.warning("PyMuPDF render failed for %s: %s", pdf_path, exc)
|
||||
return out
|
||||
finally:
|
||||
doc.close()
|
||||
return out
|
||||
|
||||
|
||||
def jpeg_data_url(jpeg: bytes) -> str:
|
||||
b64 = base64.standard_b64encode(jpeg).decode("ascii")
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def _page_has_artwork(page) -> bool:
|
||||
try:
|
||||
if page.get_images():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return bool(page.get_drawings())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def pdf_to_openai_content(
|
||||
path: Path | str,
|
||||
*,
|
||||
vision: bool,
|
||||
max_chars: int = _DEFAULT_MAX_CHARS,
|
||||
max_images: int = _DEFAULT_MAX_IMAGES,
|
||||
) -> list[dict]:
|
||||
"""OpenAI-style content parts for one PDF: text, plus images if vision."""
|
||||
pdf_path = Path(path)
|
||||
try:
|
||||
mtime = pdf_path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mtime = 0
|
||||
key = (str(pdf_path.resolve()), mtime, vision, max_chars, max_images)
|
||||
cached = _PDF_CONTENT_CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
parts = _pdf_to_openai_content_uncached(
|
||||
pdf_path, vision=vision, max_chars=max_chars, max_images=max_images,
|
||||
)
|
||||
if len(_PDF_CONTENT_CACHE) > 32:
|
||||
_PDF_CONTENT_CACHE.clear()
|
||||
_PDF_CONTENT_CACHE[key] = parts
|
||||
return parts
|
||||
|
||||
|
||||
_PDF_CONTENT_CACHE: dict[tuple, list[dict]] = {}
|
||||
|
||||
|
||||
def _pdf_to_openai_content_uncached(
|
||||
path: Path,
|
||||
*,
|
||||
vision: bool,
|
||||
max_chars: int,
|
||||
max_images: int,
|
||||
) -> list[dict]:
|
||||
text = extract_pdf_text(path, max_chars=max_chars)
|
||||
parts: list[dict] = [{"type": "text", "text": text}]
|
||||
if not vision:
|
||||
return parts
|
||||
images = render_pdf_page_jpegs(path, max_pages=max_images)
|
||||
if not images:
|
||||
return parts
|
||||
parts.append({
|
||||
"type": "text",
|
||||
"text": (
|
||||
f"The following {len(images)} image(s) are rendered pages of "
|
||||
f"{path.name} (pin tables, abs-max, electrical, and "
|
||||
f"application sections preferred over the front matter). "
|
||||
f"Use them for diagrams and tables that text extraction may have missed."
|
||||
),
|
||||
})
|
||||
for page_no, jpeg in images:
|
||||
parts.append({
|
||||
"type": "text",
|
||||
"text": f"[page {page_no} image]",
|
||||
})
|
||||
parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": jpeg_data_url(jpeg), "detail": "high"},
|
||||
})
|
||||
return parts
|
||||
|
||||
|
||||
def make_text_pdf(pages: list[str]) -> bytes:
|
||||
"""Build a tiny text-only PDF for tests. Uses PyMuPDF when available,
|
||||
otherwise a hand-rolled one-page PDF."""
|
||||
try:
|
||||
import fitz
|
||||
doc = fitz.open()
|
||||
for body in pages:
|
||||
page = doc.new_page()
|
||||
page.insert_text((72, 72), body, fontsize=11)
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
doc.close()
|
||||
return buf.getvalue()
|
||||
except ImportError:
|
||||
pass
|
||||
# Minimal one-page PDF with the first page's text.
|
||||
payload = (pages[0] if pages else "test").encode("latin-1", "replace")
|
||||
stream = b"BT /F1 12 Tf 72 720 Td (" + payload.replace(b"(", b"[").replace(b")", b"]") + b") Tj ET"
|
||||
return (
|
||||
b"%PDF-1.1\n"
|
||||
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
|
||||
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
|
||||
b"4 0 obj<</Length " + str(len(stream)).encode() + b">>stream\n"
|
||||
+ stream + b"\nendstream\nendobj\n"
|
||||
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
|
||||
b"xref\n0 6\n0000000000 65535 f \n"
|
||||
b"trailer<</Size 6/Root 1 0 R>>\nstartxref\n0\n%%EOF\n"
|
||||
)
|
||||
@@ -8,10 +8,26 @@ from __future__ import annotations
|
||||
|
||||
|
||||
# Per-million-token USD rates. Source-of-truth links:
|
||||
# DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
|
||||
# Anthropic: https://docs.anthropic.com/en/docs/about-claude/pricing
|
||||
# Google: https://ai.google.dev/pricing
|
||||
# Last updated: 2026-07-01
|
||||
# Last updated: 2026-09-10
|
||||
#
|
||||
# DeepSeek: peak weekday rates (conservative). Off-peak is 50% of these.
|
||||
# Cache-hit input is billed via CACHE_RATES["deepseek"]["read"] as a
|
||||
# multiplier on the miss input rate (0.006 / 0.30 = 0.02).
|
||||
_DEEPSEEK_FLASH = {"input": 0.30, "output": 1.20}
|
||||
_DEEPSEEK_PRO = {"input": 1.32, "output": 3.96}
|
||||
|
||||
PRICING: dict[str, dict[str, dict[str, float]]] = {
|
||||
"deepseek": {
|
||||
"deepseek-flash": _DEEPSEEK_FLASH,
|
||||
"deepseek-v4-flash": _DEEPSEEK_FLASH,
|
||||
"deepseek-v4-flash-vision-exp": _DEEPSEEK_FLASH,
|
||||
# Billed at Pro until 2026-09-14 04:00 UTC, then routed to Flash.
|
||||
"deepseek-v4-pro": _DEEPSEEK_PRO,
|
||||
"default": _DEEPSEEK_FLASH,
|
||||
},
|
||||
"anthropic": {
|
||||
"claude-opus-4-6": {"input": 5.00, "output": 25.00},
|
||||
"claude-opus-4-5": {"input": 5.00, "output": 25.00},
|
||||
@@ -54,6 +70,7 @@ PRICING: dict[str, dict[str, dict[str, float]]] = {
|
||||
# normal input pass)
|
||||
# read: cost when a cached prefix is *reused* (much cheaper)
|
||||
CACHE_RATES: dict[str, dict[str, float]] = {
|
||||
"deepseek": {"create": 1.00, "read": 0.02},
|
||||
"anthropic": {"create": 1.25, "read": 0.10},
|
||||
"gemini": {"create": 1.00, "read": 0.25},
|
||||
}
|
||||
@@ -62,10 +79,10 @@ CACHE_RATES: dict[str, dict[str, float]] = {
|
||||
def cost_for_entry(entry: dict) -> float:
|
||||
"""USD cost for an api_logs entry. Reads ``provider`` (default
|
||||
``anthropic`` for legacy entries) and ``model`` to pick rates."""
|
||||
provider = entry.get("provider") or "anthropic"
|
||||
table = PRICING.get(provider) or PRICING["anthropic"]
|
||||
provider = entry.get("provider") or "deepseek"
|
||||
table = PRICING.get(provider) or PRICING["deepseek"]
|
||||
rates = table.get(entry.get("model", ""), table["default"])
|
||||
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["anthropic"])
|
||||
cache_rates = CACHE_RATES.get(provider, CACHE_RATES["deepseek"])
|
||||
input_rate = rates["input"]
|
||||
output_rate = rates["output"]
|
||||
return (
|
||||
|
||||
@@ -26,12 +26,17 @@ class TextBlock:
|
||||
# this turn is fed back into the conversation, or the next call 400s.
|
||||
# Anthropic: always None.
|
||||
thought_signature: bytes | None = None
|
||||
# DeepSeek thinking-mode: assistant ``reasoning_content`` that must be
|
||||
# replayed on the next turn or the API returns 400.
|
||||
reasoning_content: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfBlock:
|
||||
"""Inline PDF document. Provider encodes as base64 (Anthropic) or
|
||||
inline_data (Gemini) and applies caching policy if cacheable=True."""
|
||||
"""Inline PDF document. Anthropic encodes as base64, Gemini as
|
||||
inline_data. DeepSeek does not accept PDFs natively — the provider
|
||||
converts the file to extracted text (and page images on a vision
|
||||
model) before sending."""
|
||||
path: Path
|
||||
cacheable: bool = False
|
||||
|
||||
@@ -45,6 +50,7 @@ class ToolCall:
|
||||
# Same purpose as TextBlock.thought_signature — Gemini 3 attaches one
|
||||
# to every function_call part when thinking is on. Round-trip required.
|
||||
thought_signature: bytes | None = None
|
||||
reasoning_content: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Periscope local JWT helpers (HS256)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_TTL_DAYS = 30
|
||||
|
||||
|
||||
def issue_token(user_id: str, email: str) -> str:
|
||||
secret = settings.auth_jwt_secret
|
||||
if not secret:
|
||||
raise RuntimeError("AUTH_JWT_SECRET is not configured")
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"email": email,
|
||||
"iss": "periscope-local",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(days=TOKEN_TTL_DAYS),
|
||||
}
|
||||
return jwt.encode(payload, secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any] | None:
|
||||
secret = settings.auth_jwt_secret
|
||||
if not secret:
|
||||
return None
|
||||
# Accept pre-rebrand issuer so existing sessions keep working.
|
||||
for issuer in ("periscope-local", "pinscope-local"):
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=[ALGORITHM],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
leeway=10,
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
continue
|
||||
return None
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Local Periscope user directory (self-host auth, no Clerk).
|
||||
|
||||
Users live under ``data/auth/users/{user_id}.json`` with an email index.
|
||||
Passwords use stdlib ``hashlib.scrypt``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalUser:
|
||||
user_id: str
|
||||
email: str
|
||||
name: str | None
|
||||
password_hash: str
|
||||
is_admin: bool = False
|
||||
created_at: str = ""
|
||||
|
||||
def public(self) -> dict:
|
||||
return {
|
||||
"user_id": self.user_id,
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"is_admin": self.is_admin,
|
||||
}
|
||||
|
||||
|
||||
def _auth_root() -> Path:
|
||||
root = Path(settings.data_dir) / "auth"
|
||||
(root / "users").mkdir(parents=True, exist_ok=True)
|
||||
(root / "by_email").mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _email_key(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _email_path(email: str) -> Path:
|
||||
# Filesystem-safe key from normalized email
|
||||
key = _email_key(email).replace("/", "_")
|
||||
return _auth_root() / "by_email" / f"{key}.json"
|
||||
|
||||
|
||||
def _user_path(user_id: str) -> Path:
|
||||
return _auth_root() / "users" / f"{user_id}.json"
|
||||
|
||||
|
||||
def hash_password(password: str, *, salt: bytes | None = None) -> str:
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
|
||||
)
|
||||
return f"scrypt${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
algo, salt_hex, digest_hex = encoded.split("$", 2)
|
||||
except ValueError:
|
||||
return False
|
||||
if algo != "scrypt":
|
||||
return False
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
check = hash_password(password, salt=salt)
|
||||
return secrets.compare_digest(check, encoded)
|
||||
|
||||
|
||||
def validate_email(email: str) -> str:
|
||||
e = email.strip().lower()
|
||||
if not _EMAIL_RE.match(e):
|
||||
raise ValueError("Invalid email address")
|
||||
return e
|
||||
|
||||
|
||||
def validate_password(password: str) -> None:
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
|
||||
|
||||
def get_user(user_id: str) -> LocalUser | None:
|
||||
path = _user_path(user_id)
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return LocalUser(**data)
|
||||
|
||||
|
||||
def find_by_email(email: str) -> LocalUser | None:
|
||||
path = _email_path(email)
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
uid = data.get("user_id")
|
||||
if not uid:
|
||||
return None
|
||||
return get_user(uid)
|
||||
|
||||
|
||||
def list_users() -> list[LocalUser]:
|
||||
users_dir = _auth_root() / "users"
|
||||
out: list[LocalUser] = []
|
||||
for path in sorted(users_dir.glob("*.json")):
|
||||
try:
|
||||
out.append(LocalUser(**json.loads(path.read_text(encoding="utf-8"))))
|
||||
except Exception:
|
||||
logger.exception("Skipping corrupt user file %s", path)
|
||||
return out
|
||||
|
||||
|
||||
def _save_user(user: LocalUser) -> None:
|
||||
_user_path(user.user_id).write_text(
|
||||
json.dumps(asdict(user), indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
_email_path(user.email).write_text(
|
||||
json.dumps({"user_id": user.user_id}) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def user_count() -> int:
|
||||
return len(list((_auth_root() / "users").glob("*.json")))
|
||||
|
||||
|
||||
def _migrate_local_projects(new_owner_id: str) -> int:
|
||||
"""Move ``users/local/projects/*`` under the first admin, if present."""
|
||||
local_projects = Path(settings.data_dir) / "users" / "local" / "projects"
|
||||
if not local_projects.is_dir():
|
||||
return 0
|
||||
dest_root = Path(settings.data_dir) / "users" / new_owner_id / "projects"
|
||||
dest_root.mkdir(parents=True, exist_ok=True)
|
||||
moved = 0
|
||||
for child in local_projects.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
target = dest_root / child.name
|
||||
if target.exists():
|
||||
continue
|
||||
shutil.move(str(child), str(target))
|
||||
moved += 1
|
||||
logger.info("Migrated project %s → user %s", child.name, new_owner_id)
|
||||
return moved
|
||||
|
||||
|
||||
def create_user(email: str, password: str, name: str | None = None) -> LocalUser:
|
||||
email = validate_email(email)
|
||||
validate_password(password)
|
||||
if find_by_email(email):
|
||||
raise ValueError("An account with that email already exists")
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
first = user_count() == 0
|
||||
admin_emails = {
|
||||
e.strip().lower()
|
||||
for e in (settings.auth_admin_emails or "").split(",")
|
||||
if e.strip()
|
||||
}
|
||||
is_admin = first or email in admin_emails
|
||||
|
||||
user = LocalUser(
|
||||
user_id="usr_" + uuid.uuid4().hex,
|
||||
email=email,
|
||||
name=(name or "").strip() or None,
|
||||
password_hash=hash_password(password),
|
||||
is_admin=is_admin,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
_save_user(user)
|
||||
|
||||
if first:
|
||||
try:
|
||||
n = _migrate_local_projects(user.user_id)
|
||||
if n:
|
||||
logger.info("First admin inherited %s local project(s)", n)
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate users/local projects")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(email: str, password: str) -> LocalUser | None:
|
||||
user = find_by_email(email)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Mouser Search API — optional fourth datasheet source (same MPN matching as DigiKey)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.datasheet_finder import (
|
||||
_alnum,
|
||||
_download_pdf,
|
||||
mpn_catalog_match,
|
||||
mpn_matches,
|
||||
mpn_query_variants,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_URL = "https://api.mouser.com/api/v1/search/partnumber"
|
||||
|
||||
|
||||
class MouserFetchResult:
|
||||
def __init__(
|
||||
self,
|
||||
mpn: str,
|
||||
pdf_bytes: bytes | None = None,
|
||||
error: str | None = None,
|
||||
url: str | None = None,
|
||||
catalog_mpn: str | None = None,
|
||||
):
|
||||
self.mpn = mpn
|
||||
self.pdf_bytes = pdf_bytes
|
||||
self.error = error
|
||||
self.url = url
|
||||
self.catalog_mpn = catalog_mpn
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.pdf_bytes is not None
|
||||
|
||||
|
||||
def _product_mpn(product: dict) -> str:
|
||||
return (
|
||||
product.get("ManufacturerPartNumber")
|
||||
or product.get("MouserPartNumber")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _product_ds(product: dict) -> str:
|
||||
url = product.get("DataSheetUrl") or product.get("DatasheetUrl") or ""
|
||||
if url.startswith("//"):
|
||||
url = "https:" + url
|
||||
return url
|
||||
|
||||
|
||||
def _pick_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
exact: dict | None = None
|
||||
loose: dict | None = None
|
||||
family: dict | None = None
|
||||
want = _alnum(mpn)
|
||||
for product in products:
|
||||
cand = _product_mpn(product)
|
||||
if not cand:
|
||||
continue
|
||||
got = _alnum(cand)
|
||||
if got == want:
|
||||
exact = product
|
||||
break
|
||||
if loose is None and mpn_matches(mpn, cand):
|
||||
loose = product
|
||||
elif family is None and mpn_catalog_match(mpn, cand):
|
||||
family = product
|
||||
return exact or loose or family
|
||||
|
||||
|
||||
async def _keyword_search(mpn: str) -> list[dict]:
|
||||
key = settings.mouser_api_key
|
||||
payload = {
|
||||
"SearchByPartRequest": {
|
||||
"mouserPartNumber": mpn,
|
||||
"partSearchOptions": "Exact",
|
||||
}
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(
|
||||
_SEARCH_URL,
|
||||
params={"apiKey": key},
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("SearchResults") or {}
|
||||
return result.get("Parts") or []
|
||||
|
||||
|
||||
def pick_mouser_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
"""Public for tests — same family/packing rules as DigiKey/LCSC."""
|
||||
return _pick_product(mpn, products)
|
||||
|
||||
|
||||
async def fetch_datasheet(mpn: str) -> MouserFetchResult:
|
||||
if not settings.use_mouser:
|
||||
return MouserFetchResult(mpn, error="Mouser API not configured")
|
||||
|
||||
product: dict | None = None
|
||||
last_err: str | None = None
|
||||
for query in mpn_query_variants(mpn):
|
||||
try:
|
||||
products = await _keyword_search(query)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
last_err = f"Mouser search failed ({exc.response.status_code})"
|
||||
logger.info("Mouser search %s: %s", query, last_err)
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_err = f"Mouser search error: {exc}"
|
||||
logger.info("Mouser search %s: %s", query, last_err)
|
||||
continue
|
||||
product = _pick_product(mpn, products)
|
||||
if product:
|
||||
break
|
||||
|
||||
if not product:
|
||||
return MouserFetchResult(mpn, error=last_err or "No datasheet found on Mouser")
|
||||
|
||||
url = _product_ds(product)
|
||||
catalog = _product_mpn(product) or None
|
||||
if not url:
|
||||
return MouserFetchResult(
|
||||
mpn, error="No datasheet URL on Mouser", catalog_mpn=catalog,
|
||||
)
|
||||
|
||||
try:
|
||||
pdf = await _download_pdf(url, mpn=mpn)
|
||||
except Exception as exc:
|
||||
logger.info("Mouser PDF download failed for %s (%s): %s", mpn, url, exc)
|
||||
return MouserFetchResult(
|
||||
mpn, error=f"Mouser download failed: {exc}", url=url, catalog_mpn=catalog,
|
||||
)
|
||||
logger.info("Fetched datasheet for %s via Mouser (%d KB)", mpn, len(pdf) // 1024)
|
||||
return MouserFetchResult(mpn, pdf_bytes=pdf, url=url, catalog_mpn=catalog)
|
||||
@@ -23,7 +23,7 @@ from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from backend.config import settings
|
||||
from backend.pinscopex.models import Finding
|
||||
from backend.periscopex.models import Finding
|
||||
from backend.services.api_logs import ApiLogger
|
||||
from backend.services.llm import Message, TextBlock
|
||||
from backend.services.llm.factory import call_with_fallback
|
||||
@@ -394,20 +394,15 @@ def _build_normalized(
|
||||
new_why = "Unverified: " + new_why
|
||||
|
||||
try:
|
||||
result.append(Finding(
|
||||
finding_id=canon.finding_id,
|
||||
designator=canon.designator,
|
||||
mpn=canon.mpn,
|
||||
aspect=canon.aspect,
|
||||
finding=str(entry.get("finding") or canon.finding),
|
||||
why=new_why,
|
||||
source_page=entry.get("source_page", canon.source_page),
|
||||
source_quote=str(entry.get("source_quote") or canon.source_quote),
|
||||
source_designator=canon.source_designator,
|
||||
status=final_status,
|
||||
recommendation=str(entry.get("recommendation") or canon.recommendation),
|
||||
reference=str(entry.get("reference") or canon.reference),
|
||||
))
|
||||
result.append(canon.model_copy(update={
|
||||
"finding": str(entry.get("finding") or canon.finding),
|
||||
"why": new_why,
|
||||
"source_page": entry.get("source_page", canon.source_page),
|
||||
"source_quote": str(entry.get("source_quote") or canon.source_quote),
|
||||
"status": final_status,
|
||||
"recommendation": str(entry.get("recommendation") or canon.recommendation),
|
||||
"reference": str(entry.get("reference") or canon.reference),
|
||||
}))
|
||||
except Exception:
|
||||
log.exception("normalize: failed to build merged Finding")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Map distributor parameters / LCSC descriptions to typed passive specs.
|
||||
|
||||
Used so capacitor / resistor / inductor rows skip the LLM when the catalog
|
||||
already states value, voltage, tolerance, and package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import ComponentModel, SimpleComponentSpecs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
|
||||
_CAP = re.compile(
|
||||
r"(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*[fF]\b",
|
||||
)
|
||||
_RES = re.compile(
|
||||
r"(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmkM])?\s*(?:ohms?|Ω|R)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_IND = re.compile(
|
||||
r"(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*H\b",
|
||||
)
|
||||
_TOL = re.compile(r"±\s*(?P<num>\d+(?:\.\d+)?)\s*%")
|
||||
_VOLT = re.compile(r"(?P<num>\d+(?:\.\d+)?)\s*V\b")
|
||||
_PKG = re.compile(r"\b(?P<pkg>0201|0402|0603|0805|1206|1210|1812|2220|2512)\b")
|
||||
_DIEL = re.compile(r"\b(?P<diel>C0G|NP0|X5R|X6S|X7R|X7S|X8R|Y5V|Z5U)\b", re.I)
|
||||
_IMP_FREQ = re.compile(r"@\s*\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)", re.I)
|
||||
|
||||
_MUL = {
|
||||
"p": 1e-12, "n": 1e-9, "u": 1e-6, "μ": 1e-6, "µ": 1e-6,
|
||||
"m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6,
|
||||
}
|
||||
|
||||
|
||||
def _spice(num: str, mul: str | None, unit: str) -> str:
|
||||
n = float(num)
|
||||
factor = _MUL.get((mul or ""), 1.0)
|
||||
value = n * factor
|
||||
if unit == "F":
|
||||
if value >= 1e-6:
|
||||
return f"{value * 1e6:g}uF"
|
||||
if value >= 1e-9:
|
||||
return f"{value * 1e9:g}nF"
|
||||
return f"{value * 1e12:g}pF"
|
||||
if unit == "ohm":
|
||||
if value >= 1e6:
|
||||
return f"{value / 1e6:g}Mohm"
|
||||
if value >= 1e3:
|
||||
return f"{value / 1e3:g}kohm"
|
||||
return f"{value:g}ohm"
|
||||
if unit == "H":
|
||||
if value >= 1e-3:
|
||||
return f"{value * 1e3:g}mH"
|
||||
if value >= 1e-6:
|
||||
return f"{value * 1e6:g}uH"
|
||||
return f"{value * 1e9:g}nH"
|
||||
return f"{value:g}{unit}"
|
||||
|
||||
|
||||
def _param_map(params: list[dict[str, str]]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for p in params:
|
||||
name = (p.get("name") or "").strip().lower()
|
||||
value = (p.get("value") or "").strip()
|
||||
if name and value:
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
def _first(pmap: dict[str, str], *needles: str) -> str | None:
|
||||
for key, val in pmap.items():
|
||||
for needle in needles:
|
||||
if needle in key:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _classify(category: str, description: str, pmap: dict[str, str]) -> str | None:
|
||||
blob = f"{category} {description} {' '.join(pmap.values())}".lower()
|
||||
if "ferrite" in blob or re.search(r"\bbead\b", blob):
|
||||
return "passive.ferrite_bead"
|
||||
if "capacitor" in blob or "mlcc" in blob or "ceramic" in blob:
|
||||
diel = _DIEL.search(description) or _DIEL.search(" ".join(pmap.values()))
|
||||
if diel or "ceramic" in blob or "mlcc" in blob:
|
||||
return "passive.capacitor.ceramic"
|
||||
if "tantalum" in blob:
|
||||
return "passive.capacitor.tantalum"
|
||||
if "electrolytic" in blob or "aluminum" in blob:
|
||||
return "passive.capacitor.electrolytic"
|
||||
return "passive.capacitor.ceramic"
|
||||
if "resistor" in blob:
|
||||
if "thin film" in blob:
|
||||
return "passive.resistor.thin_film"
|
||||
if "thick film" in blob:
|
||||
return "passive.resistor.thick_film"
|
||||
return "passive.resistor"
|
||||
if "inductor" in blob or "choke" in blob:
|
||||
return "passive.inductor"
|
||||
return None
|
||||
|
||||
|
||||
def specs_from_distributor(
|
||||
*,
|
||||
mpn: str,
|
||||
params: list[dict[str, str]],
|
||||
category: str,
|
||||
description: str,
|
||||
) -> ComponentModel | None:
|
||||
"""Return a ComponentModel when value + type can be parsed without an LLM."""
|
||||
pmap = _param_map(params)
|
||||
text = " ".join(
|
||||
[description, category, *pmap.values()],
|
||||
)
|
||||
subtype = _classify(category, description, pmap)
|
||||
if not subtype:
|
||||
# Infer from parsed units in the description alone
|
||||
if _CAP.search(text) and not _RES.search(text):
|
||||
subtype = "passive.capacitor.ceramic"
|
||||
elif _RES.search(text) and "capacitor" not in text.lower():
|
||||
subtype = "passive.resistor"
|
||||
elif _IND.search(text):
|
||||
subtype = "passive.inductor"
|
||||
else:
|
||||
return None
|
||||
|
||||
values: dict[str, str] = {}
|
||||
cap = _first(pmap, "capacitance") or (
|
||||
_CAP.search(text).group(0) if _CAP.search(text) else None
|
||||
)
|
||||
res = _first(pmap, "resistance") or (
|
||||
_RES.search(text).group(0) if _RES.search(text) else None
|
||||
)
|
||||
ind = _first(pmap, "inductance") or (
|
||||
_IND.search(text).group(0) if _IND.search(text) else None
|
||||
)
|
||||
|
||||
if subtype.startswith("passive.capacitor"):
|
||||
if not cap:
|
||||
return None
|
||||
m = _CAP.search(cap) or _CAP.search(text)
|
||||
if not m:
|
||||
return None
|
||||
values["value_farads"] = _spice(m.group("num"), m.group("mul"), "F")
|
||||
values["value_formatted"] = values["value_farads"]
|
||||
volt = _first(pmap, "voltage") or (
|
||||
f"{_VOLT.search(text).group('num')}V" if _VOLT.search(text) else None
|
||||
)
|
||||
if volt:
|
||||
values["voltage_rating_v"] = volt if volt.lower().endswith("v") else f"{volt}V"
|
||||
diel = _first(pmap, "temperature coefficient", "dielectric")
|
||||
dm = _DIEL.search(diel or "") or _DIEL.search(text)
|
||||
if dm:
|
||||
values["dielectric"] = dm.group("diel").upper().replace("NP0", "C0G")
|
||||
elif subtype.startswith("passive.resistor"):
|
||||
if not res:
|
||||
return None
|
||||
m = _RES.search(res) or _RES.search(text)
|
||||
if not m:
|
||||
return None
|
||||
values["value_ohms"] = _spice(m.group("num"), m.group("mul"), "ohm")
|
||||
values["value_formatted"] = values["value_ohms"]
|
||||
power = _first(pmap, "power")
|
||||
if power:
|
||||
values["power_rating_w"] = power
|
||||
elif subtype == "passive.ferrite_bead":
|
||||
imp_src = _first(pmap, "impedance") or text
|
||||
m = _RES.search(imp_src) or _RES.search(text)
|
||||
if not m:
|
||||
return None
|
||||
z = _spice(m.group("num"), m.group("mul"), "ohm")
|
||||
values["impedance_ohm"] = z
|
||||
freq = _IMP_FREQ.search(imp_src) or _IMP_FREQ.search(text)
|
||||
values["value_formatted"] = f"{z}@{freq.group(0)[1:].strip()}" if freq else z
|
||||
cur = _first(pmap, "current rating", "rated current")
|
||||
if cur:
|
||||
values["current_rating_a"] = cur
|
||||
dcr = _first(pmap, "dc resistance", "dcr")
|
||||
if dcr:
|
||||
values["dcr_ohms"] = dcr
|
||||
elif subtype.startswith("passive.inductor"):
|
||||
if not ind:
|
||||
return None
|
||||
m = _IND.search(ind) or _IND.search(text)
|
||||
if not m:
|
||||
return None
|
||||
values["value_henries"] = _spice(m.group("num"), m.group("mul"), "H")
|
||||
values["value_formatted"] = values["value_henries"]
|
||||
else:
|
||||
return None
|
||||
|
||||
tol = _first(pmap, "tolerance")
|
||||
tm = _TOL.search(tol or "") or _TOL.search(text)
|
||||
if tm:
|
||||
values["tolerance"] = f"±{tm.group('num')}%"
|
||||
elif tol:
|
||||
values["tolerance"] = tol
|
||||
|
||||
pkg = _first(pmap, "package", "case", "size")
|
||||
pm = _PKG.search(pkg or "") or _PKG.search(text)
|
||||
if pm:
|
||||
values["package"] = pm.group("pkg")
|
||||
elif pkg and len(pkg) <= 12:
|
||||
values["package"] = pkg
|
||||
|
||||
specs = SimpleComponentSpecs(
|
||||
specs_type="passive",
|
||||
component_subtype=subtype,
|
||||
values=values,
|
||||
)
|
||||
try:
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
|
||||
def lcsc_payload_args(payload: dict) -> tuple[list[dict[str, str]], str, str]:
|
||||
"""Turn a cached LCSC product dict into distributor mapper arguments."""
|
||||
category = " / ".join(
|
||||
p for p in (payload.get("category"), payload.get("subcategory")) if p
|
||||
)
|
||||
params: list[dict[str, str]] = []
|
||||
if payload.get("package"):
|
||||
params.append({"name": "Package / Case", "value": str(payload["package"])})
|
||||
if payload.get("manufacturer"):
|
||||
params.append({"name": "Manufacturer", "value": str(payload["manufacturer"])})
|
||||
return params, category, str(payload.get("description") or "")
|
||||
|
||||
|
||||
def specs_from_lcsc_payload(mpn: str, payload: dict) -> ComponentModel | None:
|
||||
params, category, description = lcsc_payload_args(payload)
|
||||
if not description:
|
||||
return None
|
||||
return specs_from_distributor(
|
||||
mpn=mpn, params=params, category=category, description=description,
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Decode common chip R/C/L MPNs into typed specs without an LLM.
|
||||
|
||||
Only encodings that carry package + value (and voltage for capacitors when
|
||||
the manufacturer puts it in the code) are accepted. Incomplete BOM-value
|
||||
guesses stay out of the shared library.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import ComponentModel, SimpleComponentSpecs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
from backend.services.passive_from_distributor import _spice
|
||||
|
||||
_SIZE = r"(?:0201|0402|0603|0805|1206|1210|1812|2010|2512)"
|
||||
_DIEL = r"(?:C0G|NP0|CG|X8R|X7R|X7S|X6S|X5R|Y5V)"
|
||||
_TOL = {
|
||||
"B": "±0.1%",
|
||||
"C": "±0.25%",
|
||||
"D": "±0.5%",
|
||||
"F": "±1%",
|
||||
"G": "±2%",
|
||||
"J": "±5%",
|
||||
"K": "±10%",
|
||||
"M": "±20%",
|
||||
}
|
||||
_AVX_V = {
|
||||
"4": "4V",
|
||||
"6": "6.3V",
|
||||
"Z": "10V",
|
||||
"Y": "16V",
|
||||
"3": "25V",
|
||||
"5": "50V",
|
||||
"1": "100V",
|
||||
"2": "200V",
|
||||
"7": "500V",
|
||||
}
|
||||
_AVX_DIEL = {"A": "C0G", "C": "X7R", "D": "X5R", "Z": "Y5V"}
|
||||
_DIEL_NORM = {"CG": "C0G", "NP0": "C0G"}
|
||||
|
||||
_WALSIN_CAP = re.compile(
|
||||
rf"^({_SIZE})({_DIEL})(\d{{3}})([{''.join(_TOL)}])(\d{{3}}|[0-9]R[0-9])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AVX_CAP = re.compile(
|
||||
rf"^({_SIZE})([46ZY35127])([{''.join(_AVX_DIEL)}])(\d{{3}})([{''.join(_TOL)}])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CHIP_R = re.compile(
|
||||
rf"^(?:FRC)?({_SIZE})(?:W\d)?([{''.join('FJKG')}])(\d{{4}})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Murata LQW18AN: 0603 wirewound. Inductance is three chars (12N, 2N2, R10)
|
||||
# then EIA tolerance, then a two-digit spec (00/10) and packing.
|
||||
_LQW18AN = re.compile(
|
||||
r"^LQW18AN(?P<l>[0-9]N[0-9]|[0-9]{2}N|R[0-9]{2})(?P<tol>[BCSGHJKD])\d{2}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LQW_TOL = {
|
||||
"B": "±0.1nH",
|
||||
"C": "±0.2nH",
|
||||
"S": "±0.3nH",
|
||||
"D": "±0.5nH",
|
||||
"G": "±2%",
|
||||
"H": "±3%",
|
||||
"J": "±5%",
|
||||
"K": "±10%",
|
||||
}
|
||||
|
||||
|
||||
def _eia3_pf(digits: str) -> float:
|
||||
return float(int(digits[:2]) * (10 ** int(digits[2])))
|
||||
|
||||
|
||||
def _eia3_volts(code: str) -> str | None:
|
||||
code = code.upper()
|
||||
if "R" in code:
|
||||
try:
|
||||
return f"{float(code.replace('R', '.')):g}V"
|
||||
except ValueError:
|
||||
return None
|
||||
if len(code) != 3 or not code.isdigit():
|
||||
return None
|
||||
volts = int(code[:2]) * (10 ** int(code[2]))
|
||||
return f"{volts}V"
|
||||
|
||||
|
||||
def _eia4_ohm(digits: str) -> float:
|
||||
return float(int(digits[:3]) * (10 ** int(digits[3])))
|
||||
|
||||
|
||||
def _lqw_nh(code: str) -> float | None:
|
||||
c = code.upper()
|
||||
if re.fullmatch(r"[0-9]N[0-9]", c):
|
||||
return float(f"{c[0]}.{c[2]}")
|
||||
if re.fullmatch(r"[0-9]{2}N", c):
|
||||
return float(c[:2])
|
||||
if re.fullmatch(r"R[0-9]{2}", c):
|
||||
return float(f"0.{c[1:]}") * 1000.0
|
||||
return None
|
||||
|
||||
|
||||
def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None:
|
||||
specs = SimpleComponentSpecs(
|
||||
specs_type="passive",
|
||||
component_subtype=subtype,
|
||||
values=values,
|
||||
)
|
||||
try:
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
|
||||
def specs_from_mpn(mpn: str) -> ComponentModel | None:
|
||||
"""Return a ComponentModel when the MPN itself encodes enough specs."""
|
||||
raw = (mpn or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
m = _WALSIN_CAP.match(raw)
|
||||
if m:
|
||||
size, diel, cap, tol, volt = m.groups()
|
||||
pf = _eia3_pf(cap)
|
||||
values = {
|
||||
"value_farads": _spice(str(pf), "p", "F") if pf else None,
|
||||
"package": size.upper(),
|
||||
"dielectric": _DIEL_NORM.get(diel.upper(), diel.upper()),
|
||||
"tolerance": _TOL[tol.upper()],
|
||||
}
|
||||
rated = _eia3_volts(volt)
|
||||
if rated:
|
||||
values["voltage_rating_v"] = rated
|
||||
values["value_formatted"] = values["value_farads"]
|
||||
if values["value_farads"]:
|
||||
return _model(raw, "passive.capacitor.ceramic", values)
|
||||
|
||||
m = _AVX_CAP.match(raw)
|
||||
if m:
|
||||
size, vcode, diel, cap, tol = m.groups()
|
||||
pf = _eia3_pf(cap)
|
||||
values = {
|
||||
"value_farads": _spice(str(pf), "p", "F"),
|
||||
"value_formatted": _spice(str(pf), "p", "F"),
|
||||
"package": size.upper(),
|
||||
"dielectric": _AVX_DIEL[diel.upper()],
|
||||
"tolerance": _TOL[tol.upper()],
|
||||
"voltage_rating_v": _AVX_V[vcode.upper()],
|
||||
}
|
||||
return _model(raw, "passive.capacitor.ceramic", values)
|
||||
|
||||
m = _CHIP_R.match(raw)
|
||||
if m:
|
||||
size, tol, code = m.groups()
|
||||
ohms = _eia4_ohm(code)
|
||||
values = {
|
||||
"value_ohms": _spice(str(ohms), None, "ohm"),
|
||||
"value_formatted": _spice(str(ohms), None, "ohm"),
|
||||
"package": size.upper(),
|
||||
"tolerance": _TOL[tol.upper()],
|
||||
}
|
||||
return _model(raw, "passive.resistor.thick_film", values)
|
||||
|
||||
m = _LQW18AN.match(raw)
|
||||
if m:
|
||||
nh = _lqw_nh(m.group("l"))
|
||||
if nh is not None:
|
||||
henries = _spice(str(nh), "n", "H")
|
||||
values = {
|
||||
"value_henries": henries,
|
||||
"value_formatted": henries,
|
||||
"package": "0603",
|
||||
"tolerance": _LQW_TOL[m.group("tol").upper()],
|
||||
}
|
||||
return _model(raw, "passive.inductor", values)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Parse a BOM Value string into typed passive specs without an LLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.periscopex.models import ComponentModel, SimpleComponentSpecs
|
||||
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
||||
from backend.services.passive_from_distributor import _spice
|
||||
|
||||
_PLACEHOLDER = re.compile(
|
||||
r"^(?:dnp|dni|dns|nc|n/?c|n/?a|np|nfs|tbd|todo|jumper|jmp|short|open|-|—|–)$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_CAP = re.compile(
|
||||
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*[fF]$",
|
||||
)
|
||||
_IND = re.compile(
|
||||
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*H$",
|
||||
re.I,
|
||||
)
|
||||
_RES_UNIT = re.compile(
|
||||
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmkM])?\s*(?:ohms?|Ω|R)$",
|
||||
re.I,
|
||||
)
|
||||
_RES_BARE_MUL = re.compile(
|
||||
r"^(?P<num>\d+(?:\.\d+)?)(?P<mul>[kKmM])$",
|
||||
)
|
||||
_EURO_R = re.compile(
|
||||
r"^(?P<a>\d+)[kK](?P<b>\d+)$",
|
||||
)
|
||||
_FB = re.compile(
|
||||
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[kKmM])?\s*(?:ohms?|Ω|R)"
|
||||
r"(?:@\s*(?P<freq>\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)))?$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def is_placeholder_value(value: str) -> bool:
|
||||
return bool(_PLACEHOLDER.match((value or "").strip()))
|
||||
|
||||
|
||||
def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None:
|
||||
specs = SimpleComponentSpecs(
|
||||
specs_type="passive",
|
||||
component_subtype=subtype,
|
||||
values=values,
|
||||
)
|
||||
try:
|
||||
typed = simple_to_typed_passive_specs(specs)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return ComponentModel(mpn=mpn, specs=typed)
|
||||
|
||||
|
||||
def specs_from_bom_value(
|
||||
mpn: str, value: str, ref_prefix: str,
|
||||
) -> ComponentModel | None:
|
||||
"""Map ``18pF`` / ``4.7k`` / ``10uH`` / ``600R@100MHz`` to typed specs.
|
||||
|
||||
Returns None for placeholders (DNP, NC, JUMPER) and for strings that
|
||||
are not a single passive value. Callers must not save the result to the
|
||||
shared library.
|
||||
"""
|
||||
raw = (value or "").strip()
|
||||
prefix = (ref_prefix or "").upper()
|
||||
if not raw or is_placeholder_value(raw):
|
||||
return None
|
||||
|
||||
compact = re.sub(r"\s+", "", raw)
|
||||
|
||||
if prefix == "FB" or "@" in compact:
|
||||
m = _FB.match(compact) or _FB.match(raw)
|
||||
if m:
|
||||
z = _spice(m.group("num"), m.group("mul"), "ohm")
|
||||
freq = re.sub(r"\s+", "", m.group("freq") or "")
|
||||
values = {
|
||||
"impedance_ohm": z,
|
||||
"value_formatted": f"{z}@{freq}" if freq else z,
|
||||
}
|
||||
return _model(mpn, "passive.ferrite_bead", values)
|
||||
|
||||
if prefix in {"C", ""}:
|
||||
m = _CAP.match(compact) or _CAP.match(raw)
|
||||
if m:
|
||||
farads = _spice(m.group("num"), m.group("mul"), "F")
|
||||
return _model(mpn, "passive.capacitor", {
|
||||
"value_farads": farads,
|
||||
"value_formatted": farads,
|
||||
})
|
||||
|
||||
if prefix in {"L", ""}:
|
||||
m = _IND.match(compact) or _IND.match(raw)
|
||||
if m:
|
||||
henries = _spice(m.group("num"), m.group("mul"), "H")
|
||||
return _model(mpn, "passive.inductor", {
|
||||
"value_henries": henries,
|
||||
"value_formatted": henries,
|
||||
})
|
||||
|
||||
if prefix in {"R", ""}:
|
||||
m = _EURO_R.match(compact)
|
||||
if m:
|
||||
ohms = float(f"{m.group('a')}.{m.group('b')}") * 1e3
|
||||
formatted = _spice(str(ohms), None, "ohm")
|
||||
return _model(mpn, "passive.resistor", {
|
||||
"value_ohms": formatted,
|
||||
"value_formatted": formatted,
|
||||
})
|
||||
m = _RES_UNIT.match(compact) or _RES_BARE_MUL.match(compact)
|
||||
if m:
|
||||
formatted = _spice(m.group("num"), m.group("mul"), "ohm")
|
||||
return _model(mpn, "passive.resistor", {
|
||||
"value_ohms": formatted,
|
||||
"value_formatted": formatted,
|
||||
})
|
||||
|
||||
return None
|
||||
+630
-279
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
"""Placement pipeline — parallel to analysis (no LLM).
|
||||
|
||||
Stages: ensure_graph → classify → write_plan → pack (F2 gated).
|
||||
Writes ``placement_plan.json`` (+ ``functional_groups.json``) and
|
||||
``placement_pack.json`` (mm only when PCB + numeric layout_rules exist).
|
||||
Uses ``placement_status`` so analysis ``status`` is untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.periscopex.functional_groups import build_placement_plan
|
||||
from backend.periscopex.graph import build_graph
|
||||
from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph
|
||||
from backend.periscopex.placement_pack import build_placement_pack
|
||||
from backend.services import projects as proj_svc
|
||||
from backend.services.pipeline import PipelineWorkspace, broker
|
||||
from backend.services.storage import StorageBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLACEMENT_ACTIVE = frozenset({"queued", "running"})
|
||||
_ANALYSIS_BUSY = frozenset({
|
||||
proj_svc.STATUS_QUEUED,
|
||||
proj_svc.STATUS_RUNNING,
|
||||
})
|
||||
|
||||
|
||||
def _load_constraints_map(extracted_dir: Path) -> dict[str, ComponentConstraints]:
|
||||
"""Load per-MPN extractions without importing the Anthropic review path."""
|
||||
result: dict[str, ComponentConstraints] = {}
|
||||
if not extracted_dir.is_dir():
|
||||
return result
|
||||
for f in extracted_dir.glob("*.json"):
|
||||
try:
|
||||
c = ComponentConstraints.model_validate_json(
|
||||
f.read_text(encoding="utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("skipping bad extraction %s", f)
|
||||
continue
|
||||
result[c.mpn] = c
|
||||
return result
|
||||
|
||||
|
||||
def _publish(project_id: str, event: str, data: dict) -> None:
|
||||
broker.publish(project_id, event, data)
|
||||
|
||||
|
||||
def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
|
||||
payload: dict = {"stage": stage, "status": status}
|
||||
if detail:
|
||||
payload["detail"] = detail
|
||||
_publish(project_id, "placement_step_update", payload)
|
||||
|
||||
|
||||
async def run_placement_pipeline(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
"""Run the placement topology pipeline (no extraction / review)."""
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
if not meta:
|
||||
raise ValueError(f"Project {project_id} not found")
|
||||
|
||||
# Boot: queued → running on placement_status only.
|
||||
if meta.placement_status not in _PLACEMENT_ACTIVE:
|
||||
logger.warning(
|
||||
"placement worker booted with placement_status=%s for %s; exiting",
|
||||
meta.placement_status, project_id,
|
||||
)
|
||||
return
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="running",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=None,
|
||||
)
|
||||
|
||||
try:
|
||||
async with PipelineWorkspace(storage, user_id, project_id) as ws:
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
graph = await _ensure_graph(ws, meta, project_id)
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "classify", "running", "domains and satellite roles")
|
||||
extracted_dir = ws.local_path("extracted")
|
||||
cmap = _load_constraints_map(extracted_dir)
|
||||
plan = build_placement_plan(graph, cmap)
|
||||
_step(
|
||||
project_id, "classify", "complete",
|
||||
f"{len(plan.domains)} domains, {len(plan.groups)} IC groups",
|
||||
)
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "write_plan", "running")
|
||||
plan_path = ws.local_path("placement_plan.json")
|
||||
plan_json = plan.model_dump_json(indent=2) + "\n"
|
||||
plan_path.write_text(plan_json)
|
||||
# Keep functional_groups.json in sync for consumers that already read it.
|
||||
fg_path = ws.local_path("functional_groups.json")
|
||||
fg_path.write_text(plan_json)
|
||||
ws._upload_file("placement_plan.json")
|
||||
ws._upload_file("functional_groups.json")
|
||||
_step(project_id, "write_plan", "complete", "placement_plan.json")
|
||||
|
||||
if _cancelled(storage, user_id, project_id):
|
||||
_finish_cancelled(storage, user_id, project_id)
|
||||
return
|
||||
|
||||
_step(project_id, "pack", "running", "F2 gated pack")
|
||||
layout = _load_layout(ws)
|
||||
pack = build_placement_pack(plan, layout, graph)
|
||||
pack_path = ws.local_path("placement_pack.json")
|
||||
pack_path.write_text(pack.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("placement_pack.json")
|
||||
pack_detail = (
|
||||
f"{len(pack.placements)} proposals"
|
||||
if pack.status == "packed"
|
||||
else f"skipped:{pack.skip_reason}"
|
||||
)
|
||||
_step(project_id, "pack", "complete", pack_detail)
|
||||
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="complete",
|
||||
placement_state={
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"pack_status": pack.status,
|
||||
"pack_count": len(pack.placements),
|
||||
"pack_skip_reason": pack.skip_reason,
|
||||
},
|
||||
placement_cancel_requested=False,
|
||||
)
|
||||
_publish(project_id, "placement_complete", {
|
||||
"domains": len(plan.domains),
|
||||
"groups": len(plan.groups),
|
||||
"pack_status": pack.status,
|
||||
"pack_count": len(pack.placements),
|
||||
"pack_skip_reason": pack.skip_reason,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("placement pipeline failed for %s", project_id)
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_state={"error": str(e)},
|
||||
)
|
||||
_publish(project_id, "placement_error", {"error": str(e)})
|
||||
|
||||
|
||||
async def _ensure_graph(ws: PipelineWorkspace, meta, project_id: str) -> DesignGraph:
|
||||
"""Reuse design_graph.json when present; otherwise graph_build only."""
|
||||
graph_path = ws.local_path("design_graph.json")
|
||||
if graph_path.is_file():
|
||||
_step(project_id, "ensure_graph", "running", "reusing design_graph.json")
|
||||
graph = DesignGraph.model_validate_json(graph_path.read_text(encoding="utf-8"))
|
||||
_step(
|
||||
project_id, "ensure_graph", "complete",
|
||||
f"{len(graph.components)} components (cached)",
|
||||
)
|
||||
return graph
|
||||
|
||||
_step(project_id, "ensure_graph", "running", "building design graph")
|
||||
bom_path = ws.local_path("uploads/bom.csv")
|
||||
netlist_path = ws.netlist_local_path()
|
||||
if not bom_path.is_file() or not Path(netlist_path).is_file():
|
||||
raise FileNotFoundError("Missing BOM or netlist for placement graph_build")
|
||||
|
||||
col_map = meta.bom_columns or {}
|
||||
graph = build_graph(
|
||||
str(netlist_path),
|
||||
str(bom_path),
|
||||
str(ws.local_path("extracted")),
|
||||
str(ws.local_path("patterns")),
|
||||
str(ws.local_path("models")),
|
||||
reference_col=col_map.get("reference", "Reference"),
|
||||
mpn_col=col_map.get("mpn", "Manufacturer Part Number"),
|
||||
include_subdesigns=(
|
||||
set(meta.netlist_subdesigns)
|
||||
if meta.netlist_subdesigns is not None
|
||||
else None
|
||||
),
|
||||
pcb_path=ws.local_path("uploads/pcb.kicad_pcb"),
|
||||
)
|
||||
graph_path.write_text(graph.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("design_graph.json")
|
||||
_step(
|
||||
project_id, "ensure_graph", "complete",
|
||||
f"{len(graph.components)} components, {len(graph.nets)} nets",
|
||||
)
|
||||
return graph
|
||||
|
||||
|
||||
def _load_layout(ws: PipelineWorkspace) -> LayoutGraph | None:
|
||||
"""Reuse layout_graph.json, or parse uploads/pcb.kicad_pcb once."""
|
||||
cached = ws.local_path("layout_graph.json")
|
||||
if cached.is_file():
|
||||
try:
|
||||
return LayoutGraph.model_validate_json(
|
||||
cached.read_text(encoding="utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("bad layout_graph.json — trying pcb parse")
|
||||
|
||||
pcb = ws.local_path("uploads/pcb.kicad_pcb")
|
||||
if not pcb.is_file():
|
||||
return None
|
||||
try:
|
||||
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
|
||||
|
||||
layout = parse_kicad_pcb(pcb)
|
||||
cached.write_text(layout.model_dump_json(indent=2) + "\n")
|
||||
ws._upload_file("layout_graph.json")
|
||||
return layout
|
||||
except Exception:
|
||||
logger.exception("kicad_pcb parse failed during placement pack")
|
||||
return None
|
||||
|
||||
|
||||
def _cancelled(storage: StorageBackend, user_id: str, project_id: str) -> bool:
|
||||
meta = proj_svc.get_project(storage, user_id, project_id)
|
||||
return bool(meta and meta.placement_cancel_requested)
|
||||
|
||||
|
||||
def _finish_cancelled(storage: StorageBackend, user_id: str, project_id: str) -> None:
|
||||
proj_svc.update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="cancelled",
|
||||
placement_cancel_requested=False,
|
||||
)
|
||||
_publish(project_id, "placement_cancelled", {})
|
||||
|
||||
|
||||
def placement_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return (meta.placement_status or "draft") in _PLACEMENT_ACTIVE
|
||||
|
||||
|
||||
def analysis_busy(meta: proj_svc.ProjectMeta) -> bool:
|
||||
return meta.status in _ANALYSIS_BUSY
|
||||
+376
-19
@@ -5,11 +5,13 @@ Each project lives at users/{user_id}/projects/{id}/ with:
|
||||
uploads/bom.csv — uploaded BOM
|
||||
uploads/netlist.asc — uploaded netlist
|
||||
uploads/datasheets/*.pdf — uploaded datasheets
|
||||
uploads/pcb.kicad_pcb — optional KiCad board (layout checks)
|
||||
extracted/ — IC extraction output
|
||||
patterns/ — passive patterns
|
||||
models/ — cached component specs
|
||||
design_graph.json — graph output
|
||||
report.json — validation report
|
||||
periscope-findings.json — KiCad cad-bridge (plugin pan-and-zoom)
|
||||
|
||||
Library (global, shared across users):
|
||||
library/extracted/{mpn}.json
|
||||
@@ -21,13 +23,17 @@ Library (global, shared across users):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.services.storage import StaleGeneration, StorageBackend
|
||||
|
||||
|
||||
@@ -79,6 +85,7 @@ class ProjectMeta(BaseModel):
|
||||
updated: str = ""
|
||||
has_bom: bool = False
|
||||
has_netlist: bool = False
|
||||
has_pcb: bool = False
|
||||
# "pads" | "edif" | None — None for legacy projects (pre-EDIF-support).
|
||||
# Legacy reads fall back to looking for netlist.asc on disk.
|
||||
netlist_format: str | None = None
|
||||
@@ -113,9 +120,13 @@ class ProjectMeta(BaseModel):
|
||||
pause_reason: str | None = None
|
||||
completed_review_refs: list[str] = [] # IC refs already reviewed (persists across pauses)
|
||||
|
||||
# Pinscope app version that generated the project's report.
|
||||
# Periscope app version that generated the project's report.
|
||||
# Stamped on the first /start transition and preserved thereafter.
|
||||
pinscope_version: str | None = None
|
||||
# Accept legacy pinscope_version from project.json written before the rebrand.
|
||||
periscope_version: str | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("periscope_version", "pinscope_version"),
|
||||
)
|
||||
|
||||
# Worker bookkeeping (set by the API on enqueue, read by /events SSE
|
||||
# and by the stale-running sweeper).
|
||||
@@ -125,6 +136,44 @@ class ProjectMeta(BaseModel):
|
||||
# gate (inside _charge_for_logs) and exits cleanly.
|
||||
cancel_requested: bool = False
|
||||
|
||||
# Placement pipeline (parallel to analysis — does not overwrite status).
|
||||
# draft | queued | running | complete | error | cancelled
|
||||
placement_status: str = "draft"
|
||||
placement_state: dict[str, Any] | None = None
|
||||
placement_execution_name: str | None = None
|
||||
placement_cancel_requested: bool = False
|
||||
|
||||
|
||||
def completed_review_refs_for_retry(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> list[str]:
|
||||
"""ICs that already finished review and should be skipped on reprocess.
|
||||
|
||||
Drops refs that failed (skipped_components / report.review_errors) so
|
||||
those ICs are tried again.
|
||||
"""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if not meta:
|
||||
return []
|
||||
failed: set[str] = set()
|
||||
for item in meta.skipped_components or []:
|
||||
stage = (item.get("stage") or "")
|
||||
ident = (item.get("identifier") or "").strip()
|
||||
if ident and stage in ("validation", "review"):
|
||||
failed.add(ident)
|
||||
report_key = f"{_project_prefix(user_id, project_id)}/report.json"
|
||||
if storage.exists(report_key):
|
||||
try:
|
||||
report = storage.read_json(report_key)
|
||||
except Exception:
|
||||
report = {}
|
||||
for ref in (report.get("review_errors") or {}):
|
||||
if ref:
|
||||
failed.add(str(ref))
|
||||
from backend.periscopex.utils import natural_sort_key
|
||||
kept = [r for r in (meta.completed_review_refs or []) if r and r not in failed]
|
||||
return sorted(kept, key=natural_sort_key)
|
||||
|
||||
|
||||
def _project_prefix(user_id: str, project_id: str) -> str:
|
||||
return f"users/{user_id}/projects/{project_id}"
|
||||
@@ -236,6 +285,122 @@ def mark_stale_running(
|
||||
return None
|
||||
|
||||
|
||||
def heal_if_pipeline_finished(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""If meta says queued/running but events already ended with
|
||||
``pipeline_complete``, flip status to ``complete``.
|
||||
|
||||
Covers zombies where the worker wrote the terminal event (and often
|
||||
the report) then died before the meta transition — e.g. container
|
||||
rebuild mid-shutdown. Returns updated meta, or ``None`` if no heal.
|
||||
"""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if meta is None or meta.status not in (STATUS_RUNNING, STATUS_QUEUED):
|
||||
return None
|
||||
|
||||
events_prefix = f"{_project_prefix(user_id, project_id)}/events/"
|
||||
try:
|
||||
keys = storage.list_prefix(events_prefix)
|
||||
except Exception:
|
||||
return None
|
||||
event_keys = sorted(
|
||||
k for k in keys if k.endswith(".json") and "/events/" in k
|
||||
)
|
||||
if not event_keys:
|
||||
return None
|
||||
try:
|
||||
last = storage.read_json(event_keys[-1])
|
||||
except Exception:
|
||||
return None
|
||||
if (last or {}).get("event") != "pipeline_complete":
|
||||
return None
|
||||
|
||||
summary = (last.get("data") or {}).get("summary")
|
||||
try:
|
||||
return transition_status(
|
||||
storage, user_id, project_id,
|
||||
from_status={STATUS_RUNNING, STATUS_QUEUED},
|
||||
to_status=STATUS_COMPLETE,
|
||||
summary=summary if isinstance(summary, dict) else meta.summary,
|
||||
cancel_requested=False,
|
||||
pipeline_state=None,
|
||||
)
|
||||
except StatusConflict:
|
||||
return None
|
||||
|
||||
|
||||
def heal_if_placement_stuck(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> ProjectMeta | None:
|
||||
"""Unstick placement_status queued/running when the worker is gone.
|
||||
|
||||
- Last event ``placement_complete`` → ``complete``
|
||||
- Dead worker + plan artifact present → ``complete``
|
||||
- Dead worker otherwise → ``error``
|
||||
"""
|
||||
meta = get_project(storage, user_id, project_id)
|
||||
if meta is None:
|
||||
return None
|
||||
pst = meta.placement_status or "draft"
|
||||
if pst not in ("queued", "running"):
|
||||
return None
|
||||
|
||||
prefix = _project_prefix(user_id, project_id)
|
||||
events_prefix = f"{prefix}/events/"
|
||||
last_event = None
|
||||
try:
|
||||
keys = sorted(
|
||||
k for k in storage.list_prefix(events_prefix)
|
||||
if k.endswith(".json") and "/events/" in k
|
||||
)
|
||||
if keys:
|
||||
last_event = storage.read_json(keys[-1])
|
||||
except Exception:
|
||||
last_event = None
|
||||
|
||||
if (last_event or {}).get("event") == "placement_complete":
|
||||
data = (last_event or {}).get("data") or {}
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="complete",
|
||||
placement_cancel_requested=False,
|
||||
placement_state={
|
||||
"domains": data.get("domains"),
|
||||
"groups": data.get("groups"),
|
||||
},
|
||||
)
|
||||
|
||||
from backend.services import job_runner
|
||||
|
||||
exec_name = meta.placement_execution_name or f"local/placement/{project_id}"
|
||||
try:
|
||||
state = job_runner.get_execution_state(exec_name)
|
||||
except Exception:
|
||||
state = "unknown"
|
||||
|
||||
if state in ("pending", "running"):
|
||||
return None
|
||||
|
||||
has_plan = (
|
||||
storage.exists(f"{prefix}/placement_plan.json")
|
||||
or storage.exists(f"{prefix}/functional_groups.json")
|
||||
)
|
||||
if has_plan:
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="complete",
|
||||
placement_cancel_requested=False,
|
||||
placement_state=meta.placement_state,
|
||||
)
|
||||
return update_project(
|
||||
storage, user_id, project_id,
|
||||
placement_status="error",
|
||||
placement_cancel_requested=False,
|
||||
placement_state={"error": f"Placement worker terminated ({state})"},
|
||||
)
|
||||
|
||||
|
||||
# --- CRUD ---
|
||||
|
||||
|
||||
@@ -323,6 +488,8 @@ def clear_project_extractions(
|
||||
"bom_summary.json",
|
||||
"derating.json",
|
||||
"report.json",
|
||||
"periscope-findings.json",
|
||||
"review_fingerprints.json",
|
||||
"api_logs.jsonl",
|
||||
"graph_voltage_updates.json",
|
||||
):
|
||||
@@ -356,6 +523,8 @@ def reopen_project(
|
||||
"bom_summary.json",
|
||||
"derating.json",
|
||||
"report.json",
|
||||
"periscope-findings.json",
|
||||
"review_fingerprints.json",
|
||||
"api_logs.jsonl",
|
||||
"graph_voltage_updates.json",
|
||||
):
|
||||
@@ -577,7 +746,13 @@ def save_bom(
|
||||
return key
|
||||
|
||||
|
||||
_NETLIST_EXT = {"pads": "asc", "edif": "edn"}
|
||||
_NETLIST_EXT = {
|
||||
"pads": "asc",
|
||||
"edif": "edn",
|
||||
"kicad_xml": "xml",
|
||||
"kicad_sexp": "kicad_net",
|
||||
"kicad_sch": "kicad_sch",
|
||||
}
|
||||
|
||||
|
||||
def _netlist_key(user_id: str, project_id: str, fmt: str) -> str:
|
||||
@@ -595,16 +770,17 @@ def save_netlist(
|
||||
) -> str:
|
||||
"""Persist the uploaded netlist with the extension matching ``fmt``.
|
||||
|
||||
Also clears any previously-saved netlist in the *other* format so we
|
||||
never have stale ``.asc`` and ``.edn`` files side-by-side (e.g. user
|
||||
re-uploads with a different format).
|
||||
Also clears any previously-saved netlist in another format so we
|
||||
never have stale files side-by-side (e.g. user re-uploads KiCad after PADS).
|
||||
"""
|
||||
key = _netlist_key(user_id, project_id, fmt)
|
||||
storage.write_bytes(key, data)
|
||||
other_fmt = "edif" if fmt == "pads" else "pads"
|
||||
other_key = _netlist_key(user_id, project_id, other_fmt)
|
||||
if storage.exists(other_key):
|
||||
storage.delete_key(other_key)
|
||||
for other in _NETLIST_EXT:
|
||||
if other == fmt:
|
||||
continue
|
||||
other_key = _netlist_key(user_id, project_id, other)
|
||||
if storage.exists(other_key):
|
||||
storage.delete_key(other_key)
|
||||
# Reset sub-design selection on every upload — the prior selection may
|
||||
# reference IDs that no longer exist in the new file. Frontend resets
|
||||
# the picker after upload too; this keeps backend in sync.
|
||||
@@ -615,23 +791,73 @@ def save_netlist(
|
||||
return key
|
||||
|
||||
|
||||
def clear_companion_sheets(
|
||||
storage: StorageBackend, user_id: str, project_id: str,
|
||||
) -> None:
|
||||
prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
|
||||
for key in storage.list_recursive(prefix):
|
||||
rel = key[len(prefix):]
|
||||
if rel.endswith(".kicad_sch") and rel != "netlist.kicad_sch":
|
||||
storage.delete_key(key)
|
||||
|
||||
|
||||
def save_companion_sheets(
|
||||
storage: StorageBackend,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
root: Path,
|
||||
extras: list[Path],
|
||||
) -> None:
|
||||
"""Keep Sheetfile children next to ``uploads/netlist.kicad_sch``."""
|
||||
clear_companion_sheets(storage, user_id, project_id)
|
||||
parent = root.parent
|
||||
prefix = f"{_project_prefix(user_id, project_id)}/uploads/"
|
||||
for extra in extras:
|
||||
rel = extra.relative_to(parent).as_posix()
|
||||
if rel == "netlist.kicad_sch":
|
||||
continue
|
||||
storage.write_bytes(prefix + rel, extra.read_bytes())
|
||||
|
||||
|
||||
def save_pcb(
|
||||
storage: StorageBackend, user_id: str, project_id: str, data: bytes
|
||||
) -> str:
|
||||
key = f"{_project_prefix(user_id, project_id)}/uploads/pcb.kicad_pcb"
|
||||
storage.write_bytes(key, data)
|
||||
update_project(storage, user_id, project_id, has_pcb=True)
|
||||
return key
|
||||
|
||||
|
||||
def save_datasheet(
|
||||
storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes
|
||||
) -> str:
|
||||
"""Save a datasheet PDF to the project uploads directory.
|
||||
"""Save a datasheet PDF to the project and to the shared library.
|
||||
|
||||
Library writes happen during pattern extraction (one PDF per pattern series).
|
||||
The project copy is what the pipeline reads for this run. The library
|
||||
copy means a later project with the same MPN can skip the download.
|
||||
"""
|
||||
safe = safe_mpn(mpn)
|
||||
key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe}.pdf"
|
||||
storage.write_bytes(key, data)
|
||||
# Count datasheets
|
||||
remember_datasheet(storage, mpn, data)
|
||||
ds_prefix = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/"
|
||||
count = sum(1 for k in storage.list_prefix(ds_prefix) if k.endswith(".pdf"))
|
||||
update_project(storage, user_id, project_id, datasheet_count=count)
|
||||
return key
|
||||
|
||||
|
||||
def remember_datasheet(
|
||||
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Write a datasheet into the shared library without failing the caller."""
|
||||
try:
|
||||
from backend.services.datasheet_store import store_datasheet_bytes
|
||||
|
||||
store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns)
|
||||
except Exception:
|
||||
log.exception("Failed to store datasheet for %s in the shared library", mpn)
|
||||
|
||||
|
||||
def get_bom_key(
|
||||
storage: StorageBackend, user_id: str, project_id: str
|
||||
) -> str | None:
|
||||
@@ -643,7 +869,7 @@ def get_netlist_key(
|
||||
storage: StorageBackend, user_id: str, project_id: str
|
||||
) -> str | None:
|
||||
"""Return the storage key of whichever netlist file exists (.asc or .edn)."""
|
||||
for fmt in ("pads", "edif"):
|
||||
for fmt in _NETLIST_EXT:
|
||||
key = _netlist_key(user_id, project_id, fmt)
|
||||
if storage.exists(key):
|
||||
return key
|
||||
@@ -714,7 +940,7 @@ def library_has_datasheet(
|
||||
return key
|
||||
# 3. Pattern-based fallback for passives
|
||||
if patterns:
|
||||
from backend.pinscopex.resolve_passives import resolve_mpn
|
||||
from backend.periscopex.resolve_passives import resolve_mpn
|
||||
|
||||
match = resolve_mpn(mpn, patterns)
|
||||
if match is not None:
|
||||
@@ -759,6 +985,137 @@ def save_to_library(
|
||||
return dst_key
|
||||
|
||||
|
||||
def _specs_param_count(specs: dict) -> int:
|
||||
if not isinstance(specs, dict):
|
||||
return 0
|
||||
values = specs.get("values")
|
||||
if isinstance(values, dict):
|
||||
return sum(1 for v in values.values() if v not in (None, "", []))
|
||||
skip = {"specs_type", "component_subtype"}
|
||||
return sum(
|
||||
1 for k, v in specs.items()
|
||||
if k not in skip and v not in (None, "", [])
|
||||
)
|
||||
|
||||
|
||||
def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict:
|
||||
mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
specs = data.get("specs", {}) or {}
|
||||
return {
|
||||
"mpn": mpn,
|
||||
"type": row_type,
|
||||
"specs_type": specs.get("specs_type", ""),
|
||||
"subtype": specs.get("component_subtype", ""),
|
||||
"param_count": _specs_param_count(specs),
|
||||
}
|
||||
|
||||
|
||||
def list_library_catalog(storage: StorageBackend) -> dict:
|
||||
"""List ICs, passive patterns, discrete specs, and datasheet refs.
|
||||
|
||||
Used by the user-facing library page and the admin components panel.
|
||||
"""
|
||||
from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet
|
||||
|
||||
ics: list[dict] = []
|
||||
seen_ic_mpns: set[str] = set()
|
||||
for key in storage.list_prefix("library/extracted/"):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ic_mpns:
|
||||
continue
|
||||
seen_ic_mpns.add(mpn)
|
||||
ics.append({
|
||||
"mpn": mpn,
|
||||
"type": "ic",
|
||||
"subtype": data.get("component_subtype", ""),
|
||||
"pin_count": len(data.get("pintable", [])),
|
||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
passives: list[dict] = []
|
||||
seen_passive_names: set[str] = set()
|
||||
for key in storage.list_prefix("library/patterns/"):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if name in seen_passive_names:
|
||||
continue
|
||||
seen_passive_names.add(name)
|
||||
passives.append({
|
||||
"mpn": name,
|
||||
"type": "passive",
|
||||
"subtype": data.get("component_type", ""),
|
||||
"description": data.get("description", ""),
|
||||
"regex": data.get("regex", ""),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
simple_models: list[dict] = []
|
||||
passive_parts: list[dict] = []
|
||||
seen_model_mpns: set[str] = set()
|
||||
for prefix, row_type, dest in (
|
||||
("library/passives/", "passive_part", passive_parts),
|
||||
("library/models/", "simple", simple_models),
|
||||
):
|
||||
for key in storage.list_prefix(prefix):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
row = _catalog_model_row(data, key, row_type=row_type)
|
||||
mpn = row["mpn"]
|
||||
if mpn in seen_model_mpns:
|
||||
continue
|
||||
seen_model_mpns.add(mpn)
|
||||
row["has_datasheet"] = bool(resolve_datasheet(storage, mpn))
|
||||
dest.append(row)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
datasheets: list[dict] = []
|
||||
seen_ds: set[str] = set()
|
||||
for key in storage.list_prefix(REF_PREFIX):
|
||||
if not key.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
ref = storage.read_json(key)
|
||||
mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||
if mpn in seen_ds:
|
||||
continue
|
||||
seen_ds.add(mpn)
|
||||
datasheets.append({
|
||||
"mpn": mpn,
|
||||
"hash": ref.get("hash"),
|
||||
"has_extraction": mpn in seen_ic_mpns,
|
||||
"has_model": mpn in seen_model_mpns,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
ics.sort(key=lambda r: r["mpn"].lower())
|
||||
passives.sort(key=lambda r: r["mpn"].lower())
|
||||
passive_parts.sort(key=lambda r: r["mpn"].lower())
|
||||
simple_models.sort(key=lambda r: r["mpn"].lower())
|
||||
datasheets.sort(key=lambda r: r["mpn"].lower())
|
||||
return {
|
||||
"ics": ics,
|
||||
"passives": passives,
|
||||
"passive_parts": passive_parts,
|
||||
"simple": simple_models,
|
||||
"datasheets": datasheets,
|
||||
}
|
||||
|
||||
|
||||
def list_library_patterns(storage: StorageBackend) -> list[str]:
|
||||
"""List all pattern keys in the library."""
|
||||
prefix = "library/patterns/"
|
||||
@@ -768,11 +1125,11 @@ def list_library_patterns(storage: StorageBackend) -> list[str]:
|
||||
def load_library_patterns(storage: StorageBackend):
|
||||
"""Load and parse all passive patterns from the library.
|
||||
|
||||
For local backend, delegates to pinscopex. For GCS, downloads to temp first.
|
||||
For local backend, delegates to periscopex. For GCS, downloads to temp first.
|
||||
This function is only used by the library/check endpoint — during pipeline
|
||||
execution, patterns are loaded from the workspace temp directory.
|
||||
"""
|
||||
from backend.pinscopex.resolve_passives import load_patterns
|
||||
from backend.periscopex.resolve_passives import load_patterns
|
||||
|
||||
from backend.services.storage import LocalStorageBackend
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""User profile lookup for collaborators / admin — Clerk or local auth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def find_user_id_by_email(email: str) -> str | None:
|
||||
email = email.strip().lower()
|
||||
if not email:
|
||||
return None
|
||||
if settings.use_clerk:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://api.clerk.com/v1/users",
|
||||
params={"email_address": [email]},
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Clerk email lookup failed: %s", resp.status_code)
|
||||
return None
|
||||
users = resp.json()
|
||||
if not users:
|
||||
return None
|
||||
return users[0].get("id")
|
||||
if settings.use_local_auth:
|
||||
from backend.services import local_users
|
||||
|
||||
user = local_users.find_by_email(email)
|
||||
return user.user_id if user else None
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_profile(user_id: str) -> dict:
|
||||
"""Return {user_id, name, email, image_url, is_admin?}."""
|
||||
entry = {
|
||||
"user_id": user_id,
|
||||
"name": None,
|
||||
"email": None,
|
||||
"image_url": None,
|
||||
"is_admin": False,
|
||||
}
|
||||
if settings.use_clerk:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"https://api.clerk.com/v1/users/{user_id}",
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
clerk = resp.json()
|
||||
first = clerk.get("first_name") or ""
|
||||
last = clerk.get("last_name") or ""
|
||||
entry["name"] = f"{first} {last}".strip() or None
|
||||
emails = clerk.get("email_addresses", [])
|
||||
if emails:
|
||||
entry["email"] = emails[0].get("email_address")
|
||||
entry["image_url"] = clerk.get("image_url")
|
||||
role = (clerk.get("public_metadata") or {}).get("role")
|
||||
entry["is_admin"] = role == "admin"
|
||||
except Exception:
|
||||
logger.exception("Clerk profile fetch failed for %s", user_id)
|
||||
return entry
|
||||
if settings.use_local_auth:
|
||||
from backend.services import local_users
|
||||
|
||||
user = local_users.get_user(user_id)
|
||||
if user:
|
||||
entry["name"] = user.name
|
||||
entry["email"] = user.email
|
||||
entry["is_admin"] = user.is_admin
|
||||
return entry
|
||||
return entry
|
||||
+124
-23
@@ -21,15 +21,16 @@ from typing import Awaitable, Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
from backend.periscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
LayoutGraph,
|
||||
NetType,
|
||||
ValidationReport,
|
||||
)
|
||||
from backend.pinscopex.validate import (
|
||||
from backend.periscopex.validate import (
|
||||
SYSTEM_PROMPT,
|
||||
_MAX_REVIEW_TURNS,
|
||||
ReviewResult,
|
||||
@@ -40,9 +41,30 @@ from backend.pinscopex.validate import (
|
||||
build_component_context,
|
||||
_parse_review,
|
||||
)
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
|
||||
from backend.pinscopex.led_current_check import check_led_current
|
||||
from backend.periscopex.quote_verify import verify_finding_citations
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
from backend.periscopex.pin_mux_check import check_pin_mux_feasibility
|
||||
from backend.periscopex.led_current_check import check_led_current
|
||||
from backend.periscopex.passive_rail_check import (
|
||||
check_i2c_pullups,
|
||||
check_reset_pullups,
|
||||
check_supply_decoupling,
|
||||
)
|
||||
from backend.periscopex.bom_match_check import check_bom_schematic_match
|
||||
from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage
|
||||
from backend.periscopex.cad_bridge import annotate_findings_cad, build_cad_bridge, write_cad_bridge
|
||||
from backend.periscopex.filter_check import check_filters
|
||||
from backend.periscopex.thermal_check import check_thermal
|
||||
from backend.periscopex.power_margin_check import check_power_margin
|
||||
from backend.periscopex.sequencing_check import check_power_sequencing
|
||||
from backend.periscopex.dnp_check import check_dnp_enables
|
||||
from backend.periscopex.lifecycle import check_lifecycle, load_lifecycle_dir
|
||||
from backend.periscopex.errata_check import check_errata
|
||||
from backend.periscopex.internal_features_check import check_internal_features
|
||||
from backend.periscopex.placement_check import check_placement
|
||||
from backend.periscopex.si_check import check_si
|
||||
from backend.periscopex.crystal_cl_check import check_crystal_cl
|
||||
from backend.periscopex.nc_pin_check import check_nc_pins
|
||||
|
||||
TRACE_VERSION = 1
|
||||
|
||||
@@ -53,7 +75,9 @@ def _is_deterministic(f: Finding) -> bool:
|
||||
|
||||
|
||||
def _run_deterministic_checks(
|
||||
graph: DesignGraph, constraints_map: dict
|
||||
graph: DesignGraph, constraints_map: dict,
|
||||
lifecycle_map: dict | None = None,
|
||||
layout: LayoutGraph | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Run the deterministic graph checks, fail-soft per check — a check bug
|
||||
can never break the review or the report."""
|
||||
@@ -61,6 +85,25 @@ def _run_deterministic_checks(
|
||||
for name, fn in (
|
||||
("pin_mux_check", lambda: check_pin_mux_feasibility(graph, constraints_map)),
|
||||
("led_current_check", lambda: check_led_current(graph)),
|
||||
("supply_decoupling_check", lambda: check_supply_decoupling(graph, constraints_map)),
|
||||
("i2c_pullup_check", lambda: check_i2c_pullups(graph, constraints_map)),
|
||||
("reset_pullup_check", lambda: check_reset_pullups(graph, constraints_map)),
|
||||
("bom_match_check", lambda: check_bom_schematic_match(
|
||||
graph.schematic_fields, graph.bom_fields,
|
||||
)),
|
||||
("hf_coverage_check", lambda: check_hf_decoupling_coverage(graph, constraints_map)),
|
||||
("filter_check", lambda: check_filters(graph, constraints_map)),
|
||||
("thermal_check", lambda: check_thermal(graph, constraints_map)),
|
||||
("power_margin_check", lambda: check_power_margin(graph, constraints_map)),
|
||||
("sequencing_check", lambda: check_power_sequencing(graph, constraints_map)),
|
||||
("dnp_check", lambda: check_dnp_enables(graph, constraints_map)),
|
||||
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
|
||||
("errata_check", lambda: check_errata(graph, constraints_map)),
|
||||
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
|
||||
("placement_check", lambda: check_placement(graph, constraints_map, layout)),
|
||||
("si_check", lambda: check_si(graph, constraints_map, layout)),
|
||||
("crystal_cl_check", lambda: check_crystal_cl(graph)),
|
||||
("nc_pin_check", lambda: check_nc_pins(graph, constraints_map)),
|
||||
):
|
||||
try:
|
||||
out.extend(fn())
|
||||
@@ -69,6 +112,17 @@ def _run_deterministic_checks(
|
||||
return out
|
||||
|
||||
|
||||
def _load_layout_graph(graph_path: str) -> LayoutGraph | None:
|
||||
path = Path(graph_path).with_name("layout_graph.json")
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return LayoutGraph.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
log.exception("layout_graph.json invalid — skipping placement_check")
|
||||
return None
|
||||
|
||||
|
||||
def _assistant_text(blocks) -> str:
|
||||
"""Best-effort extraction of text content from a completion's raw
|
||||
assistant blocks. Provider-agnostic and never raises."""
|
||||
@@ -85,14 +139,14 @@ def _assistant_text(blocks) -> str:
|
||||
except Exception:
|
||||
log.exception("trace: assistant_text extraction failed")
|
||||
return "\n".join(parts)
|
||||
from backend.pinscopex.validation_tools import (
|
||||
from backend.periscopex.validation_tools import (
|
||||
ALL_TOOLS,
|
||||
SUBMIT_REVIEW_SCHEMA,
|
||||
ConstraintsMap,
|
||||
ExcerptState,
|
||||
execute_tool,
|
||||
)
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.periscopex.utils import safe_mpn
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.api_logs import ApiLogger
|
||||
@@ -144,7 +198,7 @@ _REVIEW_KEYWORDS = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_MAX_PDF_PAGES = 90
|
||||
_MAX_PDF_PAGES = 120
|
||||
|
||||
# Per-review excerpt budget — keeps fan-out cost bounded on hub ICs (e.g. an
|
||||
# MCU connected to many neighbors). On exhaustion, the tool returns a budget
|
||||
@@ -159,9 +213,9 @@ _MAX_PDF_PAGES = 90
|
||||
# per neighbor fits the ~3 topic fetches (pin levels + abs-max + electrical)
|
||||
# one interface check needs; 60 global allows ~2 such neighbors before the
|
||||
# fan-out ceiling kicks in.
|
||||
_PER_REVIEW_FETCH_BUDGET = 8
|
||||
_PER_REVIEW_PAGE_BUDGET = 60
|
||||
_PER_NEIGHBOR_PAGE_BUDGET = 30
|
||||
_PER_REVIEW_FETCH_BUDGET = 12
|
||||
_PER_REVIEW_PAGE_BUDGET = 90
|
||||
_PER_NEIGHBOR_PAGE_BUDGET = 45
|
||||
|
||||
# A signal net with more components than this is treated as a hub/bus and
|
||||
# excluded from the neighbor set even if classified as "signal". Bounds
|
||||
@@ -379,6 +433,12 @@ async def review_ic_async(
|
||||
tools = _ALL_TOOL_SCHEMAS
|
||||
tool_choice = "auto"
|
||||
|
||||
if on_progress:
|
||||
await on_progress(
|
||||
ic_ref, turn, "waiting",
|
||||
f"model turn {turn + 1}/{_MAX_REVIEW_TURNS}",
|
||||
)
|
||||
|
||||
completion = await session.complete(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
@@ -416,6 +476,13 @@ async def review_ic_async(
|
||||
mpn_by_designator=mpn_by_designator,
|
||||
connected=connected_designators,
|
||||
)
|
||||
verify_finding_citations(
|
||||
result.findings,
|
||||
default_pdf=Path(pdf_path),
|
||||
default_mpn=mpn,
|
||||
pdf_dir=excerpt_state.pdf_dir,
|
||||
mpn_by_designator=mpn_by_designator,
|
||||
)
|
||||
turn_record["tool_calls"].append({
|
||||
"name": "submit_review",
|
||||
"input": tc.input,
|
||||
@@ -571,18 +638,29 @@ def _find_pdf(
|
||||
"""Find the datasheet PDF for an MPN. Checks local dir first,
|
||||
then tries to download from the library.
|
||||
"""
|
||||
safe = safe_mpn(mpn)
|
||||
local = pdf_dir / f"{safe}.pdf"
|
||||
if local.is_file():
|
||||
from backend.services.datasheet_finder import find_local_pdf
|
||||
from backend.periscopex.utils import safe_mpn as _safe
|
||||
|
||||
mpn = (mpn or "").strip()
|
||||
if not mpn:
|
||||
return None
|
||||
|
||||
local = find_local_pdf(pdf_dir, mpn)
|
||||
if local is not None and local.is_file():
|
||||
wanted = pdf_dir / f"{_safe(mpn)}.pdf"
|
||||
if local.resolve() != wanted.resolve() and not wanted.is_file():
|
||||
wanted.write_bytes(local.read_bytes())
|
||||
return wanted
|
||||
return local
|
||||
|
||||
if storage:
|
||||
from backend.services import projects as proj_svc
|
||||
lib_key = proj_svc.library_has_datasheet(storage, mpn)
|
||||
if lib_key:
|
||||
storage.download_to_local(lib_key, local)
|
||||
if local.is_file():
|
||||
return local
|
||||
wanted = pdf_dir / f"{_safe(mpn)}.pdf"
|
||||
storage.download_to_local(lib_key, wanted)
|
||||
if wanted.is_file():
|
||||
return wanted
|
||||
|
||||
return None
|
||||
|
||||
@@ -643,10 +721,17 @@ async def validate_design_async(
|
||||
graph = DesignGraph.model_validate(raw)
|
||||
datasheets = _load_datasheets(datasheets_dir)
|
||||
constraints_map = _build_constraints_map(datasheets)
|
||||
|
||||
# Deterministic graph checks (pin-mux feasibility, LED current). Pure
|
||||
# functions of the graph; fail-soft. Seeded into all_findings below.
|
||||
deterministic_findings = _run_deterministic_checks(graph, constraints_map)
|
||||
lifecycle_map = {}
|
||||
for cand in (
|
||||
Path(datasheets_dir).parent / "lifecycle",
|
||||
Path(datasheets_dir) / "lifecycle",
|
||||
):
|
||||
loaded = load_lifecycle_dir(cand)
|
||||
if loaded:
|
||||
lifecycle_map.update(loaded)
|
||||
deterministic_findings = _run_deterministic_checks(
|
||||
graph, constraints_map, lifecycle_map, layout=_load_layout_graph(graph_path),
|
||||
)
|
||||
|
||||
pdf_dir_path = Path(pdf_dir)
|
||||
|
||||
@@ -656,7 +741,12 @@ async def validate_design_async(
|
||||
for ref, comp in sorted(graph.components.items()):
|
||||
if comp.component_type != ComponentType.IC:
|
||||
continue
|
||||
mpn = comp.mpn or comp.value
|
||||
mpn = (comp.mpn or "").strip() or (comp.value or "").strip()
|
||||
if not mpn:
|
||||
not_reviewed.append({"designator": ref, "reason": "no MPN in BOM"})
|
||||
if on_progress:
|
||||
await on_progress(ref, 0, "skipped", "no MPN in BOM")
|
||||
continue
|
||||
pdf = _find_pdf(mpn, pdf_dir_path, storage=storage)
|
||||
if pdf:
|
||||
ic_tasks.append((ref, str(pdf)))
|
||||
@@ -671,10 +761,12 @@ async def validate_design_async(
|
||||
preserved_findings: list[Finding] = []
|
||||
preserved_coverage: dict[str, list[str]] = {}
|
||||
preserved_comments = None
|
||||
preserved_review_states = None
|
||||
if existing_path.is_file():
|
||||
try:
|
||||
existing = json.loads(existing_path.read_text())
|
||||
preserved_comments = existing.get("comments")
|
||||
preserved_review_states = existing.get("review_states")
|
||||
if before_ic is not None:
|
||||
# Resume mode — keep findings for refs we're about to skip
|
||||
for f in existing.get("findings", []):
|
||||
@@ -707,6 +799,7 @@ async def validate_design_async(
|
||||
return clean
|
||||
|
||||
def _write_report(paused: bool = False) -> ValidationReport:
|
||||
annotate_findings_cad(all_findings, graph.cad_index)
|
||||
assign_finding_ids(all_findings)
|
||||
summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0}
|
||||
for f in all_findings:
|
||||
@@ -735,9 +828,17 @@ async def validate_design_async(
|
||||
report_dict = json.loads(report.model_dump_json(indent=2))
|
||||
if preserved_comments is not None:
|
||||
report_dict["comments"] = preserved_comments
|
||||
if preserved_review_states is not None:
|
||||
report_dict["review_states"] = preserved_review_states
|
||||
if paused:
|
||||
report_dict["partial"] = True
|
||||
existing_path.write_text(json.dumps(report_dict, indent=2))
|
||||
try:
|
||||
prefix_id = (project_prefix or "").rstrip("/").rsplit("/", 1)[-1]
|
||||
bridge = build_cad_bridge(report, prefix_id or report.project)
|
||||
write_cad_bridge(existing_path.with_name("periscope-findings.json"), bridge)
|
||||
except Exception:
|
||||
log.exception("cad bridge write failed")
|
||||
return report
|
||||
|
||||
git_commit = (run_meta or {}).get("git_commit", "unknown")
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"default_model_version": "1.4.0",
|
||||
"default_model_version": "1.10.0",
|
||||
"extract-pintable": {
|
||||
"skill_id": "skill_013cTQFk8bqwJVemreNihQRW",
|
||||
"latest_version": "1777167199421424",
|
||||
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
|
||||
"latest_version": "1784798970179642",
|
||||
"display_title": "Extract Pin Table"
|
||||
},
|
||||
"extract-pattern": {
|
||||
"skill_id": "skill_0195iVb55HeQgKHFkePC56hP",
|
||||
"latest_version": "1777167200857394",
|
||||
"skill_id": "skill_01JuA5xdSJsz2V4dcwzpTRpe",
|
||||
"latest_version": "1784798971057751",
|
||||
"display_title": "Extract Passive Pattern"
|
||||
},
|
||||
"extract-specs": {
|
||||
"skill_id": "skill_016sqcgvuVea95Nb4uJYBj7h",
|
||||
"latest_version": "1777167202182784",
|
||||
"skill_id": "skill_01NHZY6K3tvdbAzBo7eGT8qD",
|
||||
"latest_version": "1784798971971891",
|
||||
"display_title": "Extract Component Specs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Put vendored ImpedenceFinder on sys.path (closed-form package only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VENDOR_DIR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
|
||||
|
||||
def ensure_impedancefinder() -> None:
|
||||
root = str(VENDOR_DIR)
|
||||
if root not in sys.path:
|
||||
sys.path.insert(0, root)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user