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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 17:50:47 +02:00
co-authored by Cursor
parent 54b0b07917
commit 86121e6547
10 changed files with 465 additions and 10 deletions
+188
View File
@@ -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]
+11
View File
@@ -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)."""
+55 -4
View File
@@ -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)
+4 -4
View File
@@ -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 sullIC (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.
---
+8
View File
@@ -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).
@@ -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<string>("draft");
const [plan, setPlan] = useState<PlacementPlan | null>(null);
const [pack, setPack] = useState<PlacementPack | null>(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({
<span className="text-muted-foreground">
· {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 ?? "—"})`
: ""}
</span>
</div>
) : (
@@ -246,6 +260,37 @@ export default function PlacementPage({
</CardContent>
</Card>
)}
{pack && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Pack (F2)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{pack.status === "skipped" ? (
<p className="text-muted-foreground">
Skipped: {pack.skip_reason ?? "—"}. Needs uploaded{" "}
<code className="text-xs">.kicad_pcb</code> and numeric{" "}
<code className="text-xs">max_distance_mm</code> on
decoupling rules.
</p>
) : (
<div className="space-y-1">
{pack.placements.map((p) => (
<p
key={`${p.anchor_ref}-${p.ref}`}
className="text-xs text-muted-foreground"
>
<span className="text-foreground font-medium">{p.ref}</span>
{" "}near {p.anchor_ref} {p.max_distance_mm} mm (
{p.proposed_x}, {p.proposed_y}) {p.layer || ""}
</p>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
)}
</div>
+16 -1
View File
@@ -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<string, number> = 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<string | null>(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<EventSource | null>(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;
+10
View File
@@ -22,6 +22,7 @@ import type {
NetlistPreviewDesignator,
PauseCheckpoint,
PlacementPlan,
PlacementPack,
Project,
SkippedComponent,
ValidationReport,
@@ -618,6 +619,15 @@ export async function fetchPlacementPlan(projectId: string): Promise<PlacementPl
return res.json();
}
export async function fetchPlacementPack(projectId: string): Promise<PlacementPack> {
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(
+18
View File
@@ -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.
+109
View File
@@ -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