From 54b0b0791714091484a7022554305af466c24f5e Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 13 Sep 2026 17:39:11 +0200 Subject: [PATCH] Split Domains by primary supply rail instead of POWER union-find. Converters bridging VBUS/VSYS/3V3 no longer collapse the whole board into one domain. Co-authored-by: Cursor --- backend/pinscopex/functional_groups.py | 109 +++++++++++++++++-------- frontend/content/changelog.md | 7 ++ tests/test_functional_groups.py | 86 +++++++++++++++++++ 3 files changed, 167 insertions(+), 35 deletions(-) diff --git a/backend/pinscopex/functional_groups.py b/backend/pinscopex/functional_groups.py index 9a8a1b9..dcbabb4 100644 --- a/backend/pinscopex/functional_groups.py +++ b/backend/pinscopex/functional_groups.py @@ -1,6 +1,7 @@ """Topology-only functional groups for Layout F1 (routing-first floorplan). -No millimetres. Domains = power-net islands; satellites = 1-hop neighbors +No millimetres. Domains = primary supply-rail clusters (not transitive +POWER connectivity through converters); satellites = 1-hop neighbors classified with role_hint; layout_rules attached from IC extraction when present. Self-contained helpers (no import of ``validate`` / Anthropic). @@ -331,52 +332,90 @@ def _net_is_ic_supply( return _is_power_net(graph, net_name) +_UPSTREAM_BUS_RE = re.compile( + r"(?:^|[_/\-])(VBUS|VBAT|VIN|VCHG|VAC|VPH)(?:$|[_/\-\d])", + re.IGNORECASE, +) +_OUTPUT_BUS_RE = re.compile( + r"(?:^|[_/\-])(VSYS|VOUT|VREG)(?:$|[_/\-\d])", + re.IGNORECASE, +) +_REGULATED_RAIL_RE = re.compile(r"^\+?\d+V\d*", re.IGNORECASE) + + +def _primary_supply_net(comp: Component, power_nets: set[str]) -> str | None: + """Pick one supply rail per IC so converters do not merge the whole board. + + Consumers prefer regulated digital rails (3V3 / VDD). Power ICs prefer + output-ish nets (VSYS / VOUT / regulated) over upstream buses (VBUS / VIN). + """ + if not power_nets: + return None + + sub = (comp.component_subtype or "").lower() + is_power_ic = sub.startswith("ic.power") + + def score(name: str) -> tuple[int, str]: + u = name.upper() + s = 0 + if _UPSTREAM_BUS_RE.search(u): + s -= 100 + if _OUTPUT_BUS_RE.search(u): + s += 50 + if _REGULATED_RAIL_RE.match(u): + s += 40 + if "3V3" in u or "3.3V" in u: + s += 25 + elif re.search(r"1V\d+|1\.?\d+V", u): + s += 10 # core rails still regulated, but secondary to I/O + if "VDD" in u or "VCC" in u: + s += 15 + if is_power_ic: + if _UPSTREAM_BUS_RE.search(u): + s -= 40 + if _OUTPUT_BUS_RE.search(u) or _REGULATED_RAIL_RE.match(u): + s += 30 + return (s, name) + + return max(power_nets, key=score) + + +def _domain_id_for_rail(rail: str) -> str: + safe = re.sub(r"[^A-Za-z0-9]+", "_", rail or "").strip("_") + return f"domain_{safe}" if safe else "domain_unknown" + + def _build_domains(graph: DesignGraph, ic_refs: list[str]) -> list[PlacementDomain]: - parent = {r: r for r in ic_refs} - - def find(x: str) -> str: - while parent[x] != x: - parent[x] = parent[parent[x]] - x = parent[x] - return x - - def union(a: str, b: str) -> None: - ra, rb = find(a), find(b) - if ra != rb: - parent[rb] = ra - + """Cluster ICs by primary supply rail (not union-find across converters).""" power_by_ic: dict[str, set[str]] = {} for ref in ic_refs: - nets = set() + nets: set[str] = set() for n in graph.nets_of_component(ref): if _is_power_net(graph, n) and not _is_ground_net(graph, n): nets.add(n) power_by_ic[ref] = nets - rail_owners: dict[str, list[str]] = {} - for ref, nets in power_by_ic.items(): - for n in nets: - rail_owners.setdefault(n, []).append(ref) - for refs in rail_owners.values(): - for i in range(1, len(refs)): - union(refs[0], refs[i]) - - clusters: dict[str, list[str]] = {} + by_rail: dict[str, list[str]] = {} + no_rail: list[str] = [] for ref in ic_refs: - clusters.setdefault(find(ref), []).append(ref) + primary = _primary_supply_net(graph.components[ref], power_by_ic[ref]) + if primary is None: + no_rail.append(ref) + else: + by_rail.setdefault(primary, []).append(ref) domains: list[PlacementDomain] = [] - for i, (_root, members) in enumerate( - sorted(clusters.items(), key=lambda x: sorted(x[1])[0]), - ): - members_sorted = sorted(members) - rails: set[str] = set() - for m in members_sorted: - rails |= power_by_ic.get(m, set()) + for rail, members in sorted(by_rail.items(), key=lambda x: x[0].upper()): domains.append(PlacementDomain( - domain_id=f"domain_{i + 1}", - power_nets=sorted(rails), - ic_refs=members_sorted, + domain_id=_domain_id_for_rail(rail), + power_nets=[rail], + ic_refs=sorted(members), + )) + if no_rail: + domains.append(PlacementDomain( + domain_id="domain_unpowered", + power_nets=[], + ic_refs=sorted(no_rail), )) return domains diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index a7b405c..7b13ea9 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Pinscope. +## 2.28.6 — 2026-09-13 — Split Domains by primary supply rail + +Domains no longer merge the whole board when a charger/LDO bridges VBUS→VSYS→3V3. Each IC is clustered by its primary supply rail (output for power ICs, regulated digital rail for consumers). + +- [Fixed] `_build_domains` groups by primary rail instead of transitive POWER union-find. +- [Changed] Domain ids use the rail name (e.g. `domain_3V3_DIGITAL`). + ## 2.28.5 — 2026-09-13 — Domains & power-rail topology views Browse routing-first functional groups from the project sidebar: Domains (power-net islands) and Power rails, each highlighting IC groups and satellite roles. diff --git a/tests/test_functional_groups.py b/tests/test_functional_groups.py index f8d7e87..6528ed8 100644 --- a/tests/test_functional_groups.py +++ b/tests/test_functional_groups.py @@ -58,6 +58,92 @@ def test_domains_cover_all_ics(): 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_multi_rail_board_does_not_collapse_to_one_domain(): + """Charger→LDO→MCU must not become a single domain via shared POWER nets.""" + from backend.pinscopex.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.pinscopex.functional_groups import build_placement_plan report = build_placement_plan(_graph())