From a2d5900b645a4b4d08dbc19e558e94122799c43f Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 21 Sep 2026 00:19:06 +0200 Subject: [PATCH] Recover ferrite bead Z from datasheet evidence without aborting graph_build. --- .../dependency/backend/periscopex/models.py | 6 +- .../backend/periscopex/resolve_passives.py | 12 +- periscope/src/backend/periscopex/ferrite_z.py | 151 ++++++++++++++++++ periscope/src/backend/periscopex/graph.py | 21 ++- periscope/src/backend/periscopex/models.py | 6 +- .../backend/periscopex/resolve_passives.py | 4 +- .../src/backend/services/datasheet_extract.py | 30 +++- periscope/src/backend/skills_manifest.json | 2 +- periscope/src/frontend/content/changelog.md | 7 + periscope/src/skills/extract-specs/SKILL.md | 2 + periscope/src/taxonomy/passive.json | 5 +- tests/test_inductor_specs.py | 58 ++++++- tests/test_periscope_taxonomy_skills_src.py | 2 +- 13 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 periscope/src/backend/periscopex/ferrite_z.py diff --git a/periscope/dependency/backend/periscopex/models.py b/periscope/dependency/backend/periscopex/models.py index a90fedd..473c907 100644 --- a/periscope/dependency/backend/periscopex/models.py +++ b/periscope/dependency/backend/periscopex/models.py @@ -158,9 +158,9 @@ class InductorSpecs(BaseModel): @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 + from backend.periscopex.ferrite_z import recover_bead_specs + + return recover_bead_specs(self) if self.value_henries is None: raise ValueError("inductor requires value_henries") return self diff --git a/periscope/dependency/backend/periscopex/resolve_passives.py b/periscope/dependency/backend/periscopex/resolve_passives.py index 0c4a6f4..3c7e6ce 100644 --- a/periscope/dependency/backend/periscopex/resolve_passives.py +++ b/periscope/dependency/backend/periscopex/resolve_passives.py @@ -306,19 +306,21 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec 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) + impedance_ohm = None + if raw is not None: + 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") + formatted = value_formatted + if not formatted and impedance_ohm is not None: + formatted = _format_value(impedance_ohm, "ohm") return InductorSpecs( component_subtype=subtype_for_specs, value_henries=None, - value_formatted=formatted, + value_formatted=formatted or "FB", tolerance=tolerance, package=package, current_rating_a=current_rating_a, diff --git a/periscope/src/backend/periscopex/ferrite_z.py b/periscope/src/backend/periscopex/ferrite_z.py new file mode 100644 index 0000000..0f1fe91 --- /dev/null +++ b/periscope/src/backend/periscopex/ferrite_z.py @@ -0,0 +1,151 @@ +"""Ferrite-bead impedance from stated evidence only. + +Never invent Z from MPN codes. Prefer datasheet text, then a quoted +``N ohm @ freq`` value string. Do not copy ohms into henries. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import TYPE_CHECKING + +from backend.periscopex.utils import safe_mpn + +if TYPE_CHECKING: + from backend.periscopex.models import InductorSpecs + +# 120 ohm @ 100 MHz | 120Ω at 100MHz +_OHM_AT = re.compile( + r"(?P\d+(?:\.\d+)?)\s*(?Pk)?\s*(?:ohms?|Ω|R)\s*" + r"(?:@|at|/)\s*(?P\d+(?:\.\d+)?)\s*(?PkHz|MHz|GHz|Hz)", + re.I, +) +# Impedance (at 100MHz): 120 ohm +_IMP_TABLE = re.compile( + r"impedance.{0,120}?(?P\d+(?:\.\d+)?)\s*(?PkHz|MHz|GHz|Hz)" + r".{0,80}?(?P\d+(?:\.\d+)?)\s*(?:k)?\s*(?:ohms?|Ω)", + re.I | re.S, +) +_DCR = re.compile(r"\bdcr\b|dc\s*resistance", re.I) + + +def _z_float(num: str, k: str | None) -> float: + z = float(num) + if k: + z *= 1e3 + return z + + +def parse_quoted_z(text: str) -> tuple[float, str] | None: + """Return (ohm, 'N ohm @ freq') if the text states Z at a frequency.""" + if not (text or "").strip(): + return None + blob = text.replace("\u00a0", " ") + hits: list[tuple[float, str, float]] = [] + for m in _OHM_AT.finditer(blob): + start = max(0, m.start() - 40) + if _DCR.search(blob[start:m.start()]): + continue + z = _z_float(m.group("z"), m.group("k")) + freq = f"{m.group('freq')} {m.group('fun')}" + mhz = _to_mhz(m.group("freq"), m.group("fun")) + hits.append((z, f"{z:g} ohm @ {freq}", mhz)) + for m in _IMP_TABLE.finditer(blob): + z = float(m.group("z")) + freq = f"{m.group('freq')} {m.group('fun')}" + mhz = _to_mhz(m.group("freq"), m.group("fun")) + hits.append((z, f"{z:g} ohm @ {freq}", mhz)) + if not hits: + return None + hits.sort(key=lambda h: abs(h[2] - 100.0)) + z, formatted, _ = hits[0] + return z, formatted + + +def _to_mhz(num: str, unit: str) -> float: + n = float(num) + u = unit.lower() + if u == "hz": + return n / 1e6 + if u == "khz": + return n / 1e3 + if u == "ghz": + return n * 1e3 + return n + + +def z_from_datasheet_text(text: str) -> tuple[float, str] | None: + hit = parse_quoted_z(text) + if hit is None: + return None + z, formatted = hit + return z, f"{formatted} (datasheet)" + + +def z_from_pdf(path: Path | str, *, max_chars: int = 80_000) -> tuple[float, str] | None: + pdf = Path(path) + if not pdf.is_file(): + return None + from backend.periscopex.pdf_text import extract_pdf_document_text + + text = extract_pdf_document_text(pdf, max_chars=max_chars) + return z_from_datasheet_text(text) + + +def find_mpn_pdf(mpn: str, *dirs: Path | str) -> Path | None: + safe = safe_mpn(mpn) + names = (f"{safe}.pdf", f"{mpn}.pdf") + for d in dirs: + root = Path(d) + if not root.is_dir(): + continue + for name in names: + p = root / name + if p.is_file(): + return p + for p in root.glob("*.pdf"): + if safe.lower() in p.stem.lower() or mpn.lower() in p.stem.lower(): + return p + return None + + +def recover_bead_specs( + specs: InductorSpecs, + *, + extra_text: str = "", + pdf_path: Path | str | None = None, +) -> InductorSpecs: + """Fill impedance_ohm from quoted/PDF evidence. Never invent. Unstuff henries.""" + z = specs.impedance_ohm + formatted = specs.value_formatted or "" + source = extra_text + if z is None: + quoted = parse_quoted_z(formatted) or parse_quoted_z(extra_text) + if quoted is not None: + z, fmt = quoted + if "ohm" not in formatted.lower() and "Ω" not in formatted: + formatted = f"{fmt} (value string)" + else: + formatted = fmt if "ohm" in fmt.lower() else formatted + elif pdf_path is not None: + pdf_hit = z_from_pdf(pdf_path) + if pdf_hit is not None: + z, formatted = pdf_hit + elif extra_text: + ds = z_from_datasheet_text(extra_text) + if ds is not None: + z, formatted = ds + source = extra_text + + henries = specs.value_henries + if z is not None and henries is not None and abs(henries - z) < 1e-6: + if parse_quoted_z(formatted) or parse_quoted_z(source): + henries = None + + if z == specs.impedance_ohm and henries == specs.value_henries and formatted == specs.value_formatted: + return specs + specs.impedance_ohm = z + specs.value_henries = henries + specs.value_formatted = formatted or specs.value_formatted + return specs diff --git a/periscope/src/backend/periscopex/graph.py b/periscope/src/backend/periscopex/graph.py index b707afb..c870fad 100644 --- a/periscope/src/backend/periscopex/graph.py +++ b/periscope/src/backend/periscopex/graph.py @@ -169,7 +169,26 @@ def _load_component_models(directory: str | Path) -> dict[str, ComponentSpecs]: except Exception: logger.exception("skipping bad component model %s", json_file) continue - result[model.mpn] = model.specs + specs = model.specs + if getattr(specs, "component_subtype", None) == "passive.ferrite_bead": + from backend.periscopex.ferrite_z import find_mpn_pdf, recover_bead_specs + + pdf = find_mpn_pdf( + model.mpn, + dirpath.parent / "uploads" / "datasheets", + dirpath.parent / "datasheets", + ) + filled = recover_bead_specs(specs, extra_text=json.dumps(raw), pdf_path=pdf) + if ( + filled.impedance_ohm != getattr(specs, "impedance_ohm", None) + or filled.value_henries != getattr(specs, "value_henries", None) + ): + try: + _save_component_model(model.mpn, filled, dirpath) + except Exception: + logger.exception("could not persist recovered bead Z for %s", model.mpn) + specs = filled + result[model.mpn] = specs return result diff --git a/periscope/src/backend/periscopex/models.py b/periscope/src/backend/periscopex/models.py index 53df249..e9f80a9 100644 --- a/periscope/src/backend/periscopex/models.py +++ b/periscope/src/backend/periscopex/models.py @@ -163,9 +163,9 @@ class InductorSpecs(BaseModel): @model_validator(mode="after") def _require_primary_value(self) -> InductorSpecs: if self.component_subtype == "passive.ferrite_bead": - # Z at test frequency is optional. Missing Z is INSUFFICIENT - # evidence later — never invent impedance_ohm, never crash ingest. - return self + from backend.periscopex.ferrite_z import recover_bead_specs + + return recover_bead_specs(self) if self.value_henries is None: raise ValueError("inductor requires value_henries") return self diff --git a/periscope/src/backend/periscopex/resolve_passives.py b/periscope/src/backend/periscopex/resolve_passives.py index 9b85a25..e77320e 100644 --- a/periscope/src/backend/periscopex/resolve_passives.py +++ b/periscope/src/backend/periscopex/resolve_passives.py @@ -185,13 +185,15 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec formatted = value_formatted if not formatted and z is not None: formatted = _format_value(z, "ohm") - return InductorSpecs( + spec = InductorSpecs( component_subtype=st, value_henries=None, value_formatted=formatted or "FB", tolerance=tolerance, package=package, current_rating_a=str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None, dcr_ohms=dcr, impedance_ohm=z, ) + from backend.periscopex.ferrite_z import recover_bead_specs + return recover_bead_specs(spec, extra_text=" ".join(str(v) for v in vals.values() if v is not None)) if subtype.startswith("passive.inductor"): raw = vals.get("value_henries") if raw is None: diff --git a/periscope/src/backend/services/datasheet_extract.py b/periscope/src/backend/services/datasheet_extract.py index 8c6d1d7..6c96c56 100644 --- a/periscope/src/backend/services/datasheet_extract.py +++ b/periscope/src/backend/services/datasheet_extract.py @@ -902,7 +902,24 @@ async def extract_specs( pintable=result.get("pintable", []), package_info=result.get("package_info"), ) - model_obj = ComponentModel(mpn=mpn, specs=specs) + if component_type == "passive": + from backend.periscopex.ferrite_z import recover_bead_specs + from backend.periscopex.resolve_passives import simple_to_typed_passive_specs + + try: + typed = simple_to_typed_passive_specs(specs) + except (ValueError, TypeError): + typed = specs + else: + if getattr(typed, "component_subtype", None) == "passive.ferrite_bead": + typed = recover_bead_specs( + typed, + extra_text=json.dumps(filtered_values), + pdf_path=pdf_path, + ) + model_obj = ComponentModel(mpn=mpn, specs=typed) + else: + model_obj = ComponentModel(mpn=mpn, specs=specs) output_dir.mkdir(parents=True, exist_ok=True) safe = safe_mpn(mpn) @@ -1114,8 +1131,15 @@ async def auto_resolve_specs( # Convert passive SimpleComponentSpecs to typed models if component_type == "passive": + from backend.periscopex.ferrite_z import recover_bead_specs from backend.periscopex.resolve_passives import simple_to_typed_passive_specs + typed = simple_to_typed_passive_specs(specs) + if getattr(typed, "component_subtype", None) == "passive.ferrite_bead": + extra = f"{digikey_description}\n" + "\n".join( + f"{p.get('name')}: {p.get('value')}" for p in digikey_params + ) + typed = recover_bead_specs(typed, extra_text=extra) return ComponentModel(mpn=mpn, specs=typed) return ComponentModel(mpn=mpn, specs=specs) @@ -1286,8 +1310,12 @@ async def resolve_from_value( ) if component_type == "passive": + from backend.periscopex.ferrite_z import recover_bead_specs from backend.periscopex.resolve_passives import simple_to_typed_passive_specs + typed = simple_to_typed_passive_specs(specs) + if getattr(typed, "component_subtype", None) == "passive.ferrite_bead": + typed = recover_bead_specs(typed, extra_text=value) return ComponentModel(mpn=mpn, specs=typed) return ComponentModel(mpn=mpn, specs=specs) diff --git a/periscope/src/backend/skills_manifest.json b/periscope/src/backend/skills_manifest.json index 89877fb..7c7ff83 100644 --- a/periscope/src/backend/skills_manifest.json +++ b/periscope/src/backend/skills_manifest.json @@ -1,5 +1,5 @@ { - "default_model_version": "1.14.0", + "default_model_version": "1.15.0", "extract-pintable": { "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "latest_version": "1784798970179642", diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index f204c46..e456794 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Periscope. +## 2.60.2 — 2026-09-20 — Ferrite Z from datasheet, no graph_build abort + +Beads with an MPN pull `impedance_ohm` from the datasheet or a quoted `N ohm @ freq` string. Missing Z after that is INSUFFICIENT, not a crash. Ohms are never written to `value_henries`. Leftover dependency validator no longer requires Z. + +- [Fixed] `graph_build` loads DCR-only beads; leftover `ferrite bead requires impedance_ohm` removed. +- [Changed] Ferrite Z recovered from datasheet PDF / quoted value; extract-specs skill 1.15.0. + ## 2.60.1 — 2026-09-20 — Ferrite bead without Z still loads Ferrite beads that only have current/DCR validate. Missing `impedance_ohm` is not invented and does not crash `graph_build`. Other inductors still need `value_henries`. diff --git a/periscope/src/skills/extract-specs/SKILL.md b/periscope/src/skills/extract-specs/SKILL.md index d262cfb..f6b0abd 100644 --- a/periscope/src/skills/extract-specs/SKILL.md +++ b/periscope/src/skills/extract-specs/SKILL.md @@ -31,6 +31,8 @@ Use SPICE prefixes with units: `T` `G` `M` `k` `m` `u` `n` `p`. Good: `"30V"`, ` Do not infer or calculate. If min/typ/max all matter, include them in the string (`"550mV typ, 850mV max"`). Extra keys are discarded. +Ferrite beads (`passive.ferrite_bead`): put Z at the test frequency in `impedance_ohm` (e.g. 120 Ω @ 100 MHz). Never write that impedance into `value_henries`. If the datasheet does not state impedance, omit `impedance_ohm`. + ## save_specs payload - `component_subtype` — dotted path (`discrete.diode.schottky`) diff --git a/periscope/src/taxonomy/passive.json b/periscope/src/taxonomy/passive.json index f91302e..525e320 100644 --- a/periscope/src/taxonomy/passive.json +++ b/periscope/src/taxonomy/passive.json @@ -143,9 +143,8 @@ "extra_specs": [ { "name": "impedance_ohm", - "description": "Impedance at the stated test frequency", - "unit": "ohm", - "required": true + "description": "Impedance at the stated test frequency (do not invent; omit if the datasheet has no Z)", + "unit": "ohm" }, { "name": "current_rating_a", diff --git a/tests/test_inductor_specs.py b/tests/test_inductor_specs.py index c7a2dd8..2a8bce0 100644 --- a/tests/test_inductor_specs.py +++ b/tests/test_inductor_specs.py @@ -1,4 +1,4 @@ -"""Ferrite beads without Z validate; other inductors still need henries.""" +"""Ferrite beads: recover Z from evidence; never invent; never abort ingest.""" from __future__ import annotations @@ -8,25 +8,26 @@ from pathlib import Path import pytest from pydantic import ValidationError +from backend.periscopex.ferrite_z import recover_bead_specs, z_from_datasheet_text from backend.periscopex.graph import _load_component_models -from backend.periscopex.models import ComponentModel, InductorSpecs +from backend.periscopex.models import ComponentModel, InductorSpecs, SimpleComponentSpecs from backend.periscopex.resolve_passives import simple_to_typed_passive_specs -from backend.periscopex.models import SimpleComponentSpecs -def test_ferrite_bead_without_impedance_ohm_validates(): +def test_ferrite_bead_quoted_ohm_fills_z_not_henries(): specs = InductorSpecs( component_subtype="passive.ferrite_bead", value_formatted="120 ohm @ 100 MHz", current_rating_a="3A", dcr_ohms=0.03, ) - assert specs.impedance_ohm is None + assert specs.impedance_ohm == 120.0 + assert specs.value_henries is None model = ComponentModel(mpn="BLM21PG121SN1D", specs=specs) - assert model.specs.impedance_ohm is None + assert model.specs.impedance_ohm == 120.0 -def test_ferrite_bead_live_payload_without_z_validates(): +def test_ferrite_bead_live_payload_fills_z_from_formatted(): model = ComponentModel.model_validate({ "mpn": "BLM21PG121SN1D", "specs": { @@ -40,7 +41,8 @@ def test_ferrite_bead_live_payload_without_z_validates(): "dcr_ohms": 0.03, }, }) - assert model.specs.impedance_ohm is None + assert model.specs.impedance_ohm == 120.0 + assert model.specs.value_henries is None assert model.specs.current_rating_a == "3A" assert model.specs.dcr_ohms == 0.03 @@ -54,6 +56,7 @@ def test_ferrite_bead_with_impedance_ohm_still_ok(): dcr_ohms=0.03, ) assert specs.impedance_ohm == 120.0 + assert specs.value_henries is None def test_inductor_still_requires_value_henries(): @@ -105,3 +108,42 @@ def test_simple_to_typed_bead_without_z(): assert specs.specs_type == "inductor" assert specs.impedance_ohm is None assert specs.dcr_ohms == 0.03 + + +def test_bead_mpn_datasheet_text_fills_z(): + text = ( + "BLM21PG121SN1D chip ferrite bead\n" + "Impedance (at 100MHz / 20°C) 120 ohm\n" + "Rated current 3A\n" + "DC resistance 0.03 ohm\n" + ) + hit = z_from_datasheet_text(text) + assert hit is not None + z, formatted = hit + assert z == 120.0 + assert "100" in formatted + assert "datasheet" in formatted + specs = InductorSpecs( + component_subtype="passive.ferrite_bead", + value_formatted="FB", + current_rating_a="3A", + dcr_ohms=0.03, + ) + filled = recover_bead_specs(specs, extra_text=text) + assert filled.impedance_ohm == 120.0 + assert filled.value_henries is None + + +def test_bead_mpn_datasheet_without_z_does_not_invent(): + text = "BLM21PG121SN1D\nRated current 3 A\nDCR 0.03 ohm max\nPackage 0805" + assert z_from_datasheet_text(text) is None + specs = InductorSpecs( + component_subtype="passive.ferrite_bead", + value_formatted="FB", + current_rating_a="3A", + dcr_ohms=0.03, + ) + filled = recover_bead_specs(specs, extra_text=text) + assert filled.impedance_ohm is None + model = ComponentModel(mpn="BLM21PG121SN1D", specs=filled) + assert model.specs.impedance_ohm is None diff --git a/tests/test_periscope_taxonomy_skills_src.py b/tests/test_periscope_taxonomy_skills_src.py index 2a8c3af..0d31bfa 100644 --- a/tests/test_periscope_taxonomy_skills_src.py +++ b/tests/test_periscope_taxonomy_skills_src.py @@ -61,6 +61,6 @@ def test_skills_manifest_bumped_for_prompt_change(): text = (ROOT / "periscope" / "src" / "backend" / "skills_manifest.json").read_text( encoding="utf-8" ) - assert '"default_model_version": "1.14.0"' in text + assert '"default_model_version": "1.15.0"' in text validate = load_skill_validator("extract-pintable") assert validate is not None