Resolve passives from catalog, BOM value, and Murata LQW18AN without an LLM.

Skip the model (and the credit gate) when LCSC/DigiKey already list C/V/Z, when the BOM value is a single R/C/L/FB token, or when the MPN is an LQW18AN inductor. Ferrite beads keep impedance, not invented henries; value-only specs stay out of the shared library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 20:25:34 +02:00
co-authored by Cursor
parent 2113e3975e
commit edbb47a08b
14 changed files with 596 additions and 88 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ def build_bom_summary(
# Drop None values and internal numeric fields
raw = {
k: v for k, v in raw.items()
if v is not None and k not in ("value_ohms", "value_farads", "value_henries")
if v is not None and k not in ("value_ohms", "value_farads", "value_henries", "impedance_ohm")
}
specs_dict = raw if raw else None
+14 -3
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator
from pydantic import BaseModel, Discriminator, Field, Tag, field_validator, model_validator
class Pin(BaseModel):
@@ -131,20 +131,31 @@ class CapacitorSpecs(BaseModel):
class InductorSpecs(BaseModel):
"""Standardised inductor parameters. Value always in henries."""
"""Standardised inductor / ferrite-bead parameters."""
specs_type: Literal["inductor"] = "inductor"
component_subtype: str | None = None # e.g. "passive.inductor" or "passive.ferrite_bead"
value_henries: float
value_henries: float | None = None
value_formatted: str
tolerance: str | None = None # "±5%" or "±0.1uH"
package: str | None = None
current_rating_a: str | None = None
dcr_ohms: float | None = None
impedance_ohm: float | None = None # ferrite beads: Z at test frequency
_validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype)
)
@model_validator(mode="after")
def _require_primary_value(self) -> InductorSpecs:
if self.component_subtype == "passive.ferrite_bead":
if self.impedance_ohm is None:
raise ValueError("ferrite bead requires impedance_ohm")
return self
if self.value_henries is None:
raise ValueError("inductor requires value_henries")
return self
class SimpleComponentSpecs(BaseModel):
"""Specs for discrete/simple components. Schema defined in taxonomy JSON."""
+23 -1
View File
@@ -304,7 +304,29 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
dielectric=dielectric,
)
if subtype.startswith("passive.inductor") or subtype == "passive.ferrite_bead":
if subtype == "passive.ferrite_bead":
raw = vals.get("impedance_ohm") or vals.get("value_ohms")
if raw is None:
raise ValueError("Missing impedance_ohm in auto-resolved ferrite bead specs")
impedance_ohm = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw)
current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None
dcr_raw = vals.get("dcr_ohms")
dcr_ohms: float | None = None
if dcr_raw is not None:
dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw)
formatted = value_formatted or _format_value(impedance_ohm, "ohm")
return InductorSpecs(
component_subtype=subtype_for_specs,
value_henries=None,
value_formatted=formatted,
tolerance=tolerance,
package=package,
current_rating_a=current_rating_a,
dcr_ohms=dcr_ohms,
impedance_ohm=impedance_ohm,
)
if subtype.startswith("passive.inductor"):
raw = vals.get("value_henries")
if raw is None:
raise ValueError(f"Missing value_henries in auto-resolved inductor specs")
+31 -11
View File
@@ -808,12 +808,10 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
import asyncio
from backend.services.digikey import fetch_params
from backend.services.extraction import auto_resolve_specs
from backend.services.extraction import CatalogResolveMiss, auto_resolve_specs
if not settings.use_digikey:
raise HTTPException(400, "DigiKey API not configured")
if not settings.has_llm_credentials():
raise HTTPException(400, "LLM API key not configured")
storage = get_storage(request)
sem = asyncio.Semaphore(10)
@@ -840,14 +838,18 @@ async def auto_resolve(req: AutoResolveRequest, request: Request):
if not result.ok or not result.params:
return {"mpn": item.mpn, "status": "failed", "error": result.error or "No parameters"}
# Map params to taxonomy via Haiku
model = await auto_resolve_specs(
mpn=item.mpn,
digikey_params=result.params.parameters,
digikey_category=result.params.category,
digikey_description=result.params.description,
component_type=item.component_type,
)
# Map params: catalog parse first, LLM only if needed.
try:
model = await auto_resolve_specs(
mpn=item.mpn,
digikey_params=result.params.parameters,
digikey_category=result.params.category,
digikey_description=result.params.description,
component_type=item.component_type,
use_llm=settings.has_llm_credentials(),
)
except CatalogResolveMiss as e:
return {"mpn": item.mpn, "status": "failed", "error": str(e)}
# Save to library
storage.write_json(lib_key, model.model_dump())
@@ -963,6 +965,24 @@ async def lcsc_resolve_passive(
"cannot auto-resolve",
)
from backend.services.passive_from_distributor import specs_from_lcsc_payload
catalog_model = specs_from_lcsc_payload(mpn, payload)
if catalog_model is not None:
storage.write_json(project_model_key, catalog_model.model_dump())
proj_svc.save_to_library(
storage, project_model_key, "passives", f"{safe}.json",
)
return {
"mpn": mpn,
"safe_mpn": safe,
"model": catalog_model.model_dump(),
"cached": False,
"lcsc_id": req.lcsc_id,
}
# Catalog miss (ferrite, odd text): LLM path, charged if the logger has tokens.
# Download taxonomy to a temp dir so auto_resolve_specs can read/write it.
# Mirrors the PipelineWorkspace pattern: pinscopex operates on local paths.
api_logger = ApiLogger()
+30 -5
View File
@@ -871,6 +871,10 @@ Call save_resolved_specs with the mapped values.\
"""
class CatalogResolveMiss(RuntimeError):
"""Distributor params did not parse and ``use_llm`` was false."""
async def auto_resolve_specs(
mpn: str,
digikey_params: list[dict[str, str]],
@@ -879,10 +883,13 @@ async def auto_resolve_specs(
component_type: str,
taxonomy_dir: Path | None = None,
api_logger: ApiLogger | None = None,
*,
use_llm: bool = True,
) -> ComponentModel:
"""Map DigiKey product parameters to taxonomy specs using a lightweight model.
"""Map DigiKey/LCSC product parameters to taxonomy specs.
Returns a ComponentModel ready to persist. Raises on failure.
Passives with a parseable value skip the model. ``use_llm=False`` returns
only that catalog parse or raises :class:`CatalogResolveMiss`.
"""
tax_dir = taxonomy_dir or settings.taxonomy_dir
@@ -902,6 +909,9 @@ async def auto_resolve_specs(
)
return direct
if not use_llm:
raise CatalogResolveMiss(f"No catalog specs for {mpn}")
# Auto-generate type-level specs if none exist
if not has_specs(component_type, tax_dir):
try:
@@ -1020,7 +1030,7 @@ _PASSIVE_PREFIX_HINT: dict[str, str] = {
"C": "capacitor — populate value_farads",
"R": "resistor — populate value_ohms",
"L": "inductor — populate value_henries",
"FB": "ferrite bead — populate value_ohms (impedance)",
"FB": "ferrite bead — populate impedance_ohm (Z at test frequency, not henries)",
}
_VALUE_RESOLVE_SYSTEM = """\
@@ -1041,8 +1051,9 @@ CRITICAL RULES:
dielectric, package, or power rating. Never invent these.
- Populate EXACTLY TWO fields: ``value_formatted`` (a normalized human-readable
string) and the matching primary numeric field
(``value_farads`` / ``value_ohms`` / ``value_henries``). Leave every other
parameter out (do not include a null entry — omit the key entirely).
(``value_farads`` / ``value_ohms`` / ``value_henries`` / ``impedance_ohm``
for ferrite beads). Leave every other parameter out (do not include a null
entry — omit the key entirely). Never invent henries for a ferrite bead.
- Express numeric values with SPICE multiplier prefixes and units
(u=1e-6, n=1e-9, p=1e-12, k=1e3, M=1e6). Examples: ``10uF``, ``4.7kohm``, ``100nH``.
- Pick the GENERIC parent subtype — e.g. ``passive.capacitor``, ``passive.resistor``,
@@ -1076,6 +1087,20 @@ async def resolve_from_value(
"""
tax_dir = taxonomy_dir or settings.taxonomy_dir
from backend.services.passive_from_value import (
is_placeholder_value,
specs_from_bom_value,
)
parsed = specs_from_bom_value(mpn, value, ref_prefix)
if parsed is not None:
logging.getLogger(__name__).info(
"Resolved from value %s=%r without LLM", mpn, value,
)
return parsed
if is_placeholder_value(value):
raise ValueError(f"Placeholder BOM value {value!r} for {mpn}")
if not has_specs(component_type, tax_dir):
try:
await _generate_type_specs(component_type, tax_dir, api_logger=api_logger)
+40 -2
View File
@@ -25,6 +25,7 @@ _TOL = re.compile(r"±\s*(?P<num>\d+(?:\.\d+)?)\s*%")
_VOLT = re.compile(r"(?P<num>\d+(?:\.\d+)?)\s*V\b")
_PKG = re.compile(r"\b(?P<pkg>0201|0402|0603|0805|1206|1210|1812|2220|2512)\b")
_DIEL = re.compile(r"\b(?P<diel>C0G|NP0|X5R|X6S|X7R|X7S|X8R|Y5V|Z5U)\b", re.I)
_IMP_FREQ = re.compile(r"@\s*\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)", re.I)
_MUL = {
"p": 1e-12, "n": 1e-9, "u": 1e-6, "μ": 1e-6, "µ": 1e-6,
@@ -77,8 +78,8 @@ def _first(pmap: dict[str, str], *needles: str) -> str | None:
def _classify(category: str, description: str, pmap: dict[str, str]) -> str | None:
blob = f"{category} {description} {' '.join(pmap.values())}".lower()
if "ferrite" in blob or "bead" in blob:
return None # typed model wants henries; leave to the LLM
if "ferrite" in blob or re.search(r"\bbead\b", blob):
return "passive.ferrite_bead"
if "capacitor" in blob or "mlcc" in blob or "ceramic" in blob:
diel = _DIEL.search(description) or _DIEL.search(" ".join(pmap.values()))
if diel or "ceramic" in blob or "mlcc" in blob:
@@ -162,6 +163,21 @@ def specs_from_distributor(
power = _first(pmap, "power")
if power:
values["power_rating_w"] = power
elif subtype == "passive.ferrite_bead":
imp_src = _first(pmap, "impedance") or text
m = _RES.search(imp_src) or _RES.search(text)
if not m:
return None
z = _spice(m.group("num"), m.group("mul"), "ohm")
values["impedance_ohm"] = z
freq = _IMP_FREQ.search(imp_src) or _IMP_FREQ.search(text)
values["value_formatted"] = f"{z}@{freq.group(0)[1:].strip()}" if freq else z
cur = _first(pmap, "current rating", "rated current")
if cur:
values["current_rating_a"] = cur
dcr = _first(pmap, "dc resistance", "dcr")
if dcr:
values["dcr_ohms"] = dcr
elif subtype.startswith("passive.inductor"):
if not ind:
return None
@@ -197,3 +213,25 @@ def specs_from_distributor(
except (ValueError, TypeError):
return None
return ComponentModel(mpn=mpn, specs=typed)
def lcsc_payload_args(payload: dict) -> tuple[list[dict[str, str]], str, str]:
"""Turn a cached LCSC product dict into distributor mapper arguments."""
category = " / ".join(
p for p in (payload.get("category"), payload.get("subcategory")) if p
)
params: list[dict[str, str]] = []
if payload.get("package"):
params.append({"name": "Package / Case", "value": str(payload["package"])})
if payload.get("manufacturer"):
params.append({"name": "Manufacturer", "value": str(payload["manufacturer"])})
return params, category, str(payload.get("description") or "")
def specs_from_lcsc_payload(mpn: str, payload: dict) -> ComponentModel | None:
params, category, description = lcsc_payload_args(payload)
if not description:
return None
return specs_from_distributor(
mpn=mpn, params=params, category=category, description=description,
)
+41 -1
View File
@@ -1,4 +1,4 @@
"""Decode common chip R/C MPNs into typed specs without an LLM.
"""Decode common chip R/C/L MPNs into typed specs without an LLM.
Only encodings that carry package + value (and voltage for capacitors when
the manufacturer puts it in the code) are accepted. Incomplete BOM-value
@@ -51,6 +51,22 @@ _CHIP_R = re.compile(
rf"^(?:FRC)?({_SIZE})(?:W\d)?([{''.join('FJKG')}])(\d{{4}})",
re.IGNORECASE,
)
# Murata LQW18AN: 0603 wirewound. Inductance is three chars (12N, 2N2, R10)
# then EIA tolerance, then a two-digit spec (00/10) and packing.
_LQW18AN = re.compile(
r"^LQW18AN(?P<l>[0-9]N[0-9]|[0-9]{2}N|R[0-9]{2})(?P<tol>[BCSGHJKD])\d{2}",
re.IGNORECASE,
)
_LQW_TOL = {
"B": "±0.1nH",
"C": "±0.2nH",
"S": "±0.3nH",
"D": "±0.5nH",
"G": "±2%",
"H": "±3%",
"J": "±5%",
"K": "±10%",
}
def _eia3_pf(digits: str) -> float:
@@ -74,6 +90,17 @@ def _eia4_ohm(digits: str) -> float:
return float(int(digits[:3]) * (10 ** int(digits[3])))
def _lqw_nh(code: str) -> float | None:
c = code.upper()
if re.fullmatch(r"[0-9]N[0-9]", c):
return float(f"{c[0]}.{c[2]}")
if re.fullmatch(r"[0-9]{2}N", c):
return float(c[:2])
if re.fullmatch(r"R[0-9]{2}", c):
return float(f"0.{c[1:]}") * 1000.0
return None
def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None:
specs = SimpleComponentSpecs(
specs_type="passive",
@@ -136,4 +163,17 @@ def specs_from_mpn(mpn: str) -> ComponentModel | None:
}
return _model(raw, "passive.resistor.thick_film", values)
m = _LQW18AN.match(raw)
if m:
nh = _lqw_nh(m.group("l"))
if nh is not None:
henries = _spice(str(nh), "n", "H")
values = {
"value_henries": henries,
"value_formatted": henries,
"package": "0603",
"tolerance": _LQW_TOL[m.group("tol").upper()],
}
return _model(raw, "passive.inductor", values)
return None
+119
View File
@@ -0,0 +1,119 @@
"""Parse a BOM Value string into typed passive specs without an LLM."""
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
_PLACEHOLDER = re.compile(
r"^(?:dnp|dni|dns|nc|n/?c|n/?a|np|nfs|tbd|todo|jumper|jmp|short|open|-|—|)$",
re.I,
)
_CAP = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*[fF]$",
)
_IND = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmk])?\s*H$",
re.I,
)
_RES_UNIT = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[pnuμµmkM])?\s*(?:ohms?|Ω|R)$",
re.I,
)
_RES_BARE_MUL = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)(?P<mul>[kKmM])$",
)
_EURO_R = re.compile(
r"^(?P<a>\d+)[kK](?P<b>\d+)$",
)
_FB = re.compile(
r"^(?P<num>\d+(?:\.\d+)?)\s*(?P<mul>[kKmM])?\s*(?:ohms?|Ω|R)"
r"(?:@\s*(?P<freq>\d+(?:\.\d+)?\s*(?:kHz|MHz|GHz|Hz)))?$",
re.I,
)
def is_placeholder_value(value: str) -> bool:
return bool(_PLACEHOLDER.match((value or "").strip()))
def _model(mpn: str, subtype: str, values: dict[str, str]) -> ComponentModel | None:
specs = SimpleComponentSpecs(
specs_type="passive",
component_subtype=subtype,
values=values,
)
try:
typed = simple_to_typed_passive_specs(specs)
except (ValueError, TypeError):
return None
return ComponentModel(mpn=mpn, specs=typed)
def specs_from_bom_value(
mpn: str, value: str, ref_prefix: str,
) -> ComponentModel | None:
"""Map ``18pF`` / ``4.7k`` / ``10uH`` / ``600R@100MHz`` to typed specs.
Returns None for placeholders (DNP, NC, JUMPER) and for strings that
are not a single passive value. Callers must not save the result to the
shared library.
"""
raw = (value or "").strip()
prefix = (ref_prefix or "").upper()
if not raw or is_placeholder_value(raw):
return None
compact = re.sub(r"\s+", "", raw)
if prefix == "FB" or "@" in compact:
m = _FB.match(compact) or _FB.match(raw)
if m:
z = _spice(m.group("num"), m.group("mul"), "ohm")
freq = re.sub(r"\s+", "", m.group("freq") or "")
values = {
"impedance_ohm": z,
"value_formatted": f"{z}@{freq}" if freq else z,
}
return _model(mpn, "passive.ferrite_bead", values)
if prefix in {"C", ""}:
m = _CAP.match(compact) or _CAP.match(raw)
if m:
farads = _spice(m.group("num"), m.group("mul"), "F")
return _model(mpn, "passive.capacitor", {
"value_farads": farads,
"value_formatted": farads,
})
if prefix in {"L", ""}:
m = _IND.match(compact) or _IND.match(raw)
if m:
henries = _spice(m.group("num"), m.group("mul"), "H")
return _model(mpn, "passive.inductor", {
"value_henries": henries,
"value_formatted": henries,
})
if prefix in {"R", ""}:
m = _EURO_R.match(compact)
if m:
ohms = float(f"{m.group('a')}.{m.group('b')}") * 1e3
formatted = _spice(str(ohms), None, "ohm")
return _model(mpn, "passive.resistor", {
"value_ohms": formatted,
"value_formatted": formatted,
})
m = _RES_UNIT.match(compact) or _RES_BARE_MUL.match(compact)
if m:
formatted = _spice(m.group("num"), m.group("mul"), "ohm")
return _model(mpn, "passive.resistor", {
"value_ohms": formatted,
"value_formatted": formatted,
})
return None
+91 -56
View File
@@ -1082,67 +1082,65 @@ async def _catalog_resolve_unresolved_passives(
"detail": "specs from library"})
return
if not _check_credit_gate(ctx, "passive_extraction", mpn,
estimate_stage_cost_usd("digikey_resolve")):
await _paused_stage_publish(ctx, "passive_extraction", "out of credits")
return
private = ApiLogger(free=ctx.api_logger.free)
model = None
resolved_via: str | None = None
first_error: str | None = None
llm_args: tuple[list[dict[str, str]], str, str, str] | None = None
try:
from backend.services.passive_from_distributor import (
specs_from_distributor,
specs_from_lcsc_payload,
lcsc_payload_args,
)
async def _gate_llm() -> bool:
if not _check_credit_gate(
ctx, "passive_extraction", mpn,
estimate_stage_cost_usd("digikey_resolve"),
):
await _paused_stage_publish(
ctx, "passive_extraction", "out of credits",
)
return False
return True
lcsc = ctx.lcsc_data.get(mpn)
if lcsc and lcsc.get("description"):
try:
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": "auto-resolving via LCSC"})
synth_category = " / ".join(
p for p in (lcsc.get("category"), lcsc.get("subcategory")) if p
)
synth_params: list[dict[str, str]] = []
if lcsc.get("package"):
synth_params.append(
{"name": "Package / Case", "value": lcsc["package"]},
)
if lcsc.get("manufacturer"):
synth_params.append(
{"name": "Manufacturer", "value": lcsc["manufacturer"]},
)
model = await extraction.auto_resolve_specs(
mpn=mpn,
digikey_params=synth_params,
digikey_category=synth_category or "",
digikey_description=lcsc["description"],
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
if model is not None:
resolved_via = "lcsc"
except Exception as e:
first_error = str(e)
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": "resolving from LCSC catalog"})
model = specs_from_lcsc_payload(mpn, lcsc)
if model is not None:
resolved_via = "lcsc"
else:
params, category, description = lcsc_payload_args(lcsc)
llm_args = (params, category, description, "lcsc")
if model is None and settings.use_digikey:
try:
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": "auto-resolving via DigiKey"})
"detail": "resolving from DigiKey catalog"})
result = await fetch_params(mpn)
if result.ok and result.params:
model = await extraction.auto_resolve_specs(
model = specs_from_distributor(
mpn=mpn,
digikey_params=result.params.parameters,
digikey_category=result.params.category,
digikey_description=result.params.description,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
params=result.params.parameters,
category=result.params.category,
description=result.params.description,
)
resolved_via = "digikey"
if model is not None:
resolved_via = "digikey"
else:
llm_args = (
result.params.parameters,
result.params.category,
result.params.description,
"digikey",
)
else:
first_error = result.error or "no DigiKey parameters"
except Exception as e:
@@ -1158,27 +1156,64 @@ async def _catalog_resolve_unresolved_passives(
"substep": mpn, "status": "running",
"detail": "decoded from MPN"})
if model is None and llm_args is not None:
if not await _gate_llm():
return
params, category, description, via = llm_args
try:
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": f"auto-resolving via {via} (LLM)"})
model = await extraction.auto_resolve_specs(
mpn=mpn,
digikey_params=params,
digikey_category=category,
digikey_description=description,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
if model is not None:
resolved_via = via
except Exception as e:
first_error = str(e)
if model is None:
from backend.services.passive_from_value import (
is_placeholder_value,
specs_from_bom_value,
)
bom_value = ctx.passive_values.get(mpn, "").strip()
refs = ctx.passive_mpns.get(mpn, [])
pref_match = re.match(r"^[A-Za-z]+", refs[0]) if refs else None
ref_prefix = pref_match.group(0).upper() if pref_match else ""
if bom_value and ref_prefix in {"C", "R", "L", "FB"}:
try:
model = specs_from_bom_value(mpn, bom_value, ref_prefix)
if model is not None:
resolved_via = "value"
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": f"resolving from BOM value {bom_value!r}"})
model = await extraction.resolve_from_value(
mpn=mpn, value=bom_value,
ref_prefix=ref_prefix,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
resolved_via = "value"
except Exception as e:
first_error = first_error or str(e)
"detail": f"parsed BOM value {bom_value!r}"})
elif not is_placeholder_value(bom_value):
if not await _gate_llm():
return
try:
broker.publish(ctx.project_id, "step_update",
{"stage": "passive_extraction",
"substep": mpn, "status": "running",
"detail": f"resolving from BOM value {bom_value!r}"})
model = await extraction.resolve_from_value(
mpn=mpn, value=bom_value,
ref_prefix=ref_prefix,
component_type="passive",
taxonomy_dir=ctx.ws.taxonomy_dir,
api_logger=private,
)
resolved_via = "value"
except Exception as e:
first_error = first_error or str(e)
if model is None:
err = first_error or "no LCSC/DigiKey hit and no usable BOM value"