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:
@@ -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]
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user