Recover ferrite bead Z from datasheet evidence without aborting graph_build.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user