"""Topology-only functional groups for Layout F1 (routing-first floorplan). 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). """ from __future__ import annotations import re from typing import Any, Literal from pydantic import BaseModel from backend.periscopex.models import ( CapacitorSpecs, Component, ComponentConstraints, ComponentType, DesignGraph, NetType, SimpleComponentSpecs, ) from backend.periscopex.resolve_passives import _parse_spice_value RoleHint = Literal[ "decoupling", "bulk", "load_cap", "filter", "pullup", "series", "divider", "bridge", "crystal", "other", ] # Roles kept in satellites / assemble_order. Unclassified "other" is dropped. _ASSEMBLE_ROLES = frozenset({ "decoupling", "bulk", "load_cap", "filter", "pullup", "series", "divider", "bridge", "crystal", }) _POWER_SAT_ROLES = frozenset({"decoupling", "bulk", "filter", "pullup"}) _SKIP_OTHER_TYPES = frozenset({ ComponentType.CONNECTOR, ComponentType.SWITCH, ComponentType.TEST_POINT, ComponentType.FIDUCIAL, ComponentType.MECHANICAL, }) # Bias / charge-pump / bootstrap nets often stay SIGNAL in the graph. _BIAS_NET_RE = re.compile( r"(?:^|[_/\-])(REGN|PMID|BTST|BOOT|SW|LX|BST|VREG|VLDO|VREF)" r"(?:$|[_/\-\d])", re.IGNORECASE, ) _STRAP_NET_RE = re.compile( r"(?:EN|ENABLE|RESET|NRST|BOOT|CHIP_PU|GPIO0)", re.IGNORECASE, ) _BULK_F = 1e-6 # >= 1 µF → bulk candidate _XTAL_RE = re.compile( r"(?:^|[_/])(X(?:IN|OUT)|XTAL|OSC|HFX(?:IN|OUT)|LFX(?:IN|OUT)|CLK(?:IN|OUT)?)(?:$|[_/\d])", re.I, ) _SUPPLY_PIN_RE = re.compile( r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|" r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])", re.IGNORECASE, ) _RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\d*)$", re.IGNORECASE) _NOT_SUPPLY_RE = re.compile( r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b", re.IGNORECASE, ) _RANK_PREFIXES: list[tuple[str, int]] = [ ("ic.mcu", 0), ("ic.mpu", 0), ("ic.fpga", 0), ("ic.soc", 0), ("ic.power", 1), ("ic.interface", 2), ("ic.protection", 3), ("ic.", 4), ] class PlacementSatellite(BaseModel): ref: str component_type: str component_subtype: str | None = None nets: list[str] = [] hop: int = 1 role_hint: RoleHint = "other" class PlacementIcGroup(BaseModel): ref: str mpn: str | None = None component_subtype: str | None = None rank: int = 99 nets: list[str] = [] satellites: list[PlacementSatellite] = [] layout_rules: list[dict[str, Any]] = [] assemble_order: list[str] = [] class PlacementDomain(BaseModel): domain_id: str power_nets: list[str] = [] ic_refs: list[str] = [] assemble_order: list[str] = [] class FunctionalGroupsReport(BaseModel): """Routing-first placement topology (no coordinates).""" objective: Literal["routing"] = "routing" domains: list[PlacementDomain] = [] groups: list[PlacementIcGroup] = [] def build_functional_groups( graph: DesignGraph, constraints_map: dict[str, ComponentConstraints] | None = None, ) -> FunctionalGroupsReport: """Build domains + per-IC satellite groups from the design graph.""" cmap = constraints_map or {} ic_refs = [ r for r, c in graph.components.items() if c.component_type == ComponentType.IC ] groups: list[PlacementIcGroup] = [] for ref in sorted(ic_refs, key=lambda r: (_ic_rank(graph.components[r]), r)): groups.append(_group_for_ic(graph, ref, cmap)) domains = _build_domains(graph, ic_refs) by_ref = {g.ref: g for g in groups} for dom in domains: order: list[str] = [] ranked = sorted( dom.ic_refs, key=lambda r: (by_ref[r].rank if r in by_ref else 99, r), ) for iref in ranked: order.append(iref) g = by_ref.get(iref) if g: for sat in g.satellites: if sat.ref not in order: order.append(sat.ref) dom.assemble_order = order return FunctionalGroupsReport(objective="routing", domains=domains, groups=groups) # Alias used by the dedicated Placement pipeline (same topology artifact). build_placement_plan = build_functional_groups PlacementPlan = FunctionalGroupsReport def load_capacitance_farads(comp: Component) -> float | None: """Crystal CL from SimpleComponentSpecs.values, if present.""" specs = comp.specs if not isinstance(specs, SimpleComponentSpecs): return None raw = specs.values.get("load_capacitance_f") if raw is None: return None try: v = float(raw) except (TypeError, ValueError): return None return v if v > 0 else None def _match_constraints( mpn: str | None, datasheets: dict[str, ComponentConstraints], ) -> ComponentConstraints | None: if not mpn: return None if mpn in datasheets: return datasheets[mpn] norm = re.sub(r"[/_\-\s]", "", mpn).upper() for ds_mpn, constraints in datasheets.items(): if re.sub(r"[/_\-\s]", "", ds_mpn).upper() == norm: return constraints return None def _ic_rank(comp: Component) -> int: sub = (comp.component_subtype or "").lower() for prefix, rank in _RANK_PREFIXES: if sub == prefix.rstrip(".") or sub.startswith(prefix): return rank return 9 def _group_for_ic( graph: DesignGraph, ref: str, cmap: dict[str, ComponentConstraints], ) -> PlacementIcGroup: 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(): if _is_ground_net(graph, net_name): continue for oref in others: if oref == ref or oref in sat_map: continue other = graph.components.get(oref) 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} # Drop power-role parts that sit on a *different* named power rail # (LDO must not inherit VSYS input caps). Strap/bias caps with no # typed POWER net still attach. if role in _POWER_SAT_ROLES and primary: sat_power = {n for n in sat_nets if _is_power_net(graph, n)} if sat_power and primary not in sat_power: continue if role == "other" and other.component_type in _SKIP_OTHER_TYPES: continue if role not in _ASSEMBLE_ROLES: continue sat_map[oref] = PlacementSatellite( ref=oref, component_type=other.component_type.value, component_subtype=other.component_subtype, nets=sorted(sat_nets), hop=1, role_hint=role, ) # 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 for cref in graph.capacitors_on_net(net_name): if cref == ref: continue cap = graph.components.get(cref) if not cap: continue others = {n for n in cap.pins.values() if n != net_name} if not any(_is_ground_net(graph, n) for n in others): continue farads = _cap_farads(cap) role: RoleHint = "bulk" if farads is not None and farads >= _BULK_F else "decoupling" existing = sat_map.get(cref) if existing is None or existing.role_hint in ("other", "series"): sat_map[cref] = PlacementSatellite( ref=cref, component_type=cap.component_type.value, component_subtype=cap.component_subtype, nets=sorted({n for n in cap.pins.values() if n}), hop=1, role_hint=role, ) satellites = sorted(sat_map.values(), key=lambda s: (_role_sort(s.role_hint), s.ref)) assemble = [ref] + [s.ref for s in satellites] rules: list[dict[str, Any]] = list(cons.layout_rules) if cons and cons.layout_rules else [] return PlacementIcGroup( ref=ref, mpn=comp.mpn, component_subtype=comp.component_subtype or (cons.component_subtype if cons else None), rank=_ic_rank(comp), nets=sorted(nets), satellites=satellites, layout_rules=rules, assemble_order=assemble, ) def _role_sort(role: RoleHint) -> int: order = [ "decoupling", "bulk", "load_cap", "crystal", "filter", "pullup", "divider", "series", "bridge", "other", ] try: return order.index(role) except ValueError: return 99 def _role_hint( graph: DesignGraph, ic: Component, cons: ComponentConstraints | None, other: Component, via_net: str, ) -> RoleHint: if other.component_type == ComponentType.CRYSTAL: return "crystal" if other.component_type == ComponentType.CAPACITOR: if _looks_xtal_net(via_net) or _ic_pin_is_xtal(cons, via_net, ic): return "load_cap" pin_nets = {n for n in other.pins.values() if n} gnd_nets = {n for n in pin_nets if _is_ground_net(graph, n)} live = [n for n in pin_nets if n not in gnd_nets] ic_nets = {n for n in ic.pins.values() if n} # Bootstrap / flying cap between two pins of this IC. if len(live) == 2 and all(n in ic_nets for n in live): return "bridge" # Cap to GND on an IC pin / bias / strap / power net → local bypass. if gnd_nets and len(live) == 1: net = live[0] if ( net in ic_nets or _is_power_net(graph, net) or _net_is_ic_supply(graph, ic, cons, net) or _BIAS_NET_RE.search(net or "") or _STRAP_NET_RE.search(net or "") ): farads = _cap_farads(other) return "bulk" if farads is not None and farads >= _BULK_F else "decoupling" return "other" if other.component_type == ComponentType.INDUCTOR: return "filter" if other.component_type == ComponentType.RESISTOR: nets = list(dict.fromkeys(n for n in other.pins.values() if n)) if len(nets) == 2: a, b = nets if _is_power_net(graph, a) or _is_power_net(graph, b) or ( _BIAS_NET_RE.search(a or "") or _BIAS_NET_RE.search(b or "") ): if _is_ground_net(graph, a) or _is_ground_net(graph, b): return "divider" return "pullup" ic_nets = set(ic.pins.values()) if a in ic_nets and b in ic_nets: return "bridge" if a in ic_nets or b in ic_nets: # Set resistor / NTC leg to GND stays series (placement-local). return "series" return "other" return "other" def _looks_xtal_net(name: str) -> bool: return bool(_XTAL_RE.search(name or "")) def _ic_pin_is_xtal( cons: ComponentConstraints | None, net_name: str, ic: Component, ) -> bool: for pin_num, n in ic.pins.items(): if n != net_name: continue tokens = _pin_name_tokens(cons, pin_num) if any(_XTAL_RE.search(t) for t in tokens): return True return _looks_xtal_net(net_name) def _net_is_ic_supply( graph: DesignGraph, ic: Component, cons: ComponentConstraints | None, net_name: str, ) -> bool: for pin_num, n in ic.pins.items(): if n == net_name and _is_ic_supply_pin(graph, cons, pin_num, net_name): return True 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]: """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[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 by_rail: dict[str, list[str]] = {} no_rail: list[str] = [] for ref in ic_refs: 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 rail, members in sorted(by_rail.items(), key=lambda x: x[0].upper()): domains.append(PlacementDomain( 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 def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]: if not cons: return [] pin = cons.pin_by_number(pin_num) if not pin or not pin.name: return [] return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()] def _looks_like_supply(text: str) -> bool: t = (text or "").strip() if not t: return False if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t): return False return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t)) def _is_ic_supply_pin( graph: DesignGraph, cons: ComponentConstraints | None, pin_num: str, net_name: str, ) -> bool: tokens = _pin_name_tokens(cons, pin_num) if tokens: return any(_looks_like_supply(t) for t in tokens) if _looks_like_supply(net_name or ""): return True net = graph.nets.get(net_name) return bool(net and net.net_type == NetType.POWER) def _is_ground_net(graph: DesignGraph, name: str) -> bool: net = graph.nets.get(name) if net and net.net_type == NetType.GROUND: return True u = name.upper().replace("-", "_") return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or ( u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS") ) def _is_power_net(graph: DesignGraph, name: str) -> bool: net = graph.nets.get(name) if net and net.net_type == NetType.POWER: return True return bool(re.match(r"^\+?\d+V\d*", (name or "").upper())) def _cap_farads(comp: Component) -> float | None: specs = comp.specs if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0: return float(specs.value_farads) raw = (comp.value or "").strip() if not raw: return None try: v = _parse_spice_value(raw) except ValueError: return None return v if v > 0 else None