Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam. Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI. Document Rust criteria (none chosen; no rustup) and coding conformity.
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()) == []
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 81–99, 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 MAC–PHY 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)
|
||||
@@ -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) == []
|
||||
Reference in New Issue
Block a user