Keep validate.py and the finding engine as-is. AGPL LICENSE stays at the repo root. Docker overlays dependency then src. Do not delete the inherited tree.
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""Layout F1 functional_groups on simple_project — topology only, no mm."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from tests.paths import SIMPLE_PROJECT, TAXONOMY
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from backend.periscopex.functional_groups import build_functional_groups
|
|
from backend.periscopex.models import DesignGraph
|
|
|
|
SIMPLE = SIMPLE_PROJECT
|
|
|
|
|
|
def _graph() -> DesignGraph:
|
|
return DesignGraph.model_validate_json(
|
|
(SIMPLE / "design_graph.json").read_text(encoding="utf-8"),
|
|
)
|
|
|
|
|
|
def test_simple_project_has_mcu_ldo_bridge_groups():
|
|
report = build_functional_groups(_graph())
|
|
assert report.objective == "routing"
|
|
refs = {g.ref for g in report.groups}
|
|
assert {"U1", "U2", "U3"} <= refs
|
|
# No millimetre fields on the report model dump
|
|
raw = json.loads(report.model_dump_json())
|
|
blob = json.dumps(raw)
|
|
assert "max_distance_mm" not in blob or all(
|
|
r.get("max_distance_mm") is None
|
|
for g in raw["groups"]
|
|
for r in g.get("layout_rules") or []
|
|
)
|
|
|
|
|
|
def test_u3_owns_crystal_load_caps():
|
|
report = build_functional_groups(_graph())
|
|
u3 = next(g for g in report.groups if g.ref == "U3")
|
|
sat = {s.ref: s.role_hint for s in u3.satellites}
|
|
assert "X1" in sat
|
|
assert sat["X1"] == "crystal"
|
|
assert sat.get("C9") == "load_cap" or sat.get("C10") == "load_cap"
|
|
assert "C9" in sat and "C10" in sat
|
|
assert sat["C9"] == "load_cap"
|
|
assert sat["C10"] == "load_cap"
|
|
|
|
|
|
def test_u1_has_decoupling_or_bulk_on_rails():
|
|
report = build_functional_groups(_graph())
|
|
u1 = next(g for g in report.groups if g.ref == "U1")
|
|
roles = {s.role_hint for s in u1.satellites}
|
|
assert roles & {"decoupling", "bulk"}
|
|
|
|
|
|
def test_domains_cover_all_ics():
|
|
report = build_functional_groups(_graph())
|
|
covered = {r for d in report.domains for r in d.ic_refs}
|
|
assert covered == {"U1", "U2", "U3"}
|
|
assert all(d.assemble_order for d in report.domains)
|
|
|
|
|
|
def test_simple_project_splits_5v_and_3v3_domains():
|
|
"""LDO bridges +5V/+3V3 electrically but domains follow primary rails."""
|
|
report = build_functional_groups(_graph())
|
|
by_rail = {d.power_nets[0]: set(d.ic_refs) for d in report.domains if d.power_nets}
|
|
assert by_rail.get("+5V") == {"U2"}
|
|
assert by_rail.get("+3V3") == {"U1", "U3"}
|
|
assert len(report.domains) == 2
|
|
|
|
|
|
def test_ldo_power_satellites_stay_on_primary_rail():
|
|
"""LDO input-rail caps must not appear as primary-rail satellites."""
|
|
from backend.periscopex.models import (
|
|
CapacitorSpecs,
|
|
Component,
|
|
ComponentType,
|
|
DesignGraph,
|
|
InductorSpecs,
|
|
Net,
|
|
NetType,
|
|
PinConnection,
|
|
)
|
|
|
|
components = {
|
|
"U1": Component(
|
|
reference="U1", value="LDO", footprint="",
|
|
component_type=ComponentType.IC,
|
|
component_subtype="ic.power.ldo",
|
|
pins={"1": "VSYS", "2": "3V3_DIGITAL", "3": "GND", "4": "EN"},
|
|
),
|
|
"C_in": Component(
|
|
reference="C_in", value="10u", footprint="",
|
|
component_type=ComponentType.CAPACITOR,
|
|
pins={"1": "VSYS", "2": "GND"},
|
|
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10uF"),
|
|
),
|
|
"L1": Component(
|
|
reference="L1", value="2.2u", footprint="",
|
|
component_type=ComponentType.INDUCTOR,
|
|
pins={"1": "VSYS", "2": "VSYS"},
|
|
specs=InductorSpecs(value_henries=2.2e-6, value_formatted="2.2uH"),
|
|
),
|
|
"C_out": Component(
|
|
reference="C_out", value="100n", footprint="",
|
|
component_type=ComponentType.CAPACITOR,
|
|
pins={"1": "3V3_DIGITAL", "2": "GND"},
|
|
specs=CapacitorSpecs(value_farads=100e-9, value_formatted="100nF"),
|
|
),
|
|
"C_en": Component(
|
|
reference="C_en", value="1u", footprint="",
|
|
component_type=ComponentType.CAPACITOR,
|
|
pins={"1": "EN", "2": "GND"},
|
|
specs=CapacitorSpecs(value_farads=1e-6, value_formatted="1uF"),
|
|
),
|
|
"C_boot": Component(
|
|
reference="C_boot", value="47n", footprint="",
|
|
component_type=ComponentType.CAPACITOR,
|
|
pins={"1": "BTST", "2": "SW"},
|
|
specs=CapacitorSpecs(value_farads=47e-9, value_formatted="47nF"),
|
|
),
|
|
"J1": Component(
|
|
reference="J1", value="USB", footprint="",
|
|
component_type=ComponentType.CONNECTOR,
|
|
pins={"1": "3V3_DIGITAL", "2": "GND"},
|
|
),
|
|
"C_noise": Component(
|
|
reference="C_noise", value="100n", footprint="",
|
|
component_type=ComponentType.CAPACITOR,
|
|
pins={"1": "orphan", "2": "somewhere"},
|
|
),
|
|
}
|
|
# Extend U1 pins for bootstrap
|
|
components["U1"].pins["5"] = "BTST"
|
|
components["U1"].pins["6"] = "SW"
|
|
nets = {
|
|
"VSYS": Net(
|
|
name="VSYS", net_type=NetType.POWER,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="1"),
|
|
PinConnection(component_ref="C_in", pin_number="1"),
|
|
PinConnection(component_ref="L1", pin_number="1"),
|
|
],
|
|
),
|
|
"3V3_DIGITAL": Net(
|
|
name="3V3_DIGITAL", net_type=NetType.POWER,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="2"),
|
|
PinConnection(component_ref="C_out", pin_number="1"),
|
|
PinConnection(component_ref="J1", pin_number="1"),
|
|
],
|
|
),
|
|
"EN": Net(
|
|
name="EN", net_type=NetType.SIGNAL,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="4"),
|
|
PinConnection(component_ref="C_en", pin_number="1"),
|
|
],
|
|
),
|
|
"BTST": Net(
|
|
name="BTST", net_type=NetType.SIGNAL,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="5"),
|
|
PinConnection(component_ref="C_boot", pin_number="1"),
|
|
],
|
|
),
|
|
"SW": Net(
|
|
name="SW", net_type=NetType.SIGNAL,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="6"),
|
|
PinConnection(component_ref="C_boot", pin_number="2"),
|
|
],
|
|
),
|
|
"GND": Net(
|
|
name="GND", net_type=NetType.GROUND,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="3"),
|
|
PinConnection(component_ref="C_in", pin_number="2"),
|
|
PinConnection(component_ref="C_out", pin_number="2"),
|
|
PinConnection(component_ref="C_en", pin_number="2"),
|
|
PinConnection(component_ref="J1", pin_number="2"),
|
|
],
|
|
),
|
|
"orphan": Net(
|
|
name="orphan", net_type=NetType.SIGNAL,
|
|
pins=[PinConnection(component_ref="C_noise", pin_number="1")],
|
|
),
|
|
"somewhere": Net(
|
|
name="somewhere", net_type=NetType.SIGNAL,
|
|
pins=[PinConnection(component_ref="C_noise", pin_number="2")],
|
|
),
|
|
}
|
|
report = build_functional_groups(DesignGraph(components=components, nets=nets))
|
|
u1 = next(g for g in report.groups if g.ref == "U1")
|
|
sat = {s.ref: s.role_hint for s in u1.satellites}
|
|
assert sat.get("C_out") == "decoupling"
|
|
assert sat.get("C_en") == "bulk"
|
|
assert sat.get("C_boot") == "bridge"
|
|
assert "C_in" not in sat
|
|
assert "L1" not in sat
|
|
assert "J1" not in sat
|
|
assert "C_noise" not in sat
|
|
assert "other" not in sat.values()
|
|
|
|
|
|
def test_multi_rail_board_does_not_collapse_to_one_domain():
|
|
"""Charger→LDO→MCU must not become a single domain via shared POWER nets."""
|
|
from backend.periscopex.models import (
|
|
Component,
|
|
ComponentType,
|
|
DesignGraph,
|
|
Net,
|
|
NetType,
|
|
PinConnection,
|
|
)
|
|
|
|
components = {
|
|
"U1": Component(
|
|
reference="U1", value="BQ25896", footprint="",
|
|
component_type=ComponentType.IC,
|
|
component_subtype="ic.power.battery_charger",
|
|
pins={"1": "VBUS", "2": "VSYS", "3": "GND"},
|
|
),
|
|
"U2": Component(
|
|
reference="U2", value="AP2112", footprint="",
|
|
component_type=ComponentType.IC,
|
|
component_subtype="ic.power.ldo",
|
|
pins={"1": "VSYS", "2": "3V3_DIGITAL", "3": "GND"},
|
|
),
|
|
"U3": Component(
|
|
reference="U3", value="ESP32", footprint="",
|
|
component_type=ComponentType.IC,
|
|
component_subtype="ic.rf.wifi_module",
|
|
pins={"1": "3V3_DIGITAL", "2": "GND"},
|
|
),
|
|
"U4": Component(
|
|
reference="U4", value="SRV05", footprint="",
|
|
component_type=ComponentType.IC,
|
|
component_subtype="ic.protection.esd",
|
|
pins={"1": "VBUS", "2": "GND"},
|
|
),
|
|
}
|
|
nets = {
|
|
"VBUS": Net(
|
|
name="VBUS", net_type=NetType.POWER,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="1"),
|
|
PinConnection(component_ref="U4", pin_number="1"),
|
|
],
|
|
),
|
|
"VSYS": Net(
|
|
name="VSYS", net_type=NetType.POWER,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="2"),
|
|
PinConnection(component_ref="U2", pin_number="1"),
|
|
],
|
|
),
|
|
"3V3_DIGITAL": Net(
|
|
name="3V3_DIGITAL", net_type=NetType.POWER,
|
|
pins=[
|
|
PinConnection(component_ref="U2", pin_number="2"),
|
|
PinConnection(component_ref="U3", pin_number="1"),
|
|
],
|
|
),
|
|
"GND": Net(
|
|
name="GND", net_type=NetType.GROUND,
|
|
pins=[
|
|
PinConnection(component_ref="U1", pin_number="3"),
|
|
PinConnection(component_ref="U2", pin_number="3"),
|
|
PinConnection(component_ref="U3", pin_number="2"),
|
|
PinConnection(component_ref="U4", pin_number="2"),
|
|
],
|
|
),
|
|
}
|
|
report = build_functional_groups(DesignGraph(components=components, nets=nets))
|
|
by_rail = {d.power_nets[0]: set(d.ic_refs) for d in report.domains if d.power_nets}
|
|
assert len(report.domains) >= 3
|
|
assert by_rail.get("VBUS") == {"U4"}
|
|
assert by_rail.get("VSYS") == {"U1"}
|
|
assert by_rail.get("3V3_DIGITAL") == {"U2", "U3"}
|
|
|
|
|
|
def test_build_placement_plan_alias():
|
|
from backend.periscopex.functional_groups import build_placement_plan
|
|
report = build_placement_plan(_graph())
|
|
assert report.objective == "routing"
|
|
assert {g.ref for g in report.groups} >= {"U1", "U2", "U3"}
|
|
|