diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py index 5fc8497..361a7a1 100644 --- a/backend/services/pcb_pipeline.py +++ b/backend/services/pcb_pipeline.py @@ -23,7 +23,9 @@ from backend.periscopex.finding_engine import apply_decisions from backend.periscopex.functional_groups import build_placement_plan from backend.periscopex.graph import build_graph from backend.periscopex.layout_rules import needs_layout_rules_refresh -from backend.periscopex.models import ComponentConstraints, DesignGraph, LayoutGraph, ValidationReport +from backend.periscopex.models import ( + ComponentConstraints, ComponentType, DesignGraph, LayoutGraph, ValidationReport, +) from backend.periscopex.pcb_checks import assign_pcb_finding_ids, run_pcb_checks from backend.periscopex.pcb_inventory import build_pcb_inventory from backend.services import projects as proj_svc @@ -104,6 +106,50 @@ def _load_constraints_map( return result +def si_extract_needed_skips( + graph: DesignGraph, + cmap: dict, + scan_ver: str, +) -> list[dict]: + """Stale SI extracts for *board* ICs only — not the shared library dump. + + ``cmap`` includes every ``library/extracted`` JSON. Warning those MPNs + as ``not_reviewed`` on an unrelated project (Emmaforo vs LAN8720A) is + a false banner. + """ + board_mpns: set[str] = set() + refs_by_mpn: dict[str, list[str]] = {} + for ref, comp in graph.components.items(): + if comp.component_type != ComponentType.IC: + continue + mpn = (comp.mpn or comp.value or "").strip() + if not mpn: + continue + board_mpns.add(mpn) + refs_by_mpn.setdefault(mpn, []).append(ref) + out: list[dict] = [] + for mpn, cons in sorted(cmap.items()): + if mpn not in board_mpns: + continue + payload = { + "model_version": cons.model_version, + "layout_rules": cons.layout_rules, + } + if not needs_layout_rules_refresh(payload, min_scan_version=scan_ver): + continue + refs = ",".join(sorted(refs_by_mpn.get(mpn, []))) or mpn + out.append({ + "designator": refs, + "reason": ( + f"SI layout_rules empty at model_version=" + f"{cons.model_version}; re-run schematic review " + f"to re-extract pintable {scan_ver} " + "(PCB does not re-read the PDF)." + ), + }) + return out + + def _publish(project_id: str, event: str, data: dict) -> None: broker.publish(project_id, event, data) @@ -222,29 +268,11 @@ async def run_pcb_pipeline( _step(project_id, "checks", "running") findings = run_pcb_checks(graph, cmap, layout, plan, zrep) - si_skip: list[dict] = [] scan_ver = app_settings.get_default_model_version() - for mpn, cons in sorted(cmap.items()): - payload = { - "model_version": cons.model_version, - "layout_rules": cons.layout_rules, - } - if needs_layout_rules_refresh( - payload, min_scan_version=scan_ver, - ): - si_skip.append({ - "designator": mpn, - "reason": ( - f"SI layout_rules empty at model_version=" - f"{cons.model_version}; re-run schematic review " - f"to re-extract pintable {scan_ver} " - "(PCB does not re-read the PDF)." - ), - }) - if si_skip: - sipath = ws.local_path("si_extract_needed.json") - sipath.write_text(json.dumps(si_skip, indent=2) + "\n") - ws._upload_file("si_extract_needed.json") + si_skip = si_extract_needed_skips(graph, cmap, scan_ver) + sipath = ws.local_path("si_extract_needed.json") + sipath.write_text(json.dumps(si_skip, indent=2) + "\n") + ws._upload_file("si_extract_needed.json") _step( project_id, "checks", "complete", f"{len(findings)} deterministic findings" diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 35c8efc..5d85c31 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,12 @@ What's new in Periscope. +## 2.37.1 — 2026-09-20 — PCB not_reviewed is board ICs, not the shared library + +Emmaforo showed 20 “components not reviewed” that were other projects’ 1.5–1.7 extracts (LAN8720A, W25Q, …). BOM ICs were already 1.13.0 and *were* reviewed. + +- [Fixed] `si_extract_needed_skips` only flags ICs on this graph. Shared `library/extracted` cache is not `not_reviewed`. + ## 2.37.0 — 2026-09-20 — SI bus gate (EN RC ≠ USB series R) Datasheet SI facts apply only to the bus in the quote. ESP32 EN RC 10 kΩ / 1 µF is not a USB D+/D− series resistor. USB without a library Z number stays measurement-only (88 Ω), not a 90 Ω FAIL. Coverage: PHY+RJ45 MDI, MAC–PHY RGMII/SGMII, DDR3, USB-C SuperSpeed. diff --git a/tests/test_pcb_plan_closeout.py b/tests/test_pcb_plan_closeout.py index a3f4bbd..1b33eea 100644 --- a/tests/test_pcb_plan_closeout.py +++ b/tests/test_pcb_plan_closeout.py @@ -80,6 +80,52 @@ def test_si_refresh_when_old_extract_has_only_decoupling(): ) +def test_si_extract_needed_skips_board_ics_only_not_shared_library(): + from backend.services.pcb_pipeline import si_extract_needed_skips + + graph = DesignGraph( + components={ + "U5": Component( + reference="U5", value="ESP32-C6-WROOM-1", footprint="", + component_type=ComponentType.IC, mpn="ESP32-C6-WROOM-1", + pins={"1": "EN"}, + ), + "J1": Component( + reference="J1", value="USB-C", footprint="", + component_type=ComponentType.CONNECTOR, mpn="TYPE-C-31-M-12", + pins={}, + ), + }, + nets={}, + ) + fresh = ComponentConstraints( + mpn="ESP32-C6-WROOM-1", + model_version="1.13.0", + pintable=[Pin(number="1", name="EN")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[{"kind": "decoupling_proximity", "pin": "EN"}], + ) + stale_lib = ComponentConstraints( + mpn="LAN8720A", + model_version="1.5.0", + pintable=[Pin(number="1", name="TXP")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[], + ) + stale_board = fresh.model_copy(update={"model_version": "1.5.0", "layout_rules": []}) + assert si_extract_needed_skips( + graph, {"ESP32-C6-WROOM-1": fresh, "LAN8720A": stale_lib}, "1.13.0", + ) == [] + skips = si_extract_needed_skips( + graph, {"ESP32-C6-WROOM-1": stale_board, "LAN8720A": stale_lib}, "1.13.0", + ) + assert len(skips) == 1 + assert skips[0]["designator"] == "U5" + assert "LAN8720A" not in skips[0]["designator"] + + def test_package_family_mismatch_is_pe_bom_010(): from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet