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
+255
View File
@@ -0,0 +1,255 @@
"""Antenna RF verify + design recipe — no invented EM."""
from __future__ import annotations
from backend.periscopex.antenna_rf import build_antenna_report, build_design_recipe
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentType,
DesignGraph,
InductorSpecs,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutStackup,
LayoutZone,
Net,
NetType,
PinConnection,
)
def _graph_with_pi_match():
components = {
"U1": Component(
reference="U1", value="RFIC", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.rf.wifi_module",
pins={"1": "RF_ANT", "2": "GND"},
),
"L1": Component(
reference="L1", value="2.2n", footprint="",
component_type=ComponentType.INDUCTOR,
pins={"1": "RF_ANT", "2": "ANT_MID"},
specs=InductorSpecs(value_henries=2.2e-9, value_formatted="2.2nH"),
),
"C1": Component(
reference="C1", value="1p", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "RF_ANT", "2": "GND"},
specs=CapacitorSpecs(value_farads=1e-12, value_formatted="1pF"),
),
"C2": Component(
reference="C2", value="1p", footprint="",
component_type=ComponentType.CAPACITOR,
pins={"1": "ANT_MID", "2": "GND"},
specs=CapacitorSpecs(value_farads=1e-12, value_formatted="1pF"),
),
"ANT1": Component(
reference="ANT1", value="PCB_ANT", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "ANT_MID", "2": "GND"},
),
}
nets = {
"RF_ANT": Net(
name="RF_ANT", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="L1", pin_number="1"),
PinConnection(component_ref="C1", pin_number="1"),
],
),
"ANT_MID": Net(
name="ANT_MID", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="L1", pin_number="2"),
PinConnection(component_ref="C2", pin_number="1"),
PinConnection(component_ref="ANT1", pin_number="1"),
],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="C1", pin_number="2"),
PinConnection(component_ref="C2", pin_number="2"),
PinConnection(component_ref="ANT1", pin_number="2"),
],
),
}
return DesignGraph(components=components, nets=nets)
def test_verify_finds_matching_path_to_ant_footprint():
report = build_antenna_report(_graph_with_pi_match())
assert report.verify
row = report.verify[0]
assert row.ic_ref == "U1"
assert row.topology in ("pi", "LC", "series_L", "unknown")
assert "L1" in row.parts
assert row.status == "ok"
assert row.marker_ref == "ANT1"
def test_verify_missing_matching_is_warning():
components = {
"U1": Component(
reference="U1", value="RFIC", footprint="",
component_type=ComponentType.IC,
component_subtype="ic.rf.transceiver",
pins={"1": "RF_OUT", "2": "GND"},
),
}
nets = {
"RF_OUT": Net(
name="RF_OUT", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
}
report = build_antenna_report(DesignGraph(components=components, nets=nets))
assert report.verify
assert report.verify[0].topology == "missing"
assert report.verify[0].status == "warning"
def test_design_recipe_needs_marker_without_ant():
g = DesignGraph(components={}, nets={})
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="FR4", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
recipe = build_design_recipe(g, layout, f0_mhz=2440.0)
assert recipe.status == "need_marker"
def test_design_recipe_ready_with_ant_and_stackup():
g = DesignGraph(
components={
"ANT1": Component(
reference="ANT1", value="feed", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "ANT_FEED"},
),
},
nets={
"ANT_FEED": Net(
name="ANT_FEED", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="ANT1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={
"ANT1": LayoutFootprint(
reference="ANT1", x=10.0, y=20.0, layer="F.Cu",
pads=[LayoutPad(number="1", x=10.0, y=20.0, net="ANT_FEED")],
),
},
nets={"ANT_FEED": 1, "antenna": 2},
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="FR4", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
zones=[
LayoutZone(
net="antenna",
layer="F.Cu",
outlines=[[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)]],
),
],
)
recipe = build_design_recipe(g, layout, f0_mhz=2440.0, target_z_ohm=50.0)
assert recipe.status == "ready"
assert recipe.feed_line is not None
assert recipe.feed_line.w_mm is not None and recipe.feed_line.w_mm > 0
assert recipe.radiator is not None
assert recipe.radiator.length_mm_suggest is not None
assert recipe.zone is not None
assert recipe.zone.bbox_mm is not None
assert recipe.geometry is not None
assert recipe.geometry.template == "ifa"
assert recipe.geometry.fit in ("ok", "scaled")
assert len(recipe.geometry.segments) >= 2
assert recipe.geometry.svg and "<svg" in recipe.geometry.svg
assert recipe.geometry.kicad_mod and '(footprint "' in recipe.geometry.kicad_mod
assert "fp_line" in recipe.geometry.kicad_mod
def test_geometry_templates_produce_export():
from backend.periscopex.antenna_geometry import build_geometry
for tmpl in ("ifa", "meander", "stub"):
geo = build_geometry(tmpl, f0_mhz=2440.0, w_mm=0.4, er=4.5)
assert geo.fit == "ok"
assert geo.total_length_mm is not None and geo.total_length_mm > 10
assert geo.length_ideal_mm is not None
assert geo.total_length_mm >= geo.length_ideal_mm * 0.9
assert geo.svg and "path" in geo.svg
assert geo.kicad_mod and '(pad "1"' in geo.kicad_mod
assert len(geo.segments) >= 1
def test_geometry_overflow_tiny_zone():
from backend.periscopex.antenna_geometry import build_geometry
geo = build_geometry(
"ifa",
f0_mhz=2440.0,
w_mm=0.4,
er=4.5,
zone_bbox_mm=(0.0, 0.0, 2.0, 1.0),
feed_xy=(0.0, 0.0),
)
assert geo.fit == "overflow"
assert not geo.segments
def test_design_recipe_meander_template():
g = DesignGraph(
components={
"ANT1": Component(
reference="ANT1", value="feed", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "ANT_FEED"},
),
},
nets={
"ANT_FEED": Net(
name="ANT_FEED", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="ANT1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={
"ANT1": LayoutFootprint(
reference="ANT1", x=0.0, y=0.0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0.0, y=0.0, net="ANT_FEED")],
),
},
nets={"ANT_FEED": 1},
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="FR4", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
recipe = build_design_recipe(
g, layout, f0_mhz=2440.0, template="meander",
)
assert recipe.status == "ready"
assert recipe.geometry is not None
assert recipe.geometry.template == "meander"
assert recipe.geometry.fit == "ok"
assert recipe.geometry.kicad_mod is not None
+107
View File
@@ -0,0 +1,107 @@
"""E2 cad-bridge JSON and KiCad plugin focus helpers."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.cad_bridge import annotate_findings_cad, build_cad_bridge
from backend.periscopex.models import CadIndexEntry, Finding, ValidationReport
from backend.periscopex.parsers_kicad import kicad_part_fields, parse_kicad
from plugins.kicad.focus import find_bridge_file, focus_target, load_bridge
def _f(**kwargs) -> Finding:
defaults = dict(designator="U3", finding="mux", status="WARNING")
defaults.update(kwargs)
return Finding(**defaults)
def test_bridge_exports_version_and_strips_pin_prefix():
report = ValidationReport(
project="p", timestamp="t",
findings=[_f(
finding_id="U3-001", rule_id="PE-MUX-001",
pins=["U3.12"], cad_sheet="power.kicad_sch",
cad_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
net="UART5_TX",
)],
summary={"total": 1},
)
payload = build_cad_bridge(report, "proj-1", url_base="https://app/report")
assert payload["version"] == 1
assert payload["project_id"] == "proj-1"
row = payload["findings"][0]
assert row["pins"] == ["12"]
assert row["sheet"] == "power.kicad_sch"
assert row["uuid"].startswith("aaaa")
assert row["severity"] == "warning"
assert "finding=U3-001" in row["url"]
assert row["target"] == "sch"
def test_missing_uuid_stays_empty_and_pcb_rule_targets_board():
report = ValidationReport(
project="p", timestamp="t",
findings=[_f(rule_id="PE-PLC-001", pins=["1"], status="ERROR")],
summary={"total": 1},
)
row = build_cad_bridge(report, "x")["findings"][0]
assert row["uuid"] == ""
assert row["sheet"] == ""
assert row["target"] == "pcb"
assert row["severity"] == "error"
def test_annotate_does_not_overwrite_existing_cad_fields():
idx = {"U3": CadIndexEntry(uuid="from-index", sheet="child.kicad_sch")}
f = _f(cad_uuid="already", cad_sheet=None)
annotate_findings_cad([f], idx)
assert f.cad_uuid == "already"
assert f.cad_sheet == "child.kicad_sch"
def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path):
from tests.schematic.test_kicad_parser import _resistor, _sch
child = tmp_path / "analog.kicad_sch"
child.write_text(_sch(
_resistor("U3", "MCU"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
""",
))
root = tmp_path / "root.kicad_sch"
root.write_text(_sch(
_resistor("R1", "10k"),
"""
(global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
(sheet
(at 50 0)
(size 20 20)
(property "Sheetname" "Analog" (at 50 0 0) (effects (font (size 1.27 1.27))))
(property "Sheetfile" "analog.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27))))
)
""",
))
parse_kicad(root)
fields = kicad_part_fields(root)
assert fields["U3"]["cad_sheet"] == "analog.kicad_sch"
assert fields["R1"]["cad_sheet"] == "root.kicad_sch"
assert fields["U3"].get("cad_uuid")
assert fields["U3"]["cad_uuid"] != fields["R1"]["cad_uuid"]
def test_plugin_finds_bridge_and_pcb_target(tmp_path: Path):
(tmp_path / "periscope-findings.json").write_text(
'{"version":1,"project_id":"p","findings":[]}\n'
)
nested = tmp_path / "board"
nested.mkdir()
found = find_bridge_file(nested / "x.kicad_pcb")
assert found == tmp_path / "periscope-findings.json"
assert load_bridge(found)["version"] == 1
t = focus_target({"ref": "U1", "rule_id": "PE-PLC-001", "uuid": "x", "sheet": ""})
assert t["kind"] == "pcb" and t["ref"] == "U1"
missing = tmp_path / "nowhere"
missing.mkdir()
assert find_bridge_file(missing, max_up=0) is None
+495
View File
@@ -0,0 +1,495 @@
"""Fase B slices: hierarchy, derating stress, timing, PI, ESD/return."""
from __future__ import annotations
from backend.periscopex.esd_return_check import check_esd, check_return_path
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.hierarchy import build_hierarchy, check_hierarchy
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InternalFeatures,
LayoutFootprint,
LayoutGraph,
LayoutSegment,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
SimpleComponentSpecs,
)
from backend.periscopex.pcb_checks import check_pcb_derating
from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.timing_check import check_timing
def test_hierarchy_component_pin_net_block():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VIN", "2": "+3V3", "3": ""},
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"+3V3": Net(
name="+3V3", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
hier = build_hierarchy(graph)
assert hier.components[0].ref == "U1"
assert {p.number: p.net for p in hier.components[0].pins}["1"] == "VIN"
assert any(b.kind == "rail" for b in hier.blocks)
dangling = check_hierarchy(graph)
assert dangling and dangling[0].rule_id == "PE-HIER-001"
assert dangling[0].status == "INFO"
assert dangling[0].facts
def test_derating_pass_margin_risk():
def cap(ref, rated, net="3V3"):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.CAPACITOR,
component_subtype="passive.capacitor.ceramic",
mpn=ref,
pins={"1": net, "2": "GND"},
specs=CapacitorSpecs(
value_farads=1e-6, value_formatted="1uF",
voltage_rating_v=f"{rated}V", dielectric="X7R",
),
)
g = DesignGraph(
components={
"C1": cap("C1", 16),
"C2": cap("C2", 4.0),
"C3": cap("C3", 3.0),
},
nets={
"3V3": Net(
name="3V3", net_type=NetType.POWER, voltage=3.3,
pins=[
PinConnection(component_ref="C1", pin_number="1"),
PinConnection(component_ref="C2", pin_number="1"),
PinConnection(component_ref="C3", pin_number="1"),
],
),
"GND": Net(name="GND", net_type=NetType.GROUND, voltage=0.0, pins=[]),
},
)
from backend.periscopex.derating import build_derating_table
by = {r["designator"]: r["stress"] for r in build_derating_table(g)}
assert by["C1"] == "PASS"
assert by["C2"] == "MARGIN"
assert by["C3"] == "RISK"
findings = check_pcb_derating(g)
ids = {f.rule_id for f in findings}
assert "PE-DRT-001" in ids
assert "PE-DRT-002" in ids
assert "PE-DRT-003" in ids
exceed = next(f for f in findings if f.rule_id == "PE-DRT-001")
complete_finding(exceed)
assert exceed.status == "ERROR"
assert exceed.finding_class == "RULE"
margin = next(f for f in findings if f.rule_id == "PE-DRT-002")
complete_finding(margin)
assert margin.status == "WARNING"
assert margin.finding_class == "RISK"
def test_timing_skips_without_numbers():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "NRST"},
),
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "NRST", "2": "+3V3"},
),
},
nets={"NRST": Net(name="NRST", net_type=NetType.SIGNAL, pins=[])},
)
cons = ComponentConstraints(
mpn="MCU",
pintable=[Pin(number="1", name="NRST")],
absolute_maximum_ratings=[],
rules=[],
)
assert check_timing(graph, {"MCU": cons}) == []
def test_timing_reset_rc_vs_t_reset():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "NRST"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"t_reset_min_s": 0.01},
),
),
"R1": Component(
reference="R1", value="1k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "NRST", "2": "+3V3"},
specs=ResistorSpecs(value_ohms=1000, value_formatted="1k"),
),
"C1": Component(
reference="C1", value="100n", footprint="",
component_type=ComponentType.CAPACITOR, mpn="",
pins={"1": "NRST", "2": "GND"},
specs=CapacitorSpecs(value_farads=100e-9, value_formatted="100n"),
),
},
nets={
"NRST": Net(
name="NRST", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="R1", pin_number="1"),
PinConnection(component_ref="C1", pin_number="1"),
],
),
"+3V3": Net(name="+3V3", net_type=NetType.POWER, voltage=3.3, pins=[]),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
},
)
cons = ComponentConstraints(
mpn="MCU",
pintable=[Pin(number="1", name="NRST")],
absolute_maximum_ratings=[],
rules=[],
)
findings = check_timing(graph, {"MCU": cons})
assert findings and findings[0].rule_id == "PE-TIM-001"
complete_finding(findings[0])
assert findings[0].status == "ERROR"
assert findings[0].finding_class == "RULE"
def test_pi_missing_local_is_risk_not_error():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VIN"},
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
cons = ComponentConstraints(
mpn="LDO",
pintable=[Pin(number="1", name="VIN")],
absolute_maximum_ratings=[],
rules=[],
)
findings = check_power_integrity(graph, {"LDO": cons})
assert findings and findings[0].rule_id == "PE-PI-001"
complete_finding(findings[0])
assert findings[0].status == "WARNING"
assert findings[0].finding_class == "RISK"
def test_esd_without_part_is_review_not_error():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={"1": "USB_DP"},
),
"U2": Component(
reference="U2", value="UART", footprint="",
component_type=ComponentType.IC, mpn="CH",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="J1", pin_number="1"),
PinConnection(component_ref="U2", pin_number="1"),
],
),
},
)
cons = ComponentConstraints(
mpn="CH",
pintable=[Pin(number="1", name="UD+")],
absolute_maximum_ratings=[],
rules=[],
internal_features=InternalFeatures(esd_clamp_pins=["UD+"]),
)
findings = check_esd(graph, {"CH": cons})
assert findings and findings[0].rule_id == "PE-ESD-001"
complete_finding(findings[0])
assert findings[0].finding_class == "REVIEW"
assert findings[0].status != "ERROR"
def test_return_path_hs_is_review():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, layer="F.Cu")},
segments=[
LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"),
],
zones=[
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]),
],
vias=[],
)
findings = check_return_path(graph, layout)
assert findings and findings[0].rule_id == "PE-RET-001"
complete_finding(findings[0])
assert findings[0].finding_class == "REVIEW"
assert findings[0].status != "ERROR"
layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)]
assert check_return_path(graph, layout) == []
def test_llm_error_review_clamped_in_complete_finding():
f = Finding(
designator="U1",
finding="no vias",
why="PowerPAD",
status="ERROR",
source="pcb_review",
facts="0 vias",
requirement="must have vias",
source_quote="Use thermal vias in the exposed pad.",
evidence_status="SUFFICIENT",
)
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.status == "WARNING"
def test_esd_skips_gpio_nc_unconnected_and_onboard_power():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="HDR", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={
"1": "unconnected-J1-Pad1",
"2": "NC",
"3": "VSYS",
"4": "GND",
"5": "3V3",
"6": "USB_DP",
},
),
"U1": Component(
reference="U1", value="MCU", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={
"1": "GPIO9",
"2": "LED_SDATA",
"3": "VSYS",
"4": "GND",
"5": "3V3",
"6": "USB_DP",
},
),
},
nets={},
)
findings = check_esd(graph, {})
nets = {f.net for f in findings}
assert nets == {"USB_DP"}
assert "unused" not in (findings[0].recommendation or "").lower()
assert len(findings) == 1
def test_esd_one_finding_per_connector_ic_path():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={"1": "USB_DP"},
),
"U2": Component(
reference="U2", value="A", footprint="",
component_type=ComponentType.IC, mpn="A",
pins={"1": "USB_DP"},
),
"U3": Component(
reference="U3", value="B", footprint="",
component_type=ComponentType.IC, mpn="B",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="J1", pin_number="1"),
PinConnection(component_ref="U2", pin_number="1"),
PinConnection(component_ref="U3", pin_number="1"),
],
),
},
)
findings = check_esd(graph, {})
assert len(findings) == 2
assert {f.designator for f in findings} == {"U2", "U3"}
def test_esd_skips_when_protection_part_on_net():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={"1": "USB_DP"},
),
"U2": Component(
reference="U2", value="UART", footprint="",
component_type=ComponentType.IC, mpn="CH",
pins={"1": "USB_DP"},
),
"D1": Component(
reference="D1", value="USBLC6 ESD", footprint="",
component_type=ComponentType.DISCRETE, mpn="",
pins={"1": "USB_DP"},
),
},
nets={},
)
assert check_esd(graph, {}) == []
def test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local():
cons = ComponentConstraints(
mpn="LDO",
pintable=[Pin(number="1", name="VIN")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VBUS"},
),
"C68": Component(
reference="C68", value="10u", footprint="",
component_type=ComponentType.CAPACITOR, mpn="",
pins={"1": "/VBUS", "2": "GND"},
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10u"),
),
},
nets={
"VBUS": Net(
name="VBUS", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"/VBUS": Net(
name="/VBUS", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="C68", pin_number="1")],
),
},
)
cons.pintable = [Pin(number="1", name="VBUS")]
findings = check_power_integrity(graph, {"LDO": cons})
assert [f.rule_id for f in findings if f.rule_id == "PE-PI-001"] == []
def test_pi_bulk_only_does_not_claim_no_local():
cons = ComponentConstraints(
mpn="LDO",
pintable=[Pin(number="1", name="VIN")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VSYS"},
),
"C71": Component(
reference="C71", value="10u", footprint="",
component_type=ComponentType.CAPACITOR, mpn="",
pins={"1": "VSYS", "2": "GND"},
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10u"),
),
},
nets={
"VSYS": Net(
name="VSYS", net_type=NetType.POWER, voltage=5.0,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="C71", pin_number="1"),
],
),
},
)
findings = check_power_integrity(graph, {"LDO": cons})
assert [f.rule_id for f in findings if f.rule_id == "PE-PI-001"] == []
def test_sort_findings_error_then_class():
from backend.periscopex.finding_engine import sort_findings
items = [
Finding(designator="U1", finding="info review", why="x", status="INFO", finding_class="REVIEW"),
Finding(designator="U1", finding="warn risk", why="x", status="WARNING", finding_class="RISK"),
Finding(designator="U1", finding="err rule", why="x", status="ERROR", finding_class="RULE"),
Finding(designator="U1", finding="warn review", why="x", status="WARNING", finding_class="REVIEW"),
]
sort_findings(items)
assert [f.finding for f in items] == [
"err rule",
"warn risk",
"warn review",
"info review",
]
+93
View File
@@ -0,0 +1,93 @@
"""HF coverage INFO when bulk C exists without a 100 nF-class ceramic."""
from backend.periscopex.hf_coverage_check import check_hf_decoupling_coverage
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
)
def _graph(components, nets):
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=components, nets=net_objs)
def _ic():
return Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="UTEST",
pins={"1": "3V3", "2": "GND"},
)
def _cmap():
return {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=1, name="VDD"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
)
}
def _cap(ref, farads, net="3V3"):
return Component(
reference=ref, value="", footprint="C_0603",
component_type=ComponentType.CAPACITOR, mpn=ref,
pins={"1": net, "2": "GND"},
specs=CapacitorSpecs(value_farads=farads, value_formatted="x"),
)
def test_bulk_only_is_info_ps_esr_001():
g = _graph(
{"U1": _ic(), "C1": _cap("C1", 10e-6)},
{
"3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
findings = check_hf_decoupling_coverage(g, _cmap())
assert len(findings) == 1
assert findings[0].rule_id == "PE-ESR-001"
assert findings[0].status == "INFO"
def test_bulk_plus_100n_is_silent():
g = _graph(
{"U1": _ic(), "C1": _cap("C1", 10e-6), "C2": _cap("C2", 100e-9)},
{
"3V3": (NetType.POWER, [("U1", "1"), ("C1", "1"), ("C2", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2"), ("C2", "2")]),
},
)
assert check_hf_decoupling_coverage(g, _cmap()) == []
def test_unknown_cap_value_is_not_guessed():
c = Component(
reference="C1", value="", footprint="",
component_type=ComponentType.CAPACITOR, mpn="C1",
pins={"1": "3V3", "2": "GND"},
)
g = _graph(
{"U1": _ic(), "C1": c},
{
"3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]),
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
},
)
assert check_hf_decoupling_coverage(g, _cmap()) == []
+398
View File
@@ -0,0 +1,398 @@
"""Phase A: HF pair integrity + CPU/FPGA/DDR class — only if present.
Via ≠ pad ≠ track ≠ zone. No invented Z/I/mm. Skip when evidence is missing.
"""
from __future__ import annotations
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.hf_bus_class import check_memory_fpga_classes
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.si_check import bus_class, check_si, skip_si_net
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY", subtype: str | None = None) -> Component:
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn,
component_subtype=subtype, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str], ntype: NetType = NetType.SIGNAL) -> Net:
return Net(
name=name, net_type=ntype,
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_with_pads(ref: str, pads: list[LayoutPad], x: float = 0.0, y: float = 0.0) -> LayoutFootprint:
return LayoutFootprint(reference=ref, footprint="P", x=x, y=y, pads=pads)
def test_bus_class_mipi_hf_clk_not_xtal():
assert bus_class("MIPI_D0_P") == "mipi"
assert bus_class("CSI_CLK_N") == "mipi"
assert bus_class("DSI_D1_P") == "mipi"
assert bus_class("GTX_CLK") == "hf_clk"
assert bus_class("PCIE_REFCLK") == "pcie"
assert bus_class("CLK_P") == "hf_clk"
assert bus_class("XTAL_IN") is None
assert bus_class("OSCIN") is None
assert bus_class("HFXIN") is None
assert skip_si_net("USB_CC1")
assert bus_class("USB_D+") == "usb2"
def test_no_layout_skips_hf_geometry():
assert check_hf_lines(_usb_graph(), {}, None) == []
assert check_hf_lines(_usb_graph(), {}, LayoutGraph()) == []
def test_pad_to_pad_usb_has_no_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [
LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+"),
LayoutPad(number="2", x=0.0, y=0.4, net="USB_D-"),
]),
"J2": _fp_with_pads("J2", [
LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+"),
LayoutPad(number="A7", x=10.0, y=0.4, 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=(0, 0.4), end=(10, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
def test_via_is_not_a_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(5, 0), end=(10, 0), width=0.2, layer="B.Cu", net="USB_D+"),
],
vias=[LayoutVia(x=5.0, y=0.0, net="USB_D+", drill=0.3)],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
assert all(not isinstance(v, LayoutPad) for v in layout.vias)
def test_zone_is_not_a_track_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
},
segments=[
LayoutSegment(start=(0, 0), end=(4, 0), width=0.2, layer="F.Cu", net="USB_D+"),
],
zones=[LayoutZone(
net="USB_D+", layer="F.Cu",
outlines=[[(3.5, -1.0), (8.0, -1.0), (8.0, 1.0), (3.5, 1.0)]],
)],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
def test_dangling_track_stub_vs_datasheet_mm_is_fail():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("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+"),
],
)
cons = 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,
}],
)
findings = check_hf_lines(_usb_graph(), {"PHY": cons}, layout)
stubs = [f for f in findings if f.rule_id == "PE-SI-007"]
assert len(stubs) == 1
assert stubs[0].finding.startswith("FAIL:")
assert stubs[0].facts.startswith("stub_mm=")
assert "1" in stubs[0].requirement
complete_finding(stubs[0])
assert stubs[0].status == "WARNING"
assert stubs[0].finding_class != "RULE" or stubs[0].provenance != "MANDATORY"
assert stubs[0].evidence_status == "SUFFICIENT"
def test_measured_stub_without_datasheet_mm_is_insufficient():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("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=(5, 0), end=(5, 4), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
stubs = [f for f in findings if f.rule_id == "PE-SI-007"]
assert len(stubs) == 1
assert stubs[0].status == "INFO"
assert stubs[0].evidence_status == "INSUFFICIENT"
assert "90" not in (stubs[0].requirement or "")
assert "FAIL" not in stubs[0].finding
def test_split_under_pair_needs_stackup_and_zone():
segs = [
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"),
]
bare = LayoutGraph(segments=segs)
assert [f for f in check_hf_lines(_usb_graph(), {}, bare) if f.rule_id == "PE-SI-011"] == []
stack = LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="dielectric_1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
)
no_zone = LayoutGraph(segments=segs, stackup=stack)
assert [f for f in check_hf_lines(_usb_graph(), {}, no_zone) if f.rule_id == "PE-SI-011"] == []
covered = LayoutGraph(
segments=segs, stackup=stack,
zones=[LayoutZone(
net="GND", layer="B.Cu",
outlines=[[(-1, -2), (21, -2), (21, 2), (-1, 2)]],
)],
)
assert [f for f in check_hf_lines(_usb_graph(), {}, covered) if f.rule_id == "PE-SI-011"] == []
split = LayoutGraph(
segments=segs, stackup=stack,
zones=[LayoutZone(
net="GND", layer="B.Cu",
outlines=[[(0, -2), (5, -2), (5, 2), (0, 2)]],
)],
)
hits = [f for f in check_hf_lines(_usb_graph(), {}, split) if f.rule_id == "PE-SI-011"]
assert len(hits) == 1
assert hits[0].status == "WARNING"
assert hits[0].finding_class == "REVIEW"
assert hits[0].facts
assert hits[0].requirement
assert hits[0].inference
complete_finding(hits[0])
assert hits[0].status != "ERROR"
def test_bom_termination_is_fact_missing_is_skip():
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
g = _usb_graph()
assert [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"] == []
g.components["R22"] = Component(
reference="R22", value="22R", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "USB_D+", "2": "USB_D+_PHY"},
specs=ResistorSpecs(value_ohms=22.0, value_formatted="22"),
)
g.nets["USB_D+_PHY"] = _net("USB_D+_PHY", ("R22", "2"))
g.nets["USB_D+"].pins.append(PinConnection(component_ref="R22", pin_number="1"))
found = [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"]
assert len(found) == 1
assert found[0].status == "INFO"
assert found[0].evidence_status == "SUFFICIENT"
assert "R22" in found[0].facts
assert "22" in found[0].facts
assert "50 Ω" not in found[0].requirement
def test_no_ddr_cpu_fpga_on_usb_only_graph():
findings = check_memory_fpga_classes(_usb_graph())
assert findings == []
ids = {f.rule_id for f in run_pcb_checks(_usb_graph(), {}, None)}
assert not any(str(i).startswith(("PE-DDR", "PE-CPU", "PE-FPGA")) for i in ids)
def test_ddr_present_class_and_nets_skip_invented_vtt():
pins = {str(i + 1): f"DDR3_DQ{i}" for i in range(8)}
pins["20"] = "DDR3_DQS0_P"
pins["21"] = "DDR3_DQS0_N"
pins["22"] = "DDR3_CK_P"
pins["23"] = "DDR3_CK_N"
pins["24"] = "DDR3_A0"
comps = {"U10": _ic("U10", pins, mpn="MT41K256M16TW", subtype="ic.memory.ddr")}
nets = {n: _net(n, ("U10", p)) for p, n in pins.items()}
g = DesignGraph(components=comps, nets=nets)
findings = check_memory_fpga_classes(g)
for f in findings:
complete_finding(f)
assert [f.rule_id for f in findings if f.rule_id == "PE-DDR-001"]
assert [f for f in findings if f.rule_id == "PE-DDR-001"][0].status == "INFO"
assert [f for f in findings if f.rule_id == "PE-DDR-002"][0].status == "INFO"
assert not any(f.rule_id == "PE-DDR-003" for f in findings)
assert not any((f.rule_id or "").startswith("PE-CPU") for f in findings)
assert not any((f.rule_id or "").startswith("PE-FPGA") for f in findings)
g.nets["DDR_VTT"] = _net("DDR_VTT", ("U10", "30"), ntype=NetType.POWER)
g.components["U10"].pins["30"] = "DDR_VTT"
with_vtt = check_memory_fpga_classes(g)
assert [f for f in with_vtt if f.rule_id == "PE-DDR-003"][0].status == "INFO"
def test_cpu_parallel_bus_only_when_data_addr_control_exist():
mcu_pins = {str(i + 1): f"MCU_D{i}" for i in range(8)}
for i in range(8):
mcu_pins[str(20 + i)] = f"MCU_A{i}"
mcu_pins["40"] = "MCU_NWE"
mcu_pins["41"] = "MCU_NOE"
mcu_pins["42"] = "MCU_NCS"
sram_pins = dict(mcu_pins)
comps = {
"U1": _ic("U1", mcu_pins, mpn="STM32F407", subtype="ic.mcu"),
"U2": _ic("U2", sram_pins, mpn="IS61WV51216", subtype="ic.memory.sram"),
}
nets = {}
for p, n in mcu_pins.items():
nets[n] = _net(n, ("U1", p), ("U2", p))
g = DesignGraph(components=comps, nets=nets)
findings = check_memory_fpga_classes(g)
ids = {f.rule_id for f in findings}
assert "PE-CPU-001" in ids
assert "PE-CPU-002" in ids
assert "PE-CPU-003" in ids
assert "PE-CPU-004" in ids
assert not any(str(i).startswith("PE-DDR") for i in ids)
assert not any(str(i).startswith("PE-FPGA") for i in ids)
gpio = DesignGraph(
components={"U1": _ic("U1", {"1": "GPIO0", "2": "GPIO1"}, mpn="MSPM0", subtype="ic.mcu")},
nets={
"GPIO0": _net("GPIO0", ("U1", "1")),
"GPIO1": _net("GPIO1", ("U1", "2")),
},
)
assert check_memory_fpga_classes(gpio) == []
def test_fpga_only_when_present_config_flash_optional():
fpga_pins = {"1": "IO_L1P", "2": "IO_L1N", "3": "VCCINT", "4": "VCCIO"}
g = DesignGraph(
components={"U5": _ic("U5", fpga_pins, mpn="XC7A35T", subtype="ic.fpga")},
nets={
"IO_L1P": _net("IO_L1P", ("U5", "1")),
"IO_L1N": _net("IO_L1N", ("U5", "2")),
"VCCINT": _net("VCCINT", ("U5", "3"), ntype=NetType.POWER),
"VCCIO": _net("VCCIO", ("U5", "4"), ntype=NetType.POWER),
},
)
findings = check_memory_fpga_classes(g)
ids = {f.rule_id for f in findings}
assert "PE-FPGA-001" in ids
assert "PE-FPGA-003" in ids
assert "PE-FPGA-002" not in ids
assert not any(str(i).startswith("PE-DDR") for i in ids)
assert not any(str(i).startswith("PE-CPU") for i in ids)
g.components["U6"] = _ic("U6", {"1": "FPGA_CS", "2": "FPGA_MOSI"}, mpn="W25Q64", subtype="ic.memory.flash")
g.nets["FPGA_CS"] = _net("FPGA_CS", ("U6", "1"), ("U5", "10"))
g.components["U5"].pins["10"] = "FPGA_CS"
with_flash = check_memory_fpga_classes(g)
assert any(f.rule_id == "PE-FPGA-002" for f in with_flash)
def test_rule_catalog_hf_ids():
for rid, domain in (
("PE-SI-007", "pcb"),
("PE-SI-011", "pcb"),
("PE-SI-012", "pcb"),
("PE-DDR-001", "shared"),
("PE-DDR-002", "shared"),
("PE-DDR-003", "shared"),
("PE-CPU-001", "shared"),
("PE-CPU-002", "shared"),
("PE-CPU-003", "shared"),
("PE-CPU-004", "shared"),
("PE-FPGA-001", "shared"),
("PE-FPGA-002", "shared"),
("PE-FPGA-003", "shared"),
):
rec = lookup_rule(rid)
assert rec is not None, rid
assert rec.domain == domain
def test_existing_si_usb_not_polluted_by_hf_geometry():
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
],
vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)],
)
findings = check_si(_usb_graph(), {}, layout, [
{
"net_name": "USB_D+", "partner_net_name": "USB_D-",
"z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0,
"length_mm": 10.0, "topologies": ["MICROSTRIP"],
},
{
"net_name": "USB_D-", "partner_net_name": "USB_D+",
"z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0,
"length_mm": 12.5, "topologies": ["MICROSTRIP"],
},
])
assert any(f.rule_id == "PE-SI-010" for f in findings)
assert all(f.rule_id != "PE-SI-002" for f in findings)
+167
View File
@@ -0,0 +1,167 @@
"""D1 impedance — ImpedenceFinder closed forms, no second formula set.
Favor: Periscope Z0 equals vendored ImpedenceFinder bit-for-bit; classic
3 mm / 1.6 mm FR4 is ~50 Ω; solve_width round-trips.
Against: h<=0 invents nothing; CPWG stays unimplemented; stripline t=0
raises; calculator emits no findings. OpenEMS is not imported.
"""
from __future__ import annotations
import pytest
from impedancefinder import zsolver as ifz
from backend.periscopex.impedance import (
GeometryError,
TraceGeometry,
coupled_diff_z,
cpw_z0,
export_kicad_dru,
microstrip_z0,
solve_width,
stackup_targets,
stripline_z0,
)
def test_microstrip_matches_impedancefinder_bit_for_bit():
geo = TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.30)
ours = microstrip_z0(geo)
theirs = ifz.microstrip_z0(0.30, 0.15, 4.3, 0.035)
assert ours == theirs
def test_classic_fr4_50ohm_rule_of_thumb():
z = microstrip_z0(TraceGeometry(h=1.6, er=4.5, t=0.035, w=3.0))
assert z == pytest.approx(50.0, rel=0.05)
def test_microstrip_zero_height_does_not_invent_z():
with pytest.raises(GeometryError):
microstrip_z0(TraceGeometry(h=0.0, er=4.5, t=0.035, w=0.35))
def test_stripline_matches_impedancefinder():
geo = TraceGeometry(h=0.5, er=4.4, t=0.035, w=0.15)
assert stripline_z0(geo) == ifz.stripline_z0(0.15, 0.5, 4.4, 0.035)
def test_stripline_zero_thickness_does_not_invent_z():
with pytest.raises(GeometryError):
stripline_z0(TraceGeometry(h=0.4, er=4.5, t=0.0, w=0.12))
def test_stripline_missing_width_is_invalid():
with pytest.raises(GeometryError):
stripline_z0(TraceGeometry(h=0.4, er=4.5, t=0.035, w=None))
def test_diff_matches_impedancefinder():
geo = TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.20)
_, _, zdiff = coupled_diff_z(geo)
assert zdiff == ifz.diff_microstrip_z0(0.20, 0.15, 0.20, 4.3, 0.035)
def test_wider_gap_raises_zdiff():
tight = coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.08))
loose = coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.40))
assert loose[2] > tight[2]
def test_coupled_diff_without_gap_is_invalid():
with pytest.raises(GeometryError):
coupled_diff_z(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=None))
def test_cpwg_is_not_invented():
with pytest.raises(GeometryError, match="not implemented"):
cpw_z0(TraceGeometry(h=0.15, er=4.3, t=0.035, w=0.20, s=0.15))
def test_solve_width_roundtrips_50_ohm_microstrip():
w = solve_width("microstrip", target_z=50.0, h=1.6, er=4.5, t=0.035)
z = microstrip_z0(TraceGeometry(h=1.6, er=4.5, t=0.035, w=w))
assert z == pytest.approx(50.0, rel=0.01)
assert w > 0
def test_solve_width_rejects_non_positive_target():
with pytest.raises(GeometryError):
solve_width("microstrip", target_z=0.0, h=1.6, er=4.5, t=0.035)
def test_stackup_suggests_50_90_100_without_findings():
out = stackup_targets(h=0.20, er=4.5, t=0.035, s=0.20)
assert out["microstrip_50"].z0 == pytest.approx(50.0, rel=0.02)
assert out["diff_90"].zdiff == pytest.approx(90.0, rel=0.02)
assert out["diff_100"].zdiff == pytest.approx(100.0, rel=0.02)
assert "finding" not in out
def test_stackup_rejects_non_positive_h():
with pytest.raises(GeometryError):
stackup_targets(h=0.0, er=4.5, t=0.035, s=0.2)
def test_kicad_dru_is_advice_not_a_finding():
dru = export_kicad_dru(stackup_targets(h=0.20, er=4.5, t=0.035, s=0.20))
assert "(rule PERISCOPE_50OHM" in dru
assert "PE-Z" not in dru
def _impedance_client():
from fastapi.testclient import TestClient
from backend.main import app
return TestClient(app)
def test_api_microstrip_equals_impedancefinder():
res = _impedance_client().post("/api/impedance", json={
"mode": "trace",
"kind": "microstrip",
"h": 1.6, "er": 4.5, "t": 0.035, "w": 3.0,
})
assert res.status_code == 200
body = res.json()
assert body["z0"] == ifz.microstrip_z0(3.0, 1.6, 4.5, 0.035)
assert "findings" not in body
def test_api_zero_height_is_400():
res = _impedance_client().post("/api/impedance", json={
"mode": "trace",
"kind": "microstrip",
"h": 0, "er": 4.5, "t": 0.035, "w": 0.35,
})
assert res.status_code == 400
def test_api_cpw_is_400_not_a_fake_number():
res = _impedance_client().post("/api/impedance", json={
"mode": "trace",
"kind": "cpw",
"h": 0.15, "er": 4.3, "t": 0.035, "w": 0.2, "s": 0.15,
})
assert res.status_code == 400
def test_openems_is_not_on_the_impedancefinder_package():
import impedancefinder
import pkgutil
names = {m.name for m in pkgutil.iter_modules(impedancefinder.__path__)}
assert "gerber2ems_export" not in names
assert "board_model" not in names
def test_api_stackup_returns_dru_not_findings():
res = _impedance_client().post("/api/impedance", json={
"mode": "stackup",
"h": 0.20, "er": 4.5, "t": 0.035, "s": 0.20,
})
assert res.status_code == 200
body = res.json()
assert body["targets"]["microstrip_50"]["z0"] == pytest.approx(50.0, rel=0.02)
assert "(rule PERISCOPE_50OHM" in body["kicad_dru"]
assert "findings" not in body
+194
View File
@@ -0,0 +1,194 @@
"""ImpedenceFinder analysis on specified nets — widths/stackup from the board.
Favor: named net Z0 matches zsolver on the same w/h/εr/t.
Against: empty net list; missing net; no stackup.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.periscopex.impedance import GeometryError
from backend.periscopex.impedance_traces import (
analyze_specified_nets,
analyze_where_needed,
)
from backend.periscopex.models import (
DesignGraph,
LayoutDielectric,
LayoutGraph,
LayoutSegment,
LayoutStackup,
LayoutZone,
Net,
NetType,
)
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
from backend.vendor_path import ensure_impedancefinder
ensure_impedancefinder()
from impedancefinder import zsolver
_H = 0.15
_ER = 4.3
_T = 0.035
_W = 0.2
_PITCH = 2.0
def _layout() -> LayoutGraph:
return LayoutGraph(
segments=[
LayoutSegment(
start=(0.0, 0.0), end=(10.0, 0.0),
width=_W, layer="F.Cu", net="SIG",
),
LayoutSegment(
start=(0.0, 2.0), end=(4.0, 2.0),
width=0.3, layer="F.Cu", net="OTHER",
),
],
stackup=LayoutStackup(
copper_layers=["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"],
dielectrics=[
LayoutDielectric(name="prepreg_top", er=_ER, height_mm=_H),
LayoutDielectric(name="core", er=4.4, height_mm=0.7),
LayoutDielectric(name="prepreg_bottom", er=4.3, height_mm=0.15),
],
copper_thickness_mm=_T,
),
zones=[
LayoutZone(
net="GND",
layer="In1.Cu",
outlines=[[(-5.0, -5.0), (50.0, -5.0), (50.0, 5.0), (-5.0, 5.0)]],
),
],
)
def test_specified_net_z0_matches_impedancefinder_zsolver():
rows = analyze_specified_nets(_layout(), ["SIG"], _PITCH)
assert len(rows) == 1
row = rows[0]
assert row["net_name"] == "SIG"
expect = zsolver.microstrip_z0(_W, _H, _ER, _T)
assert row["z0_avg_ohms"] == pytest.approx(expect, rel=1e-6)
assert row["z0_min_ohms"] == pytest.approx(expect, rel=1e-6)
def test_only_specified_nets_are_analyzed():
rows = analyze_specified_nets(_layout(), ["SIG"], _PITCH)
assert [r["net_name"] for r in rows] == ["SIG"]
def test_missing_net_is_error_not_invented_z0():
rows = analyze_specified_nets(_layout(), ["NO_SUCH_NET"], _PITCH)
assert rows[0]["error"] == "no segments on this net"
assert "z0_avg_ohms" not in rows[0] or rows[0].get("z0_avg_ohms") is None
def test_empty_net_list_is_silent():
assert analyze_specified_nets(_layout(), [" ", ""], _PITCH) == []
def test_no_stackup_raises():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="SIG")],
)
with pytest.raises(GeometryError, match="stackup"):
analyze_specified_nets(layout, ["SIG"], _PITCH)
_PCB_STACKUP = """(kicad_pcb (version 20240108) (generator pcbnew)
(net 0 "")
(net 1 "GND")
(net 2 "SIG")
(setup
(stackup
(layer "F.Cu" (type copper) (thickness 0.035))
(layer "dielectric 1" (type core) (thickness 0.15) (epsilon_r 4.3))
(layer "In1.Cu" (type copper) (thickness 0.035))
(layer "dielectric 2" (type core) (thickness 0.7) (epsilon_r 4.4))
(layer "In2.Cu" (type copper) (thickness 0.035))
(layer "dielectric 3" (type prepreg) (thickness 0.15) (epsilon_r 4.3))
(layer "B.Cu" (type copper) (thickness 0.035))
)
)
(segment (start 0 0) (end 10 0) (width 0.2) (layer "F.Cu") (net 2))
(zone (net 1) (net_name "GND") (layer "In1.Cu")
(filled_polygon (layer "In1.Cu")
(pts (xy -5 -5) (xy 50 -5) (xy 50 5) (xy -5 5))
)
)
)
"""
def test_parse_stackup_and_zone_then_analyze_sig(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB_STACKUP)
layout = parse_kicad_pcb(p)
assert layout.stackup is not None
assert layout.stackup.copper_layers[0] == "F.Cu"
assert layout.stackup.dielectrics[0].er == 4.3
assert layout.zones and layout.zones[0].net == "GND"
rows = analyze_specified_nets(layout, ["SIG"], _PITCH)
expect = zsolver.microstrip_z0(_W, _H, _ER, _T)
assert rows[0]["z0_avg_ohms"] == pytest.approx(expect, rel=1e-6)
def test_project_analysis_skips_power_and_runs_signal():
graph = DesignGraph(nets={
"SIG": Net(name="SIG", net_type=NetType.SIGNAL),
"OTHER": Net(name="OTHER", net_type=NetType.POWER),
})
report = analyze_where_needed(_layout(), graph, pitch_mm=_PITCH)
assert report["skipped"] is None
names = [r["net_name"] for r in report["nets"]]
assert names == ["SIG"]
expect = zsolver.microstrip_z0(_W, _H, _ER, _T)
assert report["nets"][0]["z0_avg_ohms"] == pytest.approx(expect, rel=1e-6)
def test_project_analysis_skips_without_stackup():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="SIG")],
)
report = analyze_where_needed(layout, None, pitch_mm=_PITCH)
assert report["nets"] == []
assert report["skipped"] == "no stackup"
def test_pipeline_writes_impedance_nets_for_signal(tmp_path: Path):
import json
from backend.services.pipeline import _write_impedance_nets
class Ws:
def local_path(self, rel: str) -> Path:
return tmp_path / rel
layout = _layout()
(tmp_path / "layout_graph.json").write_text(layout.model_dump_json())
graph = DesignGraph(nets={
"SIG": Net(name="SIG", net_type=NetType.SIGNAL),
"OTHER": Net(name="OTHER", net_type=NetType.POWER),
})
_write_impedance_nets(Ws(), graph)
data = json.loads((tmp_path / "impedance_nets.json").read_text())
assert [r["net_name"] for r in data["nets"]] == ["SIG"]
def test_get_impedance_nets_without_run_is_empty(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "z0"}).json()["id"]
body = client.get(f"/api/projects/{pid}/impedance/nets").json()
assert body["nets"] == []
assert body["skipped"] == "not run"
+126
View File
@@ -0,0 +1,126 @@
"""Partial PCB: footprints with few tracks still parse and run MODE=pcb checks."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
)
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.placement_check import check_placement
_PARTIAL = """(kicad_pcb (version 20240108) (generator pcbnew)
(net 0 "")
(net 1 "GND")
(net 2 "+3V3")
(net 3 "USB_DP")
(footprint "Package_SO:SOIC-8"
(layer "F.Cu")
(at 20 10 0)
(property "Reference" "U1" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at -2 1) (size 1 0.6) (layers "F.Cu") (net 2 "+3V3"))
(pad "2" smd rect (at -2 -1) (size 1 0.6) (layers "F.Cu") (net 1 "GND"))
(pad "3" smd rect (at 2 1) (size 1 0.6) (layers "F.Cu") (net 3 "USB_DP"))
)
(footprint "Capacitor_SMD:C_0603"
(layer "F.Cu")
(at 40 30 0)
(property "Reference" "C1" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at -0.5 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3"))
(pad "2" smd rect (at 0.5 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND"))
)
(segment (start 18 11) (end 19 11) (width 0.2) (layer "F.Cu") (net 2))
)
"""
def test_parse_footprints_with_few_tracks(tmp_path: Path):
p = tmp_path / "partial.kicad_pcb"
p.write_text(_PARTIAL)
g = parse_kicad_pcb(p)
assert "U1" in g.footprints and "C1" in g.footprints
assert len(g.segments) == 1
assert g.segments[0].net == "+3V3"
assert len(g.vias) == 0
def _partial_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": Component(
reference="U1", value="MCU", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "+3V3", "2": "GND", "3": "USB_DP"},
),
"C1": Component(
reference="C1", value="100n", footprint="",
component_type=ComponentType.CAPACITOR, mpn="C",
pins={"1": "+3V3", "2": "GND"},
),
"R9": Component(
reference="R9", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="R",
pins={"1": "NRESET"},
),
},
nets={
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"USB_DP": Net(name="USB_DP", net_type=NetType.SIGNAL, pins=[]),
"NRESET": Net(name="NRESET", net_type=NetType.SIGNAL, pins=[]),
},
)
def test_run_pcb_checks_on_partial_layout_does_not_invent_tracks(tmp_path: Path):
p = tmp_path / "partial.kicad_pcb"
p.write_text(_PARTIAL)
layout = parse_kicad_pcb(p)
graph = _partial_graph()
findings = run_pcb_checks(graph, {}, layout)
lay = [f for f in findings if f.rule_id == "PE-LAY-004"]
assert lay, findings
assert lay[0].evidence_status == "INSUFFICIENT"
assert lay[0].status == "INFO"
assert all(f.rule_id != "PE-PLC-001" for f in findings)
unplaced = [f for f in findings if f.rule_id == "PE-LAY-002"]
assert any(f.designator == "R9" for f in unplaced)
def test_partial_board_via_is_not_a_pad(tmp_path: Path):
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"]
assert len(u1.pads) == 24
assert len(layout.vias) == 1
via = layout.vias[0]
assert all(abs(p.x - via.x) > 0.01 or abs(p.y - via.y) > 0.01 for p in u1.pads)
def test_unplaced_ref_is_lay_002_not_invented_xy():
graph = _partial_graph()
from backend.periscopex.models import LayoutFootprint, LayoutGraph, LayoutPad
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert any(f.rule_id == "PE-LAY-002" and f.designator == "C1" for f in findings)
assert check_placement(graph, {}, layout) == [] or all(
f.evidence_status == "INSUFFICIENT" or f.rule_id != "PE-PLC-001"
for f in check_placement(graph, {}, layout)
)
+278
View File
@@ -0,0 +1,278 @@
"""KiCad PCB ingest — parse only, no SI/creepage checks.
Favor: footprint+pad net; named nets; segment on a net.
Against: .kicad_sch rejected; missing Reference skipped; empty board ok.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
_PCB = """(kicad_pcb (version 20240108) (generator pcbnew)
(net 0 "")
(net 1 "GND")
(net 2 "+3V3")
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 10 20 0)
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
(property "Value" "10k" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd roundrect (at -0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 2 "+3V3"))
(pad "2" smd roundrect (at 0.75 0) (size 0.8 0.9) (layers "F.Cu") (net 1 "GND"))
)
(footprint "Resistor_SMD:R_0603_1608Metric"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "#PWR01" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))
)
(segment (start 10 20) (end 12 20) (width 0.25) (layer "F.Cu") (net 1))
(via (at 11 20) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1))
)
"""
def test_parse_footprint_pads_and_nets(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert "GND" in g.nets and "+3V3" in g.nets
assert "R1" in g.footprints
r1 = g.footprints["R1"]
assert r1.x == 10 and r1.y == 20
by_num = {pad.number: pad for pad in r1.pads}
assert by_num["1"].net == "+3V3"
assert by_num["2"].net == "GND"
assert by_num["1"].x == pytest.approx(9.25)
assert by_num["2"].x == pytest.approx(10.75)
assert r1.courtyard == []
def test_parse_segment_and_via_resolve_net_name(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert len(g.segments) == 1
assert g.segments[0].net == "GND"
assert len(g.vias) == 1
assert g.vias[0].net == "GND"
def test_power_flag_footprint_is_skipped(tmp_path: Path):
p = tmp_path / "board.kicad_pcb"
p.write_text(_PCB)
g = parse_kicad_pcb(p)
assert "#PWR01" not in g.footprints
def test_kicad_sch_is_rejected_as_pcb(tmp_path: Path):
p = tmp_path / "sheet.kicad_sch"
p.write_text("(kicad_sch (version 20250114) (uuid \"1\"))\n")
with pytest.raises(ValueError, match="kicad_pcb"):
parse_kicad_pcb(p)
def test_parse_keepout_zone_polygon(tmp_path: Path):
p = tmp_path / "keepout.kicad_pcb"
p.write_text("""(kicad_pcb (version 20240108) (generator pcbnew)
(zone (net 0) (net_name "") (layer "F.Cu") (name "ANT_KEEPOUT")
(keepout (tracks not_allowed) (vias not_allowed) (copperpour not_allowed))
(polygon (pts (xy 0 0) (xy 10 0) (xy 10 8) (xy 0 8)))
)
)
""")
g = parse_kicad_pcb(p)
assert len(g.zones) == 1
z = g.zones[0]
assert z.keepout is True
assert z.name == "ANT_KEEPOUT"
assert len(z.outlines[0]) == 4
def test_empty_board_parses(tmp_path: Path):
p = tmp_path / "empty.kicad_pcb"
p.write_text("(kicad_pcb (version 20240108) (generator pcbnew))\n")
g = parse_kicad_pcb(p)
assert g.footprints == {}
assert g.segments == []
_PCB_V10 = """(kicad_pcb (version 20260206) (generator pcbnew)
(footprint "Package_TO_SOT_SMD:SOT-23-5"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "U3" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VSYS"))
(pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "GND"))
(pad "5" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL"))
)
(footprint "RF_Module:ESP"
(layer "F.Cu")
(at 10 0 0)
(property "Reference" "U5" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "GND"))
(pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL"))
(pad "3" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "/ESP32_EN"))
)
)
"""
def test_kicad10_pad_net_string_form(tmp_path: Path):
from backend.periscopex.parsers_kicad_pcb import nets_from_pcb
p = tmp_path / "v10.kicad_pcb"
p.write_text(_PCB_V10)
g = parse_kicad_pcb(p)
assert g.footprints["U3"].pads[1].net == "GND"
nets = nets_from_pcb(g)
assert ("U3", "2") in nets["GND"]
assert ("U3", "5") in nets["3V3_DIGITAL"]
assert ("U5", "3") in nets["ESP32_EN"] # leading / stripped
def test_build_graph_prefers_pcb_nets_over_sch(tmp_path: Path):
"""Board pad nets win when sch geometry would swap rails."""
from backend.periscopex.graph import build_graph
sch = tmp_path / "netlist.kicad_sch"
# Minimal sch: only needs to parse as kicad_sch with some parts.
sch.write_text("""(kicad_sch (version 20250114) (uuid "1")
(lib_symbols
(symbol "Device:R"
(pin passive (at 0 3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive (at 0 -3.81 90) (length 2.54)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol (lib_id "Device:R") (at 0 0 0) (unit 1) (uuid "a")
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1.27 1.27))))
(property "Value" "10k" (at 0 0 0) (effects (font (size 1.27 1.27))))
(pin "1" (uuid "p1")) (pin "2" (uuid "p2"))
)
(global_label "GND" (at 0 3.81 0) (uuid "b"))
)
""")
pcb = tmp_path / "pcb.kicad_pcb"
pcb.write_text(_PCB_V10)
bom = tmp_path / "bom.csv"
bom.write_text(
"Reference,Value,Footprint,Manufacturer Part Number\n"
"U3,AP2112,SOT23,\nU5,ESP32,,\nR1,10k,,\n"
)
g = build_graph(
sch, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod",
pcb_path=pcb,
)
assert g.components["U3"].pins["2"] == "GND"
assert g.components["U3"].pins["5"] == "3V3_DIGITAL"
assert g.components["U5"].pins["1"] == "GND"
assert g.components["U5"].pins["2"] == "3V3_DIGITAL"
def test_upload_pcb_sets_has_pcb(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "board"}).json()["id"]
resp = client.post(
f"/api/projects/{pid}/upload/pcb",
files={"file": ("board.kicad_pcb", _PCB.encode(), "text/plain")},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["footprints"] == 1
assert body["nets"] >= 2
fresh = client.get(f"/api/projects/{pid}").json()
assert fresh["has_pcb"] is True
assert fresh["has_netlist"] is False
def test_upload_sch_as_pcb_is_rejected(tmp_path: Path):
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "board"}).json()["id"]
resp = client.post(
f"/api/projects/{pid}/upload/pcb",
files={"file": ("sheet.kicad_sch", b"(kicad_sch (version 1))\n", "text/plain")},
)
assert resp.status_code == 400
fresh = client.get(f"/api/projects/{pid}").json()
assert not fresh.get("has_pcb")
class _FakeWs:
def __init__(self, root: Path):
self.root = root
def local_path(self, rel: str) -> Path:
return self.root / rel
def test_layout_graph_skipped_when_pcb_missing(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
(tmp_path / "uploads").mkdir()
_write_layout_graph(_FakeWs(tmp_path), "p1")
assert not (tmp_path / "layout_graph.json").is_file()
def test_layout_graph_fail_soft_on_invalid_pcb(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "pcb.kicad_pcb").write_text("(kicad_sch (version 1))\n")
_write_layout_graph(_FakeWs(tmp_path), "p1")
assert not (tmp_path / "layout_graph.json").is_file()
def test_layout_graph_written_from_valid_pcb(tmp_path: Path):
from backend.services.pipeline import _write_layout_graph
uploads = tmp_path / "uploads"
uploads.mkdir()
(uploads / "pcb.kicad_pcb").write_text(_PCB)
_write_layout_graph(_FakeWs(tmp_path), "p1")
out = tmp_path / "layout_graph.json"
assert out.is_file()
data = __import__("json").loads(out.read_text())
assert "R1" in data["footprints"]
def test_stackup_thickness_without_full_dielectrics(tmp_path: Path):
p = tmp_path / "t.kicad_pcb"
p.write_text("""(kicad_pcb (version 20240108)
(net 0 "")
(net 1 "VOUT")
(setup
(stackup
(layer "F.Cu" (type "copper") (thickness 0.035mm))
(layer "B.Cu" (type "copper") (thickness 0.035))
)
)
(via (at 1 1) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net 1))
(arc (start 0 0) (end 2 0) (width 0.4) (layer "F.Cu") (net 1))
)
""")
g = parse_kicad_pcb(p)
assert g.stackup is not None
assert g.stackup.copper_thickness_mm == pytest.approx(0.035)
assert len(g.vias) == 1
assert any(s.width == pytest.approx(0.4) and s.net == "VOUT" for s in g.segments)
+297
View File
@@ -0,0 +1,297 @@
"""Phase B: stackup, via I, ESD mm, USB-PD, PoE isolation, antenna, PDN.
Skip without evidence. No invented I/Z/mm/IPC via chart. Via ≠ pad ≠ track ≠ zone.
"""
from __future__ import annotations
from backend.periscopex.antenna_layout_check import check_antenna_layout
from backend.periscopex.esd_return_check import check_esd_distance
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
SimpleComponentSpecs,
)
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.pcb_power_thermal import check_pcb_via_current
from backend.periscopex.pdn_check import check_pdn
from backend.periscopex.stackup_check import check_stackup
from backend.periscopex.usb_pd_check import check_usb_pd
def _ic(ref, pins, *, mpn="PART", specs=None, subtype=None):
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn,
component_subtype=subtype, pins=pins, specs=specs,
)
def _net(name, *pairs, ntype=NetType.SIGNAL):
return Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def test_stackup_skips_without_fab_spec():
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="GND")],
)
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND)})
assert check_stackup(g, {}, layout) == []
assert check_stackup(g, {}, None) == []
def test_stackup_vs_fab_spec_is_review_not_invented_oz():
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
cons = ComponentConstraints(
mpn="FAB", pintable=[Pin(number="1", name="GND")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stackup", "copper_thickness_mm": 0.070,
"note": "fab 2 oz", "source_page": 1,
}],
)
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"}, mpn="FAB")}, nets={})
findings = check_stackup(g, {"FAB": cons}, layout)
assert len(findings) == 1
assert findings[0].rule_id == "PE-STK-001"
assert findings[0].status == "WARNING"
assert findings[0].finding_class == "REVIEW"
assert "0.035" in findings[0].facts
assert "0.07" in findings[0].facts.replace("070", "0.07") or "0.070" in findings[0].facts
assert "1 oz" not in findings[0].finding.lower()
complete_finding(findings[0])
assert findings[0].status != "ERROR"
def test_via_current_skips_without_i_does_not_invent_ipc():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
vias=[LayoutVia(x=1, y=0, net="VOUT", drill=0.3)],
)
g = DesignGraph(
components={"U1": _ic("U1", {"1": "VIN", "2": "VOUT"}, mpn="LDO1",
specs=SimpleComponentSpecs(specs_type="discrete", values={}))},
nets={"VOUT": _net("VOUT", ("U1", "2"), ntype=NetType.POWER)},
)
cons = ComponentConstraints(mpn="LDO1", pintable=[Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[], rules=[])
assert check_pcb_via_current(g, {"LDO1": cons}, layout) == []
g.components["U1"].specs = SimpleComponentSpecs(
specs_type="discrete", values={"i_load": 1.2},
)
findings = check_pcb_via_current(g, {"LDO1": cons}, layout)
via2 = [f for f in findings if f.rule_id == "PE-VIA-002"]
assert len(via2) == 1
assert via2[0].status == "INFO"
assert via2[0].evidence_status == "INSUFFICIENT"
blob = f"{via2[0].finding} {via2[0].requirement} {via2[0].inference} {via2[0].facts}"
assert "1.2" in via2[0].facts
assert "0.3" in via2[0].facts
assert "IPC" not in via2[0].finding
assert "k =" not in blob.lower()
complete_finding(via2[0])
assert via2[0].status != "ERROR"
def test_esd_distance_uses_pads_not_vias():
g = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A6": "USB_D+"},
),
"D1": _ic("D1", {"1": "USB_D+"}, mpn="USBLC6", subtype="ic.protection.esd"),
},
nets={"USB_D+": _net("USB_D+", ("J2", "A6"), ("D1", "1"))},
)
layout = LayoutGraph(
footprints={
"J2": LayoutFootprint(reference="J2", x=0, y=0, pads=[
LayoutPad(number="A6", x=0.0, y=0.0, net="USB_D+"),
]),
"D1": LayoutFootprint(reference="D1", x=8.0, y=0.0, pads=[
LayoutPad(number="1", x=8.0, y=0.0, net="USB_D+"),
]),
},
vias=[LayoutVia(x=1.0, y=0.0, net="USB_D+", drill=0.3)],
segments=[LayoutSegment(start=(0, 0), end=(8, 0), width=0.2, layer="F.Cu", net="USB_D+")],
)
findings = check_esd_distance(g, {}, layout)
dist = [f for f in findings if f.rule_id == "PE-ESD-002"]
assert len(dist) == 1
assert dist[0].status == "INFO"
assert dist[0].evidence_status == "INSUFFICIENT"
assert "8.000" in dist[0].facts or "8.00" in dist[0].facts
assert "via" not in dist[0].facts.lower() or "pad" in dist[0].facts.lower()
assert dist[0].calculation
cons = ComponentConstraints(
mpn="USBLC6", pintable=[Pin(number="1", name="IO")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "esd", "max_distance_mm": 3.0,
"note": "TVS within 3 mm of connector", "source_page": 5,
}],
)
fail = check_esd_distance(g, {"USBLC6": cons}, layout)
hit = [f for f in fail if f.rule_id == "PE-ESD-002"][0]
assert hit.finding.startswith("FAIL:")
complete_finding(hit)
assert hit.status == "WARNING"
assert hit.status != "ERROR" or hit.provenance == "MANDATORY"
def test_usb_pd_skips_without_pd_controller():
g = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C_Receptacle_USB2.0_16P", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A5": "USB_CC1"},
),
},
nets={"USB_CC1": _net("USB_CC1", ("J2", "A5"))},
)
assert check_usb_pd(g) == []
g.components["U8"] = _ic("U8", {"1": "USB_CC1", "2": "VBUS"}, mpn="FUSB302B")
g.nets["VBUS"] = _net("VBUS", ("U8", "2"), ntype=NetType.POWER)
found = check_usb_pd(g)
assert [f.rule_id for f in found] == ["PE-PD-001"]
assert found[0].status == "INFO"
assert found[0].evidence_status == "SUFFICIENT"
def test_poe_isolation_skips_without_voltage_fact():
g = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45 PoE MagJack", footprint="",
component_type=ComponentType.CONNECTOR, mpn="ARJP11A",
pins={"1": "ETH_TX+"},
),
},
nets={"ETH_TX+": _net("ETH_TX+", ("J1", "1"))},
)
findings = check_interface_classes(g)
assert not any(f.rule_id == "PE-POE-004" for f in findings)
g.components["J1"].specs = SimpleComponentSpecs(
specs_type="connector", values={"isolation_v": 1500.0},
)
with_v = check_interface_classes(g)
iso = [f for f in with_v if f.rule_id == "PE-POE-004"]
assert len(iso) == 1
assert iso[0].status == "INFO"
assert "1500" in iso[0].facts
assert iso[0].evidence_status == "SUFFICIENT"
def test_antenna_skips_when_absent():
g = DesignGraph(
components={"U1": _ic("U1", {"1": "GPIO1"})},
nets={"GPIO1": _net("GPIO1", ("U1", "1"))},
)
layout = LayoutGraph(segments=[
LayoutSegment(start=(0, 0), end=(2, 0), width=0.2, layer="F.Cu", net="GPIO1"),
])
assert check_antenna_layout(g, layout) == []
def test_antenna_keepout_and_match_are_facts():
g = DesignGraph(
components={
"U1": _ic("U1", {"1": "ANT_FEED"}, mpn="ESP32"),
"ANT1": Component(
reference="ANT1", value="ANT", footprint="RF_Antenna:ANT",
component_type=ComponentType.UNKNOWN, pins={"1": "ANT_FEED"},
),
"L1": Component(
reference="L1", value="3.3nH", footprint="",
component_type=ComponentType.INDUCTOR, pins={"1": "ANT_FEED", "2": "RF_OUT"},
),
},
nets={
"ANT_FEED": _net("ANT_FEED", ("U1", "1"), ("ANT1", "1"), ("L1", "1")),
"RF_OUT": _net("RF_OUT", ("L1", "2")),
},
)
layout = LayoutGraph(
footprints={"ANT1": LayoutFootprint(reference="ANT1", x=10, y=10, pads=[
LayoutPad(number="1", x=10, y=10, net="ANT_FEED"),
])},
zones=[LayoutZone(
net="antenna", layer="F.Cu", keepout=True,
outlines=[[(8, 8), (14, 8), (14, 14), (8, 14)]],
)],
segments=[LayoutSegment(start=(0, 0), end=(10, 10), width=0.3, layer="F.Cu", net="ANT_FEED")],
)
findings = check_antenna_layout(g, layout)
ids = {f.rule_id for f in findings}
assert "PE-ANT-001" in ids
assert "PE-ANT-002" in ids
keep = [f for f in findings if f.rule_id == "PE-ANT-001"][0]
assert keep.status == "INFO"
assert keep.evidence_status == "SUFFICIENT"
assert "50" not in keep.requirement
def test_pdn_skips_without_zf():
g = DesignGraph(components={"U1": _ic("U1", {"1": "3V3"})}, nets={
"3V3": _net("3V3", ("U1", "1"), ntype=NetType.POWER),
})
layout = LayoutGraph(segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="3V3"),
])
assert check_pdn(g, layout, None) == []
assert check_pdn(g, layout, {"nets": [{"net_name": "USB_D+", "z0_avg_ohms": 88}]}) == []
def test_run_pcb_checks_phase_b_absent_is_silent():
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={
"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND),
})
ids = {f.rule_id for f in run_pcb_checks(g, {}, None)}
assert "PE-STK-001" not in ids
assert "PE-VIA-002" not in ids
assert "PE-ESD-002" not in ids
assert "PE-PD-001" not in ids
assert "PE-POE-004" not in ids
assert "PE-ANT-001" not in ids
assert "PE-PDN-001" not in ids
def test_phase_b_rule_catalog():
for rid in (
"PE-STK-001", "PE-VIA-002", "PE-ESD-002", "PE-PD-001",
"PE-POE-004", "PE-ANT-001", "PE-ANT-002", "PE-PDN-001",
):
rec = lookup_rule(rid)
assert rec is not None, rid
+423
View File
@@ -0,0 +1,423 @@
"""Fase B close-out: BOM↔PCB↔datasheet, SPOF, EMI FACT, gated Tj, SI refresh."""
from __future__ import annotations
from backend.periscopex.layout_rules import needs_layout_rules_refresh
from backend.periscopex.models import (
AbsMaxRating,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
InductorSpecs,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutVia,
LayoutZone,
Net,
NetType,
PackageInfo,
Pin,
PinConnection,
SimpleComponentSpecs,
)
def test_si_refresh_when_old_extract_has_only_decoupling():
assert needs_layout_rules_refresh(
{
"model_version": "1.10.0",
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": 2}],
},
min_scan_version="1.12.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [],
},
min_scan_version="1.12.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.10.0",
"layout_rules": [{"kind": "impedance", "zdiff_ohm": 90}],
},
min_scan_version="1.12.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [{"kind": "series_resistor", "value_ohms": 10000}],
},
min_scan_version="1.12.0",
)
assert needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [{
"kind": "series_resistor",
"value_ohms": 10000,
"note": "EN RC 10 kΩ / 1 µF",
}],
},
min_scan_version="1.13.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90}],
},
min_scan_version="1.13.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [],
},
min_scan_version="1.13.0",
)
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
cons = ComponentConstraints(
mpn="IC1",
package_info=PackageInfo(
base_family="MSP", package="LQFP-48", pin_count=48,
),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 49)],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="Package_DFN_QFN:QFN-16-1EP",
component_type=ComponentType.IC, mpn="IC1",
pins={str(i): "GND" for i in range(1, 17)},
),
},
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
schematic_fields={"U1": {"footprint": "Package_DFN_QFN:QFN-16-1EP"}},
bom_fields={"U1": {"footprint": "QFN-16"}},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", footprint="Package_DFN_QFN:QFN-16-1EP_3x3mm",
x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 17)],
),
},
)
findings = check_bom_pcb_datasheet(graph, {"IC1": cons}, layout)
ids = {f.rule_id for f in findings}
assert "PE-BOM-010" in ids
assert "PE-BOM-011" in ids
assert all(f.status == "ERROR" for f in findings if f.rule_id in {"PE-BOM-010", "PE-BOM-011"})
def test_abs_max_voltage_and_temp_and_current():
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
cons = ComponentConstraints(
mpn="LDO1",
package_info=PackageInfo(base_family="SPX", package="SOT-23-5", pin_count=5),
pintable=[
Pin(number="1", name="VIN"),
Pin(number="2", name="GND"),
Pin(number="5", name="VOUT"),
],
absolute_maximum_ratings=[
AbsMaxRating(parameter="VIN", min=None, max=4.0, unit="V", source_page=3),
AbsMaxRating(parameter="TJ", min=None, max=125, unit="C", source_page=3),
AbsMaxRating(parameter="IOUT", min=None, max=0.1, unit="A", source_page=4),
],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="Package_TO_SOT_SMD:SOT-23-5",
component_type=ComponentType.IC, mpn="LDO1",
pins={"1": "VIN", "2": "GND", "5": "VOUT"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": 0.5, "ta_max_c": 150},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"VOUT": Net(name="VOUT", net_type=NetType.POWER, voltage=3.3, pins=[]),
},
)
findings = check_bom_pcb_datasheet(graph, {"LDO1": cons}, None)
ids = {f.rule_id for f in findings}
assert "PE-BOM-012" in ids
assert "PE-BOM-013" in ids
assert "PE-BOM-014" in ids
def test_inductor_current_rating():
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
graph = DesignGraph(
components={
"L1": Component(
reference="L1", value="2.2uH", footprint="",
component_type=ComponentType.INDUCTOR, mpn="IND1",
pins={"1": "SW", "2": "VOUT"},
specs=InductorSpecs(
value_henries=2.2e-6,
value_formatted="2.2uH",
current_rating_a="0.2",
),
),
},
nets={},
)
graph.components["L1"].specs # type: ignore
# I_load on the inductor itself
graph.components["L1"] = graph.components["L1"].model_copy(
update={"specs": InductorSpecs(
value_henries=2.2e-6, value_formatted="2.2uH", current_rating_a="0.2",
)}
)
# specs_values reads SimpleComponentSpecs.values — inductor uses current_rating_a
findings = check_bom_pcb_datasheet(graph, {}, None)
# Without i_load on inductor specs, skip.
assert all(f.rule_id != "PE-BOM-014" for f in findings)
def test_spof_review_single_ldo():
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.spof_check import check_spof
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
component_subtype="ic.power.ldo",
pins={"1": "VIN", "2": "+3V3"},
),
"U2": Component(
reference="U2", value="MCU", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "+3V3"},
),
"U3": Component(
reference="U3", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"1": "+3V3"},
),
},
nets={
"VIN": Net(name="VIN", net_type=NetType.POWER, pins=[]),
"+3V3": Net(
name="+3V3", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="U2", pin_number="1"),
PinConnection(component_ref="U3", pin_number="1"),
],
),
},
)
findings = check_spof(graph, {"LDO1": cons})
assert findings
assert findings[0].rule_id == "PE-SPOF-001"
complete_finding(findings[0])
assert findings[0].finding_class == "REVIEW"
assert findings[0].status != "ERROR"
def test_emi_only_with_datasheet_fact():
from backend.periscopex.emi_check import check_emi
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="USB_DP")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "keepout",
"note": "Place a common-mode choke on the USB pair",
"source_page": 22,
}],
)
graph = DesignGraph(
components={
"U2": Component(
reference="U2", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U2", pin_number="1")],
),
},
)
findings = check_emi(graph, {"PHY": cons}, None)
assert findings and findings[0].rule_id == "PE-EMI-001"
cons2 = cons.model_copy(update={"layout_rules": []})
assert check_emi(graph, {"PHY": cons2}, None) == []
def test_tj_when_theta_copper_vias_exist():
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
from backend.periscopex.models import LayoutSegment, LayoutStackup
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
component_subtype="ic.power.ldo",
pins={"1": "VIN", "2": "VOUT"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": 0.5, "theta_ja": 50, "tj_max": 150},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"VOUT": Net(
name="VOUT", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(
stackup=LayoutStackup(copper_layers=["F.Cu"], dielectrics=[], copper_thickness_mm=0.035),
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
pads=[LayoutPad(number="2", x=0, y=0, net="VOUT")],
),
},
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
vias=[LayoutVia(x=0.1, y=0.1, net="GND", drill=0.3)],
zones=[LayoutZone(
net="GND", layer="F.Cu",
outlines=[[(-3, -3), (3, -3), (3, 3), (-3, 3)]],
)],
)
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
assert findings and findings[0].rule_id == "PE-THM-002"
assert findings[0].evidence_status == "SUFFICIENT"
assert "Tj" in findings[0].finding
assert findings[0].status == "INFO"
def test_tj_insufficient_without_theta():
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
pins={"1": "VIN", "2": "VOUT"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": 0.5},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"VOUT": Net(
name="VOUT", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(footprints={})
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
assert findings and findings[0].rule_id == "PE-THM-002"
assert findings[0].evidence_status == "INSUFFICIENT"
assert findings[0].status == "INFO"
+730
View File
@@ -0,0 +1,730 @@
"""PCB review checks, inventory, report merge — no invented millimetres."""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from pathlib import Path
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementDomain, PlacementIcGroup
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
Net,
NetType,
)
from backend.periscopex.pcb_checks import (
assign_pcb_finding_ids,
merge_schema_pcb_reports,
run_pcb_checks,
)
from backend.periscopex.pcb_inventory import build_pcb_inventory
from backend.periscopex.pcb_net_match import check_pcb_net_match
from backend.periscopex.pcb_review import build_pcb_layout_context
from backend.services.projects import ProjectMeta, STATUS_QUEUED, STATUS_RUNNING
SIMPLE = SIMPLE_PROJECT
def _graph() -> DesignGraph:
return DesignGraph.model_validate_json(
(SIMPLE / "design_graph.json").read_text()
)
def test_pad_net_mismatch_is_pe_lay_001():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "GND"},
),
},
nets={
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert len(findings) == 1
assert findings[0].rule_id == "PE-LAY-001"
assert findings[0].status == "ERROR"
assert findings[0].recommendation
assert "Reconnect pad" in findings[0].recommendation
def test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad():
"""PCB /X vs schematic X (Emmaforo table) — one PE-LAY-003, pads treated as same net."""
from backend.periscopex.pcb_net_match import kicad_nets_match
pairs = [
("/ESP32_EN", "ESP32_EN"),
("/ESP_32_BOOT", "ESP_32_BOOT"),
("/REGN", "REGN"),
("/LED_SDATA", "LED_SDATA"),
("/power/REGN", "REGN"),
]
for pcb, sch in pairs:
assert kicad_nets_match(pcb, sch), (pcb, sch)
assert not kicad_nets_match("/sheet1/CLK", "/sheet2/CLK")
assert not kicad_nets_match("/ESP32_EN", "ESP32_BOOT")
pins = {
"1": "ESP32_EN",
"2": "ESP_32_BOOT",
"3": "REGN",
"4": "LED_SDATA",
}
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="ESP",
pins=pins,
),
},
nets={n: Net(name=n, net_type=NetType.SIGNAL, pins=[]) for n in pins.values()},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"),
LayoutPad(number="2", x=1, y=0, net="/ESP_32_BOOT"),
LayoutPad(number="3", x=2, y=0, net="/REGN"),
LayoutPad(number="4", x=3, y=0, net="/LED_SDATA"),
],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert [f.rule_id for f in findings] == ["PE-LAY-003"]
assert findings[0].status == "INFO"
assert "individual pads" in findings[0].recommendation.lower() or "Do not retouch" in findings[0].recommendation
assert "Won't fix" not in findings[0].recommendation
assert "Reconnect pad" not in findings[0].recommendation
def test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "ESP32_EN", "2": "GND"},
),
},
nets={
"ESP32_EN": Net(name="ESP32_EN", net_type=NetType.SIGNAL, pins=[]),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"),
LayoutPad(number="2", x=1, y=0, net="+3V3"),
],
),
},
)
findings = check_pcb_net_match(graph, layout)
assert [f.rule_id for f in findings] == ["PE-LAY-001"]
assert findings[0].pins == ["2"]
assert "+3V3" in findings[0].finding
def test_missing_footprint_is_pe_lay_002():
graph = DesignGraph(
components={
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "GND"},
),
},
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
)
layout = LayoutGraph(footprints={
"U9": LayoutFootprint(reference="U9", x=0, y=0, layer="F.Cu"),
})
findings = check_pcb_net_match(graph, layout)
assert any(f.rule_id == "PE-LAY-002" for f in findings)
assert all(f.recommendation for f in findings)
def test_run_pcb_checks_assigns_pcb_ids_and_recommendations():
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
from backend.periscopex.models import LayoutSegment
segs = [
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
]
findings = run_pcb_checks(
_graph(),
_xtal_cons(max_distance_mm=2.0),
_x1_c9_layout(cap_x=10.0, segments=segs),
)
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
assert plc
assert plc[0].finding_id.startswith("PCB-")
assert plc[0].recommendation
def test_close_decoupling_has_no_plc_001():
from tests.pcb.test_placement_check import _xtal_cons, _x1_c9_layout
from backend.periscopex.models import LayoutSegment
segs = [
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
]
findings = run_pcb_checks(
_graph(),
_xtal_cons(max_distance_mm=2.0),
_x1_c9_layout(cap_x=0.5, segments=segs),
)
assert all(f.rule_id != "PE-PLC-001" for f in findings)
def test_inventory_lists_net_length_and_pair():
layout = LayoutGraph(
nets={"USB_DP": 1, "USB_DM": 2},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_DP"),
LayoutSegment(start=(0, 1), end=(8, 1), width=0.2, layer="F.Cu", net="USB_DM"),
],
)
inv = build_pcb_inventory(layout)
names = {n.name: n for n in inv.nets}
assert names["USB_DP"].length_mm == 10.0
assert names["USB_DP"].pair == "USB_DM"
def test_layout_context_includes_via_counts():
from backend.periscopex.models import LayoutStackup, LayoutVia, LayoutZone
graph = _graph()
plan = FunctionalGroupsReport(
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
groups=[PlacementIcGroup(ref="U3", satellites=[])],
)
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[],
copper_thickness_mm=0.035,
),
footprints={
"U3": LayoutFootprint(
reference="U3", x=0, y=0, layer="F.Cu",
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
pads=[LayoutPad(number="1", x=0, y=0, net="+3V3")],
),
},
vias=[
LayoutVia(x=0.2, y=0.1, net="GND", drill=0.3),
LayoutVia(x=-0.4, y=0.0, net="GND", drill=0.3),
],
segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.45, layer="F.Cu", net="+3V3"),
],
zones=[
LayoutZone(
net="",
layer="F.Cu",
keepout=True,
name="ANT_KEEPOUT",
outlines=[[(20, 20), (30, 20), (30, 30), (20, 30)]],
),
],
)
text = build_pcb_layout_context("U3", graph, layout, plan)
assert "Vias under footprint: 2" in text
assert "count=2" in text
assert "0.3 mm" in text
assert "Copper thickness: 35 µm" in text
assert "width=0.45 mm" in text
assert "ANT_KEEPOUT" in text
assert "Board keepout polygons" in text
def test_pcb_ai_finding_gets_action():
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import Finding
from backend.services.pcb_validation import _ensure_recs
f = Finding(
designator="U1",
mpn="PADIC",
finding="PowerPAD under U1",
why="datasheet thermal pad",
status="INFO",
recommendation="",
action="",
source="pcb_review",
)
_ensure_recs([f])
complete_finding(f)
assert f.action.strip()
assert f.recommendation.strip()
assert f.finding_class == "REVIEW"
assert f.facts
assert f.requirement
def test_parse_review_action_field_without_recommendation():
from backend.periscopex.review_parse import parse_submit_review as _parse_review
result = _parse_review(
{
"findings": [{
"finding": "EPAD vias present",
"why": "layout note",
"status": "INFO",
"source_page": 12,
"source_quote": "Connect EPAD with vias to GND.",
"action": "Keep the nine 0.3 mm vias under U5 EPAD.",
}],
"checked_areas": ["thermal"],
},
"U5",
"ESP",
)
f = result.findings[0]
assert f.action == "Keep the nine 0.3 mm vias under U5 EPAD."
assert f.recommendation == "Keep the nine 0.3 mm vias under U5 EPAD."
def test_layout_context_includes_domain_and_group():
graph = _graph()
plan = FunctionalGroupsReport(
domains=[PlacementDomain(domain_id="3v3", power_nets=["+3V3"], ic_refs=["U3"])],
groups=[PlacementIcGroup(ref="U3", satellites=[])],
)
layout = LayoutGraph(
footprints={"U3": LayoutFootprint(reference="U3", x=1, y=2, layer="F.Cu")},
)
text = build_pcb_layout_context("U3", graph, layout, plan)
assert "Domain: 3v3" in text
assert "Functional group: U3" in text
assert "Footprint U3" in text
assert "Vias under footprint" in text
def test_merge_reports_prefixes_do_not_collide():
schema = {"findings": [{"finding_id": "U3-001", "status": "WARNING"}]}
pcb = {"findings": [{"finding_id": "PCB-U3-001", "status": "ERROR"}]}
merged = merge_schema_pcb_reports(schema, pcb)
ids = [f["finding_id"] for f in merged["findings"]]
assert ids == ["U3-001", "PCB-U3-001"]
assert merged["summary"]["ERROR"] == 1
assert merged["summary"]["WARNING"] == 1
def test_assign_fills_empty_recommendation():
from backend.periscopex.models import Finding
f = Finding(
designator="U1", mpn="", finding="x", why="y", status="INFO",
recommendation="",
)
assign_pcb_finding_ids([f])
assert f.finding_id == "PCB-U1-001"
assert f.recommendation
def test_pcb_busy_helpers():
active = frozenset({"queued", "running"})
analysis = frozenset({STATUS_QUEUED, STATUS_RUNNING})
draft = ProjectMeta(id="p", name="t", created="2026-01-01", user_id="u")
assert (draft.pcb_status or "draft") not in active
draft.pcb_status = "queued"
assert (draft.pcb_status or "draft") in active
draft.status = STATUS_RUNNING
assert draft.status in analysis
def test_heal_dead_analysis_worker_unblocks_pcb(tmp_path: Path, monkeypatch):
from backend.services import job_runner, projects as proj_svc
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
meta = proj_svc.create_project(storage, "usr_jwt", "board")
proj_svc.update_project(
storage, "usr_jwt", meta.id,
status=STATUS_RUNNING,
has_pcb=True,
has_bom=True,
has_netlist=True,
pcb_status="draft",
)
monkeypatch.setattr(job_runner, "get_execution_state", lambda _n: "failed")
healed = proj_svc.heal_if_pipeline_finished(storage, "usr_jwt", meta.id)
assert healed is not None
assert healed.status == "error"
from backend.services.pcb_pipeline import analysis_busy
assert not analysis_busy(healed)
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.storage import LocalStorageBackend
app.state.storage = LocalStorageBackend(tmp_path)
client = TestClient(app)
pid = client.post("/api/projects", json={"name": "noboard"}).json()["id"]
resp = client.post(f"/api/pipeline/{pid}/pcb/start")
assert resp.status_code == 400
assert "kicad_pcb" in resp.json()["detail"]
def test_update_project_writes_storage_prefix_not_stale_user_id(tmp_path: Path):
"""JWT owner path with JSON still saying user_id=local (live Emmaforo)."""
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
pid = "759a4dea9612"
owner = "usr_jwt_owner"
storage.write_json(
f"users/{owner}/projects/{pid}/project.json",
{
"id": pid,
"name": "Emmaforo",
"user_id": "local",
"created": "2026-01-01T00:00:00Z",
"status": "complete",
"has_pcb": True,
"has_bom": True,
"has_netlist": True,
"pcb_status": "draft",
},
)
from backend.services import projects as proj_svc
proj_svc.update_project(storage, owner, pid, pcb_status="queued")
jwt_meta = proj_svc.get_project(storage, owner, pid)
assert jwt_meta is not None
assert jwt_meta.pcb_status == "queued"
assert proj_svc.get_project(storage, "local", pid) is None
def _ldo_graph_layout(*, i_load=10.0, tj_max=150.0, width=0.15, thickness=0.035):
from backend.periscopex.models import (
ComponentConstraints,
LayoutStackup,
Pin,
PinConnection,
SimpleComponentSpecs,
)
pins = {"1": "VIN", "2": "VOUT"}
cons = ComponentConstraints(
mpn="LDO1",
pintable=[
Pin(number="1", name="VIN"),
Pin(number="2", name="VOUT"),
],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
component_subtype="ic.power.ldo",
pins=pins,
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": i_load, "tj_max": tj_max, "theta_ja": 50},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"VOUT": Net(
name="VOUT", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[],
copper_thickness_mm=thickness,
),
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
pads=[LayoutPad(number="2", x=0, y=0, net="VOUT")],
),
},
segments=[
LayoutSegment(start=(0, 0), end=(20, 0), width=width, layer="F.Cu", net="VOUT"),
],
)
return graph, {"LDO1": cons}, layout
def test_power_trace_ipc2221_errors_when_load_exceeds_ampacity():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout()
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings
assert findings[0].rule_id == "PE-PWR-001"
assert findings[0].status == "ERROR"
assert "IPC-2221" in findings[0].finding
assert findings[0].recommendation
def test_power_trace_uses_parsed_width_and_thickness():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=0.05, width=1.0, thickness=0.035)
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings == []
def test_power_trace_skips_without_i_load():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=10)
graph.components["U1"].specs.values["i_load_a"] = None # type: ignore[union-attr]
graph.components["U1"].specs = None
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings == []
def test_power_trace_skips_iout_max_as_load():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=10)
graph.components["U1"].specs.values.clear() # type: ignore[union-attr]
graph.components["U1"].specs.values["iout_max_a"] = 10.0 # type: ignore[union-attr]
graph.components["U1"].specs.values["tj_max"] = 150.0 # type: ignore[union-attr]
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings == []
def test_power_trace_uses_imax_when_i_load_absent():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=10)
vals = graph.components["U1"].specs.values # type: ignore[union-attr]
vals.pop("i_load_a", None)
vals["i_max"] = 10.0
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings
assert findings[0].rule_id == "PE-PWR-001"
assert findings[0].status == "ERROR"
def test_power_trace_uses_i_abs_when_i_load_absent():
from backend.periscopex.pcb_power_thermal import check_pcb_power_traces
graph, cmap, layout = _ldo_graph_layout(i_load=10)
vals = graph.components["U1"].specs.values # type: ignore[union-attr]
vals.pop("i_load_a", None)
vals["i_abs"] = 10.0
findings = check_pcb_power_traces(graph, cmap, layout)
assert findings
assert findings[0].rule_id == "PE-PWR-001"
assert findings[0].status == "ERROR"
def test_kelvin_sense_pin_on_shared_net():
from backend.periscopex.models import ComponentConstraints, Pin, PinConnection
from backend.periscopex.pcb_power_thermal import check_pcb_kelvin
cons = ComponentConstraints(
mpn="AMP",
pintable=[Pin(number="4", name="ISNS", description="current sense")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U2": Component(
reference="U2", value="", footprint="",
component_type=ComponentType.IC, mpn="AMP",
pins={"4": "ISNS_NET"},
),
"R1": Component(
reference="R1", value="10m", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "ISNS_NET", "2": "GND"},
),
"U9": Component(
reference="U9", value="", footprint="",
component_type=ComponentType.IC, mpn="LOAD",
pins={"1": "ISNS_NET"},
),
},
nets={
"ISNS_NET": Net(
name="ISNS_NET", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U2", pin_number="4"),
PinConnection(component_ref="R1", pin_number="1"),
PinConnection(component_ref="U9", pin_number="1"),
],
),
},
)
findings = check_pcb_kelvin(graph, {"AMP": cons})
assert findings and findings[0].rule_id == "PE-KEL-001"
assert findings[0].status == "WARNING"
def test_pcb_ai_skips_without_library_extraction():
import asyncio
from backend.services.pcb_validation import review_pcb_ics
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="X", footprint="",
component_type=ComponentType.IC, mpn="NOEXT",
pins={"1": "GND"},
),
},
nets={},
)
async def _run():
return await review_pcb_ics(
graph, {}, None, None, None, Path("/tmp"),
)
_f, _c, skipped = asyncio.run(_run())
assert skipped
assert "library extraction" in skipped[0]["reason"]
def test_library_constraints_fill_from_storage(tmp_path: Path):
from backend.periscopex.models import ComponentConstraints, Pin
from backend.services.pcb_pipeline import _load_constraints_map
from backend.services.storage import LocalStorageBackend
storage = LocalStorageBackend(tmp_path)
cons = ComponentConstraints(
mpn="LIBIC",
pintable=[Pin(number="1", name="VCC")],
absolute_maximum_ratings=[],
rules=[],
)
storage.write_json("library/extracted/LIBIC.json", cons.model_dump())
cmap = _load_constraints_map(tmp_path / "missing", storage)
assert "LIBIC" in cmap
assert cmap["LIBIC"].pintable[0].name == "VCC"
def test_thermal_copper_warns_without_pour_or_vias():
from backend.periscopex.pcb_power_thermal import check_pcb_thermal_copper
graph, cmap, layout = _ldo_graph_layout(i_load=0.5, width=1.0)
findings = check_pcb_thermal_copper(graph, cmap, layout)
assert findings
assert findings[0].rule_id == "PE-THM-001"
assert findings[0].status == "WARNING"
assert findings[0].recommendation
def test_gnd_stitch_skips_tiny_gpio():
from backend.periscopex.models import LayoutZone, PinConnection
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "GPIO4"},
),
},
nets={
"GPIO4": Net(
name="GPIO4", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={},
segments=[
LayoutSegment(start=(0, 0), end=(0.1, 3), width=0.2, layer="F.Cu", net="GPIO4"),
],
zones=[
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 5), (0, 5)]]),
],
vias=[],
)
assert check_pcb_gnd_stitch(graph, layout) == []
def test_gnd_stitch_info_when_signal_has_pour_but_no_via():
from backend.periscopex.models import LayoutVia, LayoutZone, PinConnection
from backend.periscopex.pcb_power_thermal import check_pcb_gnd_stitch
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={},
segments=[
LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"),
],
zones=[
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]),
],
vias=[],
)
findings = check_pcb_gnd_stitch(graph, layout)
assert findings and findings[0].rule_id == "PE-STCH-001"
assert findings[0].status == "INFO"
assert findings[0].action
layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)]
assert check_pcb_gnd_stitch(graph, layout) == []
def test_pcb_pipeline_imports_build_placement_plan():
import backend.services.pcb_pipeline as pcb_pipeline
from backend.periscopex.functional_groups import build_placement_plan as expected
assert pcb_pipeline.build_placement_plan is expected
+357
View File
@@ -0,0 +1,357 @@
"""Regression: PCB software false positives (not HubAudio copper)."""
from __future__ import annotations
import re
from pathlib import Path
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.emi_check import check_emi
from backend.periscopex.models import (
AbsMaxRating,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
InductorSpecs,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutZone,
Net,
NetType,
PackageInfo,
Pin,
PinConnection,
ResistorSpecs,
SimpleComponentSpecs,
ValueDecoder,
)
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
from backend.periscopex.pcb_checks import unrouted_pad_nets
from backend.periscopex.pcb_power_thermal import (
check_pcb_kelvin,
check_pcb_power_traces,
check_pcb_via_current,
)
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 = FIXTURES / "HubAudio-DRC.rpt"
_HUB_PCB = Path(
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
)
_KICAD10 = """(kicad_pcb (version 20260206) (generator pcbnew)
(net "GND")
(net "+3V3")
(footprint "R_0603"
(layer "F.Cu")
(at 10 10 0)
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" smd rect (at -0.5 0) (size 0.8 0.9) (layers "F.Cu") (net "+3V3"))
(pad "2" smd rect (at 0.5 0) (size 0.8 0.9) (layers "F.Cu") (net "GND"))
)
(segment (start 10 10) (end 12 10) (width 0.2) (layer "F.Cu") (net "+3V3"))
(via (at 11 10) (size 0.8) (drill 0.4) (layers "F.Cu" "B.Cu") (net "GND"))
)
"""
def test_kicad10_name_only_nets_fill_index(tmp_path: Path):
p = tmp_path / "k10.kicad_pcb"
p.write_text(_KICAD10)
g = parse_kicad_pcb(p)
assert "GND" in g.nets and "+3V3" in g.nets
assert g.footprints["R1"].pads[0].net == "+3V3"
assert g.segments[0].net == "+3V3"
assert g.vias[0].net == "GND"
def test_lay004_zone_counts_as_copper():
layout = LayoutGraph(
footprints={
"C1": LayoutFootprint(
reference="C1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0, y=0, net="GND"),
LayoutPad(number="2", x=1, y=0, net="USB_DP"),
],
),
},
zones=[
LayoutZone(
net="GND", layer="In1.Cu",
outlines=[[(0, 0), (10, 0), (10, 10), (0, 10)]],
),
],
)
missing = unrouted_pad_nets(layout)
assert missing == ["USB_DP"]
def test_lay004_skips_unconnected_nc_and_matches_sheet_prefix():
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0, y=0, net="/Codec/LINE_OUT_R"),
LayoutPad(number="2", x=1, y=0, net="unconnected-(U1-NC-Pad2)"),
],
),
},
segments=[
LayoutSegment(
start=(0, 0), end=(2, 0), width=0.2, layer="F.Cu",
net="Codec/LINE_OUT_R",
),
],
)
assert unrouted_pad_nets(layout) == []
def test_drc_unconnected_items_count():
text = _DRC.read_text(encoding="utf-8", errors="replace")
m = re.search(r"Found (\d+) unconnected pads", text)
assert m and int(m.group(1)) == 30
n = len(re.findall(r"^\[unconnected_items\]", text, re.M))
assert n == 30
def test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude():
if not _HUB_PCB.is_file():
return
layout = parse_kicad_pcb(_HUB_PCB)
assert layout.nets, "KiCad 10 name-only nets must fill LayoutGraph.nets"
missing = unrouted_pad_nets(layout)
assert len(missing) <= 30, missing[:20]
assert len(missing) < 139
def test_ep_split_pads_not_bom_011():
cons = ComponentConstraints(
mpn="SW",
package_info=PackageInfo(base_family="TI", package="SON-8-EP", pin_count=8),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 9)]
pads += [
LayoutPad(number="9", x=0, y=0, net="GND", pinfunction="EP"),
LayoutPad(number="9_1", x=0.2, y=0, net="GND"),
LayoutPad(number="9_2", x=0.4, y=0, net="GND"),
]
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="SW", footprint="SON-8",
component_type=ComponentType.IC, mpn="SW",
pins={str(i): "GND" for i in range(1, 9)},
),
},
nets={},
)
layout = LayoutGraph(
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)},
)
ids = {f.rule_id for f in check_bom_pcb_datasheet(graph, {"SW": cons}, layout)}
assert "PE-BOM-011" not in ids
def test_vddcr_absmax_does_not_bind_3v3_io():
cons = ComponentConstraints(
mpn="PHY",
package_info=PackageInfo(base_family="SMS", package="QFN-24", pin_count=24),
pintable=[
Pin(number="6", name="VDDCR"),
Pin(number="8", name="VDDIO"),
],
absolute_maximum_ratings=[
AbsMaxRating(
parameter="Digital Core Supply Voltage (VDDCR)",
min=None, max=1.5, unit="V", source_page=52,
),
AbsMaxRating(
parameter="Positive voltage on XTAL2, with respect to ground",
min=None, max=2.5, unit="V", source_page=52,
),
AbsMaxRating(
parameter="Ground voltage differences VSS to VSS (thermal pad)",
min=None, max=0.3, unit="V", source_page=7,
),
],
rules=[],
)
graph = DesignGraph(
components={
"U19": Component(
reference="U19", value="PHY", footprint="QFN-24",
component_type=ComponentType.IC, mpn="PHY",
pins={"6": "Net-VDDCR", "8": "3V3_ETHERNET"},
),
},
nets={
"3V3_ETHERNET": Net(
name="3V3_ETHERNET", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U19", pin_number="8")],
),
"Net-VDDCR": Net(
name="Net-VDDCR", net_type=NetType.POWER, voltage=1.2,
pins=[PinConnection(component_ref="U19", pin_number="6")],
),
},
)
findings = check_bom_pcb_datasheet(graph, {"PHY": cons}, None)
assert all(f.rule_id != "PE-BOM-012" for f in findings)
def test_kelvin_ignores_crs_and_in_plus():
cons = ComponentConstraints(
mpn="PHY",
pintable=[
Pin(number="11", name="CRS_DV/MODE2", description="carrier sense"),
Pin(number="10", name="IN+", description="differential input"),
],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U19": Component(
reference="U19", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"11": "ETH_CRS_DV", "10": "VSYS"},
),
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "ETH_CRS_DV", "2": "VSYS"},
),
"U4": Component(
reference="U4", value="AMP", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "ETH_CRS_DV", "2": "VSYS"},
),
},
nets={},
)
assert check_pcb_kelvin(graph, {"PHY": cons}) == []
def test_emi_and_pi_skip_unconnected_nets():
cons = ComponentConstraints(
mpn="MOD",
pintable=[
Pin(number="38", name="USB_DN"),
Pin(number="23", name="NC"),
Pin(number="31", name="VDD_USB"),
],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "emi",
"note": "antenna port 50 ohm ferrite",
"source_page": 12,
}],
)
graph = DesignGraph(
components={
"U38": Component(
reference="U38", value="BT", footprint="",
component_type=ComponentType.IC, mpn="MOD",
pins={
"38": "unconnected-(U38-USB_DN-Pad38)",
"31": "unconnected-(U38-VDD_USB-Pad31)",
"23": "unconnected-(U12-NC-Pad23)",
},
),
},
nets={},
)
assert check_emi(graph, {"MOD": cons}, None) == []
assert check_power_integrity(graph, {"MOD": cons}) == []
def test_lqw_decoder_and_unknown_skip():
dec = ValueDecoder(
type="murata_lqw_inductance", base_unit="nH", output_unit="H",
)
assert abs(decode_value("18N", dec) - 18e-9) < 1e-15
model = specs_from_mpn("LQW18AN18NJ00D")
assert model is not None
assert abs(model.specs.value_henries - 18e-9) < 1e-15
bad = ValueDecoder(type="nope", base_unit="H", output_unit="H")
try:
decode_value("18N", bad)
raise AssertionError("expected unknown decoder")
except ValueError as exc:
assert "Unknown decoder type" in str(exc)
def test_fb1_blm_is_bead_not_dcr_resistor():
model = specs_from_mpn("BLM18BB600SN1D")
assert model is not None
assert model.specs.component_subtype == "passive.ferrite_bead"
assert model.specs.impedance_ohm == 60.0
dcr = specs_from_bom_value("BLM18BB600SN1D", "0.25 ohm", "FB")
assert dcr is not None
assert dcr.specs.component_subtype == "passive.ferrite_bead"
assert isinstance(dcr.specs, InductorSpecs)
assert not isinstance(dcr.specs, ResistorSpecs)
assert dcr.specs.impedance_ohm == 60.0
assert getattr(dcr.specs, "value_ohms", None) is None
def test_via_current_skips_without_i_load():
from backend.periscopex.models import LayoutStackup
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
pins={"1": "VIN", "2": "VOUT"},
specs=SimpleComponentSpecs(specs_type="discrete", values={}),
),
},
nets={
"VOUT": Net(name="VOUT", net_type=NetType.POWER, pins=[]),
},
)
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu"], dielectrics=[], copper_thickness_mm=0.035,
),
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, layer="F.Cu")},
segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT"),
],
)
findings = check_pcb_via_current(graph, {"LDO1": cons}, layout)
assert findings == []
pwr = check_pcb_power_traces(graph, {"LDO1": cons}, layout)
assert pwr == []
def test_thinking_reasoning_content_echo_still_present():
from pathlib import Path
src = (
Path(__file__).resolve().parents[2]
/ "periscope" / "src" / "backend" / "services" / "llm" / "deepseek_provider.py"
)
text = src.read_text(encoding="utf-8")
assert "reasoning_content" in text
assert "asst[\"reasoning_content\"] = reasoning" in text or 'asst["reasoning_content"] = reasoning' in text
+43
View File
@@ -0,0 +1,43 @@
"""PCB SSE terminal mapping — complete is not pcb_error."""
from backend.services.pcb_pipeline import pcb_sse_terminal_from_status
def test_complete_status_is_pcb_complete_not_error():
ev, data = pcb_sse_terminal_from_status(
"complete",
{"findings": 38, "domains": 3, "groups": 4},
"pcb_status=complete (terminal)",
)
assert ev == "pcb_complete"
assert data["findings"] == 38
assert data["domains"] == 3
assert data["groups"] == 4
assert "error" not in data
def test_complete_reason_without_status_still_completes():
ev, data = pcb_sse_terminal_from_status(
"running",
{},
"pcb_status=complete (terminal)",
)
assert ev == "pcb_complete"
assert data.get("synthetic") is True
def test_error_status_stays_pcb_error():
ev, data = pcb_sse_terminal_from_status(
"error",
{"error": "parse failed"},
"pcb_status=error (terminal)",
)
assert ev == "pcb_error"
assert data["error"] == "parse failed"
def test_cancelled_status_is_pcb_cancelled():
ev, _data = pcb_sse_terminal_from_status(
"cancelled", {}, "pcb_status=cancelled (terminal)",
)
assert ev == "pcb_cancelled"
+311
View File
@@ -0,0 +1,311 @@
"""Via is never a footprint pad. PE-BOM-011 uses parsed component pads only."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutGraph,
PackageInfo,
Pin,
)
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
def _smd_pad(num: int, x: float, y: float, net: str = "SIG") -> str:
return (
f' (pad "{num}" smd rect (at {x} {y}) (size 0.25 0.4) '
f'(layers "F.Cu" "F.Mask" "F.Paste") (net 2 "{net}") '
f'(pinfunction "P{num}") (pintype "passive"))\n'
)
def _via_pad(num: int, x: float, y: float, net: str, net_code: int = 1) -> str:
"""KiCad stitching via stored as a copper-only thru_hole pad."""
return (
f' (pad "{num}" thru_hole circle (at {x} {y}) (size 0.6 0.6) '
f'(drill 0.3) (layers "*.Cu") (net {net_code} "{net}"))\n'
)
def _board_via(x: float, y: float, net: str, net_code: int = 1) -> str:
return (
f' (via (at {x} {y}) (size 0.8) (drill 0.4) '
f'(layers "F.Cu" "B.Cu") (net {net_code}))\n'
)
def _fp_via(x: float, y: float, net: str, net_code: int = 1) -> str:
return (
f' (via (at {x} {y}) (size 0.8) (drill 0.4) '
f'(layers "F.Cu" "B.Cu") (net {net_code} "{net}"))\n'
)
def _qfn24_pcb(
tmp_path: Path,
*,
n_pads: int = 24,
stitch_via_pads: list[tuple[int, float, float, str]] | None = None,
nested_vias: list[tuple[float, float, str]] | None = None,
board_vias: list[tuple[float, float, str, int]] | None = None,
extra_smd: list[tuple[int, float, float, str]] | None = None,
ep_pad: bool = False,
) -> Path:
nets = {
0: "",
1: "GND",
2: "SIG",
3: "3V3",
4: "VCC",
}
net_code = {v: k for k, v in nets.items() if v}
body = ["(kicad_pcb (version 20240108) (generator pcbnew)\n"]
for code, name in nets.items():
body.append(f' (net {code} "{name}")\n')
body.append(
' (footprint "Package_DFN_QFN:QFN-24"\n'
' (layer "F.Cu")\n'
" (at 50 40 0)\n"
' (property "Reference" "U1" (at 0 0 0) (effects (font (size 1 1))))\n'
' (fp_rect (start -2.2 -2.2) (end 2.2 2.2) (layer "F.CrtYd") (stroke (width 0.05)))\n'
)
for i in range(1, n_pads + 1):
body.append(_smd_pad(i, -1.5 + (i % 6) * 0.5, -1.5 + (i // 6) * 0.5))
if extra_smd:
for num, x, y, net in extra_smd:
body.append(_smd_pad(num, x, y, net))
if ep_pad:
body.append(
' (pad "25" smd custom (at 0 0) (size 0.2 0.2) '
'(layers "F.Cu") (net 1 "GND") '
'(pinfunction "EXP_25") (pintype "power_in"))\n'
)
for num, x, y, net in stitch_via_pads or []:
body.append(_via_pad(num, x, y, net, net_code.get(net, 2)))
for x, y, net in nested_vias or []:
body.append(_fp_via(x, y, net, net_code.get(net, 2)))
body.append(" )\n")
for x, y, net, _code in board_vias or []:
body.append(_board_via(x, y, net, net_code.get(net, 2)))
body.append(")\n")
p = tmp_path / "board.kicad_pcb"
p.write_text("".join(body))
return p
def _ds24() -> tuple[DesignGraph, dict]:
pins = [Pin(number=str(i), name=f"P{i}") for i in range(1, 25)]
cons = ComponentConstraints(
mpn="IC24",
package_info=PackageInfo(base_family="QFN", package="QFN-24", pin_count=24),
pintable=pins,
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1",
value="IC",
footprint="Package_DFN_QFN:QFN-24",
component_type=ComponentType.IC,
mpn="IC24",
pins={str(i): "SIG" for i in range(1, 25)},
),
},
)
return graph, {"IC24": cons}
def _bom_ids(tmp_path: Path, **pcb_kw) -> tuple[set[str], LayoutGraph]:
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, **pcb_kw))
graph, cmap = _ds24()
ids = {f.rule_id for f in check_bom_pcb_datasheet(graph, cmap, layout)}
return ids, layout
def test_24_pads_0_vias_no_mismatch(tmp_path: Path):
ids, layout = _bom_ids(tmp_path, n_pads=24)
u1 = layout.footprints["U1"]
assert len(u1.pads) == 24
assert layout.vias == []
assert "PE-BOM-011" not in ids
def test_stitching_via_pads_are_vias_not_pads(tmp_path: Path):
stitches = [(25 + i, -1.0 + (i % 3) * 0.7, -1.0 + (i // 3) * 0.7, "GND") for i in range(9)]
ids, layout = _bom_ids(tmp_path, n_pads=24, stitch_via_pads=stitches)
u1 = layout.footprints["U1"]
assert [p.number for p in u1.pads] == [str(i) for i in range(1, 25)]
assert len(u1.pads) == 24
assert len(layout.vias) == 9
assert all(v.net == "GND" for v in layout.vias)
assert "PE-BOM-011" not in ids
def test_nearby_external_board_vias_not_counted_as_pads(tmp_path: Path):
nearby = [
(52.0, 40.0, "GND", 1),
(48.0, 42.0, "3V3", 3),
(50.5, 37.5, "SIG", 2),
]
ids, layout = _bom_ids(tmp_path, n_pads=24, board_vias=nearby)
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 3
assert "PE-BOM-011" not in ids
def test_vias_inside_footprint_courtyard_not_pads(tmp_path: Path):
inside = [
(50.0, 40.0, "GND", 1),
(50.4, 40.4, "VCC", 4),
(49.6, 39.6, "SIG", 2),
]
ids, layout = _bom_ids(tmp_path, n_pads=24, board_vias=inside)
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 3
assert "PE-BOM-011" not in ids
def test_nested_footprint_via_token_is_via(tmp_path: Path):
ids, layout = _bom_ids(
tmp_path,
n_pads=24,
nested_vias=[(0.0, 0.0, "GND"), (0.5, 0.5, "3V3")],
)
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 2
nets = {v.net for v in layout.vias}
assert nets == {"GND", "3V3"}
assert "PE-BOM-011" not in ids
def test_via_pads_on_gnd_3v3_vcc_signal(tmp_path: Path):
stitches = [
(26, 0.0, 0.0, "GND"),
(27, 0.5, 0.0, "3V3"),
(28, 0.0, 0.5, "VCC"),
(29, 0.5, 0.5, "SIG"),
]
ids, layout = _bom_ids(tmp_path, n_pads=24, stitch_via_pads=stitches)
assert len(layout.footprints["U1"].pads) == 24
assert {v.net for v in layout.vias} == {"GND", "3V3", "VCC", "SIG"}
assert "PE-BOM-011" not in ids
def test_true_mismatch_24_vs_23_still_fires(tmp_path: Path):
ids, layout = _bom_ids(tmp_path, n_pads=23)
assert len(layout.footprints["U1"].pads) == 23
assert "PE-BOM-011" in ids
def test_true_mismatch_24_vs_25_still_fires(tmp_path: Path):
ids, layout = _bom_ids(
tmp_path,
n_pads=24,
extra_smd=[(30, 1.8, 1.8, "SIG")],
)
assert len(layout.footprints["U1"].pads) == 25 # 24 signal + extra 30
assert "PE-BOM-011" in ids
def test_vias_do_not_hide_true_mismatch(tmp_path: Path):
stitches = [(40 + i, 0.2 * i, 0.2, "GND") for i in range(6)]
board = [(51.0, 41.0, "3V3", 3), (49.0, 39.0, "SIG", 2)]
ids23, layout23 = _bom_ids(
tmp_path, n_pads=23, stitch_via_pads=stitches, board_vias=board,
)
assert len(layout23.footprints["U1"].pads) == 23
assert len(layout23.vias) == 8
assert "PE-BOM-011" in ids23
tmp2 = tmp_path / "b"
tmp2.mkdir()
ids25, layout25 = _bom_ids(
tmp2,
n_pads=24,
extra_smd=[(30, 1.8, 1.8, "SIG")],
stitch_via_pads=stitches,
board_vias=board,
)
assert len(layout25.footprints["U1"].pads) == 25
assert "PE-BOM-011" in ids25
def test_exposed_pad_plus_via_array_matches_24_pin_datasheet(tmp_path: Path):
"""QFN-24 + EP land + thermal via array: pin_count 24, not 34."""
stitches = [(26 + i, -0.6 + (i % 3) * 0.6, -0.6 + (i // 3) * 0.6, "GND") for i in range(9)]
ids, layout = _bom_ids(tmp_path, n_pads=24, stitch_via_pads=stitches, ep_pad=True)
u1 = layout.footprints["U1"]
assert len(u1.pads) == 25
assert any(p.number == "25" and p.pinfunction == "EXP_25" for p in u1.pads)
assert len(layout.vias) == 9
assert "PE-BOM-011" not in ids
def test_tht_solder_pads_stay_component_pads(tmp_path: Path):
p = tmp_path / "tht.kicad_pcb"
p.write_text("""(kicad_pcb (version 20240108)
(net 0 "")
(net 1 "GND")
(net 2 "SIG")
(footprint "Resistor_THT:R_Axial"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" thru_hole circle (at -3.81 0) (size 1.6 1.6) (drill 0.8)
(layers "*.Cu" "*.Mask") (net 2 "SIG"))
(pad "2" thru_hole circle (at 3.81 0) (size 1.6 1.6) (drill 0.8)
(layers "*.Cu" "*.Mask") (net 1 "GND"))
)
)
""")
g = parse_kicad_pcb(p)
assert [p.number for p in g.footprints["R1"].pads] == ["1", "2"]
assert g.vias == []
class _Ws:
def __init__(self, root: Path):
self.root = root
def local_path(self, rel: str) -> Path:
return self.root / rel
def _upload_file(self, rel: str) -> None:
return None
def test_pcb_pipeline_reparses_stale_layout_graph(tmp_path: Path):
from backend.periscopex.models import LayoutFootprint, LayoutGraph, LayoutPad
from backend.services.pcb_pipeline import _load_layout
uploads = tmp_path / "uploads"
uploads.mkdir()
pcb = _qfn24_pcb(
tmp_path,
n_pads=24,
stitch_via_pads=[(26, 0.0, 0.0, "GND")],
)
(uploads / "pcb.kicad_pcb").write_text(pcb.read_text())
stale = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1",
footprint="QFN-24",
x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 35)],
),
},
)
(tmp_path / "layout_graph.json").write_text(stale.model_dump_json())
layout = _load_layout(_Ws(tmp_path))
assert layout is not None
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 1
+366
View File
@@ -0,0 +1,366 @@
"""G2 placement vs simple_project — no invented millimetre boards.
Favor: real U1 + C4 on +3V3; same_layer True + opposite copper → PE-PLC-003.
Against: no PCB; same copper; same_layer unset; via in courtyard.
"""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from pathlib import Path
from backend.periscopex.eval_report import eval_simple_project
from backend.periscopex.models import (
ComponentConstraints,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutVia,
Pin,
)
from backend.periscopex.placement_check import _in_poly, check_placement
SIMPLE = SIMPLE_PROJECT
def _graph() -> DesignGraph:
return DesignGraph.model_validate_json(
(SIMPLE / "design_graph.json").read_text()
)
def test_simple_project_has_ldo_and_3v3_caps():
g = _graph()
assert "U1" in g.components
assert g.components["U1"].mpn == "SPX3819M5-L-3-3/TR"
caps = g.capacitors_on_net("+3V3")
assert caps, "simple_project must keep decoupling caps on +3V3"
def test_simple_project_without_pcb_has_no_ps_plc():
findings = check_placement(_graph(), {}, None)
assert findings == []
assert all(not (f.rule_id or "").startswith("PE-PLC-") for f in findings)
def test_simple_project_eval_has_no_placement_keys():
scores = eval_simple_project(SIMPLE)
assert scores.finding_count == 8
assert scores.precision == 1.0
assert scores.recall == 1.0
assert not any(k.startswith("PE-PLC-") for k in scores.extra_keys)
def test_via_count_is_calculated_from_courtyard_and_min_parameter():
courtyard = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
via_xy = [(0.5, 0.5), (10.0, 10.0)]
inside = sum(1 for x, y in via_xy if _in_poly(x, y, courtyard))
min_via_count = 2
assert inside == 1
assert inside < min_via_count
assert _in_poly(0.5, 0.5, courtyard) is True
assert _in_poly(10.0, 10.0, courtyard) is False
def _ldo_cons(*, same_layer: bool | None):
mpn = "SPX3819M5-L-3-3/TR"
rule: dict = {"kind": "decoupling_proximity", "pin": "5"}
if same_layer is not None:
rule["same_layer"] = same_layer
return {
mpn: ComponentConstraints(
mpn=mpn,
pintable=[Pin(number=5, name="+3V3")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[rule],
)
}
def _u1_c4_layout(*, ic_layer: str, cap_layer: str, via_xy=None, courtyard=None):
vias = []
if via_xy is not None:
vias = [LayoutVia(x=via_xy[0], y=via_xy[1], net="+3V3")]
return LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer=ic_layer,
pads=[LayoutPad(number="5", x=0.0, y=0.0, net="+3V3")],
courtyard=list(courtyard or []),
),
"C4": LayoutFootprint(
reference="C4", x=0.5, y=0, layer=cap_layer,
pads=[LayoutPad(number="1", x=0.5, y=0.0, net="+3V3")],
),
},
vias=vias,
)
def test_same_layer_param_opposite_layers_is_ps_plc_003():
findings = check_placement(
_graph(),
_ldo_cons(same_layer=True),
_u1_c4_layout(ic_layer="F.Cu", cap_layer="B.Cu"),
)
plc = [f for f in findings if f.rule_id == "PE-PLC-003"]
assert len(plc) == 1
assert plc[0].status == "WARNING"
assert plc[0].net == "+3V3"
assert plc[0].designator == "U1"
def test_same_layer_param_same_copper_is_silent():
assert check_placement(
_graph(),
_ldo_cons(same_layer=True),
_u1_c4_layout(ic_layer="F.Cu", cap_layer="F.Cu"),
) == []
def test_opposite_layers_without_same_layer_param_is_silent():
assert check_placement(
_graph(),
_ldo_cons(same_layer=None),
_u1_c4_layout(ic_layer="F.Cu", cap_layer="B.Cu"),
) == []
def test_opposite_layers_with_via_in_courtyard_is_silent():
courtyard = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
findings = check_placement(
_graph(),
_ldo_cons(same_layer=True),
_u1_c4_layout(
ic_layer="F.Cu",
cap_layer="B.Cu",
via_xy=(0.5, 0.5),
courtyard=courtyard,
),
)
assert all(f.rule_id != "PE-PLC-003" for f in findings)
def test_simple_project_has_crystal_load_caps():
g = _graph()
assert g.components["X1"].mpn == "AV08000301"
assert "C9" in g.capacitors_on_net("/HFXIN")
assert "C10" in g.capacitors_on_net("/HFXOUT")
def _xtal_cons(*, max_distance_mm: float | None):
mpn = "AV08000301"
rule: dict = {"kind": "decoupling_proximity", "pin": "1"}
if max_distance_mm is not None:
rule["max_distance_mm"] = max_distance_mm
return {
mpn: ComponentConstraints(
mpn=mpn,
pintable=[Pin(number=1, name="/HFXIN")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[rule],
)
}
def _x1_c9_layout(*, segments=None, cap_x: float = 0.5):
return LayoutGraph(
footprints={
"X1": LayoutFootprint(
reference="X1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0.0, y=0.0, net="/HFXIN")],
),
"C9": LayoutFootprint(
reference="C9", x=cap_x, y=0, layer="F.Cu",
pads=[LayoutPad(number="1", x=cap_x, y=0.0, net="/HFXIN")],
),
},
segments=list(segments or []),
)
def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001():
limit = 2.0
segs = [
LayoutSegment(start=(0.0, 0.0), end=(10.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
]
findings = check_placement(
_graph(),
_xtal_cons(max_distance_mm=limit),
_x1_c9_layout(cap_x=10.0, segments=segs),
)
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
assert len(plc) == 1
assert plc[0].designator == "X1"
assert plc[0].net == "/HFXIN"
def test_crystal_load_cap_within_max_distance_mm_is_silent():
segs = [
LayoutSegment(start=(0.0, 0.0), end=(0.5, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
]
assert check_placement(
_graph(),
_xtal_cons(max_distance_mm=2.0),
_x1_c9_layout(cap_x=0.5, segments=segs),
) == []
def test_unrouted_crystal_cap_is_insufficient_not_euclidean():
findings = check_placement(
_graph(),
_xtal_cons(max_distance_mm=2.0),
_x1_c9_layout(cap_x=10.0),
)
assert all(f.rule_id != "PE-PLC-001" for f in findings)
insuf = [f for f in findings if f.rule_id == "PE-PLC-005"]
assert len(insuf) == 1
assert insuf[0].evidence_status == "INSUFFICIENT"
assert insuf[0].status == "INFO"
def test_track_path_longer_than_max_distance_mm_is_ps_plc_001():
limit = 2.0
segs = [
LayoutSegment(start=(0.0, 0.0), end=(0.0, 10.0), width=0.2, layer="F.Cu", net="/HFXIN"),
LayoutSegment(start=(0.0, 10.0), end=(1.0, 10.0), width=0.2, layer="F.Cu", net="/HFXIN"),
LayoutSegment(start=(1.0, 10.0), end=(1.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"),
]
findings = check_placement(
_graph(),
_xtal_cons(max_distance_mm=limit),
_x1_c9_layout(cap_x=1.0, segments=segs),
)
plc = [f for f in findings if f.rule_id == "PE-PLC-001"]
assert len(plc) == 1
assert plc[0].net == "/HFXIN"
def _xtal_keepout_cons():
mpn = "AV08000301"
return {
mpn: ComponentConstraints(
mpn=mpn,
pintable=[Pin(number=1, name="/HFXIN")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{"kind": "keepout", "pin": "1"}],
)
}
def _x1_keepout_layout(*, net: str, courtyard=True):
poly = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] if courtyard else []
return LayoutGraph(
footprints={
"X1": LayoutFootprint(
reference="X1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0.0, y=0.0, net="/HFXIN")],
courtyard=poly,
),
},
segments=[
LayoutSegment(
start=(0.4, 0.4), end=(0.6, 0.4),
width=0.2, layer="F.Cu", net=net,
),
],
)
def test_keepout_foreign_track_in_courtyard_is_ps_plc_004():
findings = check_placement(
_graph(),
_xtal_keepout_cons(),
_x1_keepout_layout(net="GND"),
)
plc = [f for f in findings if f.rule_id == "PE-PLC-004"]
assert len(plc) == 1
assert plc[0].designator == "X1"
assert plc[0].net == "GND"
assert plc[0].finding_class == "REVIEW"
assert plc[0].action
def test_ic_courtyard_pad_nets_are_not_keepout_violations():
"""GND/I2C into an IC courtyard is normal copper to that part's pads."""
from backend.periscopex.models import Component, ComponentType, Net, NetType, PinConnection
cons = {
"MCU": ComponentConstraints(
mpn="MCU",
pintable=[Pin(number="1", name="SDA")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{"kind": "keepout", "pin": "1"}],
)
}
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "SDA", "2": "GND"},
),
},
nets={
"SDA": Net(
name="SDA", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"GND": Net(
name="GND", net_type=NetType.GROUND,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[
LayoutPad(number="1", x=0.2, y=0.2, net="SDA"),
LayoutPad(number="2", x=0.8, y=0.2, net="GND"),
],
courtyard=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
),
},
segments=[
LayoutSegment(start=(0.4, 0.4), end=(0.6, 0.4), width=0.2, layer="F.Cu", net="GND"),
LayoutSegment(start=(0.3, 0.5), end=(0.5, 0.5), width=0.2, layer="F.Cu", net="SDA"),
],
)
findings = check_placement(graph, cons, layout)
assert [f for f in findings if f.rule_id == "PE-PLC-004"] == []
def test_keepout_own_net_in_courtyard_is_silent():
assert check_placement(
_graph(),
_xtal_keepout_cons(),
_x1_keepout_layout(net="/HFXIN"),
) == []
def test_keepout_without_courtyard_is_silent():
assert check_placement(
_graph(),
_xtal_keepout_cons(),
_x1_keepout_layout(net="GND", courtyard=False),
) == []
def test_schema_deterministic_runner_omits_layout_checks():
"""PCB placement/SI belong to MODE=pcb, not the schematic review seed."""
import inspect
from backend.services.validation import _run_deterministic_checks
src = inspect.getsource(_run_deterministic_checks)
assert "check_placement" not in src
assert "check_si" not in src
+111
View File
@@ -0,0 +1,111 @@
"""Layout F2 placement_pack — gated; no invented millimetres."""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from pathlib import Path
from backend.periscopex.functional_groups import (
PlacementIcGroup,
PlacementSatellite,
FunctionalGroupsReport,
build_functional_groups,
)
from backend.periscopex.models import (
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
)
from backend.periscopex.placement_pack import build_placement_pack
SIMPLE = SIMPLE_PROJECT
def _graph() -> DesignGraph:
return DesignGraph.model_validate_json(
(SIMPLE / "design_graph.json").read_text(encoding="utf-8"),
)
def test_simple_project_without_pcb_skips_pack():
plan = build_functional_groups(_graph())
pack = build_placement_pack(plan, None)
assert pack.status == "skipped"
assert pack.skip_reason == "no_pcb_footprints"
assert pack.placements == []
def test_layout_without_numeric_rules_skips():
plan = FunctionalGroupsReport(
groups=[
PlacementIcGroup(
ref="U1",
layout_rules=[{"kind": "decoupling_proximity", "pin": "5"}],
satellites=[
PlacementSatellite(
ref="C4",
component_type="capacitor",
role_hint="decoupling",
nets=["+3V3"],
),
],
),
],
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="5", x=0.0, y=0.0, net="+3V3")],
),
},
)
pack = build_placement_pack(plan, layout)
assert pack.status == "skipped"
assert pack.skip_reason == "no_numeric_layout_rules"
def test_numeric_rule_proposes_satellite_within_limit():
limit = 2.0
plan = FunctionalGroupsReport(
groups=[
PlacementIcGroup(
ref="U1",
layout_rules=[{
"kind": "decoupling_proximity",
"pin": "5",
"max_distance_mm": limit,
}],
satellites=[
PlacementSatellite(
ref="C4",
component_type="capacitor",
role_hint="decoupling",
nets=["+3V3"],
),
],
),
],
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number="5", x=10.0, y=20.0, net="+3V3")],
),
},
)
pack = build_placement_pack(plan, layout)
assert pack.status == "packed"
assert pack.skip_reason is None
assert len(pack.placements) == 1
p = pack.placements[0]
assert p.ref == "C4"
assert p.anchor_ref == "U1"
assert p.max_distance_mm == limit
assert p.layer == "F.Cu"
dist = ((p.proposed_x - 10.0) ** 2 + (p.proposed_y - 20.0) ** 2) ** 0.5
assert dist <= limit + 1e-6
assert dist > 0
+53
View File
@@ -0,0 +1,53 @@
"""Placement pipeline smoke — topology only, no LLM."""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from pathlib import Path
import pytest
from backend.periscopex.functional_groups import (
FunctionalGroupsReport,
build_placement_plan,
)
from backend.periscopex.models import DesignGraph
SIMPLE = SIMPLE_PROJECT
@pytest.fixture
def graph() -> DesignGraph:
path = SIMPLE / "design_graph.json"
return DesignGraph.model_validate_json(path.read_text(encoding="utf-8"))
def test_placement_plan_writes_domains_and_groups(graph: DesignGraph, tmp_path: Path):
plan = build_placement_plan(graph)
assert plan.objective == "routing"
assert plan.domains
assert plan.groups
out = tmp_path / "placement_plan.json"
out.write_text(plan.model_dump_json(indent=2) + "\n")
loaded = FunctionalGroupsReport.model_validate_json(out.read_text())
assert len(loaded.groups) == len(plan.groups)
def test_placement_busy_helpers():
from backend.services.projects import ProjectMeta, STATUS_QUEUED, STATUS_RUNNING
# Mirror placement_pipeline helpers without importing the worker stack
# (that pulls Anthropic via services.pipeline in lean test envs).
active = frozenset({"queued", "running"})
analysis = frozenset({STATUS_QUEUED, STATUS_RUNNING})
draft = ProjectMeta(id="p", name="t", created="2026-01-01", user_id="u")
assert (draft.placement_status or "draft") not in active
assert draft.status not in analysis
draft.placement_status = "queued"
assert (draft.placement_status or "draft") in active
draft.status = STATUS_RUNNING
assert draft.status in analysis
+428
View File
@@ -0,0 +1,428 @@
"""G1 SI vs simple_project — no invented millimetres or USBPHY boards.
Favor: real /USB.D+ and /USB.D- pair by suffix; eval has no PE-SI keys.
Against: no .kicad_pcb → no PE-SI-001; 3W is not invented.
I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only.
"""
from __future__ import annotations
from tests.paths import SIMPLE_PROJECT, TAXONOMY
from pathlib import Path
from backend.periscopex.eval_report import eval_simple_project
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutGraph,
LayoutSegment,
LayoutVia,
Net,
NetType,
Pin,
PinConnection,
)
from backend.periscopex.si_check import bus_class, check_si, partner_net, skip_si_net
SIMPLE = SIMPLE_PROJECT
def _graph() -> DesignGraph:
return DesignGraph.model_validate_json(
(SIMPLE / "design_graph.json").read_text()
)
def test_simple_project_usb_dp_dm_are_a_named_pair():
g = _graph()
assert "/USB.D+" in g.nets
assert "/USB.D-" in g.nets
assert partner_net("/USB.D+") == "/USB.D-"
assert partner_net("/USB.D-") == "/USB.D+"
assert partner_net("/USBC.D+") == "/USBC.D-"
def test_simple_project_without_pcb_has_no_ps_si_001():
findings = check_si(_graph(), {}, None)
assert findings == []
assert all(f.rule_id != "PE-3W-001" for f in findings)
def test_simple_project_eval_has_no_si_keys():
scores = eval_simple_project(SIMPLE)
assert scores.finding_count == 8
assert scores.precision == 1.0
assert scores.recall == 1.0
assert not any(
k.startswith("PE-SI-") or k.startswith("PE-3W-")
for k in scores.extra_keys
)
def test_skip_i2c_gpio_cc_regn():
assert skip_si_net("I2C_SDA")
assert skip_si_net("GPIO9")
assert skip_si_net("USB_CC1")
assert skip_si_net("/power/REGN")
assert skip_si_net("ESP32_EN")
assert not skip_si_net("USB_D+")
assert not skip_si_net("USB_DP")
assert bus_class("USB_D+") == "usb2"
assert bus_class("USB_SSTX_P") == "usb3"
assert bus_class("ETH_TX+") == "eth_mdi"
assert bus_class("RGMII_TXD0") == "rgmii"
assert bus_class("SGMII_TX_P") == "sgmii"
assert bus_class("DDR3_DQ0") == "ddr3_dq"
assert bus_class("DDR3_DQS0_P") == "ddr3_dqs"
assert bus_class("MIPI_D0_P") == "mipi"
assert bus_class("GTX_CLK") == "hf_clk"
assert bus_class("XTAL_IN") is None
assert bus_class("USB_CC1") is None
assert bus_class("I2C_SCL") is None
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": Component(
reference="U1", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"1": "USB_D+", "2": "USB_D-", "3": "USB_CC1", "4": "I2C_SDA"},
),
},
nets={
"USB_D+": Net(name="USB_D+", net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number="1"),
]),
"USB_D-": Net(name="USB_D-", net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number="2"),
]),
"USB_CC1": Net(name="USB_CC1", net_type=NetType.SIGNAL, pins=[]),
"I2C_SDA": Net(name="I2C_SDA", net_type=NetType.SIGNAL, pins=[]),
},
)
def _usb_layout() -> LayoutGraph:
return LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
LayoutSegment(start=(0, 5), end=(8, 5), width=0.2, layer="F.Cu", net="USB_CC1"),
LayoutSegment(start=(0, 8), end=(20, 8), width=0.2, layer="F.Cu", net="I2C_SDA"),
],
vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)],
)
def _if_rows(**kwargs):
base = {
"net_name": "USB_D+",
"length_mm": 10.0,
"partner_net_name": "USB_D-",
"is_differential": True,
"z0_avg_ohms": 88.0,
"z0_min_ohms": 46.0,
"z0_max_ohms": 92.0,
"topologies": ["MICROSTRIP"],
"flags": (),
}
base.update(kwargs)
dm = {
"net_name": "USB_D-",
"length_mm": 12.5,
"partner_net_name": "USB_D+",
"is_differential": True,
"z0_avg_ohms": 88.0,
"z0_min_ohms": 46.0,
"z0_max_ohms": 92.0,
"topologies": ["MICROSTRIP"],
"flags": (),
}
return [base, dm]
def test_emmaforo_usb_avg_in_window_min_out_is_margin():
"""Zavg 88 Ω in 8199, min 46 out → MARGIN; CC is not a 90 Ω pair."""
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "impedance",
"net_class": "usb",
"zdiff_ohm": 90,
"tolerance_pct": 10,
"note": "USB DP/DM 90 Ω ±10%",
"source_page": 12,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
zf = [f for f in findings if f.rule_id == "PE-SI-002"]
assert len(zf) == 1
assert zf[0].finding.startswith("MARGIN:")
complete_finding(zf[0])
assert zf[0].status == "WARNING"
assert zf[0].finding_class != "RULE"
assert not any("CC" in (f.net or "") for f in findings)
assert not any("I2C" in (f.net or "") for f in findings)
def test_usb_without_library_z_is_insufficient_not_90ohm_fail():
findings = check_si(_usb_graph(), {}, _usb_layout(), _if_rows())
ids = {f.rule_id for f in findings}
assert "PE-SI-010" in ids
assert "PE-SI-002" not in ids
f = next(x for x in findings if x.rule_id == "PE-SI-010")
assert f.evidence_status == "INSUFFICIENT"
assert f.status == "INFO"
assert "90" not in (f.requirement or "")
blob = f"{f.finding} {f.requirement} {f.inference}"
assert "FAIL" not in f.finding
assert "90 Ω" not in blob or "folklore" in (f.inference or "").lower()
assert "CC" not in (f.net or "")
def test_length_match_2_5mm_vs_1mm_is_fail():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "length_match",
"net_class": "usb",
"max_distance_mm": 1.0,
"note": "intra-pair < 1 mm",
"source_page": 12,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
sk = [f for f in findings if f.rule_id == "PE-SI-001"]
assert sk and sk[0].finding.startswith("FAIL:")
complete_finding(sk[0])
assert sk[0].status == "ERROR"
def test_i2c_not_checked_as_50_ohm():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "impedance",
"z0_ohm": 50,
"tolerance_pct": 10,
"note": "should not hit I2C",
"source_page": 1,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), [
*_if_rows(),
{"net_name": "I2C_SDA", "z0_avg_ohms": 120.0, "length_mm": 20.0},
])
assert all("I2C" not in (f.net or "") for f in findings)
assert all("SDA" not in f.finding for f in findings)
def test_en_rc_series_resistor_does_not_paint_usb():
"""PE-SI-009 must not use ESP32 EN RC 10 kΩ / 1 µF as a USB series R."""
cons = ComponentConstraints(
mpn="ESP32",
pintable=[Pin(number="1", name="D+"), Pin(number="3", name="EN")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "series_resistor",
"pin": "EN",
"value_ohms": 10000,
"note": "EN RC 10 kΩ / 1 µF",
"source_page": 28,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
si009 = [f for f in findings if f.rule_id == "PE-SI-009"]
assert si009 == []
zfail = [f for f in findings if f.rule_id == "PE-SI-002"]
assert zfail == []
si010 = [f for f in findings if f.rule_id == "PE-SI-010"]
assert si010
assert si010[0].evidence_status == "INSUFFICIENT"
assert "88" in (si010[0].finding or "")
assert "90" not in (si010[0].requirement or "")
def _phy_graph() -> DesignGraph:
pins = {
"1": "ETH_TX+", "2": "ETH_TX-",
"3": "RGMII_TXD0", "4": "RGMII_TXD1",
"5": "USB_SSTX_P", "6": "USB_SSTX_N",
"7": "DDR3_DQ0", "8": "DDR3_DQ1",
"9": "DDR3_DQS0_P", "10": "DDR3_DQS0_N",
"11": "USB_D+", "12": "USB_D-",
}
nets = {
n: Net(name=n, net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number=p),
])
for p, n in pins.items()
}
return DesignGraph(
components={
"U1": Component(
reference="U1", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins=pins,
),
},
nets=nets,
)
def _phy_layout() -> LayoutGraph:
segs = [
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="ETH_TX+"),
LayoutSegment(start=(0, 0.4), end=(10.2, 0.4), width=0.2, layer="F.Cu", net="ETH_TX-"),
LayoutSegment(start=(0, 2), end=(8, 2), width=0.15, layer="F.Cu", net="RGMII_TXD0"),
LayoutSegment(start=(0, 2.4), end=(8.4, 2.4), width=0.15, layer="F.Cu", net="RGMII_TXD1"),
LayoutSegment(start=(0, 4), end=(20, 4), width=0.12, layer="F.Cu", net="USB_SSTX_P"),
LayoutSegment(start=(0, 4.3), end=(20.5, 4.3), width=0.12, layer="F.Cu", net="USB_SSTX_N"),
LayoutSegment(start=(0, 6), end=(15, 6), width=0.1, layer="F.Cu", net="DDR3_DQ0"),
LayoutSegment(start=(0, 6.2), end=(15.1, 6.2), width=0.1, layer="F.Cu", net="DDR3_DQ1"),
LayoutSegment(start=(0, 7), end=(12, 7), width=0.1, layer="F.Cu", net="DDR3_DQS0_P"),
LayoutSegment(start=(0, 7.2), end=(12.3, 7.2), width=0.1, layer="F.Cu", net="DDR3_DQS0_N"),
LayoutSegment(start=(0, 9), end=(10, 9), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 9.4), end=(12.5, 9.4), width=0.2, layer="F.Cu", net="USB_D-"),
]
return LayoutGraph(segments=segs, vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)])
def _z(name, partner, avg, length, **kw):
row = {
"net_name": name,
"partner_net_name": partner,
"is_differential": True,
"z0_avg_ohms": avg,
"z0_min_ohms": avg - 2,
"z0_max_ohms": avg + 2,
"length_mm": length,
"topologies": ["MICROSTRIP"],
"flags": (),
}
row.update(kw)
return row
def test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="TX+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[
{
"kind": "impedance",
"net_class": "eth_mdi",
"zdiff_ohm": 100,
"tolerance_pct": 10,
"note": "MDI 100 Ω to RJ45",
"source_page": 40,
},
{
"kind": "max_length",
"net_class": "rgmii",
"max_distance_mm": 20,
"note": "RGMII MACPHY trace < 20 mm",
"source_page": 41,
},
{
"kind": "length_match",
"net_class": "ddr3",
"max_distance_mm": 1.0,
"note": "DDR3 DQS intra-pair",
"source_page": 42,
},
{
"kind": "impedance",
"net_class": "usb3",
"zdiff_ohm": 90,
"tolerance_pct": 10,
"note": "USB-C SuperSpeed 90 Ω",
"source_page": 43,
},
{
"kind": "series_resistor",
"net_class": "rgmii",
"value_ohms": 22,
"note": "RGMII series 22 Ω",
"source_page": 41,
},
],
)
rows = [
_z("ETH_TX+", "ETH_TX-", 95.0, 10.0),
_z("ETH_TX-", "ETH_TX+", 95.0, 10.2),
_z("USB_SSTX_P", "USB_SSTX_N", 88.0, 20.0),
_z("USB_SSTX_N", "USB_SSTX_P", 88.0, 20.5),
_z("DDR3_DQS0_P", "DDR3_DQS0_N", 50.0, 12.0),
_z("DDR3_DQS0_N", "DDR3_DQS0_P", 50.0, 12.3),
_z("USB_D+", "USB_D-", 88.0, 10.0),
_z("USB_D-", "USB_D+", 88.0, 12.5),
{"net_name": "RGMII_TXD0", "z0_avg_ohms": 48.0, "length_mm": 8.0, "topologies": ["MICROSTRIP"]},
{"net_name": "RGMII_TXD1", "z0_avg_ohms": 48.0, "length_mm": 8.4, "topologies": ["MICROSTRIP"]},
{"net_name": "DDR3_DQ0", "z0_avg_ohms": 50.0, "length_mm": 15.0, "topologies": ["MICROSTRIP"]},
]
findings = check_si(_phy_graph(), {"PHY": cons}, _phy_layout(), rows)
by = {}
for f in findings:
by.setdefault(f.rule_id, []).append(f)
mdi_z = [f for f in by.get("PE-SI-002", []) if f.net and "ETH" in f.net]
assert mdi_z and mdi_z[0].finding.startswith("PASS:")
usb3_z = [f for f in by.get("PE-SI-002", []) if f.net and "SSTX" in f.net]
assert usb3_z and usb3_z[0].finding.startswith("PASS:")
usb2_z = [f for f in by.get("PE-SI-002", []) if f.net and "USB_D" in (f.net or "")]
assert usb2_z == []
rgmii_len = [f for f in by.get("PE-SI-003", []) if f.net and "RGMII" in f.net]
assert rgmii_len and rgmii_len[0].finding.startswith("PASS:")
assert not any(f.net and "ETH_TX" in f.net for f in by.get("PE-SI-003", []))
dqs = [f for f in by.get("PE-SI-001", []) if f.net and "DQS" in f.net]
assert dqs and dqs[0].finding.startswith("PASS:")
si009 = by.get("PE-SI-009", [])
assert si009
assert all(f.net and "RGMII" in f.net for f in si009)
assert not any(f.net and "USB" in f.net for f in si009)
si010 = by.get("PE-SI-010", [])
nets010 = {f.net for f in si010}
assert any(n and "USB_D" in n for n in nets010)
assert not any(n and "ETH_TX" in n for n in nets010)
assert not any(n and "SSTX" in n for n in nets010)
def test_empty_net_class_impedance_does_not_paint_usb():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "impedance",
"z0_ohm": 50,
"tolerance_pct": 10,
"note": "generic 50 Ω with no bus",
"source_page": 1,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
assert all(f.rule_id != "PE-SI-002" for f in findings)
assert any(f.rule_id == "PE-SI-010" for f in findings)
+144
View File
@@ -0,0 +1,144 @@
"""LDO/resistor thermal — no invented I_load or θJA."""
from __future__ import annotations
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
SimpleComponentSpecs,
)
from backend.periscopex.thermal_check import check_thermal
def _graph(components, nets):
net_objs = {}
for name, (ntype, volt, conns) in nets.items():
net_objs[name] = Net(
name=name, net_type=ntype, voltage=volt,
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
)
return DesignGraph(components=components, nets=net_objs)
def _ldo(values, subtype="ic.power.ldo"):
return Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, component_subtype=subtype,
mpn="LDOX",
pins={"1": "VIN", "2": "VOUT", "3": "GND"},
specs=SimpleComponentSpecs(specs_type="ic", component_subtype=subtype, values=values),
)
def _cons():
return {
"LDOX": ComponentConstraints(
mpn="LDOX",
component_subtype="ic.power.ldo",
pintable=[
Pin(number=1, name="VIN"),
Pin(number=2, name="VOUT"),
Pin(number=3, name="GND"),
],
absolute_maximum_ratings=[], rules=[],
)
}
def test_ldo_without_theta_ja_is_info():
g = _graph(
{"U1": _ldo({"i_load_a": 0.2})},
{
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
},
)
findings = check_thermal(g, _cons())
assert len(findings) == 1
assert findings[0].rule_id == "PE-TH-001"
assert findings[0].status == "INFO"
assert "theta_ja" in findings[0].finding.lower() or "theta_ja" in findings[0].why.lower()
def test_iout_max_is_not_used_as_load():
g = _graph(
{"U1": _ldo({"iout_max_a": 0.5, "theta_ja": 160})},
{
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
},
)
assert check_thermal(g, _cons()) == []
def test_ldo_hot_tj_is_warning():
# 0.5 A * 1.7 V = 0.85 W * 160 °C/W + 25 = 161 °C
g = _graph(
{"U1": _ldo({"i_load_a": 0.5, "theta_ja": 160})},
{
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
},
)
findings = check_thermal(g, _cons())
assert len(findings) == 1
assert findings[0].rule_id == "PE-TH-002"
assert findings[0].status == "WARNING"
def test_shunt_over_rating_is_warning():
r = Component(
reference="R1", value="1", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "A", "2": "B"},
specs=ResistorSpecs(value_ohms=1.0, value_formatted="1", power_rating_w="0.125"),
)
g = _graph(
{"R1": r},
{
"A": (NetType.POWER, 3.3, [("R1", "1")]),
"B": (NetType.POWER, 0.0, [("R1", "2")]),
},
)
findings = check_thermal(g)
assert len(findings) == 1
assert findings[0].rule_id == "PE-TH-003"
assert findings[0].status == "WARNING"
def test_led_resistor_within_rating_is_silent():
led = Component(
reference="D1", value="LED", footprint="",
component_type=ComponentType.DISCRETE, component_subtype="discrete.led",
mpn="LEDX",
pins={"A": "+5V", "K": "NetK"},
specs=SimpleComponentSpecs(
specs_type="discrete", component_subtype="discrete.led",
values={"forward_voltage_v": 2.0, "forward_current_a": "20mA"},
),
)
r = Component(
reference="R1", value="330", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "NetK", "2": "GND"},
specs=ResistorSpecs(value_ohms=330.0, value_formatted="330", power_rating_w="0.125"),
)
g = _graph(
{"D1": led, "R1": r},
{
"+5V": (NetType.POWER, 5.0, [("D1", "A")]),
"NetK": (NetType.SIGNAL, None, [("D1", "K"), ("R1", "1")]),
"GND": (NetType.GROUND, 0.0, [("R1", "2")]),
},
)
assert check_thermal(g) == []