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:
@@ -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 "")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user