diff --git a/backend/pinscopex/functional_groups.py b/backend/pinscopex/functional_groups.py index 99ec2e4..e1f3481 100644 --- a/backend/pinscopex/functional_groups.py +++ b/backend/pinscopex/functional_groups.py @@ -38,6 +38,30 @@ RoleHint = Literal[ "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])", @@ -177,16 +201,6 @@ 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, @@ -210,12 +224,17 @@ def _group_for_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 + # 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, @@ -298,22 +317,40 @@ def _role_hint( if other.component_type == ComponentType.CAPACITOR: if _looks_xtal_net(via_net) or _ic_pin_is_xtal(cons, via_net, ic): return "load_cap" - others = {n for n in other.pins.values() if n != via_net} - if any(_is_ground_net(graph, n) for n in others) and ( - _is_power_net(graph, via_net) or _net_is_ic_supply(graph, ic, cons, via_net) - ): - farads = _cap_farads(other) - return "bulk" if farads is not None and farads >= _BULK_F else "decoupling" + + 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(other.pins.values())) + 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): + 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" @@ -321,12 +358,12 @@ def _role_hint( 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 "")) diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index 09c1619..40cd625 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -44,6 +44,15 @@ Obiettivo unico: **routing migliore** (loop corti, meno crossing, canali liberi) | 5 | `assemble_order` dominio → chip → gruppi (contratto packer) | F1 metadato | | 6 | Packing mm / zone PCB / export | **F2 partial** — `placement_pack.json` (skip senza PCB + `max_distance_mm`); zone/export dopo | +### Roadmap Placement (da qui a coordinate utili) + +1. **F1 polish (ora)** — satelliti classificatì; `other` nascosti; Domains/Rails = primary rail. +2. **C4 fill** — riestrazione IC → `layout_rules` con `max_distance_mm` dove il PDF lo dice (skill 1.10.0). +3. **PCB gate** — upload `.kicad_pcb` → `layout_graph.json` (footprint xy già usati da PS-PLC*). +4. **F2 pack v1** — per ogni `decoupling_proximity` numerica: proporre xy satellite entro `max_distance_mm` dal pad (già skeleton); UI Pack lista proposte. +5. **F2 pack v2** — collisioni courtyard, stesso layer, ordine `assemble_order` per dominio, ancore IC fissi se già piazzati. +6. **F2 export** — scrivere posizioni proposte in file/plugin (pcbnew) senza muovere rame; `placement_check` resta verifica. + Output F1: `functional_groups.json` scritto in `graph_build`. Pipeline parallela Placement (`POST …/placement/start`) riscrive anche `placement_plan.json` senza toccare lo `status` di analisi, poi tenta F2 pack. Verifica PCB esistente resta `placement_check` (PS-PLC*) — non confondere con packing. --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 1adc609..bb065b0 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Pinscope. +## 2.28.10 — 2026-09-13 — Classify strap/bootstrap satellites; hide other + +Caps on EN/BOOT/REGN/PMID and bootstrap caps between IC pins get real roles. Unclassified `other` parts are dropped from satellites and assemble_order. + +- [Changed] `_role_hint` classifies GND bypass on bias/strap nets and flying bootstrap caps. +- [Changed] Assemble / IC groups omit `role_hint=other`. + ## 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. diff --git a/tests/test_functional_groups.py b/tests/test_functional_groups.py index eac3bb5..802116c 100644 --- a/tests/test_functional_groups.py +++ b/tests/test_functional_groups.py @@ -85,7 +85,7 @@ def test_ldo_power_satellites_stay_on_primary_rail(): reference="U1", value="LDO", footprint="", component_type=ComponentType.IC, component_subtype="ic.power.ldo", - pins={"1": "VSYS", "2": "3V3_DIGITAL", "3": "GND"}, + pins={"1": "VSYS", "2": "3V3_DIGITAL", "3": "GND", "4": "EN"}, ), "C_in": Component( reference="C_in", value="10u", footprint="", @@ -105,12 +105,32 @@ def test_ldo_power_satellites_stay_on_primary_rail(): 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, @@ -128,23 +148,57 @@ def test_ldo_power_satellites_stay_on_primary_rail(): 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 "C_out" in sat + 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():