diff --git a/backend/pinscopex/validation_tools.py b/backend/pinscopex/validation_tools.py
index 96c203a..c75461c 100644
--- a/backend/pinscopex/validation_tools.py
+++ b/backend/pinscopex/validation_tools.py
@@ -317,18 +317,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
diff --git a/backend/services/datasheet_finder.py b/backend/services/datasheet_finder.py
index 61a22fa..b8d3383 100644
--- a/backend/services/datasheet_finder.py
+++ b/backend/services/datasheet_finder.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import re
from dataclasses import dataclass
+from pathlib import Path
import httpx
@@ -138,6 +139,34 @@ def mpn_catalog_match(query: str, candidate: str) -> bool:
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.pinscopex.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
diff --git a/backend/services/passive_from_mpn.py b/backend/services/passive_from_mpn.py
new file mode 100644
index 0000000..5300fab
--- /dev/null
+++ b/backend/services/passive_from_mpn.py
@@ -0,0 +1,139 @@
+"""Decode common chip R/C 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.pinscopex.models import ComponentModel, SimpleComponentSpecs
+from backend.pinscopex.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,
+)
+
+
+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 _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)
+
+ return None
diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py
index f3ab1c3..9b655fa 100644
--- a/backend/services/pipeline.py
+++ b/backend/services/pipeline.py
@@ -681,15 +681,16 @@ async def _ensure_local_datasheet(
"""
if pdf_path.is_file():
return True
- from backend.services.datasheet_finder import find_datasheet, mpn_query_variants
+ from backend.services.datasheet_finder import find_datasheet, find_local_pdf, mpn_query_variants
+
+ alt = find_local_pdf(pdf_path.parent, mpn)
+ if alt is not None and alt.is_file():
+ if alt.resolve() != pdf_path.resolve():
+ pdf_path.parent.mkdir(parents=True, exist_ok=True)
+ pdf_path.write_bytes(alt.read_bytes())
+ return True
for name in mpn_query_variants(mpn) or [mpn]:
- alt = pdf_path.parent / f"{safe_mpn(name)}.pdf"
- if alt.is_file():
- if alt.resolve() != pdf_path.resolve():
- pdf_path.parent.mkdir(parents=True, exist_ok=True)
- pdf_path.write_bytes(alt.read_bytes())
- return True
lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, name)
if lib_ds_key:
ctx.storage.download_to_local(lib_ds_key, pdf_path)
@@ -1116,6 +1117,16 @@ async def _catalog_resolve_unresolved_passives(
except Exception as e:
first_error = str(e)
+ if model is None:
+ from backend.services.passive_from_mpn import specs_from_mpn
+ model = specs_from_mpn(mpn)
+ if model is not None:
+ resolved_via = "mpn"
+ broker.publish(ctx.project_id, "step_update",
+ {"stage": "passive_extraction",
+ "substep": mpn, "status": "running",
+ "detail": "decoded from MPN"})
+
if model is None:
bom_value = ctx.passive_values.get(mpn, "").strip()
refs = ctx.passive_mpns.get(mpn, [])
@@ -1152,7 +1163,7 @@ async def _catalog_resolve_unresolved_passives(
model_path.write_text(model.model_dump_json(indent=2) + "\n")
model_key = f"{ctx.ws.prefix}/models/{safe}.json"
ctx.storage.upload_from_local(model_path, model_key)
- if resolved_via in ("digikey", "lcsc"):
+ if resolved_via in ("digikey", "lcsc", "mpn"):
proj_svc.save_to_library(
ctx.storage, model_key, "passives", f"{safe}.json",
)
@@ -1161,6 +1172,7 @@ async def _catalog_resolve_unresolved_passives(
detail = {
"lcsc": "auto-resolved via LCSC",
"digikey": "auto-resolved via DigiKey",
+ "mpn": "decoded from MPN (saved to library)",
"value": "resolved from BOM value (not saved to library)",
}.get(resolved_via, "resolved")
broker.publish(ctx.project_id, "step_update",
@@ -1502,14 +1514,16 @@ async def _stage_validation(ctx: PipelineContext) -> None:
# Snapshot the full review queue so pause checkpoints can show what's left.
# Mirrors the filter in validate_design_async: ICs with a PDF available.
+ from backend.services.datasheet_finder import find_local_pdf
+
planned_refs: list[str] = []
for ref, comp in ctx.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:
continue
- if (ds_dir / f"{safe_mpn(mpn)}.pdf").is_file():
+ if find_local_pdf(ds_dir, mpn) is not None:
planned_refs.append(ref)
ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key)
diff --git a/backend/services/projects.py b/backend/services/projects.py
index d84e6a4..26f74e1 100644
--- a/backend/services/projects.py
+++ b/backend/services/projects.py
@@ -775,6 +775,31 @@ 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.
@@ -826,26 +851,24 @@ def list_library_catalog(storage: StorageBackend) -> dict:
continue
simple_models: list[dict] = []
+ passive_parts: list[dict] = []
seen_model_mpns: set[str] = set()
- for prefix in ("library/models/", "library/passives/"):
+ 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)
- mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "")
+ 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)
- specs = data.get("specs", {}) or {}
- simple_models.append({
- "mpn": mpn,
- "type": "simple",
- "specs_type": specs.get("specs_type", ""),
- "subtype": specs.get("component_subtype", ""),
- "param_count": len(specs.get("values", {}) or {}),
- "has_datasheet": bool(resolve_datasheet(storage, mpn)),
- })
+ row["has_datasheet"] = bool(resolve_datasheet(storage, mpn))
+ dest.append(row)
except Exception:
continue
@@ -871,11 +894,13 @@ def list_library_catalog(storage: StorageBackend) -> dict:
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,
}
diff --git a/backend/services/validation.py b/backend/services/validation.py
index 36ffb7d..1ac0850 100644
--- a/backend/services/validation.py
+++ b/backend/services/validation.py
@@ -571,27 +571,29 @@ def _find_pdf(
"""Find the datasheet PDF for an MPN. Checks local dir first,
then tries to download from the library.
"""
- from backend.services.datasheet_finder import mpn_query_variants
+ from backend.services.datasheet_finder import find_local_pdf
from backend.pinscopex.utils import safe_mpn as _safe
- names = mpn_query_variants(mpn) or [mpn]
- for name in names:
- local = pdf_dir / f"{_safe(name)}.pdf"
- if local.is_file():
- wanted = pdf_dir / f"{_safe(mpn)}.pdf"
- if local != wanted and not wanted.is_file():
- wanted.write_bytes(local.read_bytes())
- return wanted
- return local
+ 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:
- local = pdf_dir / f"{_safe(mpn)}.pdf"
- 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
@@ -665,7 +667,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)))
diff --git a/frontend/src/app/(app)/admin/page.tsx b/frontend/src/app/(app)/admin/page.tsx
index 2cf5a6f..53a4b22 100644
--- a/frontend/src/app/(app)/admin/page.tsx
+++ b/frontend/src/app/(app)/admin/page.tsx
@@ -632,7 +632,11 @@ function ComponentsPanel() {
if (!prev) return prev;
if (type === "ic") return { ...prev, ics: prev.ics.filter((c) => c.mpn !== name) };
if (type === "passive") return { ...prev, passives: prev.passives.filter((c) => c.mpn !== name) };
- return { ...prev, simple: prev.simple.filter((c) => c.mpn !== name) };
+ return {
+ ...prev,
+ simple: prev.simple.filter((c) => c.mpn !== name),
+ passive_parts: (prev.passive_parts ?? []).filter((c) => c.mpn !== name),
+ };
});
reload();
} catch (e) {
@@ -678,6 +682,12 @@ function ComponentsPanel() {
c.subtype.toLowerCase().includes(lf) ||
c.description.toLowerCase().includes(lf),
);
+ const filteredPassiveParts = (data.passive_parts ?? []).filter(
+ (c) =>
+ c.mpn.toLowerCase().includes(lf) ||
+ c.subtype.toLowerCase().includes(lf) ||
+ c.specs_type.toLowerCase().includes(lf),
+ );
const filteredSimple = (data.simple ?? []).filter(
(c) =>
c.mpn.toLowerCase().includes(lf) ||
@@ -699,6 +709,13 @@ function ComponentsPanel() {
{data.passives.length}
Passive Patterns
+ {(data.passive_parts?.length ?? 0) > 0 && (
+
+
+ {data.passive_parts.length}
+ Passive MPNs
+
+ )}
{(data.simple?.length ?? 0) > 0 && (
@@ -827,6 +844,57 @@ function ComponentsPanel() {
)}
+ {/* Per-MPN passive specs */}
+ {filteredPassiveParts.length > 0 && (
+
+
Passive MPNs
+
+
+
+
+ | MPN |
+ Type |
+ Subtype |
+ Params |
+ |
+
+
+
+ {filteredPassiveParts.map((s) => (
+ handleRowClick("simple", s.mpn)}>
+ | {s.mpn} |
+
+ {s.specs_type || "passive"}
+ |
+
+ {s.subtype ? (
+ {s.subtype}
+ ) : (
+ -
+ )}
+ |
+ {s.param_count} |
+
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
{/* Simple component specs table */}
{filteredSimple.length > 0 && (
@@ -878,7 +946,7 @@ function ComponentsPanel() {
)}
- {filteredICs.length === 0 && filteredPassives.length === 0 && filteredSimple.length === 0 && (
+ {filteredICs.length === 0 && filteredPassives.length === 0 && filteredPassiveParts.length === 0 && filteredSimple.length === 0 && (
diff --git a/frontend/src/app/(app)/library/page.tsx b/frontend/src/app/(app)/library/page.tsx
index 1f2636d..68abcaa 100644
--- a/frontend/src/app/(app)/library/page.tsx
+++ b/frontend/src/app/(app)/library/page.tsx
@@ -55,6 +55,17 @@ export default function LibraryPage() {
),
[data, lf],
);
+ const passiveParts = useMemo(
+ () =>
+ (data?.passive_parts ?? []).filter(
+ (c) =>
+ !lf ||
+ c.mpn.toLowerCase().includes(lf) ||
+ c.subtype.toLowerCase().includes(lf) ||
+ c.specs_type.toLowerCase().includes(lf),
+ ),
+ [data, lf],
+ );
const simple = useMemo(
() =>
(data?.simple ?? []).filter(
@@ -108,6 +119,7 @@ export default function LibraryPage() {
!data ||
(data.ics.length === 0 &&
data.passives.length === 0 &&
+ (data.passive_parts ?? []).length === 0 &&
data.simple.length === 0 &&
data.datasheets.length === 0);
@@ -143,8 +155,15 @@ export default function LibraryPage() {
+
+ {(data.passive_parts ?? []).length}
+
+ passives
+
+
+
{data.passives.length}
- passive series
+ series
@@ -169,7 +188,7 @@ export default function LibraryPage() {
ICs ({ics.length})
- Passives ({passives.length})
+ Passives ({passiveParts.length + passives.length})
Discrete ({simple.length})
@@ -225,40 +244,109 @@ export default function LibraryPage() {
)}
-
- {passives.length === 0 ? (
-
+
+ {passiveParts.length === 0 && passives.length === 0 ? (
+
) : (
-
-
-
-
- | Series |
- Type |
-
- Description
- |
-
-
-
- {passives.map((p) => (
-
- | {p.mpn} |
-
- {p.subtype ? (
- {p.subtype}
- ) : (
- —
- )}
- |
-
- {p.description || "—"}
- |
-
- ))}
-
-
-
+ <>
+ {passiveParts.length > 0 && (
+
+
+
+
+ |
+ MPN
+ |
+
+ Type
+ |
+
+ Params
+ |
+
+ PDF
+ |
+
+
+
+ {passiveParts.map((p) => (
+
+ |
+ {p.mpn}
+ |
+
+
+ {p.subtype || p.specs_type || "passive"}
+
+ |
+
+ {p.param_count}
+ |
+
+ {p.has_datasheet ? (
+
+ ) : (
+
+ —
+
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+ {passives.length > 0 && (
+
+
+ Series patterns — one regex covers cousin MPNs
+
+
+
+
+
+ |
+ Series
+ |
+
+ Type
+ |
+
+ Description
+ |
+
+
+
+ {passives.map((p) => (
+
+ |
+ {p.mpn}
+ |
+
+ {p.subtype ? (
+ {p.subtype}
+ ) : (
+
+ —
+
+ )}
+ |
+
+ {p.description || "—"}
+ |
+
+ ))}
+
+
+
+
+ )}
+ >
)}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 541250e..5189f4a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -389,6 +389,15 @@ export interface LibraryPassive {
regex: string;
}
+export interface LibraryPassivePart {
+ mpn: string;
+ type: "passive_part";
+ specs_type: string;
+ subtype: string;
+ param_count: number;
+ has_datasheet: boolean;
+}
+
export interface LibrarySimple {
mpn: string;
type: "simple";
@@ -408,6 +417,7 @@ export interface LibraryDatasheet {
export interface LibraryCatalog {
ics: LibraryIC[];
passives: LibraryPassive[];
+ passive_parts?: LibraryPassivePart[];
simple: LibrarySimple[];
datasheets: LibraryDatasheet[];
}
@@ -606,6 +616,7 @@ export interface AdminSimple {
export interface AdminComponents {
ics: AdminIC[];
passives: AdminPassive[];
+ passive_parts?: AdminSimple[];
simple: AdminSimple[];
}
diff --git a/tests/test_library.py b/tests/test_library.py
index ee0acb0..e7a6fdd 100644
--- a/tests/test_library.py
+++ b/tests/test_library.py
@@ -78,7 +78,9 @@ def test_library_catalog_lists_ics_passives_and_pdfs(storage):
assert cat["ics"][0]["pin_count"] == 2
assert cat["ics"][0]["has_datasheet"] is True
assert cat["passives"][0]["mpn"] == "Samsung CL10"
- assert cat["simple"][0]["mpn"] == "CL10B474KA8NNNC"
+ assert cat["passive_parts"][0]["mpn"] == "CL10B474KA8NNNC"
+ assert cat["passive_parts"][0]["param_count"] >= 1
+ assert cat["simple"] == []
assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"])
diff --git a/tests/test_passive_from_mpn.py b/tests/test_passive_from_mpn.py
new file mode 100644
index 0000000..d6b8e11
--- /dev/null
+++ b/tests/test_passive_from_mpn.py
@@ -0,0 +1,65 @@
+from pathlib import Path
+
+from backend.services.datasheet_finder import find_local_pdf
+from backend.services.passive_from_mpn import specs_from_mpn
+
+
+def test_walsin_c0g_18pf():
+ model = specs_from_mpn("0805CG180J500NT")
+ assert model is not None
+ specs = model.specs
+ assert specs.specs_type == "capacitor"
+ assert abs(specs.value_farads - 18e-12) < 1e-15
+ assert specs.package == "0805"
+ assert specs.dielectric == "C0G"
+ assert specs.tolerance == "±5%"
+ assert specs.voltage_rating_v == "50V"
+
+
+def test_avx_c0g_150pf():
+ model = specs_from_mpn("08055A151FAT2A")
+ assert model is not None
+ specs = model.specs
+ assert abs(specs.value_farads - 150e-12) < 1e-15
+ assert specs.package == "0805"
+ assert specs.dielectric == "C0G"
+ assert specs.voltage_rating_v == "50V"
+
+
+def test_chip_resistor_from_mpn():
+ model = specs_from_mpn("FRC0805F1212TS")
+ assert model is not None
+ assert abs(model.specs.value_ohms - 12100) < 0.1
+ assert model.specs.package == "0805"
+ assert model.specs.tolerance == "±1%"
+
+ model = specs_from_mpn("0805W8F2201T5E")
+ assert model is not None
+ assert abs(model.specs.value_ohms - 2200) < 0.1
+
+
+def test_skips_murata_and_bare_value():
+ assert specs_from_mpn("GRM21A5C2J200JA01") is None
+ assert specs_from_mpn("18pF") is None
+ assert specs_from_mpn("CH340E") is None
+
+
+def test_find_local_pdf_family_vs_orderable(tmp_path: Path):
+ pdf = tmp_path / "ESP32-S31-WROOM-3-N16R16V.pdf"
+ pdf.write_bytes(b"%PDF-1.4\n" + b"x" * 100)
+ hit = find_local_pdf(tmp_path, "ESP32-S31-WROOM-3")
+ assert hit is not None
+ assert hit.name == pdf.name
+
+ other = tmp_path / "only"
+ other.mkdir()
+ family = other / "ESP32-S31-WROOM-3.pdf"
+ family.write_bytes(b"%PDF-1.4\n" + b"y" * 100)
+ hit = find_local_pdf(other, "ESP32-S31-WROOM-3-N16R16V")
+ assert hit is not None
+ assert hit.name == family.name
+
+
+def test_find_local_pdf_does_not_steal_sibling_die(tmp_path: Path):
+ (tmp_path / "CH340E.pdf").write_bytes(b"%PDF-1.4\n" + b"x" * 100)
+ assert find_local_pdf(tmp_path, "CH340") is None