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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user