diff --git a/periscope/src/backend/periscopex/graph.py b/periscope/src/backend/periscopex/graph.py index 2a2be0c..b707afb 100644 --- a/periscope/src/backend/periscopex/graph.py +++ b/periscope/src/backend/periscopex/graph.py @@ -7,6 +7,7 @@ in ``periscope/dependency/`` until this module is the live winner. from __future__ import annotations import json +import logging import re from pathlib import Path @@ -27,6 +28,8 @@ from backend.periscopex.parsers import parse_bom, parse_netlist_any from backend.periscopex.resolve_passives import SkippedItem, resolve_bom, resolved_to_specs from backend.periscopex.utils import safe_mpn +logger = logging.getLogger(__name__) + # Ref-des prefix → type. Longer prefixes must be checked first (LED before L). _REF_PREFIXES: tuple[tuple[str, ComponentType], ...] = ( ("LED", ComponentType.DISCRETE), @@ -160,8 +163,12 @@ def _load_component_models(directory: str | Path) -> dict[str, ComponentSpecs]: if not dirpath.is_dir(): return result for json_file in dirpath.glob("*.json"): - raw = json.loads(json_file.read_text()) - model = ComponentModel.model_validate(raw) + try: + raw = json.loads(json_file.read_text()) + model = ComponentModel.model_validate(raw) + except Exception: + logger.exception("skipping bad component model %s", json_file) + continue result[model.mpn] = model.specs return result diff --git a/periscope/src/backend/periscopex/models.py b/periscope/src/backend/periscopex/models.py index bc0d90c..53df249 100644 --- a/periscope/src/backend/periscopex/models.py +++ b/periscope/src/backend/periscopex/models.py @@ -163,8 +163,8 @@ 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") + # Z at test frequency is optional. Missing Z is INSUFFICIENT + # evidence later — never invent impedance_ohm, never crash ingest. return self if self.value_henries is None: raise ValueError("inductor requires value_henries") diff --git a/periscope/src/backend/periscopex/resolve_passives.py b/periscope/src/backend/periscopex/resolve_passives.py index 429ecbd..9b85a25 100644 --- a/periscope/src/backend/periscopex/resolve_passives.py +++ b/periscope/src/backend/periscopex/resolve_passives.py @@ -175,16 +175,19 @@ 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") - z = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw) + z = None + if raw is not None: + z = _parse_spice_value(str(raw)) if isinstance(raw, str) else float(raw) dcr_raw = vals.get("dcr_ohms") dcr = None if dcr_raw is not None: dcr = _parse_spice_value(str(dcr_raw)) if isinstance(dcr_raw, str) else float(dcr_raw) + formatted = value_formatted + if not formatted and z is not None: + formatted = _format_value(z, "ohm") return InductorSpecs( component_subtype=st, value_henries=None, - value_formatted=value_formatted or _format_value(z, "ohm"), + 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, diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index 0eb443c..f204c46 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.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`. + +- [Fixed] `InductorSpecs` no longer requires Z on `passive.ferrite_bead`. +- [Changed] Bad `models/*.json` are skipped with a log, not a pipeline abort. + ## 2.60.0 — 2026-09-20 — Incomplete PCB exam and owner project delete Unfinished placing/routing still uses MODE=pcb. Parser and checks run on partial layouts; missing copper is INSUFFICIENT or skip, never invented millimetres. Via≠Pad unchanged. Dashboard owners can delete a project after a confirm dialog. diff --git a/tests/test_inductor_specs.py b/tests/test_inductor_specs.py new file mode 100644 index 0000000..c7a2dd8 --- /dev/null +++ b/tests/test_inductor_specs.py @@ -0,0 +1,107 @@ +"""Ferrite beads without Z validate; other inductors still need henries.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from backend.periscopex.graph import _load_component_models +from backend.periscopex.models import ComponentModel, InductorSpecs +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(): + 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 + model = ComponentModel(mpn="BLM21PG121SN1D", specs=specs) + assert model.specs.impedance_ohm is None + + +def test_ferrite_bead_live_payload_without_z_validates(): + 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 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 + + +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