Show cached passives in the library and match IC PDFs by family MPN.

Decode EIA/Yageo/AVX chip codes into library specs, and find datasheets when the file is stored under the orderable catalog name.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 02:27:27 +02:00
co-authored by Cursor
parent 3be7d2fc21
commit 08cbbdc422
11 changed files with 534 additions and 80 deletions
+12 -6
View File
@@ -317,18 +317,24 @@ def _resolve_neighbor_pdf(
Mirrors validation._find_pdf's local-then-library lookup so neighbor Mirrors validation._find_pdf's local-then-library lookup so neighbor
datasheets follow the same resolution rules as the IC under review. datasheets follow the same resolution rules as the IC under review.
""" """
safe = safe_mpn(mpn) from backend.services.datasheet_finder import find_local_pdf
local = state.pdf_dir / f"{safe}.pdf"
if local.is_file(): 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 return local
if state.storage is not None: if state.storage is not None:
try: try:
from backend.services import projects as proj_svc from backend.services import projects as proj_svc
lib_key = proj_svc.library_has_datasheet(state.storage, mpn) lib_key = proj_svc.library_has_datasheet(state.storage, mpn)
if lib_key: if lib_key:
state.storage.download_to_local(lib_key, local) wanted = state.pdf_dir / f"{safe_mpn(mpn)}.pdf"
if local.is_file(): state.storage.download_to_local(lib_key, wanted)
return local if wanted.is_file():
return wanted
except Exception: except Exception:
log.exception("excerpt: library lookup failed for %s", mpn) log.exception("excerpt: library lookup failed for %s", mpn)
return None return None
+29
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
import httpx import httpx
@@ -138,6 +139,34 @@ def mpn_catalog_match(query: str, candidate: str) -> bool:
return c.startswith(q) and len(c) > len(q) 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: def _pick_lcsc_product(mpn: str, products: list[dict]) -> dict | None:
exact: dict | None = None exact: dict | None = None
loose: dict | None = None loose: dict | None = None
+139
View File
@@ -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
+24 -10
View File
@@ -681,15 +681,16 @@ async def _ensure_local_datasheet(
""" """
if pdf_path.is_file(): if pdf_path.is_file():
return True 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]: 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) lib_ds_key = proj_svc.library_has_datasheet(ctx.storage, name)
if lib_ds_key: if lib_ds_key:
ctx.storage.download_to_local(lib_ds_key, pdf_path) ctx.storage.download_to_local(lib_ds_key, pdf_path)
@@ -1116,6 +1117,16 @@ async def _catalog_resolve_unresolved_passives(
except Exception as e: except Exception as e:
first_error = str(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: if model is None:
bom_value = ctx.passive_values.get(mpn, "").strip() bom_value = ctx.passive_values.get(mpn, "").strip()
refs = ctx.passive_mpns.get(mpn, []) 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_path.write_text(model.model_dump_json(indent=2) + "\n")
model_key = f"{ctx.ws.prefix}/models/{safe}.json" model_key = f"{ctx.ws.prefix}/models/{safe}.json"
ctx.storage.upload_from_local(model_path, model_key) 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( proj_svc.save_to_library(
ctx.storage, model_key, "passives", f"{safe}.json", ctx.storage, model_key, "passives", f"{safe}.json",
) )
@@ -1161,6 +1172,7 @@ async def _catalog_resolve_unresolved_passives(
detail = { detail = {
"lcsc": "auto-resolved via LCSC", "lcsc": "auto-resolved via LCSC",
"digikey": "auto-resolved via DigiKey", "digikey": "auto-resolved via DigiKey",
"mpn": "decoded from MPN (saved to library)",
"value": "resolved from BOM value (not saved to library)", "value": "resolved from BOM value (not saved to library)",
}.get(resolved_via, "resolved") }.get(resolved_via, "resolved")
broker.publish(ctx.project_id, "step_update", 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. # 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. # 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] = [] planned_refs: list[str] = []
for ref, comp in ctx.graph.components.items(): for ref, comp in ctx.graph.components.items():
if comp.component_type != ComponentType.IC: if comp.component_type != ComponentType.IC:
continue continue
mpn = comp.mpn or comp.value mpn = (comp.mpn or "").strip() or (comp.value or "").strip()
if not mpn: if not mpn:
continue 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) planned_refs.append(ref)
ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key) ctx.all_review_refs = sorted(planned_refs, key=natural_sort_key)
+36 -11
View File
@@ -775,6 +775,31 @@ def save_to_library(
return dst_key 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: def list_library_catalog(storage: StorageBackend) -> dict:
"""List ICs, passive patterns, discrete specs, and datasheet refs. """List ICs, passive patterns, discrete specs, and datasheet refs.
@@ -826,26 +851,24 @@ def list_library_catalog(storage: StorageBackend) -> dict:
continue continue
simple_models: list[dict] = [] simple_models: list[dict] = []
passive_parts: list[dict] = []
seen_model_mpns: set[str] = set() 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): for key in storage.list_prefix(prefix):
if not key.endswith(".json"): if not key.endswith(".json"):
continue continue
try: try:
data = storage.read_json(key) 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: if mpn in seen_model_mpns:
continue continue
seen_model_mpns.add(mpn) seen_model_mpns.add(mpn)
specs = data.get("specs", {}) or {} row["has_datasheet"] = bool(resolve_datasheet(storage, mpn))
simple_models.append({ dest.append(row)
"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)),
})
except Exception: except Exception:
continue continue
@@ -871,11 +894,13 @@ def list_library_catalog(storage: StorageBackend) -> dict:
ics.sort(key=lambda r: r["mpn"].lower()) ics.sort(key=lambda r: r["mpn"].lower())
passives.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()) simple_models.sort(key=lambda r: r["mpn"].lower())
datasheets.sort(key=lambda r: r["mpn"].lower()) datasheets.sort(key=lambda r: r["mpn"].lower())
return { return {
"ics": ics, "ics": ics,
"passives": passives, "passives": passives,
"passive_parts": passive_parts,
"simple": simple_models, "simple": simple_models,
"datasheets": datasheets, "datasheets": datasheets,
} }
+22 -15
View File
@@ -571,27 +571,29 @@ def _find_pdf(
"""Find the datasheet PDF for an MPN. Checks local dir first, """Find the datasheet PDF for an MPN. Checks local dir first,
then tries to download from the library. 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 from backend.pinscopex.utils import safe_mpn as _safe
names = mpn_query_variants(mpn) or [mpn] mpn = (mpn or "").strip()
for name in names: if not mpn:
local = pdf_dir / f"{_safe(name)}.pdf" return None
if local.is_file():
wanted = pdf_dir / f"{_safe(mpn)}.pdf" local = find_local_pdf(pdf_dir, mpn)
if local != wanted and not wanted.is_file(): if local is not None and local.is_file():
wanted.write_bytes(local.read_bytes()) wanted = pdf_dir / f"{_safe(mpn)}.pdf"
return wanted if local.resolve() != wanted.resolve() and not wanted.is_file():
return local wanted.write_bytes(local.read_bytes())
return wanted
return local
if storage: if storage:
from backend.services import projects as proj_svc from backend.services import projects as proj_svc
lib_key = proj_svc.library_has_datasheet(storage, mpn) lib_key = proj_svc.library_has_datasheet(storage, mpn)
if lib_key: if lib_key:
local = pdf_dir / f"{_safe(mpn)}.pdf" wanted = pdf_dir / f"{_safe(mpn)}.pdf"
storage.download_to_local(lib_key, local) storage.download_to_local(lib_key, wanted)
if local.is_file(): if wanted.is_file():
return local return wanted
return None return None
@@ -665,7 +667,12 @@ async def validate_design_async(
for ref, comp in sorted(graph.components.items()): for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC: if comp.component_type != ComponentType.IC:
continue 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) pdf = _find_pdf(mpn, pdf_dir_path, storage=storage)
if pdf: if pdf:
ic_tasks.append((ref, str(pdf))) ic_tasks.append((ref, str(pdf)))
+70 -2
View File
@@ -632,7 +632,11 @@ function ComponentsPanel() {
if (!prev) return prev; if (!prev) return prev;
if (type === "ic") return { ...prev, ics: prev.ics.filter((c) => c.mpn !== name) }; 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) }; 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(); reload();
} catch (e) { } catch (e) {
@@ -678,6 +682,12 @@ function ComponentsPanel() {
c.subtype.toLowerCase().includes(lf) || c.subtype.toLowerCase().includes(lf) ||
c.description.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( const filteredSimple = (data.simple ?? []).filter(
(c) => (c) =>
c.mpn.toLowerCase().includes(lf) || c.mpn.toLowerCase().includes(lf) ||
@@ -699,6 +709,13 @@ function ComponentsPanel() {
<span className="font-medium">{data.passives.length}</span> <span className="font-medium">{data.passives.length}</span>
<span className="text-muted-foreground">Passive Patterns</span> <span className="text-muted-foreground">Passive Patterns</span>
</div> </div>
{(data.passive_parts?.length ?? 0) > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="font-medium">{data.passive_parts.length}</span>
<span className="text-muted-foreground">Passive MPNs</span>
</div>
)}
{(data.simple?.length ?? 0) > 0 && ( {(data.simple?.length ?? 0) > 0 && (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /> <Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
@@ -827,6 +844,57 @@ function ComponentsPanel() {
</div> </div>
)} )}
{/* Per-MPN passive specs */}
{filteredPassiveParts.length > 0 && (
<div>
<h2 className="text-sm font-medium mb-2">Passive MPNs</h2>
<div className="rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">MPN</th>
<th className="text-left px-3 py-2 font-medium">Type</th>
<th className="text-left px-3 py-2 font-medium">Subtype</th>
<th className="text-center px-3 py-2 font-medium">Params</th>
<th className="w-10 px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredPassiveParts.map((s) => (
<tr key={s.mpn} className="hover:bg-muted/30 cursor-pointer" onClick={() => handleRowClick("simple", s.mpn)}>
<td className="px-3 py-2 font-mono text-xs">{s.mpn}</td>
<td className="px-3 py-2">
<Badge variant="secondary">{s.specs_type || "passive"}</Badge>
</td>
<td className="px-3 py-2">
{s.subtype ? (
<Badge variant="outline">{s.subtype}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-3 py-2 text-center font-mono">{s.param_count}</td>
<td className="px-3 py-2 text-center">
<button
onClick={(e) => { e.stopPropagation(); handleDelete("simple", s.mpn); }}
disabled={deleting === s.mpn}
className="text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
>
{deleting === s.mpn ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Simple component specs table */} {/* Simple component specs table */}
{filteredSimple.length > 0 && ( {filteredSimple.length > 0 && (
<div> <div>
@@ -878,7 +946,7 @@ function ComponentsPanel() {
</div> </div>
)} )}
{filteredICs.length === 0 && filteredPassives.length === 0 && filteredSimple.length === 0 && ( {filteredICs.length === 0 && filteredPassives.length === 0 && filteredPassiveParts.length === 0 && filteredSimple.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground"> <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Package className="h-8 w-8 mb-2" /> <Package className="h-8 w-8 mb-2" />
<p className="text-sm"> <p className="text-sm">
+123 -35
View File
@@ -55,6 +55,17 @@ export default function LibraryPage() {
), ),
[data, lf], [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( const simple = useMemo(
() => () =>
(data?.simple ?? []).filter( (data?.simple ?? []).filter(
@@ -108,6 +119,7 @@ export default function LibraryPage() {
!data || !data ||
(data.ics.length === 0 && (data.ics.length === 0 &&
data.passives.length === 0 && data.passives.length === 0 &&
(data.passive_parts ?? []).length === 0 &&
data.simple.length === 0 && data.simple.length === 0 &&
data.datasheets.length === 0); data.datasheets.length === 0);
@@ -143,8 +155,15 @@ export default function LibraryPage() {
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" /> <Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<span className="font-medium">
{(data.passive_parts ?? []).length}
</span>
<span className="text-muted-foreground">passives</span>
</span>
<span className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-amber-600/70 dark:text-amber-400/70" />
<span className="font-medium">{data.passives.length}</span> <span className="font-medium">{data.passives.length}</span>
<span className="text-muted-foreground">passive series</span> <span className="text-muted-foreground">series</span>
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /> <Zap className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
@@ -169,7 +188,7 @@ export default function LibraryPage() {
<TabsList> <TabsList>
<TabsTrigger value="ics">ICs ({ics.length})</TabsTrigger> <TabsTrigger value="ics">ICs ({ics.length})</TabsTrigger>
<TabsTrigger value="passives"> <TabsTrigger value="passives">
Passives ({passives.length}) Passives ({passiveParts.length + passives.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="simple"> <TabsTrigger value="simple">
Discrete ({simple.length}) Discrete ({simple.length})
@@ -225,40 +244,109 @@ export default function LibraryPage() {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="passives" className="pt-4"> <TabsContent value="passives" className="pt-4 space-y-6">
{passives.length === 0 ? ( {passiveParts.length === 0 && passives.length === 0 ? (
<EmptyFilter label="passive series" /> <EmptyFilter label="passives" />
) : ( ) : (
<div className="rounded-lg border border-border overflow-x-auto"> <>
<table className="w-full text-sm"> {passiveParts.length > 0 && (
<thead> <div className="rounded-lg border border-border overflow-x-auto">
<tr className="bg-muted/50 text-muted-foreground"> <table className="w-full text-sm">
<th className="text-left px-3 py-2 font-medium">Series</th> <thead>
<th className="text-left px-3 py-2 font-medium">Type</th> <tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium"> <th className="text-left px-3 py-2 font-medium">
Description MPN
</th> </th>
</tr> <th className="text-left px-3 py-2 font-medium">
</thead> Type
<tbody className="divide-y divide-border"> </th>
{passives.map((p) => ( <th className="text-center px-3 py-2 font-medium">
<tr key={p.mpn} className="hover:bg-muted/30"> Params
<td className="px-3 py-2 font-mono text-xs">{p.mpn}</td> </th>
<td className="px-3 py-2"> <th className="text-left px-3 py-2 font-medium">
{p.subtype ? ( PDF
<Badge variant="secondary">{p.subtype}</Badge> </th>
) : ( </tr>
<span className="text-muted-foreground"></span> </thead>
)} <tbody className="divide-y divide-border">
</td> {passiveParts.map((p) => (
<td className="px-3 py-2 text-muted-foreground"> <tr key={p.mpn} className="hover:bg-muted/30">
{p.description || "—"} <td className="px-3 py-2 font-mono text-xs">
</td> {p.mpn}
</tr> </td>
))} <td className="px-3 py-2">
</tbody> <Badge variant="secondary">
</table> {p.subtype || p.specs_type || "passive"}
</div> </Badge>
</td>
<td className="px-3 py-2 text-center">
{p.param_count}
</td>
<td className="px-3 py-2">
{p.has_datasheet ? (
<DatasheetLink
mpn={p.mpn}
opening={opening}
onOpen={openDatasheet}
/>
) : (
<span className="text-muted-foreground">
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{passives.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">
Series patterns one regex covers cousin MPNs
</p>
<div className="rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">
Series
</th>
<th className="text-left px-3 py-2 font-medium">
Type
</th>
<th className="text-left px-3 py-2 font-medium">
Description
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{passives.map((p) => (
<tr key={p.mpn} className="hover:bg-muted/30">
<td className="px-3 py-2 font-mono text-xs">
{p.mpn}
</td>
<td className="px-3 py-2">
{p.subtype ? (
<Badge variant="secondary">{p.subtype}</Badge>
) : (
<span className="text-muted-foreground">
</span>
)}
</td>
<td className="px-3 py-2 text-muted-foreground">
{p.description || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)} )}
</TabsContent> </TabsContent>
+11
View File
@@ -389,6 +389,15 @@ export interface LibraryPassive {
regex: string; regex: string;
} }
export interface LibraryPassivePart {
mpn: string;
type: "passive_part";
specs_type: string;
subtype: string;
param_count: number;
has_datasheet: boolean;
}
export interface LibrarySimple { export interface LibrarySimple {
mpn: string; mpn: string;
type: "simple"; type: "simple";
@@ -408,6 +417,7 @@ export interface LibraryDatasheet {
export interface LibraryCatalog { export interface LibraryCatalog {
ics: LibraryIC[]; ics: LibraryIC[];
passives: LibraryPassive[]; passives: LibraryPassive[];
passive_parts?: LibraryPassivePart[];
simple: LibrarySimple[]; simple: LibrarySimple[];
datasheets: LibraryDatasheet[]; datasheets: LibraryDatasheet[];
} }
@@ -606,6 +616,7 @@ export interface AdminSimple {
export interface AdminComponents { export interface AdminComponents {
ics: AdminIC[]; ics: AdminIC[];
passives: AdminPassive[]; passives: AdminPassive[];
passive_parts?: AdminSimple[];
simple: AdminSimple[]; simple: AdminSimple[];
} }
+3 -1
View File
@@ -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]["pin_count"] == 2
assert cat["ics"][0]["has_datasheet"] is True assert cat["ics"][0]["has_datasheet"] is True
assert cat["passives"][0]["mpn"] == "Samsung CL10" 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"]) assert any(d["mpn"] == "CH340E" and d["has_extraction"] for d in cat["datasheets"])
+65
View File
@@ -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