From 86121e6547fba2a390a98c6293a76a7978ec371d Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 13 Sep 2026 17:50:47 +0200 Subject: [PATCH] Add gated Placement F2 pack skeleton for satellite xy. Propose positions only from PCB footprints plus numeric decoupling max_distance_mm; otherwise skip with an explicit reason. Co-authored-by: Cursor --- backend/pinscopex/placement_pack.py | 188 ++++++++++++++++++ backend/routers/pipeline.py | 11 + backend/services/placement_pipeline.py | 59 +++++- docs/piano-implementazione.md | 8 +- frontend/content/changelog.md | 8 + .../app/(app)/project/[id]/placement/page.tsx | 47 ++++- frontend/src/hooks/use-placement-progress.ts | 17 +- frontend/src/lib/api.ts | 10 + frontend/src/lib/types.ts | 18 ++ tests/test_placement_pack.py | 109 ++++++++++ 10 files changed, 465 insertions(+), 10 deletions(-) create mode 100644 backend/pinscopex/placement_pack.py create mode 100644 tests/test_placement_pack.py diff --git a/backend/pinscopex/placement_pack.py b/backend/pinscopex/placement_pack.py new file mode 100644 index 0000000..efa65f3 --- /dev/null +++ b/backend/pinscopex/placement_pack.py @@ -0,0 +1,188 @@ +"""Layout F2 skeleton — propose satellite xy from PCB anchors + numeric rules. + +No millimetres are invented. Packing runs only when a LayoutGraph has +footprints and at least one ``decoupling_proximity`` rule carries a numeric +``max_distance_mm``. Otherwise the report is ``skipped`` with an explicit reason. +""" + +from __future__ import annotations + +import math +from typing import Literal + +from pydantic import BaseModel + +from backend.pinscopex.functional_groups import FunctionalGroupsReport, PlacementIcGroup +from backend.pinscopex.models import DesignGraph, LayoutGraph, LayoutPad + +SkipReason = Literal[ + "no_pcb_footprints", + "no_numeric_layout_rules", + "no_packable_satellites", +] + + +class PlacementProposal(BaseModel): + ref: str + anchor_ref: str + rule_kind: str + max_distance_mm: float + proposed_x: float + proposed_y: float + layer: str = "" + basis: str = "ic_pad+rule" + + +class PlacementPackReport(BaseModel): + objective: Literal["routing"] = "routing" + status: Literal["packed", "skipped"] = "skipped" + skip_reason: SkipReason | None = None + placements: list[PlacementProposal] = [] + + +def build_placement_pack( + plan: FunctionalGroupsReport, + layout: LayoutGraph | None, + graph: DesignGraph | None = None, +) -> PlacementPackReport: + """Propose satellite positions within extracted proximity limits.""" + if layout is None or not layout.footprints: + return PlacementPackReport(status="skipped", skip_reason="no_pcb_footprints") + + if not _has_numeric_proximity(plan): + return PlacementPackReport( + status="skipped", + skip_reason="no_numeric_layout_rules", + ) + + placements: list[PlacementProposal] = [] + used_refs: set[str] = set() + + for group in plan.groups: + placements.extend( + _pack_group(group, layout, graph, used_refs), + ) + + if not placements: + return PlacementPackReport( + status="skipped", + skip_reason="no_packable_satellites", + ) + return PlacementPackReport(status="packed", placements=placements) + + +def _has_numeric_proximity(plan: FunctionalGroupsReport) -> bool: + for g in plan.groups: + for rule in g.layout_rules: + if rule.get("kind") != "decoupling_proximity": + continue + if _num(rule.get("max_distance_mm")) is not None: + return True + return False + + +def _num(raw) -> float | None: + if raw is None or isinstance(raw, bool): + return None + try: + v = float(raw) + except (TypeError, ValueError): + return None + return v if v > 0 else None + + +def _pack_group( + group: PlacementIcGroup, + layout: LayoutGraph, + graph: DesignGraph | None, + used_refs: set[str], +) -> list[PlacementProposal]: + ic_fp = layout.footprints.get(group.ref) + if not ic_fp: + return [] + + candidates = [ + s for s in group.satellites + if s.role_hint in ("decoupling", "bulk") and s.ref not in used_refs + ] + if not candidates: + return [] + + out: list[PlacementProposal] = [] + for rule in group.layout_rules: + if rule.get("kind") != "decoupling_proximity": + continue + limit = _num(rule.get("max_distance_mm")) + if limit is None: + continue + pad = _anchor_pad(group, layout, graph, str(rule.get("pin") or "")) + if pad is None: + # Fall back to footprint origin when pin is unknown but rule is numeric. + pad = LayoutPad(number="", x=ic_fp.x, y=ic_fp.y, net="") + basis = "ic_origin+rule" + else: + basis = "ic_pad+rule" + + net = pad.net or None + matched = [ + s for s in candidates + if s.ref not in used_refs and (not net or net in (s.nets or [])) + ] + if not matched: + matched = [s for s in candidates if s.ref not in used_refs] + if not matched: + continue + + for i, sat in enumerate(matched): + angle = (2.0 * math.pi * i) / max(len(matched), 8) + radius = limit * 0.5 + px = pad.x + radius * math.cos(angle) + py = pad.y + radius * math.sin(angle) + layer = ic_fp.layer or "" + out.append(PlacementProposal( + ref=sat.ref, + anchor_ref=group.ref, + rule_kind="decoupling_proximity", + max_distance_mm=limit, + proposed_x=round(px, 4), + proposed_y=round(py, 4), + layer=layer, + basis=basis, + )) + used_refs.add(sat.ref) + # One numeric rule per IC is enough for the skeleton. + break + return out + + +def _anchor_pad( + group: PlacementIcGroup, + layout: LayoutGraph, + graph: DesignGraph | None, + pin_token: str, +) -> LayoutPad | None: + fp = layout.footprints.get(group.ref) + if not fp or not fp.pads: + return None + want = (pin_token or "").strip() + if want: + for pad in fp.pads: + if pad.number == want: + return pad + if graph is not None: + comp = graph.components.get(group.ref) + if comp: + for pin_num, net in comp.pins.items(): + if str(pin_num) == want: + for pad in fp.pads: + if pad.number == str(pin_num): + return pad + # No matching pad number — pick any pad on that net. + for pad in fp.pads: + if pad.net and pad.net == net: + return pad + # Prefer a pad on a power-looking net shared with decoupling sats. + for pad in fp.pads: + if pad.net: + return pad + return fp.pads[0] diff --git a/backend/routers/pipeline.py b/backend/routers/pipeline.py index adf04f3..cdc23c6 100644 --- a/backend/routers/pipeline.py +++ b/backend/routers/pipeline.py @@ -630,6 +630,17 @@ async def get_placement_plan(project_id: str, request: Request): return storage.read_json(key) +@router.get("/pipeline/{project_id}/placement/pack") +async def get_placement_pack(project_id: str, request: Request): + """Return ``placement_pack.json`` (F2 — skipped without PCB + numeric rules).""" + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + key = f"{proj_svc.project_prefix(owner_user_id, project_id)}/placement_pack.json" + if not storage.exists(key): + raise HTTPException(404, "Placement pack not found — run placement first") + return storage.read_json(key) + + @router.get("/pipeline/{project_id}/placement/events") async def placement_events(project_id: str, request: Request): """SSE stream for Placement pipeline progress (watches placement_* only).""" diff --git a/backend/services/placement_pipeline.py b/backend/services/placement_pipeline.py index c52f15f..55f6a3a 100644 --- a/backend/services/placement_pipeline.py +++ b/backend/services/placement_pipeline.py @@ -1,7 +1,8 @@ -"""Placement pipeline — parallel to analysis, topology only (no LLM / no mm). +"""Placement pipeline — parallel to analysis (no LLM). -Stages: ensure_graph → classify → write_plan. -Writes ``placement_plan.json`` (+ refreshes ``functional_groups.json``). +Stages: ensure_graph → classify → write_plan → pack (F2 gated). +Writes ``placement_plan.json`` (+ ``functional_groups.json``) and +``placement_pack.json`` (mm only when PCB + numeric layout_rules exist). Uses ``placement_status`` so analysis ``status`` is untouched. """ @@ -12,7 +13,8 @@ from pathlib import Path from backend.pinscopex.functional_groups import build_placement_plan from backend.pinscopex.graph import build_graph -from backend.pinscopex.models import ComponentConstraints, DesignGraph +from backend.pinscopex.models import ComponentConstraints, DesignGraph, LayoutGraph +from backend.pinscopex.placement_pack import build_placement_pack from backend.services import projects as proj_svc from backend.services.pipeline import PipelineWorkspace, broker from backend.services.storage import StorageBackend @@ -112,18 +114,41 @@ async def run_placement_pipeline( ws._upload_file("functional_groups.json") _step(project_id, "write_plan", "complete", "placement_plan.json") + if _cancelled(storage, user_id, project_id): + _finish_cancelled(storage, user_id, project_id) + return + + _step(project_id, "pack", "running", "F2 gated pack") + layout = _load_layout(ws) + pack = build_placement_pack(plan, layout, graph) + pack_path = ws.local_path("placement_pack.json") + pack_path.write_text(pack.model_dump_json(indent=2) + "\n") + ws._upload_file("placement_pack.json") + pack_detail = ( + f"{len(pack.placements)} proposals" + if pack.status == "packed" + else f"skipped:{pack.skip_reason}" + ) + _step(project_id, "pack", "complete", pack_detail) + proj_svc.update_project( storage, user_id, project_id, placement_status="complete", placement_state={ "domains": len(plan.domains), "groups": len(plan.groups), + "pack_status": pack.status, + "pack_count": len(pack.placements), + "pack_skip_reason": pack.skip_reason, }, placement_cancel_requested=False, ) _publish(project_id, "placement_complete", { "domains": len(plan.domains), "groups": len(plan.groups), + "pack_status": pack.status, + "pack_count": len(pack.placements), + "pack_skip_reason": pack.skip_reason, }) except Exception as e: logger.exception("placement pipeline failed for %s", project_id) @@ -178,6 +203,32 @@ async def _ensure_graph(ws: PipelineWorkspace, meta, project_id: str) -> DesignG return graph +def _load_layout(ws: PipelineWorkspace) -> LayoutGraph | None: + """Reuse layout_graph.json, or parse uploads/pcb.kicad_pcb once.""" + cached = ws.local_path("layout_graph.json") + if cached.is_file(): + try: + return LayoutGraph.model_validate_json( + cached.read_text(encoding="utf-8"), + ) + except Exception: + logger.exception("bad layout_graph.json — trying pcb parse") + + pcb = ws.local_path("uploads/pcb.kicad_pcb") + if not pcb.is_file(): + return None + try: + from backend.pinscopex.parsers_kicad_pcb import parse_kicad_pcb + + layout = parse_kicad_pcb(pcb) + cached.write_text(layout.model_dump_json(indent=2) + "\n") + ws._upload_file("layout_graph.json") + return layout + except Exception: + logger.exception("kicad_pcb parse failed during placement pack") + return None + + def _cancelled(storage: StorageBackend, user_id: str, project_id: str) -> bool: meta = proj_svc.get_project(storage, user_id, project_id) return bool(meta and meta.placement_cancel_requested) diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index e780624..77e1335 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -24,9 +24,9 @@ Fonte originale: canvas *Pinscope: crescita e DeepSeek*. Qui lo stato operativo. | P3 | Plugin CI / chat report | Todo | | Layout F1 | Domini / gruppi / satelliti | **Done** — `functional_groups.json` (no mm) | | Layout F1b | Pipeline Placement parallela | **Done** — API/UI `placement_*`, `placement_plan.json` | -| Layout F2 | Placement IC packing mm | **Dopo** — gated `.kicad_pcb` + `layout_rules` numerici | +| Layout F2 | Placement IC packing mm | **Partial** — skeleton gated (`placement_pack.json`); no zone/export yet | -**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; packing mm (F2) gated. +**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; F2 packing oltre skeleton (collisioni, zone, export). --- @@ -41,9 +41,9 @@ Obiettivo unico: **routing migliore** (loop corti, meno crossing, canali liberi) | 3 | Gruppi minori: decoupling, bulk, load_cap, filter, pullup, … | F1 `role_hint` | | 4 | `layout_rules` già estratti sull’IC (nessun mm inventato) | F1 attach | | 5 | `assemble_order` dominio → chip → gruppi (contratto packer) | F1 metadato | -| 6 | Packing mm / zone PCB / export | **F2** | +| 6 | Packing mm / zone PCB / export | **F2 partial** — `placement_pack.json` (skip senza PCB + `max_distance_mm`); zone/export dopo | -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. Verifica PCB esistente resta `placement_check` (PS-PLC*) — non confondere con packing. +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 7b13ea9..a2eed29 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.28.7 — 2026-09-13 — Placement F2 pack skeleton (gated) + +Placement pipeline can propose satellite xy only when a `.kicad_pcb` layout exists and a `decoupling_proximity` rule has numeric `max_distance_mm`. No millimetres invented; otherwise `placement_pack.json` is skipped with an explicit reason. + +- [New] `placement_pack.py` + `placement_pack.json` after F1 plan. +- [New] `GET /api/pipeline/{id}/placement/pack` and Pack section on the placement page. +- [Changed] Placement stepper adds a **Pack satellites** stage. + ## 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). diff --git a/frontend/src/app/(app)/project/[id]/placement/page.tsx b/frontend/src/app/(app)/project/[id]/placement/page.tsx index 16dde0c..aa32250 100644 --- a/frontend/src/app/(app)/project/[id]/placement/page.tsx +++ b/frontend/src/app/(app)/project/[id]/placement/page.tsx @@ -9,11 +9,12 @@ import { PipelineStepper } from "@/components/progress/pipeline-stepper"; import { usePlacementProgress } from "@/hooks/use-placement-progress"; import { cancelPlacementPipeline, + fetchPlacementPack, fetchPlacementPlan, fetchProject, startPlacementPipeline, } from "@/lib/api"; -import type { PlacementPlan } from "@/lib/types"; +import type { PlacementPack, PlacementPlan } from "@/lib/types"; import { ArrowLeft, CheckCircle2, @@ -33,6 +34,7 @@ export default function PlacementPage({ const [projectName, setProjectName] = useState(""); const [placementStatus, setPlacementStatus] = useState("draft"); const [plan, setPlan] = useState(null); + const [pack, setPack] = useState(null); const [cancelling, setCancelling] = useState(false); const [starting, setStarting] = useState(false); const [statusLoaded, setStatusLoaded] = useState(false); @@ -67,6 +69,9 @@ export default function PlacementPage({ fetchPlacementPlan(id) .then(setPlan) .catch(() => setPlan(null)); + fetchPlacementPack(id) + .then(setPack) + .catch(() => setPack(null)); }, [id, alreadyDone, done, placementStatus, statusLoaded]); const handleCancel = async () => { @@ -190,6 +195,15 @@ export default function PlacementPage({ · {summary?.domains ?? plan?.domains.length ?? "?"} domains,{" "} {summary?.groups ?? plan?.groups.length ?? "?"} IC groups + {pack + ? pack.status === "packed" + ? ` · pack ${pack.placements.length} xy` + : ` · pack skipped (${pack.skip_reason ?? "—"})` + : summary?.pack_status + ? summary.pack_status === "packed" + ? ` · pack ${summary.pack_count ?? 0} xy` + : ` · pack skipped (${summary.pack_skip_reason ?? "—"})` + : ""} ) : ( @@ -246,6 +260,37 @@ export default function PlacementPage({ )} + + {pack && ( + + + Pack (F2) + + + {pack.status === "skipped" ? ( +

+ Skipped: {pack.skip_reason ?? "—"}. Needs uploaded{" "} + .kicad_pcb and numeric{" "} + max_distance_mm on + decoupling rules. +

+ ) : ( +
+ {pack.placements.map((p) => ( +

+ {p.ref} + {" "}near {p.anchor_ref} ≤ {p.max_distance_mm} mm → ( + {p.proposed_x}, {p.proposed_y}) {p.layer || ""} +

+ ))} +
+ )} +
+
+ )} )} diff --git a/frontend/src/hooks/use-placement-progress.ts b/frontend/src/hooks/use-placement-progress.ts index 02f6870..6bd34e7 100644 --- a/frontend/src/hooks/use-placement-progress.ts +++ b/frontend/src/hooks/use-placement-progress.ts @@ -21,6 +21,11 @@ const PLACEMENT_STAGES = [ title: "Write placement plan", description: "Save placement_plan.json (no millimetres)", }, + { + id: "pack", + title: "Pack satellites", + description: "Propose xy only with PCB + numeric layout_rules", + }, ] as const; const STAGE_INDEX: Record = Object.fromEntries( @@ -41,7 +46,13 @@ export function usePlacementProgress(projectId: string | null, enabled = true) { const [done, setDone] = useState(false); const [cancelled, setCancelled] = useState(false); const [error, setError] = useState(null); - const [summary, setSummary] = useState<{ domains?: number; groups?: number } | null>(null); + const [summary, setSummary] = useState<{ + domains?: number; + groups?: number; + pack_status?: string; + pack_count?: number; + pack_skip_reason?: string | null; + } | null>(null); const [started, setStarted] = useState(false); const esRef = useRef(null); const terminalRef = useRef(false); @@ -62,6 +73,10 @@ export function usePlacementProgress(projectId: string | null, enabled = true) { setSummary({ domains: Number(data.domains) || 0, groups: Number(data.groups) || 0, + pack_status: typeof data.pack_status === "string" ? data.pack_status : undefined, + pack_count: Number(data.pack_count) || 0, + pack_skip_reason: + typeof data.pack_skip_reason === "string" ? data.pack_skip_reason : null, }); setDone(true); terminalRef.current = true; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d325cfe..4e6f49b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,6 +22,7 @@ import type { NetlistPreviewDesignator, PauseCheckpoint, PlacementPlan, + PlacementPack, Project, SkippedComponent, ValidationReport, @@ -618,6 +619,15 @@ export async function fetchPlacementPlan(projectId: string): Promise { + const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/pack`); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: "Placement pack not found" })); + throw new Error(err.detail || "Placement pack not found"); + } + return res.json(); +} + // --- Logs --- export async function fetchProjectLogs( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b460165..3af9834 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -330,6 +330,24 @@ export interface PlacementPlan { groups: PlacementIcGroup[]; } +export interface PlacementProposal { + ref: string; + anchor_ref: string; + rule_kind: string; + max_distance_mm: number; + proposed_x: number; + proposed_y: number; + layer?: string; + basis?: string; +} + +export interface PlacementPack { + objective?: string; + status: "packed" | "skipped"; + skip_reason?: string | null; + placements: PlacementProposal[]; +} + // One entry per EDIF sub-design (`&NNNN` ID prefix). Returned by the upload // endpoint and the subdesigns inspection endpoint; consumed by the wizard's // sub-design picker step. diff --git a/tests/test_placement_pack.py b/tests/test_placement_pack.py new file mode 100644 index 0000000..bbbbe52 --- /dev/null +++ b/tests/test_placement_pack.py @@ -0,0 +1,109 @@ +"""Layout F2 placement_pack — gated; no invented millimetres.""" + +from __future__ import annotations + +from pathlib import Path + +from backend.pinscopex.functional_groups import ( + PlacementIcGroup, + PlacementSatellite, + FunctionalGroupsReport, + build_functional_groups, +) +from backend.pinscopex.models import ( + DesignGraph, + LayoutFootprint, + LayoutGraph, + LayoutPad, +) +from backend.pinscopex.placement_pack import build_placement_pack + +SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" + + +def _graph() -> DesignGraph: + return DesignGraph.model_validate_json( + (SIMPLE / "design_graph.json").read_text(encoding="utf-8"), + ) + + +def test_simple_project_without_pcb_skips_pack(): + plan = build_functional_groups(_graph()) + pack = build_placement_pack(plan, None) + assert pack.status == "skipped" + assert pack.skip_reason == "no_pcb_footprints" + assert pack.placements == [] + + +def test_layout_without_numeric_rules_skips(): + plan = FunctionalGroupsReport( + groups=[ + PlacementIcGroup( + ref="U1", + layout_rules=[{"kind": "decoupling_proximity", "pin": "5"}], + satellites=[ + PlacementSatellite( + ref="C4", + component_type="capacitor", + role_hint="decoupling", + nets=["+3V3"], + ), + ], + ), + ], + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", x=0, y=0, layer="F.Cu", + pads=[LayoutPad(number="5", x=0.0, y=0.0, net="+3V3")], + ), + }, + ) + pack = build_placement_pack(plan, layout) + assert pack.status == "skipped" + assert pack.skip_reason == "no_numeric_layout_rules" + + +def test_numeric_rule_proposes_satellite_within_limit(): + limit = 2.0 + plan = FunctionalGroupsReport( + groups=[ + PlacementIcGroup( + ref="U1", + layout_rules=[{ + "kind": "decoupling_proximity", + "pin": "5", + "max_distance_mm": limit, + }], + satellites=[ + PlacementSatellite( + ref="C4", + component_type="capacitor", + role_hint="decoupling", + nets=["+3V3"], + ), + ], + ), + ], + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", x=0, y=0, layer="F.Cu", + pads=[LayoutPad(number="5", x=10.0, y=20.0, net="+3V3")], + ), + }, + ) + pack = build_placement_pack(plan, layout) + assert pack.status == "packed" + assert pack.skip_reason is None + assert len(pack.placements) == 1 + p = pack.placements[0] + assert p.ref == "C4" + assert p.anchor_ref == "U1" + assert p.max_distance_mm == limit + assert p.layer == "F.Cu" + dist = ((p.proposed_x - 10.0) ** 2 + (p.proposed_y - 20.0) ** 2) ** 0.5 + assert dist <= limit + 1e-6 + assert dist > 0