diff --git a/backend/pinscopex/functional_groups.py b/backend/pinscopex/functional_groups.py index dcbabb4..99ec2e4 100644 --- a/backend/pinscopex/functional_groups.py +++ b/backend/pinscopex/functional_groups.py @@ -177,6 +177,16 @@ def _ic_rank(comp: Component) -> int: return 9 +_POWER_SAT_ROLES = frozenset({"decoupling", "bulk", "filter", "pullup"}) +_SKIP_OTHER_TYPES = frozenset({ + ComponentType.CONNECTOR, + ComponentType.SWITCH, + ComponentType.TEST_POINT, + ComponentType.FIDUCIAL, + ComponentType.MECHANICAL, +}) + + def _group_for_ic( graph: DesignGraph, ref: str, @@ -185,6 +195,8 @@ def _group_for_ic( comp = graph.components[ref] cons = _match_constraints(comp.mpn or comp.value, cmap) nets = [n for n in graph.nets_of_component(ref) if not _is_ground_net(graph, n)] + power_nets = {n for n in nets if _is_power_net(graph, n)} + primary = _primary_supply_net(comp, power_nets) sat_map: dict[str, PlacementSatellite] = {} for net_name, others in graph.neighbors(ref).items(): @@ -197,20 +209,33 @@ def _group_for_ic( if not other or other.component_type == ComponentType.IC: continue role = _role_hint(graph, comp, cons, other, net_name) + sat_nets = {n for n in other.pins.values() if n} + # Keep decoupling/bulk/filter/pullup on the IC primary rail only — + # otherwise an LDO on 3V3 also inherits VSYS input caps/inductors. + if role in _POWER_SAT_ROLES and primary and primary not in sat_nets: + continue + if role == "other" and other.component_type in _SKIP_OTHER_TYPES: + continue sat_map[oref] = PlacementSatellite( ref=oref, component_type=other.component_type.value, component_subtype=other.component_subtype, - nets=sorted({n for n in other.pins.values() if n}), + nets=sorted(sat_nets), hop=1, role_hint=role, ) - for pin_num, net_name in comp.pins.items(): + # Cap enhancement: only on the primary supply rail (when known). + supply_nets = [primary] if primary else [] + if not supply_nets: + supply_nets = [ + n for pin_num, n in comp.pins.items() + if n and not _is_ground_net(graph, n) + and _is_ic_supply_pin(graph, cons, pin_num, n) + ] + for net_name in supply_nets: if not net_name or _is_ground_net(graph, net_name): continue - if not _is_ic_supply_pin(graph, cons, pin_num, net_name): - continue for cref in graph.capacitors_on_net(net_name): if cref == ref: continue diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index dc8facd..1adc609 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Pinscope. +## 2.28.9 — 2026-09-13 — Cleaner Domains / Power rails membership + +Power rails follow primary domain only. IC satellites for decoupling/bulk/filter/pullup stay on the IC’s primary supply rail (LDO 3V3 no longer inherits VSYS input caps). Connectors/switches drop out of noisy “other” satellites. + +- [Fixed] Rails view membership = domain primary rail (not transitive satellite nets). +- [Fixed] `_group_for_ic` primary-rail filter for power-role satellites; skip connector/switch “other”. + ## 2.28.8 — 2026-09-13 — Stronger layout_rules extraction skill Pintable skill now treats PCB / typical-application layout guidance as a first-class extract. Page trim keeps layout keywords; IC cache re-extracts once when `layout_rules` are empty under an older `model_version` (1.10.0+). diff --git a/frontend/src/components/project/topology-panel.tsx b/frontend/src/components/project/topology-panel.tsx index 4b52ea1..9831adf 100644 --- a/frontend/src/components/project/topology-panel.tsx +++ b/frontend/src/components/project/topology-panel.tsx @@ -165,33 +165,23 @@ type RailRow = { function RailsView({ plan }: { plan: PlacementPlan }) { const rails = useMemo(() => { const byRef = Object.fromEntries(plan.groups.map((g) => [g.ref, g])); + // One row per domain primary rail — do not pull ICs onto a rail just + // because a connector/pullup satellite also touches it. const map = new Map; groupRefs: Set }>(); for (const dom of plan.domains) { - for (const net of dom.power_nets) { - let entry = map.get(net); - if (!entry) { - entry = { domainIds: new Set(), groupRefs: new Set() }; - map.set(net, entry); - } - entry.domainIds.add(dom.domain_id); - for (const iref of dom.ic_refs) entry.groupRefs.add(iref); + const primary = dom.power_nets[0]; + if (!primary) continue; + let entry = map.get(primary); + if (!entry) { + entry = { domainIds: new Set(), groupRefs: new Set() }; + map.set(primary, entry); } + entry.domainIds.add(dom.domain_id); + for (const iref of dom.ic_refs) entry.groupRefs.add(iref); } - // Also attach groups that list the rail on their nets / satellites - for (const g of plan.groups) { - for (const net of g.nets ?? []) { - const entry = map.get(net); - if (entry) entry.groupRefs.add(g.ref); - } - for (const s of g.satellites) { - for (const net of s.nets ?? []) { - const entry = map.get(net); - if (entry) entry.groupRefs.add(g.ref); - } - } - } + const powerRoles = new Set(["decoupling", "bulk", "filter", "pullup"]); const rows: RailRow[] = [...map.entries()] .map(([net, v]) => ({ @@ -200,7 +190,21 @@ function RailsView({ plan }: { plan: PlacementPlan }) { groups: [...v.groupRefs] .map((r) => byRef[r]) .filter(Boolean) - .sort((a, b) => (a.rank ?? 99) - (b.rank ?? 99) || a.ref.localeCompare(b.ref)), + .sort( + (a, b) => + (a.rank ?? 99) - (b.rank ?? 99) || a.ref.localeCompare(b.ref), + ) + .map((g) => { + const onRailSats = g.satellites.filter( + (s) => + (s.nets || []).includes(net) && + powerRoles.has(s.role_hint || ""), + ); + return { + ...g, + satellites: onRailSats, + } as PlacementIcGroup; + }), })) .sort((a, b) => a.net.localeCompare(b.net)); @@ -234,23 +238,11 @@ function RailsView({ plan }: { plan: PlacementPlan }) {

- Functional groups on this rail + Primary IC groups on this rail

- {rail.groups.map((g) => { - const onRailSats = g.satellites.filter( - (s) => (s.nets || []).includes(rail.net), - ); - const highlight: PlacementIcGroup = { - ...g, - satellites: - onRailSats.length > 0 - ? onRailSats - : g.satellites.filter((s) => - ["decoupling", "bulk"].includes(s.role_hint || ""), - ), - }; - return ; - })} + {rail.groups.map((g) => ( + + ))}
))} @@ -315,8 +307,8 @@ export function TopologyPanel({

{mode === "domains" - ? "Power-net islands with IC functional groups and satellite roles (routing-first, no mm)." - : "Each supply rail with the functional groups that hang off it."} + ? "Primary supply-rail domains with IC functional groups and satellite roles (routing-first, no mm)." + : "Each primary supply rail with its domain IC groups (not shared connectors across rails)."}

{mode === "domains" ? : } diff --git a/tests/test_functional_groups.py b/tests/test_functional_groups.py index 6528ed8..eac3bb5 100644 --- a/tests/test_functional_groups.py +++ b/tests/test_functional_groups.py @@ -67,6 +67,86 @@ def test_simple_project_splits_5v_and_3v3_domains(): 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.pinscopex.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"}, + ), + "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"), + ), + "J1": Component( + reference="J1", value="USB", footprint="", + component_type=ComponentType.CONNECTOR, + pins={"1": "3V3_DIGITAL", "2": "GND"}, + ), + } + 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"), + ], + ), + "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="J1", 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 "C_out" in sat + assert "C_in" not in sat + assert "L1" not in sat + assert "J1" not in sat + + 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 (