graph_build searches project uploads and library blob refs, persists recovered Z, and still loads when the PDF has no impedance. Ohms are not written to henries.
256 lines
8.7 KiB
Python
256 lines
8.7 KiB
Python
"""Ferrite beads: recover Z from evidence; never invent; never abort ingest."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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, SimpleComponentSpecs
|
|
from backend.periscopex.resolve_passives import simple_to_typed_passive_specs
|
|
|
|
|
|
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 == 120.0
|
|
assert specs.value_henries is None
|
|
model = ComponentModel(mpn="BLM21PG121SN1D", specs=specs)
|
|
assert model.specs.impedance_ohm == 120.0
|
|
|
|
|
|
def test_ferrite_bead_live_payload_fills_z_from_formatted():
|
|
model = ComponentModel.model_validate({
|
|
"mpn": "BLM21PG121SN1D",
|
|
"specs": {
|
|
"specs_type": "inductor",
|
|
"component_subtype": "passive.ferrite_bead",
|
|
"value_henries": 120.0,
|
|
"value_formatted": "120 ohm @ 100 MHz",
|
|
"tolerance": None,
|
|
"package": "0805",
|
|
"current_rating_a": "3A",
|
|
"dcr_ohms": 0.03,
|
|
},
|
|
})
|
|
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
|
|
|
|
|
|
def test_ferrite_bead_with_impedance_ohm_still_ok():
|
|
specs = InductorSpecs(
|
|
component_subtype="passive.ferrite_bead",
|
|
value_formatted="120 ohm @ 100 MHz",
|
|
impedance_ohm=120.0,
|
|
current_rating_a="3A",
|
|
dcr_ohms=0.03,
|
|
)
|
|
assert specs.impedance_ohm == 120.0
|
|
assert specs.value_henries is None
|
|
|
|
|
|
def test_inductor_still_requires_value_henries():
|
|
with pytest.raises(ValidationError, match="inductor requires value_henries"):
|
|
InductorSpecs(
|
|
component_subtype="passive.inductor",
|
|
value_formatted="15nH",
|
|
)
|
|
|
|
|
|
def test_load_models_bead_without_z_does_not_raise(tmp_path: Path):
|
|
payload = {
|
|
"mpn": "BLM21PG121SN1D",
|
|
"specs": {
|
|
"specs_type": "inductor",
|
|
"component_subtype": "passive.ferrite_bead",
|
|
"value_formatted": "FB",
|
|
"current_rating_a": "3A",
|
|
"dcr_ohms": 0.03,
|
|
},
|
|
}
|
|
(tmp_path / "BLM21PG121SN1D.json").write_text(json.dumps(payload) + "\n")
|
|
loaded = _load_component_models(tmp_path)
|
|
assert "BLM21PG121SN1D" in loaded
|
|
assert loaded["BLM21PG121SN1D"].impedance_ohm is None
|
|
|
|
|
|
def test_load_models_skips_inductor_missing_henries(tmp_path: Path):
|
|
bad = {
|
|
"mpn": "LQW-BAD",
|
|
"specs": {
|
|
"specs_type": "inductor",
|
|
"component_subtype": "passive.inductor",
|
|
"value_formatted": "x",
|
|
},
|
|
}
|
|
(tmp_path / "LQW-BAD.json").write_text(json.dumps(bad) + "\n")
|
|
loaded = _load_component_models(tmp_path)
|
|
assert loaded == {}
|
|
|
|
|
|
def test_simple_to_typed_bead_without_z():
|
|
simple = SimpleComponentSpecs(
|
|
specs_type="passive",
|
|
component_subtype="passive.ferrite_bead",
|
|
values={"current_rating_a": "3A", "dcr_ohms": 0.03},
|
|
)
|
|
specs = simple_to_typed_passive_specs(simple)
|
|
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
|
|
|
|
|
|
def _write_text_pdf(path: Path, text: str) -> None:
|
|
"""Write a one-page PDF pypdf can extract without PyMuPDF."""
|
|
escaped = (
|
|
text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
|
)
|
|
lines = escaped.split("\n")
|
|
ops = ["BT /F1 12 Tf 72 720 Td"]
|
|
for i, line in enumerate(lines):
|
|
if i:
|
|
ops.append("0 -16 Td")
|
|
ops.append(f"({line}) Tj")
|
|
ops.append("ET")
|
|
stream = "\n".join(ops).encode("latin-1", "replace")
|
|
objects = [
|
|
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
|
|
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
|
|
(
|
|
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
|
b"/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n"
|
|
),
|
|
b"4 0 obj << /Length %d >> stream\n" % len(stream) + stream + b"\nendstream endobj\n",
|
|
b"5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
|
|
]
|
|
header = b"%PDF-1.4\n"
|
|
body = header
|
|
offsets = [0]
|
|
for obj in objects:
|
|
offsets.append(len(body))
|
|
body += obj
|
|
xref_pos = len(body)
|
|
xref = [b"xref\n0 6\n0000000000 65535 f \n"]
|
|
for off in offsets[1:]:
|
|
xref.append(f"{off:010d} 00000 n \n".encode())
|
|
trailer = (
|
|
b"trailer << /Root 1 0 R /Size 6 >>\nstartxref\n"
|
|
+ str(xref_pos).encode()
|
|
+ b"\n%%EOF\n"
|
|
)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(body + b"".join(xref) + trailer)
|
|
|
|
|
|
_BEAD_MODEL = {
|
|
"mpn": "BLM21PG121SN1D",
|
|
"specs": {
|
|
"specs_type": "inductor",
|
|
"component_subtype": "passive.ferrite_bead",
|
|
"value_formatted": "FB",
|
|
"current_rating_a": "3A",
|
|
"dcr_ohms": 0.03,
|
|
},
|
|
}
|
|
|
|
|
|
def test_bead_mpn_datasheet_pdf_fills_z(tmp_path: Path):
|
|
models = tmp_path / "models"
|
|
models.mkdir()
|
|
(models / "BLM21PG121SN1D.json").write_text(json.dumps(_BEAD_MODEL) + "\n")
|
|
_write_text_pdf(
|
|
tmp_path / "uploads" / "datasheets" / "BLM21PG121SN1D.pdf",
|
|
"BLM21PG121SN1D\nImpedance (at 100MHz / 20C) 120 ohm\nRated current 3A",
|
|
)
|
|
loaded = _load_component_models(models)
|
|
assert loaded["BLM21PG121SN1D"].impedance_ohm == 120.0
|
|
assert loaded["BLM21PG121SN1D"].value_henries is None
|
|
persisted = json.loads((models / "BLM21PG121SN1D.json").read_text())
|
|
assert persisted["specs"]["impedance_ohm"] == 120.0
|
|
assert persisted["specs"]["value_henries"] is None
|
|
assert "100" in (persisted["specs"].get("value_formatted") or "")
|
|
|
|
|
|
def test_bead_mpn_datasheet_pdf_without_z_does_not_invent(tmp_path: Path):
|
|
models = tmp_path / "models"
|
|
models.mkdir()
|
|
(models / "BLM21PG121SN1D.json").write_text(json.dumps(_BEAD_MODEL) + "\n")
|
|
_write_text_pdf(
|
|
tmp_path / "uploads" / "datasheets" / "BLM21PG121SN1D.pdf",
|
|
"BLM21PG121SN1D\nRated current 3 A\nDCR 0.03 ohm max\nPackage 0805",
|
|
)
|
|
loaded = _load_component_models(models)
|
|
assert "BLM21PG121SN1D" in loaded
|
|
assert loaded["BLM21PG121SN1D"].impedance_ohm is None
|
|
assert loaded["BLM21PG121SN1D"].value_henries is None
|
|
|
|
|
|
def test_bead_mpn_library_blob_fills_z(tmp_path: Path):
|
|
models = tmp_path / "proj" / "models"
|
|
models.mkdir(parents=True)
|
|
(models / "BLM21PG121SN1D.json").write_text(json.dumps(_BEAD_MODEL) + "\n")
|
|
blob = tmp_path / "library" / "datasheets" / "blobs" / "deadbeef.pdf"
|
|
_write_text_pdf(
|
|
blob,
|
|
"Impedance (at 100MHz / 20C) 120 ohm\nRated current 3A",
|
|
)
|
|
ref = tmp_path / "library" / "datasheets" / "refs" / "BLM21PG121SN1D.json"
|
|
ref.parent.mkdir(parents=True, exist_ok=True)
|
|
ref.write_text(json.dumps({
|
|
"hash": "deadbeef",
|
|
"blob_key": "library/datasheets/blobs/deadbeef.pdf",
|
|
"mpn": "BLM21PG121SN1D",
|
|
}) + "\n")
|
|
loaded = _load_component_models(models, extra_pdf_dirs=[tmp_path])
|
|
assert loaded["BLM21PG121SN1D"].impedance_ohm == 120.0
|
|
assert loaded["BLM21PG121SN1D"].value_henries is None
|