Recover ferrite bead Z from datasheet evidence without aborting graph_build.

This commit is contained in:
2026-09-21 00:19:06 +02:00
parent 51c09b2176
commit a2d5900b64
13 changed files with 279 additions and 27 deletions
@@ -158,9 +158,9 @@ class InductorSpecs(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _require_primary_value(self) -> InductorSpecs: def _require_primary_value(self) -> InductorSpecs:
if self.component_subtype == "passive.ferrite_bead": if self.component_subtype == "passive.ferrite_bead":
if self.impedance_ohm is None: from backend.periscopex.ferrite_z import recover_bead_specs
raise ValueError("ferrite bead requires impedance_ohm")
return self return recover_bead_specs(self)
if self.value_henries is None: if self.value_henries is None:
raise ValueError("inductor requires value_henries") raise ValueError("inductor requires value_henries")
return self return self
@@ -306,19 +306,21 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
if subtype == "passive.ferrite_bead": if subtype == "passive.ferrite_bead":
raw = vals.get("impedance_ohm") or vals.get("value_ohms") raw = vals.get("impedance_ohm") or vals.get("value_ohms")
if raw is None: impedance_ohm = None
raise ValueError("Missing impedance_ohm in auto-resolved ferrite bead specs") if raw is not None:
impedance_ohm = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw) 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 current_rating_a = str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None
dcr_raw = vals.get("dcr_ohms") dcr_raw = vals.get("dcr_ohms")
dcr_ohms: float | None = None dcr_ohms: float | None = None
if dcr_raw is not None: if dcr_raw is not None:
dcr_ohms = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw) 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( return InductorSpecs(
component_subtype=subtype_for_specs, component_subtype=subtype_for_specs,
value_henries=None, value_henries=None,
value_formatted=formatted, value_formatted=formatted or "FB",
tolerance=tolerance, tolerance=tolerance,
package=package, package=package,
current_rating_a=current_rating_a, current_rating_a=current_rating_a,
@@ -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<z>\d+(?:\.\d+)?)\s*(?P<k>k)?\s*(?:ohms?|Ω|R)\s*"
r"(?:@|at|/)\s*(?P<freq>\d+(?:\.\d+)?)\s*(?P<fun>kHz|MHz|GHz|Hz)",
re.I,
)
# Impedance (at 100MHz): 120 ohm
_IMP_TABLE = re.compile(
r"impedance.{0,120}?(?P<freq>\d+(?:\.\d+)?)\s*(?P<fun>kHz|MHz|GHz|Hz)"
r".{0,80}?(?P<z>\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
+20 -1
View File
@@ -169,7 +169,26 @@ def _load_component_models(directory: str | Path) -> dict[str, ComponentSpecs]:
except Exception: except Exception:
logger.exception("skipping bad component model %s", json_file) logger.exception("skipping bad component model %s", json_file)
continue 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 return result
+3 -3
View File
@@ -163,9 +163,9 @@ class InductorSpecs(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _require_primary_value(self) -> InductorSpecs: def _require_primary_value(self) -> InductorSpecs:
if self.component_subtype == "passive.ferrite_bead": if self.component_subtype == "passive.ferrite_bead":
# Z at test frequency is optional. Missing Z is INSUFFICIENT from backend.periscopex.ferrite_z import recover_bead_specs
# evidence later — never invent impedance_ohm, never crash ingest.
return self return recover_bead_specs(self)
if self.value_henries is None: if self.value_henries is None:
raise ValueError("inductor requires value_henries") raise ValueError("inductor requires value_henries")
return self return self
@@ -185,13 +185,15 @@ def simple_to_typed_passive_specs(simple: SimpleComponentSpecs) -> ComponentSpec
formatted = value_formatted formatted = value_formatted
if not formatted and z is not None: if not formatted and z is not None:
formatted = _format_value(z, "ohm") formatted = _format_value(z, "ohm")
return InductorSpecs( spec = InductorSpecs(
component_subtype=st, value_henries=None, component_subtype=st, value_henries=None,
value_formatted=formatted or "FB", value_formatted=formatted or "FB",
tolerance=tolerance, package=package, tolerance=tolerance, package=package,
current_rating_a=str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None, current_rating_a=str(vals.get("current_rating_a")) if vals.get("current_rating_a") else None,
dcr_ohms=dcr, impedance_ohm=z, 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"): if subtype.startswith("passive.inductor"):
raw = vals.get("value_henries") raw = vals.get("value_henries")
if raw is None: if raw is None:
@@ -902,7 +902,24 @@ async def extract_specs(
pintable=result.get("pintable", []), pintable=result.get("pintable", []),
package_info=result.get("package_info"), 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) output_dir.mkdir(parents=True, exist_ok=True)
safe = safe_mpn(mpn) safe = safe_mpn(mpn)
@@ -1114,8 +1131,15 @@ async def auto_resolve_specs(
# Convert passive SimpleComponentSpecs to typed models # Convert passive SimpleComponentSpecs to typed models
if component_type == "passive": if component_type == "passive":
from backend.periscopex.ferrite_z import recover_bead_specs
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
typed = simple_to_typed_passive_specs(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=typed)
return ComponentModel(mpn=mpn, specs=specs) return ComponentModel(mpn=mpn, specs=specs)
@@ -1286,8 +1310,12 @@ async def resolve_from_value(
) )
if component_type == "passive": if component_type == "passive":
from backend.periscopex.ferrite_z import recover_bead_specs
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
typed = simple_to_typed_passive_specs(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=typed)
return ComponentModel(mpn=mpn, specs=specs) return ComponentModel(mpn=mpn, specs=specs)
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"default_model_version": "1.14.0", "default_model_version": "1.15.0",
"extract-pintable": { "extract-pintable": {
"skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY",
"latest_version": "1784798970179642", "latest_version": "1784798970179642",
@@ -2,6 +2,13 @@
What's new in Periscope. 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 ## 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`. 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`.
@@ -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. 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 ## save_specs payload
- `component_subtype` — dotted path (`discrete.diode.schottky`) - `component_subtype` — dotted path (`discrete.diode.schottky`)
+2 -3
View File
@@ -143,9 +143,8 @@
"extra_specs": [ "extra_specs": [
{ {
"name": "impedance_ohm", "name": "impedance_ohm",
"description": "Impedance at the stated test frequency", "description": "Impedance at the stated test frequency (do not invent; omit if the datasheet has no Z)",
"unit": "ohm", "unit": "ohm"
"required": true
}, },
{ {
"name": "current_rating_a", "name": "current_rating_a",
+50 -8
View File
@@ -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 from __future__ import annotations
@@ -8,25 +8,26 @@ from pathlib import Path
import pytest import pytest
from pydantic import ValidationError 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.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.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( specs = InductorSpecs(
component_subtype="passive.ferrite_bead", component_subtype="passive.ferrite_bead",
value_formatted="120 ohm @ 100 MHz", value_formatted="120 ohm @ 100 MHz",
current_rating_a="3A", current_rating_a="3A",
dcr_ohms=0.03, 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) 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({ model = ComponentModel.model_validate({
"mpn": "BLM21PG121SN1D", "mpn": "BLM21PG121SN1D",
"specs": { "specs": {
@@ -40,7 +41,8 @@ def test_ferrite_bead_live_payload_without_z_validates():
"dcr_ohms": 0.03, "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.current_rating_a == "3A"
assert model.specs.dcr_ohms == 0.03 assert model.specs.dcr_ohms == 0.03
@@ -54,6 +56,7 @@ def test_ferrite_bead_with_impedance_ohm_still_ok():
dcr_ohms=0.03, dcr_ohms=0.03,
) )
assert specs.impedance_ohm == 120.0 assert specs.impedance_ohm == 120.0
assert specs.value_henries is None
def test_inductor_still_requires_value_henries(): 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.specs_type == "inductor"
assert specs.impedance_ohm is None assert specs.impedance_ohm is None
assert specs.dcr_ohms == 0.03 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
+1 -1
View File
@@ -61,6 +61,6 @@ def test_skills_manifest_bumped_for_prompt_change():
text = (ROOT / "periscope" / "src" / "backend" / "skills_manifest.json").read_text( text = (ROOT / "periscope" / "src" / "backend" / "skills_manifest.json").read_text(
encoding="utf-8" 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") validate = load_skill_validator("extract-pintable")
assert validate is not None assert validate is not None