Register library components from a datasheet without an exam (2.63.1).
POST /api/library/datasheets now writes an IC inbox card or a passive/discrete model. Empty pintables never enter library/extracted. AF Board+AI runs after unchanged run_pcb_checks (PE-SI / HF line stay).
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
# Albero delle analisi Periscope
|
# Albero delle analisi Periscope
|
||||||
|
|
||||||
Versione codice **2.63.0** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa.
|
Versione codice **2.63.1** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa.
|
||||||
|
|
||||||
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
|
I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file.
|
||||||
|
|
||||||
Due pipeline:
|
Due pipeline più una porta libreria (non è un esame):
|
||||||
|
|
||||||
|
0. **Libreria** — `POST /api/library/datasheets` registra un componente da PDF senza `MODE=run`/`MODE=pcb`.
|
||||||
1. **Schematico / review AI** — `MODE=run`, `services/pipeline.py` → `validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
|
1. **Schematico / review AI** — `MODE=run`, `services/pipeline.py` → `validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek.
|
||||||
2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
|
2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`, lista 2.62.1 **invariata**) + sezione additiva AF Board+AI (`af_ai_hf.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place.
|
||||||
|
|
||||||
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
|
I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A.
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@ Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/A
|
|||||||
---
|
---
|
||||||
|
|
||||||
```
|
```
|
||||||
Periscope 2.62.1
|
Periscope 2.63.1
|
||||||
|
├── 0. Libreria (porta, non esame)
|
||||||
|
│ └── PDF + MPN → scheda (inbox IC / passivo / discreto) senza MODE=run/pcb
|
||||||
├── 1. Schematico / review AI (MODE=run)
|
├── 1. Schematico / review AI (MODE=run)
|
||||||
│ ├── ingest (non è analisi: parser → grafo)
|
│ ├── ingest (non è analisi: parser → grafo)
|
||||||
│ ├── estrazione datasheet
|
│ ├── estrazione datasheet
|
||||||
@@ -25,7 +28,7 @@ Periscope 2.62.1
|
|||||||
│ └── review AI per-IC + post-pass
|
│ └── review AI per-IC + post-pass
|
||||||
└── 2. Esame PCB (MODE=pcb)
|
└── 2. Esame PCB (MODE=pcb)
|
||||||
├── ingest layout (non è DRC)
|
├── ingest layout (non è DRC)
|
||||||
├── run_pcb_checks
|
├── run_pcb_checks ← 2.62.1 invariato (SI + HF line inclusi)
|
||||||
│ ├── net / footprint
|
│ ├── net / footprint
|
||||||
│ ├── placement vs layout_rules
|
│ ├── placement vs layout_rules
|
||||||
│ ├── SI + linee HF (2.62.0 Fase A)
|
│ ├── SI + linee HF (2.62.0 Fase A)
|
||||||
@@ -35,6 +38,7 @@ Periscope 2.62.1
|
|||||||
│ ├── ESD / return / EMI / SPOF
|
│ ├── ESD / return / EMI / SPOF
|
||||||
│ ├── BOM ↔ PCB ↔ datasheet
|
│ ├── BOM ↔ PCB ↔ datasheet
|
||||||
│ └── resto Fase B (2.62.1)
|
│ └── resto Fase B (2.62.1)
|
||||||
|
├── AF Board + AI (additivo: flag → investigate_hf_hypotheses; non sostituisce SI/HF)
|
||||||
├── review AI PCB
|
├── review AI PCB
|
||||||
└── tab antenna RF (non in run_pcb_checks)
|
└── tab antenna RF (non in run_pcb_checks)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non s
|
|||||||
|
|
||||||
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
|
**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”.
|
||||||
|
|
||||||
|
AF Board+AI (`af_ai_hf.py`) è **additivo**: `pcb_pipeline` chiama `append_investigated` dopo `run_pcb_checks`. Non entra nella lista 2.62.1. Non è DRC.
|
||||||
|
|
||||||
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
|
Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**.
|
||||||
|
|
||||||
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
|
## 9. Check schematico (derating, LED, crystal, mux, rail, …)
|
||||||
@@ -58,7 +60,7 @@ Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conform
|
|||||||
|
|
||||||
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e l’empty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
|
**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e l’empty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users.
|
||||||
|
|
||||||
**Dopo questa macro-fase: conforme come porta, con debito.** `services/library.py` + `routers/library.py` + `/library` (import PDF senza esame, GET/PUT scheda). Store content-addressed (`datasheet_store.py`) resta esplicito. `library_gate.py` è piccolo e duro (niente pintable velenosa). Debito: `projects.py` re-export per non riscrivere la pipeline; `list_library_catalog` prima ingoiava JSON rotti — ora logga e salta la riga (dato rotto ≠ crash dello store).
|
**Dopo 2.63.1: conforme come porta.** `POST /api/library/datasheets` registra una scheda (inbox IC o modello passivo/discreto) senza esame. Pintable vuota non va in `library/extracted/` (`library_gate`). PUT promuove inbox → extracted. `projects.py` re-export resta debito. `list_library_catalog` logga e salta JSON rotti.
|
||||||
|
|
||||||
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
|
## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job)
|
||||||
|
|
||||||
|
|||||||
+10
-12
@@ -1,27 +1,25 @@
|
|||||||
# Libreria componenti — porta prodotto
|
# Libreria componenti — porta prodotto
|
||||||
|
|
||||||
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi (preliminare datasheet, schematico, PCB) *usa* la libreria; non è l’unico modo per riempirla.
|
La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi *usa* la libreria; non è l’unico modo per riempirla.
|
||||||
|
|
||||||
## Flusso
|
## Flusso
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PDF + MPN
|
PDF + MPN + kind (ic | passive_part | simple)
|
||||||
↓
|
↓
|
||||||
POST /api/library/datasheets
|
POST /api/library/datasheets
|
||||||
↓
|
↓
|
||||||
blob MD5 + ref MPN
|
blob MD5 + ref MPN
|
||||||
↓
|
↓
|
||||||
catalogo (tab Datasheets)
|
scheda componente
|
||||||
|
IC → library/inbox/{mpn}.json (niente pintable inventata)
|
||||||
|
passive_part → library/passives/{mpn}.json
|
||||||
|
simple → library/models/{mpn}.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
|
Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`.
|
||||||
|
|
||||||
Aggiornare una scheda già estratta:
|
`library/extracted/` si scrive **solo** con pintable valida (`library_gate`: ≥2 pin, almeno un nome). PUT `/api/library/components/ic/{mpn}` promuove inbox → extracted.
|
||||||
|
|
||||||
```text
|
|
||||||
GET /api/library/components/{ic|passive|passive_part|simple}/{mpn}
|
|
||||||
PUT stesso path (JSON)
|
|
||||||
```
|
|
||||||
|
|
||||||
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
|
Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa).
|
||||||
|
|
||||||
@@ -29,9 +27,9 @@ Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in
|
|||||||
|
|
||||||
- Non è admin/Clerk/JWT (non si tocca auth).
|
- Non è admin/Clerk/JWT (non si tocca auth).
|
||||||
- Non è DRC, non è FEM.
|
- Non è DRC, non è FEM.
|
||||||
- Non estrae da sola il pintable (LLM = preliminare datasheet nell’analisi, o un job futuro). Il PDF c’è comunque.
|
- Non inventa pin dal PDF. L’LLM prelim resta un mestiere a parte; la scheda c’è comunque.
|
||||||
- Non sostituisce `library_has_*` usati dalla pipeline.
|
- Non sostituisce `library_has_*` usati dalla pipeline (`library_has_extraction` resta falso finché non c’è pintable).
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
`tests/library/` — catalogo, alias MPN, import PDF senza progetto, GET/PUT scheda, rifiuto PDF non valido.
|
`tests/library/` — catalogo, alias MPN, import PDF senza progetto (scheda IC in inbox, passivo in passives), GET/PUT scheda, promozione pintable, rifiuto PDF non valido / kind ignoto.
|
||||||
|
|||||||
@@ -112,3 +112,45 @@ def investigate_hf_hypotheses(
|
|||||||
if any(kicad_nets_match(net, flag) for flag in flagged if net):
|
if any(kicad_nets_match(net, flag) for flag in flagged if net):
|
||||||
out.append(finding)
|
out.append(finding)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def hypotheses_from_rows(rows: list | None) -> list[HfHypothesis]:
|
||||||
|
"""Parse structured AI flags. Unknown classes and empty nets are dropped."""
|
||||||
|
out: list[HfHypothesis] = []
|
||||||
|
for row in rows or []:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
net = str(row.get("net") or "").strip()
|
||||||
|
kind = str(row.get("issue_class") or "").strip().lower()
|
||||||
|
why = str(row.get("why") or "").strip()
|
||||||
|
if not net or kind not in ALLOWED_ISSUE_CLASSES:
|
||||||
|
continue
|
||||||
|
out.append(HfHypothesis(net=net, issue_class=kind, why=why))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def append_investigated(
|
||||||
|
pcb_findings: list[Finding],
|
||||||
|
graph: DesignGraph,
|
||||||
|
constraints_map: dict,
|
||||||
|
layout: LayoutGraph | None,
|
||||||
|
hypotheses: list[HfHypothesis],
|
||||||
|
impedance_nets: list[dict] | dict | None = None,
|
||||||
|
) -> list[Finding]:
|
||||||
|
"""Append AF+AI extras. Never drops ``run_pcb_checks`` rows.
|
||||||
|
|
||||||
|
Duplicate SI/HF clones (same rule_id+net already present) are skipped so
|
||||||
|
a stub FAIL is not counted twice. ``PE-AF-001`` is always additive.
|
||||||
|
"""
|
||||||
|
extra = investigate_hf_hypotheses(
|
||||||
|
graph, constraints_map, layout, hypotheses, impedance_nets,
|
||||||
|
)
|
||||||
|
seen = {(f.rule_id, f.net) for f in pcb_findings}
|
||||||
|
added: list[Finding] = []
|
||||||
|
for finding in extra:
|
||||||
|
key = (finding.rule_id, finding.net)
|
||||||
|
if finding.rule_id == RULE_Z_NO_EVIDENCE or key not in seen:
|
||||||
|
pcb_findings.append(finding)
|
||||||
|
added.append(finding)
|
||||||
|
seen.add(key)
|
||||||
|
return added
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async def post_library_datasheet(
|
|||||||
request: Request,
|
request: Request,
|
||||||
mpn: str = Form(...),
|
mpn: str = Form(...),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
kind: str = Form("ic"),
|
||||||
):
|
):
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
if len(raw) > MAX_DATASHEET_BYTES:
|
if len(raw) > MAX_DATASHEET_BYTES:
|
||||||
@@ -88,7 +89,9 @@ async def post_library_datasheet(
|
|||||||
413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
|
413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
row = lib_svc.import_datasheet(get_storage(request), mpn, raw)
|
row = lib_svc.import_component_from_datasheet(
|
||||||
|
get_storage(request), mpn, raw, kind=kind,
|
||||||
|
)
|
||||||
except LibraryError as exc:
|
except LibraryError as exc:
|
||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
return row
|
return row
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
MAX_DATASHEET_BYTES = 30 * 1024 * 1024
|
MAX_DATASHEET_BYTES = 30 * 1024 * 1024
|
||||||
COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple")
|
COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple")
|
||||||
|
# Import-from-PDF kinds. "passive" is a series regex, not a single part.
|
||||||
|
IMPORT_KINDS = ("ic", "passive_part", "simple")
|
||||||
|
INBOX_PREFIX = "library/inbox/"
|
||||||
|
|
||||||
|
|
||||||
class LibraryError(ValueError):
|
class LibraryError(ValueError):
|
||||||
@@ -111,11 +114,30 @@ def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None:
|
|||||||
return legacy if storage.exists(legacy) else None
|
return legacy if storage.exists(legacy) else None
|
||||||
|
|
||||||
|
|
||||||
|
def inbox_key(name: str) -> str:
|
||||||
|
safe = safe_mpn(name)
|
||||||
|
if not safe:
|
||||||
|
raise LibraryError("Invalid MPN")
|
||||||
|
return f"{INBOX_PREFIX}{safe}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def drop_inbox(storage: StorageBackend, name: str) -> None:
|
||||||
|
"""Remove the pending IC card once a real pintable exists."""
|
||||||
|
try:
|
||||||
|
key = inbox_key(name)
|
||||||
|
except LibraryError:
|
||||||
|
return
|
||||||
|
if storage.exists(key):
|
||||||
|
storage.delete_key(key)
|
||||||
|
|
||||||
|
|
||||||
def save_to_library(
|
def save_to_library(
|
||||||
storage: StorageBackend, src_key: str, category: str, filename: str,
|
storage: StorageBackend, src_key: str, category: str, filename: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
dst = f"library/{category}/{filename}"
|
dst = f"library/{category}/{filename}"
|
||||||
storage.copy_object(src_key, dst)
|
storage.copy_object(src_key, dst)
|
||||||
|
if category == "extracted":
|
||||||
|
drop_inbox(storage, filename.replace(".json", ""))
|
||||||
return dst
|
return dst
|
||||||
|
|
||||||
|
|
||||||
@@ -149,6 +171,66 @@ def import_datasheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_ic_card(storage: StorageBackend, name: str) -> dict:
|
||||||
|
"""Pending IC card. Empty pintable never goes to library/extracted."""
|
||||||
|
if library_has_extraction(storage, name) is not None:
|
||||||
|
return {"component_type": "ic", "status": "extracted"}
|
||||||
|
key = inbox_key(name)
|
||||||
|
if not storage.exists(key):
|
||||||
|
storage.write_json(key, {
|
||||||
|
"mpn": name,
|
||||||
|
"kind": "ic",
|
||||||
|
"status": "needs_pintable",
|
||||||
|
"source": "library_import",
|
||||||
|
"pintable": [],
|
||||||
|
"absolute_maximum_ratings": [],
|
||||||
|
"rules": [],
|
||||||
|
})
|
||||||
|
return {"component_type": "ic", "status": "needs_pintable"}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_model_card(
|
||||||
|
storage: StorageBackend, name: str, kind: str,
|
||||||
|
) -> dict:
|
||||||
|
key = component_key(kind, name)
|
||||||
|
if kind == "passive_part" and not storage.exists(key):
|
||||||
|
legacy = f"library/models/{safe_mpn(name)}.json"
|
||||||
|
if storage.exists(legacy):
|
||||||
|
return {"component_type": kind, "status": "registered"}
|
||||||
|
if not storage.exists(key):
|
||||||
|
specs_type = "passive" if kind == "passive_part" else "discrete"
|
||||||
|
storage.write_json(key, {
|
||||||
|
"mpn": name,
|
||||||
|
"specs": {"specs_type": specs_type, "values": {}},
|
||||||
|
})
|
||||||
|
return {"component_type": kind, "status": "registered"}
|
||||||
|
|
||||||
|
|
||||||
|
def import_component_from_datasheet(
|
||||||
|
storage: StorageBackend,
|
||||||
|
mpn: str,
|
||||||
|
data: bytes,
|
||||||
|
kind: str = "ic",
|
||||||
|
extra_mpns: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""PDF + MPN → library component. No project, no MODE=run/pcb."""
|
||||||
|
kind_n = (kind or "ic").strip().lower()
|
||||||
|
if kind_n not in IMPORT_KINDS:
|
||||||
|
raise LibraryError(f"Unknown component kind: {kind}")
|
||||||
|
row = import_datasheet(storage, mpn, data, extra_mpns=extra_mpns)
|
||||||
|
if kind_n == "ic":
|
||||||
|
extra = _register_ic_card(storage, row["mpn"])
|
||||||
|
else:
|
||||||
|
extra = _register_model_card(storage, row["mpn"], kind_n)
|
||||||
|
row.update(extra)
|
||||||
|
row["has_extraction"] = library_has_extraction(storage, row["mpn"]) is not None
|
||||||
|
row["has_model"] = (
|
||||||
|
library_has_model(storage, row["mpn"]) is not None
|
||||||
|
or library_has_passive_model(storage, row["mpn"]) is not None
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def remember_datasheet(
|
def remember_datasheet(
|
||||||
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -163,9 +245,11 @@ def get_component(storage: StorageBackend, component_type: str, name: str) -> di
|
|||||||
key = component_key(component_type, name)
|
key = component_key(component_type, name)
|
||||||
if component_type == "passive_part" and not storage.exists(key):
|
if component_type == "passive_part" and not storage.exists(key):
|
||||||
key = f"library/models/{safe_mpn(name)}.json"
|
key = f"library/models/{safe_mpn(name)}.json"
|
||||||
if not storage.exists(key):
|
if storage.exists(key):
|
||||||
return None
|
|
||||||
return storage.read_json(key)
|
return storage.read_json(key)
|
||||||
|
if component_type == "ic" and storage.exists(inbox_key(name)):
|
||||||
|
return storage.read_json(inbox_key(name))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def update_component(
|
def update_component(
|
||||||
@@ -181,8 +265,14 @@ def update_component(
|
|||||||
ok, reason = should_promote_extraction(body)
|
ok, reason = should_promote_extraction(body)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise LibraryError(f"IC rejected: {reason}")
|
raise LibraryError(f"IC rejected: {reason}")
|
||||||
|
body.pop("kind", None)
|
||||||
|
body.pop("status", None)
|
||||||
|
if body.get("source") == "library_import":
|
||||||
|
body.pop("source")
|
||||||
key = component_key(component_type, name)
|
key = component_key(component_type, name)
|
||||||
storage.write_json(key, body)
|
storage.write_json(key, body)
|
||||||
|
if component_type == "ic":
|
||||||
|
drop_inbox(storage, name)
|
||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
@@ -205,10 +295,34 @@ def list_library_catalog(storage: StorageBackend) -> dict:
|
|||||||
"pin_count": len(data.get("pintable", [])),
|
"pin_count": len(data.get("pintable", [])),
|
||||||
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||||
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||||
|
"library_status": "extracted",
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("skip unreadable IC catalog key %s", key)
|
log.exception("skip unreadable IC catalog key %s", key)
|
||||||
|
|
||||||
|
seen_inbox: set[str] = set()
|
||||||
|
for key in storage.list_prefix(INBOX_PREFIX):
|
||||||
|
if not key.endswith(".json"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = storage.read_json(key)
|
||||||
|
mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "")
|
||||||
|
if not mpn or mpn in seen_ic:
|
||||||
|
continue
|
||||||
|
seen_ic.add(mpn)
|
||||||
|
seen_inbox.add(mpn)
|
||||||
|
ics.append({
|
||||||
|
"mpn": mpn,
|
||||||
|
"type": "ic",
|
||||||
|
"subtype": data.get("component_subtype") or "",
|
||||||
|
"pin_count": len(data.get("pintable") or []),
|
||||||
|
"has_ratings": bool(data.get("absolute_maximum_ratings")),
|
||||||
|
"has_datasheet": bool(resolve_datasheet(storage, mpn)),
|
||||||
|
"library_status": data.get("status") or "needs_pintable",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
log.exception("skip unreadable inbox catalog key %s", key)
|
||||||
|
|
||||||
passives: list[dict] = []
|
passives: list[dict] = []
|
||||||
seen_p: set[str] = set()
|
seen_p: set[str] = set()
|
||||||
for key in storage.list_prefix("library/patterns/"):
|
for key in storage.list_prefix("library/patterns/"):
|
||||||
@@ -265,8 +379,9 @@ def list_library_catalog(storage: StorageBackend) -> dict:
|
|||||||
datasheets.append({
|
datasheets.append({
|
||||||
"mpn": mpn,
|
"mpn": mpn,
|
||||||
"hash": ref.get("hash"),
|
"hash": ref.get("hash"),
|
||||||
"has_extraction": mpn in seen_ic,
|
"has_extraction": mpn in seen_ic and mpn not in seen_inbox,
|
||||||
"has_model": mpn in seen_m,
|
"has_model": mpn in seen_m,
|
||||||
|
"has_component": mpn in seen_ic or mpn in seen_m,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("skip unreadable datasheet ref %s", key)
|
log.exception("skip unreadable datasheet ref %s", key)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
|
"""PCB review pipeline — parallel to analysis (exam, not auto-place).
|
||||||
|
|
||||||
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
|
Stages: ensure_graph → parse_pcb → classify → inventory → checks →
|
||||||
ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
af_ai → ai_review → write_report. Uses ``pcb_status`` so analysis ``status`` is
|
||||||
untouched. Does not write ``.kicad_pcb`` or packing coordinates.
|
untouched. ``af_ai`` is additive (AI flags then deterministic investigate) and
|
||||||
|
does not replace ``run_pcb_checks`` SI/HF. Does not write ``.kicad_pcb``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,6 +28,7 @@ from backend.periscopex.layout_rules import needs_layout_rules_refresh
|
|||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
|
ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport,
|
||||||
)
|
)
|
||||||
|
from backend.periscopex.af_ai_hf import append_investigated, hypotheses_from_rows
|
||||||
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks
|
||||||
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
from backend.periscopex.pcb_inventory import build_pcb_inventory
|
||||||
from backend.services import projects as proj_svc
|
from backend.services import projects as proj_svc
|
||||||
@@ -162,6 +164,29 @@ def _step(project_id: str, stage: str, status: str, detail: str = "") -> None:
|
|||||||
_publish(project_id, "pcb_step_update", payload)
|
_publish(project_id, "pcb_step_update", payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_af_ai_section(
|
||||||
|
ws: PipelineWorkspace,
|
||||||
|
graph: DesignGraph,
|
||||||
|
cmap: dict,
|
||||||
|
layout: LayoutGraph | None,
|
||||||
|
zrep,
|
||||||
|
findings: list,
|
||||||
|
) -> int:
|
||||||
|
"""AI-flag-then-investigate. Does not replace ``run_pcb_checks``."""
|
||||||
|
raw: list = []
|
||||||
|
hyp_path = ws.local_path("hf_hypotheses.json")
|
||||||
|
if hyp_path.is_file():
|
||||||
|
try:
|
||||||
|
loaded = json.loads(hyp_path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(loaded, list):
|
||||||
|
raw = loaded
|
||||||
|
except Exception:
|
||||||
|
logger.exception("hf_hypotheses.json unreadable — AF+AI flags empty")
|
||||||
|
hyps = hypotheses_from_rows(raw)
|
||||||
|
added = append_investigated(findings, graph, cmap, layout, hyps, zrep)
|
||||||
|
return len(added)
|
||||||
|
|
||||||
|
|
||||||
async def run_pcb_pipeline(
|
async def run_pcb_pipeline(
|
||||||
storage: StorageBackend, user_id: str, project_id: str,
|
storage: StorageBackend, user_id: str, project_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -280,6 +305,17 @@ async def run_pcb_pipeline(
|
|||||||
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
+ (f"; {len(si_skip)} SI re-extract" if si_skip else ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if _cancelled(storage, user_id, project_id):
|
||||||
|
_finish_cancelled(storage, user_id, project_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
_step(project_id, "af_ai", "running", "AI HF flags then deterministic investigate")
|
||||||
|
n_af = _run_af_ai_section(ws, graph, cmap, layout, zrep, findings)
|
||||||
|
_step(
|
||||||
|
project_id, "af_ai", "complete",
|
||||||
|
f"{n_af} extra AF+AI findings (SI/HF checks kept)",
|
||||||
|
)
|
||||||
|
|
||||||
if _cancelled(storage, user_id, project_id):
|
if _cancelled(storage, user_id, project_id):
|
||||||
_finish_cancelled(storage, user_id, project_id)
|
_finish_cancelled(storage, user_id, project_id)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
What's new in Periscope.
|
||||||
|
|
||||||
|
## 2.63.1 — 2026-09-21 — Library component from PDF; AF+AI beside PCB checks
|
||||||
|
|
||||||
|
Library import registers a **component** (not only a PDF blob) with no exam. AF Board+AI is a pipeline section **after** unchanged `run_pcb_checks` (PE-SI / HF line stay in 2.62.1).
|
||||||
|
|
||||||
|
- [Fix] `POST /api/library/datasheets` writes an IC inbox card or a passive/discrete model. Empty pintable never lands in `library/extracted/`.
|
||||||
|
- [Fix] PCB pipeline stage `af_ai` calls `append_investigated` beside `run_pcb_checks`. Duplicate SI clones are not double-counted. `PE-AF-001` stays additive.
|
||||||
|
- [New] Report tree root “AF Board + AI”. Progress step between deterministic checks and PCB AI exam.
|
||||||
|
|
||||||
## 2.63.0 — 2026-09-21 — Library as its own product door
|
## 2.63.0 — 2026-09-21 — Library as its own product door
|
||||||
|
|
||||||
Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad.
|
Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "periscope-web",
|
"name": "periscope-web",
|
||||||
"version": "2.63.0",
|
"version": "2.63.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -3,10 +3,21 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { importLibraryDatasheet } from "@/lib/api";
|
import { importLibraryDatasheet, type LibraryComponentType } from "@/lib/api";
|
||||||
|
|
||||||
export function LibraryImport({ onImported }: { onImported: () => void }) {
|
const KINDS: { value: "ic" | "passive_part" | "simple"; label: string }[] = [
|
||||||
|
{ value: "ic", label: "IC" },
|
||||||
|
{ value: "passive_part", label: "Passive" },
|
||||||
|
{ value: "simple", label: "Discrete" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LibraryImport({
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
onImported: (row: { type: LibraryComponentType; name: string }) => void;
|
||||||
|
}) {
|
||||||
const [mpn, setMpn] = useState("");
|
const [mpn, setMpn] = useState("");
|
||||||
|
const [kind, setKind] = useState<"ic" | "passive_part" | "simple">("ic");
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -24,10 +35,11 @@ export function LibraryImport({ onImported }: { onImported: () => void }) {
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await importLibraryDatasheet(mpn.trim(), file);
|
const row = await importLibraryDatasheet(mpn.trim(), file, kind);
|
||||||
|
const name = row.mpn;
|
||||||
setMpn("");
|
setMpn("");
|
||||||
setFile(null);
|
setFile(null);
|
||||||
onImported();
|
onImported({ type: kind, name });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Import failed");
|
setError(err instanceof Error ? err.message : "Import failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -43,8 +55,9 @@ export function LibraryImport({ onImported }: { onImported: () => void }) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Add a component from a datasheet</p>
|
<p className="text-sm font-medium">Add a component from a datasheet</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
PDF + MPN. No project and no exam required. Pin tables still come
|
PDF + MPN on this page. No exam, no PCB run, no schematic run.
|
||||||
from the datasheet prelim in analysis.
|
Pin tables are not invented: fill them here or wait for extraction
|
||||||
|
evidence.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
@@ -55,6 +68,20 @@ export function LibraryImport({ onImported }: { onImported: () => void }) {
|
|||||||
className="sm:w-56 font-mono text-sm"
|
className="sm:w-56 font-mono text-sm"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
<select
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
setKind(e.target.value as "ic" | "passive_part" | "simple")
|
||||||
|
}
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm sm:w-36"
|
||||||
|
aria-label="Component kind"
|
||||||
|
>
|
||||||
|
{KINDS.map((k) => (
|
||||||
|
<option key={k.value} value={k.value}>
|
||||||
|
{k.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/pdf,.pdf"
|
accept="application/pdf,.pdf"
|
||||||
|
|||||||
@@ -143,14 +143,15 @@ export default function LibraryPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold">Component library</h1>
|
<h1 className="text-lg font-semibold">Component library</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">
|
||||||
Shared datasheets and extracted cards. Add a PDF here without
|
Shared component store. Add a part from a manufacturer PDF here —
|
||||||
starting an exam. Analysis still runs the datasheet prelim when a
|
this page is not the exam. Analysis still uses the library; it is
|
||||||
board is reviewed.
|
not the only way to fill it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LibraryImport
|
<LibraryImport
|
||||||
onImported={() => {
|
onImported={(row) => {
|
||||||
|
setEditor(row);
|
||||||
reload();
|
reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -169,9 +170,9 @@ export default function LibraryPage() {
|
|||||||
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
|
<Library className="h-8 w-8 text-muted-foreground mx-auto" />
|
||||||
<p className="text-sm font-medium">Library is empty</p>
|
<p className="text-sm font-medium">Library is empty</p>
|
||||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||||
Drop a manufacturer PDF and its MPN above. Nothing is invented
|
Drop a manufacturer PDF and its MPN above. The part is in the
|
||||||
from an empty library — pin tables arrive when extraction has
|
library immediately. Pin tables are not invented from an empty
|
||||||
evidence.
|
PDF parse — fill the card or wait for extraction evidence.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -264,7 +265,12 @@ export default function LibraryPage() {
|
|||||||
<td className="px-3 py-2 text-center">{ic.pin_count}</td>
|
<td className="px-3 py-2 text-center">{ic.pin_count}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ic.library_status === "needs_pintable" ||
|
||||||
|
ic.pin_count === 0 ? (
|
||||||
|
<Badge variant="outline">needs pin table</Badge>
|
||||||
|
) : (
|
||||||
<Badge variant="outline">pin table</Badge>
|
<Badge variant="outline">pin table</Badge>
|
||||||
|
)}
|
||||||
{ic.has_datasheet && (
|
{ic.has_datasheet && (
|
||||||
<DatasheetLink
|
<DatasheetLink
|
||||||
mpn={ic.mpn}
|
mpn={ic.mpn}
|
||||||
@@ -471,9 +477,11 @@ export default function LibraryPage() {
|
|||||||
<Badge variant="outline">pin table ready</Badge>
|
<Badge variant="outline">pin table ready</Badge>
|
||||||
) : d.has_model ? (
|
) : d.has_model ? (
|
||||||
<Badge variant="outline">specs ready</Badge>
|
<Badge variant="outline">specs ready</Badge>
|
||||||
|
) : d.has_component ? (
|
||||||
|
<Badge variant="outline">in library</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
PDF saved — extract on next review
|
PDF saved
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
DesignGraph,
|
DesignGraph,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
|
||||||
import { isPcbExamFinding } from "@/lib/layout-finding";
|
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
|
||||||
|
|
||||||
interface FindingsTreeProps {
|
interface FindingsTreeProps {
|
||||||
findings: Finding[];
|
findings: Finding[];
|
||||||
@@ -94,11 +94,15 @@ function TreeBranch({
|
|||||||
|
|
||||||
export function FindingsTree(props: FindingsTreeProps) {
|
export function FindingsTree(props: FindingsTreeProps) {
|
||||||
const sorted = sortFindings(props.findings);
|
const sorted = sortFindings(props.findings);
|
||||||
const schematic = sorted.filter((f) => !isPcbExamFinding(f));
|
const af = sorted.filter(isAfAiFinding);
|
||||||
const pcb = sorted.filter(isPcbExamFinding);
|
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
|
||||||
|
const schematic = sorted.filter(
|
||||||
|
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
|
||||||
|
);
|
||||||
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
const roots: { id: string; label: string; items: Finding[] }[] = [];
|
||||||
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
|
||||||
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
|
||||||
|
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
|
||||||
|
|
||||||
const showRoots = roots.length > 1;
|
const showRoots = roots.length > 1;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const PCB_STAGES = [
|
|||||||
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
|
{ id: "parse_pcb", title: "Parse PCB", description: "Build layout_graph.json from .kicad_pcb" },
|
||||||
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
|
{ id: "classify", title: "Classify domains", description: "Domains and functional groups" },
|
||||||
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
|
{ id: "inventory", title: "Inventory traces", description: "Lengths, pairs, buses, Z0" },
|
||||||
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, power traces, thermal, Kelvin" },
|
{ id: "checks", title: "Deterministic checks", description: "Placement, SI, HF lines, power traces, thermal, Kelvin" },
|
||||||
|
{ id: "af_ai", title: "AF Board + AI", description: "AI flags HF issues, then deterministic investigate (does not replace SI/HF)" },
|
||||||
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
|
{ id: "ai_review", title: "PCB AI exam", description: "Layout vs shared library extraction (no second PDF pass)" },
|
||||||
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
|
{ id: "write_report", title: "Write report", description: "pcb_report.json findings" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export interface LibraryIC {
|
|||||||
pin_count: number;
|
pin_count: number;
|
||||||
has_ratings: boolean;
|
has_ratings: boolean;
|
||||||
has_datasheet: boolean;
|
has_datasheet: boolean;
|
||||||
|
library_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryPassive {
|
export interface LibraryPassive {
|
||||||
@@ -402,6 +403,7 @@ export interface LibraryDatasheet {
|
|||||||
hash: string | null;
|
hash: string | null;
|
||||||
has_extraction: boolean;
|
has_extraction: boolean;
|
||||||
has_model: boolean;
|
has_model: boolean;
|
||||||
|
has_component?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryCatalog {
|
export interface LibraryCatalog {
|
||||||
@@ -423,17 +425,24 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise<string | nu
|
|||||||
return URL.createObjectURL(await res.blob());
|
return URL.createObjectURL(await res.blob());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importLibraryDatasheet(mpn: string, file: File): Promise<{
|
export async function importLibraryDatasheet(
|
||||||
|
mpn: string,
|
||||||
|
file: File,
|
||||||
|
kind: "ic" | "passive_part" | "simple" = "ic",
|
||||||
|
): Promise<{
|
||||||
mpn: string;
|
mpn: string;
|
||||||
blob_key: string;
|
blob_key: string;
|
||||||
has_extraction: boolean;
|
has_extraction: boolean;
|
||||||
has_model: boolean;
|
has_model: boolean;
|
||||||
|
component_type?: string;
|
||||||
|
status?: string;
|
||||||
}> {
|
}> {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("mpn", mpn);
|
body.append("mpn", mpn);
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
|
body.append("kind", kind);
|
||||||
const res = await send("/api/library/datasheets", { method: "POST", body });
|
const res = await send("/api/library/datasheets", { method: "POST", body });
|
||||||
return jsonOrThrow(res, "Failed to add datasheet to the library");
|
return jsonOrThrow(res, "Failed to add component to the library");
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
|
export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple";
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function isLayoutFinding(f: {
|
|||||||
f.source === "usb_pd_check" ||
|
f.source === "usb_pd_check" ||
|
||||||
f.source === "antenna_layout_check" ||
|
f.source === "antenna_layout_check" ||
|
||||||
f.source === "pdn_check" ||
|
f.source === "pdn_check" ||
|
||||||
|
f.source === "af_ai_hf" ||
|
||||||
rid.startsWith("PE-PLC") ||
|
rid.startsWith("PE-PLC") ||
|
||||||
rid.startsWith("PE-LAY") ||
|
rid.startsWith("PE-LAY") ||
|
||||||
rid.startsWith("PE-SI") ||
|
rid.startsWith("PE-SI") ||
|
||||||
@@ -47,6 +48,7 @@ export function isLayoutFinding(f: {
|
|||||||
rid.startsWith("PE-PD-") ||
|
rid.startsWith("PE-PD-") ||
|
||||||
rid.startsWith("PE-ANT") ||
|
rid.startsWith("PE-ANT") ||
|
||||||
rid.startsWith("PE-PDN") ||
|
rid.startsWith("PE-PDN") ||
|
||||||
|
rid.startsWith("PE-AF") ||
|
||||||
/^PE-BOM-01[0-4]$/.test(rid)
|
/^PE-BOM-01[0-4]$/.test(rid)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,3 +87,11 @@ export function isPcbExamFinding(f: {
|
|||||||
/^PE-BOM-01[0-4]$/.test(rid)
|
/^PE-BOM-01[0-4]$/.test(rid)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAfAiFinding(f: {
|
||||||
|
source?: string | null;
|
||||||
|
rule_id?: string | null;
|
||||||
|
}): boolean {
|
||||||
|
const rid = f.rule_id || "";
|
||||||
|
return f.source === "af_ai_hf" || rid.startsWith("PE-AF");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
|
||||||
export const APP_VERSION = "2.63.0";
|
export const APP_VERSION = "2.63.1";
|
||||||
export const APP_VERSION_DATE = "2026-09-21";
|
export const APP_VERSION_DATE = "2026-09-21";
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ Does not replace run_pcb_checks. Not DRC. Never invents Z.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from backend.periscopex.af_ai_hf import HfHypothesis, investigate_hf_hypotheses
|
from backend.periscopex.af_ai_hf import (
|
||||||
|
HfHypothesis,
|
||||||
|
append_investigated,
|
||||||
|
hypotheses_from_rows,
|
||||||
|
investigate_hf_hypotheses,
|
||||||
|
)
|
||||||
from backend.periscopex.hf_line_check import check_hf_lines
|
from backend.periscopex.hf_line_check import check_hf_lines
|
||||||
from backend.periscopex.models import (
|
from backend.periscopex.models import (
|
||||||
Component,
|
Component,
|
||||||
@@ -144,3 +149,57 @@ def test_z0_with_stackup_does_not_invent_ohm_number():
|
|||||||
for f in out:
|
for f in out:
|
||||||
assert "50 Ω" not in (f.finding or "")
|
assert "50 Ω" not in (f.finding or "")
|
||||||
assert "90 Ω" not in (f.finding or "")
|
assert "90 Ω" not in (f.finding or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_investigated_keeps_pcb_checks_and_adds_pe_af():
|
||||||
|
graph = _usb_graph()
|
||||||
|
layout = _stub_layout()
|
||||||
|
cons = _stub_constraints()
|
||||||
|
pcb = run_pcb_checks(graph, cons, layout)
|
||||||
|
n_si = sum(1 for f in pcb if f.rule_id == "PE-SI-007")
|
||||||
|
assert n_si >= 1
|
||||||
|
flags = [
|
||||||
|
HfHypothesis(net="USB_D+", issue_class="stub", why="looks long"),
|
||||||
|
HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm"),
|
||||||
|
]
|
||||||
|
added = append_investigated(pcb, graph, cons, layout, flags)
|
||||||
|
assert any(f.rule_id == "PE-AF-001" for f in added)
|
||||||
|
assert sum(1 for f in pcb if f.rule_id == "PE-SI-007") == n_si
|
||||||
|
assert "si_check" in _pcb_checks_source()
|
||||||
|
assert "hf_line" in _pcb_checks_source()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hypotheses_from_rows_drops_unknown_class():
|
||||||
|
rows = [
|
||||||
|
{"net": "USB_D+", "issue_class": "stub", "why": "x"},
|
||||||
|
{"net": "USB_D+", "issue_class": "clearance", "why": "drc"},
|
||||||
|
{"net": "", "issue_class": "z0", "why": "empty"},
|
||||||
|
]
|
||||||
|
hyps = hypotheses_from_rows(rows)
|
||||||
|
assert len(hyps) == 1
|
||||||
|
assert hyps[0].issue_class == "stub"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pcb_pipeline_af_ai_is_after_run_pcb_checks():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from backend.periscopex import pcb_checks
|
||||||
|
from backend.services import pcb_pipeline
|
||||||
|
|
||||||
|
src = inspect.getsource(pcb_pipeline.run_pcb_pipeline)
|
||||||
|
assert "run_pcb_checks" in src
|
||||||
|
assert "_run_af_ai_section" in src
|
||||||
|
assert src.find("run_pcb_checks") < src.find("_run_af_ai_section")
|
||||||
|
checks = inspect.getsource(pcb_checks.run_pcb_checks)
|
||||||
|
assert '("si_check"' in checks or '"si_check"' in checks
|
||||||
|
assert '"hf_line"' in checks
|
||||||
|
assert "af_ai" not in checks
|
||||||
|
assert "investigate_hf_hypotheses" not in checks
|
||||||
|
|
||||||
|
|
||||||
|
def _pcb_checks_source() -> str:
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from backend.periscopex.pcb_checks import run_pcb_checks
|
||||||
|
|
||||||
|
return inspect.getsource(run_pcb_checks)
|
||||||
|
|||||||
@@ -137,19 +137,33 @@ def test_library_import_pdf_without_project(tmp_path):
|
|||||||
before = client.get("/api/projects").json()
|
before = client.get("/api/projects").json()
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
"/api/library/datasheets",
|
"/api/library/datasheets",
|
||||||
data={"mpn": "CH340E"},
|
data={"mpn": "CH340E", "kind": "ic"},
|
||||||
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["mpn"] == "CH340E"
|
assert body["mpn"] == "CH340E"
|
||||||
assert body["has_extraction"] is False
|
assert body["has_extraction"] is False
|
||||||
|
assert body["component_type"] == "ic"
|
||||||
|
assert body["status"] == "needs_pintable"
|
||||||
assert client.get("/api/projects").json() == before
|
assert client.get("/api/projects").json() == before
|
||||||
catalog = client.get("/api/library").json()
|
catalog = client.get("/api/library").json()
|
||||||
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
|
assert any(d["mpn"] == "CH340E" and d.get("has_component") for d in catalog["datasheets"])
|
||||||
|
ics = [c for c in catalog["ics"] if c["mpn"] == "CH340E"]
|
||||||
|
assert len(ics) == 1
|
||||||
|
assert ics[0]["pin_count"] == 0
|
||||||
|
assert ics[0]["library_status"] == "needs_pintable"
|
||||||
pdf = client.get("/api/library/datasheet/CH340E")
|
pdf = client.get("/api/library/datasheet/CH340E")
|
||||||
assert pdf.status_code == 200
|
assert pdf.status_code == 200
|
||||||
assert pdf.content.startswith(b"%PDF-")
|
assert pdf.content.startswith(b"%PDF-")
|
||||||
|
card = client.get("/api/library/components/ic/CH340E")
|
||||||
|
assert card.status_code == 200
|
||||||
|
assert card.json()["status"] == "needs_pintable"
|
||||||
|
from backend.main import app
|
||||||
|
from backend.services.library import library_has_extraction
|
||||||
|
|
||||||
|
assert library_has_extraction(app.state.storage, "CH340E") is None
|
||||||
|
assert not app.state.storage.exists("library/extracted/CH340E.json")
|
||||||
|
|
||||||
|
|
||||||
def test_library_import_rejects_non_pdf(tmp_path):
|
def test_library_import_rejects_non_pdf(tmp_path):
|
||||||
@@ -210,3 +224,63 @@ def test_library_put_rejects_empty_pintable(tmp_path):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
|
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_import_ic_put_pintable_promotes_out_of_inbox(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
assert client.post(
|
||||||
|
"/api/library/datasheets",
|
||||||
|
data={"mpn": "CH340E", "kind": "ic"},
|
||||||
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||||
|
).status_code == 200
|
||||||
|
payload = {
|
||||||
|
"mpn": "CH340E",
|
||||||
|
"component_subtype": "ic.interface.usb_uart_bridge",
|
||||||
|
"pintable": [
|
||||||
|
{"number": "1", "name": "VCC"},
|
||||||
|
{"number": "2", "name": "GND"},
|
||||||
|
],
|
||||||
|
"absolute_maximum_ratings": [],
|
||||||
|
"rules": [],
|
||||||
|
}
|
||||||
|
saved = client.put("/api/library/components/ic/CH340E", json=payload)
|
||||||
|
assert saved.status_code == 200
|
||||||
|
from backend.main import app
|
||||||
|
|
||||||
|
assert app.state.storage.exists("library/extracted/CH340E.json")
|
||||||
|
assert not app.state.storage.exists("library/inbox/CH340E.json")
|
||||||
|
extracted = app.state.storage.read_json("library/extracted/CH340E.json")
|
||||||
|
assert "status" not in extracted
|
||||||
|
assert extracted["pintable"][0]["name"] == "VCC"
|
||||||
|
cat = client.get("/api/library").json()
|
||||||
|
ic = next(c for c in cat["ics"] if c["mpn"] == "CH340E")
|
||||||
|
assert ic["pin_count"] == 2
|
||||||
|
assert ic["library_status"] == "extracted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_import_passive_without_exam(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/library/datasheets",
|
||||||
|
data={"mpn": "CL10B474KA8NNNC", "kind": "passive_part"},
|
||||||
|
files={"file": ("c.pdf", PDF, "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["component_type"] == "passive_part"
|
||||||
|
assert client.get("/api/projects").json() == []
|
||||||
|
cat = client.get("/api/library").json()
|
||||||
|
assert any(p["mpn"] == "CL10B474KA8NNNC" for p in cat["passive_parts"])
|
||||||
|
got = client.get("/api/library/components/passive_part/CL10B474KA8NNNC")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["specs"]["specs_type"] == "passive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_import_rejects_unknown_kind(tmp_path):
|
||||||
|
client = _client(tmp_path)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/library/datasheets",
|
||||||
|
data={"mpn": "CH340E", "kind": "project"},
|
||||||
|
files={"file": ("ch.pdf", PDF, "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert client.get("/api/library").json()["ics"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user