diff --git a/backend/pinscopex/bom_summary.py b/backend/pinscopex/bom_summary.py index f8e1be9..44efb4d 100644 --- a/backend/pinscopex/bom_summary.py +++ b/backend/pinscopex/bom_summary.py @@ -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 diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index 88ac180..a78abd3 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -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.""" diff --git a/backend/pinscopex/resolve_passives.py b/backend/pinscopex/resolve_passives.py index a51a507..e5b4955 100644 --- a/backend/pinscopex/resolve_passives.py +++ b/backend/pinscopex/resolve_passives.py @@ -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") diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 68bf486..0bbdd19 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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() diff --git a/backend/services/extraction.py b/backend/services/extraction.py index b8b0b55..df5c751 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -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) diff --git a/backend/services/passive_from_distributor.py b/backend/services/passive_from_distributor.py index bfe646c..cdd6fca 100644 --- a/backend/services/passive_from_distributor.py +++ b/backend/services/passive_from_distributor.py @@ -25,6 +25,7 @@ _TOL = re.compile(r"±\s*(?P\d+(?:\.\d+)?)\s*%") _VOLT = re.compile(r"(?P\d+(?:\.\d+)?)\s*V\b") _PKG = re.compile(r"\b(?P0201|0402|0603|0805|1206|1210|1812|2220|2512)\b") _DIEL = re.compile(r"\b(?PC0G|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, + ) diff --git a/backend/services/passive_from_mpn.py b/backend/services/passive_from_mpn.py index 5300fab..b210b19 100644 --- a/backend/services/passive_from_mpn.py +++ b/backend/services/passive_from_mpn.py @@ -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[0-9]N[0-9]|[0-9]{2}N|R[0-9]{2})(?P[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 diff --git a/backend/services/passive_from_value.py b/backend/services/passive_from_value.py new file mode 100644 index 0000000..7b1ba21 --- /dev/null +++ b/backend/services/passive_from_value.py @@ -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\d+(?:\.\d+)?)\s*(?P[pnuμµmk])?\s*[fF]$", +) +_IND = re.compile( + r"^(?P\d+(?:\.\d+)?)\s*(?P[pnuμµmk])?\s*H$", + re.I, +) +_RES_UNIT = re.compile( + r"^(?P\d+(?:\.\d+)?)\s*(?P[pnuμµmkM])?\s*(?:ohms?|Ω|R)$", + re.I, +) +_RES_BARE_MUL = re.compile( + r"^(?P\d+(?:\.\d+)?)(?P[kKmM])$", +) +_EURO_R = re.compile( + r"^(?P\d+)[kK](?P\d+)$", +) +_FB = re.compile( + r"^(?P\d+(?:\.\d+)?)\s*(?P[kKmM])?\s*(?:ohms?|Ω|R)" + r"(?:@\s*(?P\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 diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index 1d3df26..b0e4214 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -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" diff --git a/taxonomy/passive.json b/taxonomy/passive.json index e19482c..ef01040 100644 --- a/taxonomy/passive.json +++ b/taxonomy/passive.json @@ -62,7 +62,7 @@ "passive.ferrite_bead": { "description": "Ferrite bead", "extra_specs": [ - {"name": "value_henries", "description": "Impedance at test frequency (stored as inductance proxy)", "unit": "H"}, + {"name": "impedance_ohm", "description": "Impedance at the test frequency", "unit": "ohm", "required": true}, {"name": "current_rating_a", "description": "Rated current", "unit": "A"}, {"name": "dcr_ohms", "description": "DC resistance", "unit": "ohm"} ] diff --git a/tests/test_passive_from_distributor.py b/tests/test_passive_from_distributor.py index f625a98..1b630a3 100644 --- a/tests/test_passive_from_distributor.py +++ b/tests/test_passive_from_distributor.py @@ -40,3 +40,94 @@ def test_skips_when_no_value(): category="", description="some module", ) is None + + +def test_ferrite_bead_from_impedance(): + model = specs_from_distributor( + mpn="BLM18PG121SN1D", + params=[ + {"name": "Impedance @ Frequency", "value": "120 Ohms @ 100 MHz"}, + {"name": "Package / Case", "value": "0603"}, + {"name": "Current Rating", "value": "2 A"}, + ], + category="Filters / Ferrite Beads", + description="FERRITE BEAD 120 OHM 0603 1LN", + ) + assert model is not None + specs = model.specs + assert specs.component_subtype == "passive.ferrite_bead" + assert specs.impedance_ohm == 120 + assert specs.value_henries is None + assert "120ohm" in specs.value_formatted + assert "100" in specs.value_formatted + assert specs.package == "0603" + assert specs.current_rating_a == "2 A" + + +def test_specs_from_lcsc_payload(): + from backend.services.passive_from_distributor import specs_from_lcsc_payload + + model = specs_from_lcsc_payload( + "CL21B225KPFNNNE", + { + "package": "0805", + "manufacturer": "Samsung", + "category": "Capacitors", + "subcategory": "MLCC", + "description": "2.2uF ±10% 10V X7R 0805", + }, + ) + assert model is not None + assert abs(model.specs.value_farads - 2.2e-6) < 1e-12 + + +def test_auto_resolve_skips_llm_when_catalog_parses(monkeypatch): + import asyncio + from pathlib import Path + + from backend.services.extraction import auto_resolve_specs + + async def boom(*_a, **_k): + raise AssertionError("LLM must not run") + + monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom) + tax = Path(__file__).resolve().parents[1] / "taxonomy" + model = asyncio.run( + auto_resolve_specs( + mpn="CL21B225KPFNNNE", + digikey_params=[{"name": "Package / Case", "value": "0805"}], + digikey_category="Capacitors / Ceramic Capacitors", + digikey_description="2.2uF ±10% 10V X7R 0805", + component_type="passive", + taxonomy_dir=tax, + ) + ) + assert abs(model.specs.value_farads - 2.2e-6) < 1e-12 + + +def test_auto_resolve_use_llm_false_on_ferrite(monkeypatch): + import asyncio + from pathlib import Path + + from backend.services.extraction import auto_resolve_specs + + async def boom(*_a, **_k): + raise AssertionError("LLM must not run") + + monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom) + tax = Path(__file__).resolve().parents[1] / "taxonomy" + model = asyncio.run( + auto_resolve_specs( + mpn="BLM18PG121SN1D", + digikey_params=[ + {"name": "Impedance @ Frequency", "value": "120 Ohms @ 100 MHz"}, + ], + digikey_category="Filters / Ferrite Beads", + digikey_description="FERRITE BEAD 120 OHM 0603 1LN", + component_type="passive", + taxonomy_dir=tax, + use_llm=False, + ) + ) + assert model.specs.impedance_ohm == 120 + assert model.specs.component_subtype == "passive.ferrite_bead" diff --git a/tests/test_passive_from_mpn.py b/tests/test_passive_from_mpn.py index d6b8e11..361e1ec 100644 --- a/tests/test_passive_from_mpn.py +++ b/tests/test_passive_from_mpn.py @@ -38,7 +38,27 @@ def test_chip_resistor_from_mpn(): assert abs(model.specs.value_ohms - 2200) < 0.1 -def test_skips_murata_and_bare_value(): +def test_murata_lqw18an(): + model = specs_from_mpn("LQW18AN12NG00D") + assert model is not None + assert model.specs.specs_type == "inductor" + assert abs(model.specs.value_henries - 12e-9) < 1e-15 + assert model.specs.package == "0603" + assert model.specs.tolerance == "±2%" + + model = specs_from_mpn("LQW18AN18NJ00D") + assert abs(model.specs.value_henries - 18e-9) < 1e-15 + assert model.specs.tolerance == "±5%" + + model = specs_from_mpn("LQW18AN2N2D00D") + assert abs(model.specs.value_henries - 2.2e-9) < 1e-15 + assert model.specs.tolerance == "±0.5nH" + + model = specs_from_mpn("LQW18ANR10G00D") + assert abs(model.specs.value_henries - 100e-9) < 1e-15 + + +def test_skips_opaque_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 diff --git a/tests/test_passive_from_value.py b/tests/test_passive_from_value.py new file mode 100644 index 0000000..f7d080e --- /dev/null +++ b/tests/test_passive_from_value.py @@ -0,0 +1,88 @@ +import asyncio +from pathlib import Path + +import pytest + +from backend.services.passive_from_value import ( + is_placeholder_value, + specs_from_bom_value, +) + + +def test_capacitor_picofarads(): + model = specs_from_bom_value("18pF", "18pF", "C") + assert model is not None + assert model.specs.specs_type == "capacitor" + assert abs(model.specs.value_farads - 18e-12) < 1e-18 + + +def test_resistor_kilo_and_euro(): + k = specs_from_bom_value("4.7k", "4.7k", "R") + assert k is not None + assert abs(k.specs.value_ohms - 4700) < 1e-6 + euro = specs_from_bom_value("4k7", "4k7", "R") + assert euro is not None + assert abs(euro.specs.value_ohms - 4700) < 1e-6 + + +def test_inductor_uh(): + model = specs_from_bom_value("10uH", "10uH", "L") + assert model is not None + assert abs(model.specs.value_henries - 10e-6) < 1e-12 + + +def test_ferrite_impedance_at_freq(): + model = specs_from_bom_value("600R@100MHz", "600R@100MHz", "FB") + assert model is not None + assert model.specs.component_subtype == "passive.ferrite_bead" + assert model.specs.impedance_ohm == 600 + assert model.specs.value_henries is None + assert "100MHz" in model.specs.value_formatted + + +def test_placeholders_skip(): + for raw in ("DNP", "NC", "JUMPER", "TBD", "-"): + assert is_placeholder_value(raw) + assert specs_from_bom_value(raw, raw, "R") is None + + +def test_ambiguous_string_returns_none(): + assert specs_from_bom_value("mystery", "do not stuff", "R") is None + + +def test_resolve_from_value_skips_llm_when_parseable(monkeypatch): + from backend.services.extraction import resolve_from_value + + async def boom(*_a, **_k): + raise AssertionError("LLM must not run") + + monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom) + tax = Path(__file__).resolve().parents[1] / "taxonomy" + model = asyncio.run( + resolve_from_value( + mpn="18pF", + value="18pF", + ref_prefix="C", + taxonomy_dir=tax, + ) + ) + assert abs(model.specs.value_farads - 18e-12) < 1e-18 + + +def test_resolve_from_value_placeholder_no_llm(monkeypatch): + from backend.services.extraction import resolve_from_value + + async def boom(*_a, **_k): + raise AssertionError("LLM must not run") + + monkeypatch.setattr("backend.services.extraction.call_with_fallback", boom) + tax = Path(__file__).resolve().parents[1] / "taxonomy" + with pytest.raises(ValueError, match="Placeholder"): + asyncio.run( + resolve_from_value( + mpn="DNP", + value="DNP", + ref_prefix="R", + taxonomy_dir=tax, + ) + ) diff --git a/tests/test_purple_parts_resolver.py b/tests/test_purple_parts_resolver.py index c580ec7..f03eb6f 100644 --- a/tests/test_purple_parts_resolver.py +++ b/tests/test_purple_parts_resolver.py @@ -447,9 +447,8 @@ def test_lcsc_resolve_passive_404_when_lcsc_id_missing(tmp_path, monkeypatch): @pytest.mark.asyncio async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch): - """First call resolves via auto_resolve_specs (mocked) and writes the - project model + library copy. Second call short-circuits with cached=True - and does not invoke auto_resolve_specs again.""" + """First call parses the LCSC description without the LLM. Second call + short-circuits with cached=True and does not invoke auto_resolve_specs.""" from backend.config import settings from backend.pinscopex.models import CapacitorSpecs, ComponentModel from backend.services import purple_parts @@ -526,7 +525,7 @@ async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch): ) assert resp.status_code == 200, resp.text - # First call: resolves via mocked auto_resolve_specs. + # First call: catalog parse, no LLM. resp = client.post( f"/api/projects/{project_id}/lcsc/resolve-passive", json={"lcsc_id": "C15850"}, @@ -537,7 +536,7 @@ async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch): assert body["lcsc_id"] == "C15850" assert body["cached"] is False assert body["model"]["mpn"] == "CL21A106KAYNNNE" - assert call_count["n"] == 1 + assert call_count["n"] == 0 # Library copy should exist for cross-project reuse. from backend.pinscopex.utils import safe_mpn @@ -553,7 +552,7 @@ async def test_lcsc_resolve_passive_success_and_cached(tmp_path, monkeypatch): body = resp.json() assert body["cached"] is True assert body["model"]["mpn"] == "CL21A106KAYNNNE" - assert call_count["n"] == 1 # not invoked again + assert call_count["n"] == 0 # still no LLM # ---------------------------------------------------------------------------