Make the component library a standalone product door (2.63.0).

Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
This commit is contained in:
2026-09-21 22:12:00 +02:00
parent 20733f0ec6
commit 4df04df5d4
101 changed files with 2060 additions and 302 deletions
View File
+146
View File
@@ -0,0 +1,146 @@
"""AF board + AI: hypotheses of probable HF issues, then deterministic checks.
Does not replace run_pcb_checks. Not DRC. Never invents Z.
"""
from __future__ import annotations
from backend.periscopex.af_ai_hf import HfHypothesis, investigate_hf_hypotheses
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutDielectric,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.pcb_checks import run_pcb_checks
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY") -> Component:
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str]) -> Net:
return Net(
name=name, net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"A6": "USB_D+", "A7": "USB_D-"},
),
},
nets={
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
},
)
def _fp(ref: str, pads: list[LayoutPad], x: float = 0.0) -> LayoutFootprint:
return LayoutFootprint(reference=ref, footprint="P", x=x, y=0.0, pads=pads)
def _stub_layout() -> LayoutGraph:
return LayoutGraph(
footprints={
"U1": _fp("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
def _stub_constraints() -> dict:
return {
"PHY": ComponentConstraints(
mpn="PHY", pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0,
"note": "USB stub < 1 mm", "source_page": 12,
}],
),
}
def test_ai_stub_flag_then_deterministic_pe_si_007():
graph = _usb_graph()
layout = _stub_layout()
cons = _stub_constraints()
flags = [HfHypothesis(net="USB_D+", issue_class="stub", why="looks long")]
out = investigate_hf_hypotheses(graph, cons, layout, flags)
codes = [f.rule_id for f in out]
assert "PE-SI-007" in codes
hit = next(f for f in out if f.rule_id == "PE-SI-007")
assert "FAIL" in hit.finding
assert hit.evidence_status == "SUFFICIENT"
def test_ai_z0_flag_without_stackup_is_insufficient_no_ohm():
graph = _usb_graph()
layout = _stub_layout()
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="probably 90 ohm")]
out = investigate_hf_hypotheses(graph, {}, layout, flags)
assert len(out) == 1
f = out[0]
assert f.rule_id == "PE-AF-001"
assert f.evidence_status == "INSUFFICIENT"
assert "90" not in f.finding
assert "50" not in f.finding
assert "Ω" not in f.finding and "ohm" not in f.finding.lower()
def test_ai_flag_on_missing_superspeed_net_is_silence():
graph = _usb_graph()
layout = _stub_layout()
flags = [HfHypothesis(net="USB3_SSTX_P", issue_class="stub", why="SS pair")]
out = investigate_hf_hypotheses(graph, _stub_constraints(), layout, flags)
assert out == []
def test_run_pcb_checks_still_finds_stub_without_af_ai():
"""AF+AI is extra: the PCB check list must still emit PE-SI-007 alone."""
graph = _usb_graph()
layout = _stub_layout()
pcb = run_pcb_checks(graph, _stub_constraints(), layout)
assert any(f.rule_id == "PE-SI-007" for f in pcb)
hf = check_hf_lines(graph, _stub_constraints(), layout)
assert any(f.rule_id == "PE-SI-007" for f in hf)
def test_z0_with_stackup_does_not_invent_ohm_number():
graph = _usb_graph()
layout = _stub_layout()
layout.stackup = LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="core", er=4.5, height_mm=0.15)],
)
flags = [HfHypothesis(net="USB_D+", issue_class="z0", why="check Z0")]
out = investigate_hf_hypotheses(graph, {}, layout, flags)
for f in out:
assert "50 Ω" not in (f.finding or "")
assert "90 Ω" not in (f.finding or "")
View File
View File
@@ -130,3 +130,83 @@ def test_fetch_datasheet_persists_to_library(tmp_path, monkeypatch):
catalog = client.get("/api/library").json()
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
assert client.get("/api/library/datasheet/CH340E").status_code == 200
def test_library_import_pdf_without_project(tmp_path):
client = _client(tmp_path)
before = client.get("/api/projects").json()
resp = client.post(
"/api/library/datasheets",
data={"mpn": "CH340E"},
files={"file": ("ch.pdf", PDF, "application/pdf")},
)
assert resp.status_code == 200
body = resp.json()
assert body["mpn"] == "CH340E"
assert body["has_extraction"] is False
assert client.get("/api/projects").json() == before
catalog = client.get("/api/library").json()
assert any(d["mpn"] == "CH340E" for d in catalog["datasheets"])
pdf = client.get("/api/library/datasheet/CH340E")
assert pdf.status_code == 200
assert pdf.content.startswith(b"%PDF-")
def test_library_import_rejects_non_pdf(tmp_path):
client = _client(tmp_path)
resp = client.post(
"/api/library/datasheets",
data={"mpn": "CH340E"},
files={"file": ("notes.txt", b"not a pdf", "text/plain")},
)
assert resp.status_code == 400
assert client.get("/api/library").json()["datasheets"] == []
def test_library_import_rejects_blank_mpn(tmp_path):
client = _client(tmp_path)
resp = client.post(
"/api/library/datasheets",
data={"mpn": " "},
files={"file": ("ch.pdf", PDF, "application/pdf")},
)
assert resp.status_code in (400, 422)
def test_library_get_put_component(tmp_path):
client = _client(tmp_path)
from backend.main import app
storage = app.state.storage
storage.write_json(
"library/extracted/CH340E.json",
{
"mpn": "CH340E",
"component_subtype": "ic.interface.usb_uart_bridge",
"pintable": [
{"number": "1", "name": "VCC"},
{"number": "2", "name": "GND"},
],
},
)
got = client.get("/api/library/components/ic/CH340E")
assert got.status_code == 200
assert got.json()["component_subtype"] == "ic.interface.usb_uart_bridge"
payload = got.json()
payload["component_subtype"] = "ic.interface.usb_uart"
saved = client.put("/api/library/components/ic/CH340E", json=payload)
assert saved.status_code == 200
assert saved.json()["component_subtype"] == "ic.interface.usb_uart"
again = client.get("/api/library/components/ic/CH340E").json()
assert again["component_subtype"] == "ic.interface.usb_uart"
def test_library_put_rejects_empty_pintable(tmp_path):
client = _client(tmp_path)
resp = client.put(
"/api/library/components/ic/FAKEIC",
json={"mpn": "FAKEIC", "pintable": []},
)
assert resp.status_code == 400
assert client.get("/api/library/components/ic/FAKEIC").status_code == 404
+1
View File
@@ -7,3 +7,4 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SIMPLE_PROJECT = REPO_ROOT / "periscope" / "dependency" / "simple_project"
TAXONOMY = REPO_ROOT / "periscope" / "src" / "taxonomy"
FIXTURES = REPO_ROOT / "tests" / "fixtures"
View File
@@ -61,7 +61,7 @@ def test_annotate_does_not_overwrite_existing_cad_fields():
def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path):
from tests.test_kicad_parser import _resistor, _sch
from tests.schematic.test_kicad_parser import _resistor, _sch
child = tmp_path / "analog.kicad_sch"
child.write_text(_sch(
@@ -96,7 +96,7 @@ def test_run_pcb_checks_on_partial_layout_does_not_invent_tracks(tmp_path: Path)
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
from tests.test_pcb_via_not_pad import _qfn24_pcb
from tests.pcb.test_pcb_via_not_pad import _qfn24_pcb
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, board_vias=[(80.0, 80.0, "GND", 1)]))
u1 = layout.footprints["U1"]
@@ -172,7 +172,7 @@ def test_missing_footprint_is_pe_lay_002():
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
from backend.periscopex.models import LayoutSegment
segs = [
@@ -190,7 +190,7 @@ def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
def test_close_decoupling_has_no_plc_001():
from tests.test_placement_check import _xtal_cons, _x1_c9_layout
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
from backend.periscopex.models import LayoutSegment
segs = [
@@ -39,8 +39,9 @@ from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.resolve_passives import decode_value
from backend.services.passive_from_mpn import specs_from_mpn
from backend.services.passive_from_value import specs_from_bom_value
from tests.paths import FIXTURES
_DRC = Path(__file__).parent / "fixtures" / "HubAudio-DRC.rpt"
_DRC = FIXTURES / "HubAudio-DRC.rpt"
_HUB_PCB = Path(
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
)
@@ -348,7 +349,7 @@ def test_thinking_reasoning_content_echo_still_present():
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
Path(__file__).resolve().parents[2]
/ "periscope" / "src" / "backend" / "services" / "llm" / "deepseek_provider.py"
)
text = src.read_text(encoding="utf-8")
@@ -15,7 +15,6 @@ from backend.periscopex.functional_groups import (
from backend.periscopex.models import DesignGraph
ROOT = Path(__file__).resolve().parents[1]
SIMPLE = SIMPLE_PROJECT
View File
@@ -1,10 +1,10 @@
"""HubAudio KiCad BOM: PNM column, grouped refs, no invented U13…U39 MPNs."""
from pathlib import Path
from tests.paths import FIXTURES
from backend.periscopex.parsers import ic_mpn_skip_reason, parse_bom
FIXTURE = Path(__file__).parent / "fixtures" / "hubaudio_kicad_bom.csv"
FIXTURE = FIXTURES / "hubaudio_kicad_bom.csv"
SKIPPED = ("U13", "U15", "U25", "U28", "U34", "U35", "U36", "U39")
+2
View File
@@ -8,6 +8,7 @@ import backend.routers.admin as admin
import backend.routers.contact as contact
import backend.routers.deps as deps
import backend.routers.feedback as feedback
import backend.routers.library as library
import backend.routers.pipeline as pipeline
import backend.routers.projects as projects
import backend.routers.reports as reports
@@ -27,6 +28,7 @@ def test_inherited_routers_are_src():
(contact, "contact.py"),
(deps, "deps.py"),
(feedback, "feedback.py"),
(library, "library.py"),
(pipeline, "pipeline.py"),
(projects, "projects.py"),
(reports, "reports.py"),
@@ -8,6 +8,7 @@ import backend.periscopex.utils as utils
import backend.services.admin_settings as admin_settings
import backend.services.api_logs as api_logs
import backend.services.datasheet_store as datasheet_store
import backend.services.library as library
import backend.services.llm.factory as factory
import backend.services.storage as storage
from backend.periscopex.utils import natural_sort_key, safe_mpn
@@ -34,6 +35,7 @@ def test_core_service_modules_are_src():
(storage, "storage.py"),
(api_logs, "api_logs.py"),
(datasheet_store, "datasheet_store.py"),
(library, "library.py"),
(admin_settings, "admin_settings.py"),
(utils, "utils.py"),
):