Add Domains and Power rails views for functional groups.

Sidebar tabs show routing-first topology from placement_plan/functional_groups, with IC groups and satellite roles highlighted per domain and per supply rail.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 17:17:16 +02:00
co-authored by Cursor
parent e124ac2de7
commit fc11df59fc
10 changed files with 553 additions and 48 deletions
+6 -1
View File
@@ -143,7 +143,12 @@ async def get_project(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, meta = await resolve_or_404(request, project_id)
healed = proj_svc.heal_if_pipeline_finished(storage, owner_user_id, project_id)
return (healed or meta).model_dump()
if healed is not None:
meta = healed
healed_pl = proj_svc.heal_if_placement_stuck(storage, owner_user_id, project_id)
if healed_pl is not None:
meta = healed_pl
return meta.model_dump()
@router.delete("/projects/{project_id}")
+5 -3
View File
@@ -1551,9 +1551,11 @@ def _write_functional_groups(ws: PipelineWorkspace, graph) -> None:
if extracted_dir.is_dir():
cmap = _build_constraints_map(_load_datasheets(extracted_dir))
report = build_functional_groups(graph, cmap)
out = ws.local_path("functional_groups.json")
out.write_text(report.model_dump_json(indent=2) + "\n")
ws._upload_file("functional_groups.json")
payload = report.model_dump_json(indent=2) + "\n"
for name in ("functional_groups.json", "placement_plan.json"):
out = ws.local_path(name)
out.write_text(payload)
ws._upload_file(name)
except Exception:
logger.exception("functional_groups.json write failed — continuing")
+71
View File
@@ -326,6 +326,77 @@ def heal_if_pipeline_finished(
return None
def heal_if_placement_stuck(
storage: StorageBackend, user_id: str, project_id: str,
) -> ProjectMeta | None:
"""Unstick placement_status queued/running when the worker is gone.
- Last event ``placement_complete`` → ``complete``
- Dead worker + plan artifact present → ``complete``
- Dead worker otherwise → ``error``
"""
meta = get_project(storage, user_id, project_id)
if meta is None:
return None
pst = meta.placement_status or "draft"
if pst not in ("queued", "running"):
return None
prefix = _project_prefix(user_id, project_id)
events_prefix = f"{prefix}/events/"
last_event = None
try:
keys = sorted(
k for k in storage.list_prefix(events_prefix)
if k.endswith(".json") and "/events/" in k
)
if keys:
last_event = storage.read_json(keys[-1])
except Exception:
last_event = None
if (last_event or {}).get("event") == "placement_complete":
data = (last_event or {}).get("data") or {}
return update_project(
storage, user_id, project_id,
placement_status="complete",
placement_cancel_requested=False,
placement_state={
"domains": data.get("domains"),
"groups": data.get("groups"),
},
)
from backend.services import job_runner
exec_name = meta.placement_execution_name or f"local/placement/{project_id}"
try:
state = job_runner.get_execution_state(exec_name)
except Exception:
state = "unknown"
if state in ("pending", "running"):
return None
has_plan = (
storage.exists(f"{prefix}/placement_plan.json")
or storage.exists(f"{prefix}/functional_groups.json")
)
if has_plan:
return update_project(
storage, user_id, project_id,
placement_status="complete",
placement_cancel_requested=False,
placement_state=meta.placement_state,
)
return update_project(
storage, user_id, project_id,
placement_status="error",
placement_cancel_requested=False,
placement_state={"error": f"Placement worker terminated ({state})"},
)
# --- CRUD ---
+8
View File
@@ -2,6 +2,14 @@
What's new in Pinscope.
## 2.28.5 — 2026-09-13 — Domains & power-rail topology views
Browse routing-first functional groups from the project sidebar: Domains (power-net islands) and Power rails, each highlighting IC groups and satellite roles.
- [New] Sidebar tabs **Domains** and **Power rails** (from `placement_plan` / `functional_groups`).
- [Changed] Analysis `graph_build` also writes `placement_plan.json`.
- [Fixed] Heal stuck Placement `queued`/`running` when the worker is dead.
## 2.28.4 — 2026-09-13 — Unstick zombie running pipelines
A finished run whose worker died before flipping meta stayed `running`, so Progress showed Review spinning forever. Heal those projects when the event log already ends with `pipeline_complete`.
@@ -43,6 +43,7 @@ import {
} from "lucide-react";
import { useOptionalUser } from "@/hooks/use-optional-auth";
import { ImpedancePanel } from "@/components/project/impedance-panel";
import { TopologyPanel } from "@/components/project/topology-panel";
import { PcbUploadButton } from "@/components/project/pcb-upload";
import { PdfViewerSheet } from "@/components/pdf/pdf-viewer-sheet";
@@ -401,6 +402,14 @@ export default function ProjectDetailPage({
/>
)}
{tab === "domains" && (
<TopologyPanel projectId={id} mode="domains" />
)}
{tab === "rails" && (
<TopologyPanel projectId={id} mode="rails" />
)}
{tab === "derating" && (
<DeratingTable
rows={deratingRows}
@@ -11,17 +11,18 @@ import {
cancelPlacementPipeline,
fetchPlacementPlan,
fetchProject,
startPlacementPipeline,
} from "@/lib/api";
import type { PlacementPlan } from "@/lib/types";
import {
ArrowLeft,
CheckCircle2,
Loader2,
OctagonX,
Ban,
LayoutGrid,
} from "lucide-react";
type Plan = Awaited<ReturnType<typeof fetchPlacementPlan>>;
export default function PlacementPage({
params,
}: {
@@ -31,13 +32,17 @@ export default function PlacementPage({
const router = useRouter();
const [projectName, setProjectName] = useState("");
const [placementStatus, setPlacementStatus] = useState<string>("draft");
const [plan, setPlan] = useState<Plan | null>(null);
const [plan, setPlan] = useState<PlacementPlan | null>(null);
const [cancelling, setCancelling] = useState(false);
const [starting, setStarting] = useState(false);
const [statusLoaded, setStatusLoaded] = useState(false);
const active =
placementStatus === "queued" || placementStatus === "running";
const alreadyDone = placementStatus === "complete";
const { steps, done, cancelled, error, summary, started } = usePlacementProgress(
id,
!alreadyDone && placementStatus !== "draft",
statusLoaded && active,
);
useEffect(() => {
@@ -45,16 +50,24 @@ export default function PlacementPage({
.then((p) => {
setProjectName(p.name);
setPlacementStatus(p.placementStatus ?? "draft");
setStatusLoaded(true);
})
.catch(() => {});
.catch(() => setStatusLoaded(true));
}, [id]);
useEffect(() => {
if (!alreadyDone && !done) return;
if (done) setPlacementStatus("complete");
}, [done]);
useEffect(() => {
if (!statusLoaded) return;
if (!alreadyDone && !done && placementStatus !== "draft" && placementStatus !== "error") {
return;
}
fetchPlacementPlan(id)
.then(setPlan)
.catch(() => setPlan(null));
}, [id, alreadyDone, done]);
}, [id, alreadyDone, done, placementStatus, statusLoaded]);
const handleCancel = async () => {
setCancelling(true);
@@ -67,9 +80,21 @@ export default function PlacementPage({
}
};
const handleStart = async () => {
setStarting(true);
try {
await startPlacementPipeline(id);
setPlacementStatus("queued");
window.location.reload();
} catch (e) {
setStarting(false);
alert(e instanceof Error ? e.message : "Failed to start placement");
}
};
const finished = alreadyDone || done;
const isRunning = !finished && !cancelled && !error;
const isQueued = isRunning && !started && !alreadyDone;
const isRunning = statusLoaded && active && !done && !cancelled && !error;
const isQueued = isRunning && !started;
return (
<div className="flex-1 p-6 max-w-3xl mx-auto w-full space-y-6">
@@ -81,6 +106,12 @@ export default function PlacementPage({
Routing-first topology (no millimetres)
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/project/${id}?tab=domains`}>
<Button size="sm" variant="ghost">
Domains
</Button>
</Link>
<Link href={`/project/${id}`}>
<Button size="sm" variant="outline">
<ArrowLeft className="h-4 w-4 mr-1" />
@@ -88,6 +119,14 @@ export default function PlacementPage({
</Button>
</Link>
</div>
</div>
{!statusLoaded && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Checking placement status
</div>
)}
{isQueued && (
<div className="flex items-center gap-3 p-4 rounded-lg border border-blue-500/30 bg-blue-500/5">
@@ -133,25 +172,38 @@ export default function PlacementPage({
size="sm"
className="mt-3"
variant="outline"
onClick={() => router.push(`/project/${id}`)}
disabled={starting}
onClick={handleStart}
>
Back to project
<LayoutGrid className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Retry placement"}
</Button>
</div>
)}
{finished && !error && !cancelled && (
{statusLoaded && !active && !error && !cancelled && (
<div className="space-y-4">
{finished || plan ? (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
Placement plan ready
{summary && (
<span className="text-muted-foreground">
· {summary.domains ?? plan?.domains.length ?? "?"} domains,{" "}
{summary.groups ?? plan?.groups.length ?? "?"} IC groups
· {summary?.domains ?? plan?.domains.length ?? "?"} domains,{" "}
{summary?.groups ?? plan?.groups.length ?? "?"} IC groups
</span>
)}
</div>
) : (
<div className="rounded-lg border p-4 space-y-3">
<p className="text-sm text-muted-foreground">
No placement plan yet. Build one (free, no LLM) or open Domains
if analysis already wrote functional groups.
</p>
<Button size="sm" disabled={starting} onClick={handleStart}>
<LayoutGrid className="h-4 w-4 mr-1" />
{starting ? "Starting…" : "Build placement plan"}
</Button>
</div>
)}
{plan && (
<Card>
@@ -17,6 +17,8 @@ import {
ScrollText,
MessageSquareWarning,
Library,
Boxes,
CircuitBoard,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthApi } from "@/hooks/use-auth-api";
@@ -186,6 +188,8 @@ type NavItem =
const PROJECT_NAV_ITEMS: NavItem[] = [
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
@@ -0,0 +1,325 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { fetchPlacementPlan } from "@/lib/api";
import type { PlacementPlan, PlacementIcGroup, RoleHint } from "@/lib/types";
import { LayoutGrid, Zap, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
const ROLE_STYLES: Record<RoleHint | "ic", string> = {
ic: "bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30",
decoupling: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
bulk: "bg-teal-500/15 text-teal-700 dark:text-teal-300 border-teal-500/30",
load_cap: "bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/30",
crystal: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/30",
filter: "bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30",
pullup: "bg-orange-500/15 text-orange-700 dark:text-orange-300 border-orange-500/30",
series: "bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30",
divider: "bg-pink-500/15 text-pink-700 dark:text-pink-300 border-pink-500/30",
bridge: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30",
other: "bg-muted text-muted-foreground border-border",
};
function RoleBadge({ role, label }: { role: RoleHint | "ic"; label: string }) {
return (
<span
className={cn(
"inline-flex items-center rounded-md border px-1.5 py-0.5 text-[11px] font-mono tabular-nums",
ROLE_STYLES[role] ?? ROLE_STYLES.other,
)}
>
{label}
{role !== "ic" && (
<span className="ml-1 opacity-70 font-sans">{role}</span>
)}
</span>
);
}
function GroupBlock({ group, emphasize }: { group: PlacementIcGroup; emphasize?: boolean }) {
return (
<div
className={cn(
"rounded-lg border p-3 space-y-2",
emphasize
? "border-sky-500/40 bg-sky-500/[0.06]"
: "border-border bg-muted/20",
)}
>
<div className="flex flex-wrap items-center gap-2">
<RoleBadge role="ic" label={group.ref} />
{group.mpn && (
<span className="text-xs text-muted-foreground truncate">{group.mpn}</span>
)}
{group.component_subtype && (
<Badge variant="outline" className="text-[10px]">
{group.component_subtype}
</Badge>
)}
</div>
{group.satellites.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{group.satellites.map((s) => (
<RoleBadge
key={s.ref}
role={(s.role_hint as RoleHint) || "other"}
label={s.ref}
/>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No satellite parts classified</p>
)}
</div>
);
}
function EmptyPlan({ projectId }: { projectId: string }) {
return (
<Card>
<CardContent className="py-10 text-center space-y-3">
<p className="text-sm text-muted-foreground">
No topology plan yet. Run analysis (graph build writes{" "}
<code className="text-xs">functional_groups.json</code>) or build a
placement plan.
</p>
<Link href={`/project/${projectId}/placement`}>
<Button size="sm" variant="outline">
<LayoutGrid className="h-4 w-4 mr-1" />
Build placement plan
</Button>
</Link>
</CardContent>
</Card>
);
}
function DomainsView({ plan }: { plan: PlacementPlan }) {
const byRef = useMemo(
() => Object.fromEntries(plan.groups.map((g) => [g.ref, g])),
[plan.groups],
);
if (plan.domains.length === 0) {
return (
<p className="text-sm text-muted-foreground">No power domains detected.</p>
);
}
return (
<div className="space-y-4">
{plan.domains.map((dom) => {
const groups = dom.ic_refs
.map((r) => byRef[r])
.filter(Boolean) as PlacementIcGroup[];
return (
<Card key={dom.domain_id}>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-sm font-semibold">{dom.domain_id}</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{groups.length} IC group{groups.length === 1 ? "" : "s"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
Power nets:{" "}
{dom.power_nets.length
? dom.power_nets.map((n) => (
<code key={n} className="mr-1.5 font-mono text-[11px]">
{n}
</code>
))
: "—"}
</p>
{dom.assemble_order.length > 0 && (
<p className="text-[11px] text-muted-foreground mt-1 font-mono">
Assemble: {dom.assemble_order.join(" → ")}
</p>
)}
</CardHeader>
<CardContent className="space-y-2">
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Functional groups
</p>
{groups.map((g) => (
<GroupBlock key={g.ref} group={g} emphasize />
))}
</CardContent>
</Card>
);
})}
</div>
);
}
type RailRow = {
net: string;
domainIds: string[];
groups: PlacementIcGroup[];
};
function RailsView({ plan }: { plan: PlacementPlan }) {
const rails = useMemo(() => {
const byRef = Object.fromEntries(plan.groups.map((g) => [g.ref, g]));
const map = new Map<string, { domainIds: Set<string>; groupRefs: Set<string> }>();
for (const dom of plan.domains) {
for (const net of dom.power_nets) {
let entry = map.get(net);
if (!entry) {
entry = { domainIds: new Set(), groupRefs: new Set() };
map.set(net, entry);
}
entry.domainIds.add(dom.domain_id);
for (const iref of dom.ic_refs) entry.groupRefs.add(iref);
}
}
// Also attach groups that list the rail on their nets / satellites
for (const g of plan.groups) {
for (const net of g.nets) {
const entry = map.get(net);
if (entry) entry.groupRefs.add(g.ref);
}
for (const s of g.satellites) {
for (const net of s.nets || []) {
const entry = map.get(net);
if (entry) entry.groupRefs.add(g.ref);
}
}
}
const rows: RailRow[] = [...map.entries()]
.map(([net, v]) => ({
net,
domainIds: [...v.domainIds].sort(),
groups: [...v.groupRefs]
.map((r) => byRef[r])
.filter(Boolean)
.sort((a, b) => (a.rank ?? 99) - (b.rank ?? 99) || a.ref.localeCompare(b.ref)),
}))
.sort((a, b) => a.net.localeCompare(b.net));
return rows;
}, [plan]);
if (rails.length === 0) {
return (
<p className="text-sm text-muted-foreground">No power rails in the topology plan.</p>
);
}
return (
<div className="space-y-4">
{rails.map((rail) => (
<Card key={rail.net}>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-center gap-2">
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<CardTitle className="text-sm font-mono">{rail.net}</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{rail.groups.length} group{rail.groups.length === 1 ? "" : "s"}
</Badge>
</div>
{rail.domainIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
Domain{rail.domainIds.length === 1 ? "" : "s"}:{" "}
{rail.domainIds.join(", ")}
</p>
)}
</CardHeader>
<CardContent className="space-y-2">
<p className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
Functional groups on this rail
</p>
{rail.groups.map((g) => {
const onRailSats = g.satellites.filter(
(s) => (s.nets || []).includes(rail.net),
);
const highlight: PlacementIcGroup = {
...g,
satellites:
onRailSats.length > 0
? onRailSats
: g.satellites.filter((s) =>
["decoupling", "bulk"].includes(s.role_hint || ""),
),
};
return <GroupBlock key={g.ref} group={highlight} emphasize />;
})}
</CardContent>
</Card>
))}
</div>
);
}
export function TopologyPanel({
projectId,
mode,
}: {
projectId: string;
mode: "domains" | "rails";
}) {
const [plan, setPlan] = useState<PlacementPlan | null>(null);
const [loading, setLoading] = useState(true);
const [missing, setMissing] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setMissing(false);
fetchPlacementPlan(projectId)
.then((p) => {
if (!cancelled) {
setPlan(p);
setMissing(false);
}
})
.catch(() => {
if (!cancelled) {
setPlan(null);
setMissing(true);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId]);
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8">
<Loader2 className="h-4 w-4 animate-spin" />
Loading topology
</div>
);
}
if (missing || !plan) {
return <EmptyPlan projectId={projectId} />;
}
return (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold">
{mode === "domains" ? "Functional domains" : "Power rails"}
</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{mode === "domains"
? "Power-net islands with IC functional groups and satellite roles (routing-first, no mm)."
: "Each supply rail with the functional groups that hang off it."}
</p>
</div>
{mode === "domains" ? <DomainsView plan={plan} /> : <RailsView plan={plan} />}
</div>
);
}
+2 -18
View File
@@ -21,6 +21,7 @@ import type {
LcscPayload,
NetlistPreviewDesignator,
PauseCheckpoint,
PlacementPlan,
Project,
SkippedComponent,
ValidationReport,
@@ -608,24 +609,7 @@ export function placementEventsUrl(projectId: string): string {
return `${BASE}/api/pipeline/${projectId}/placement/events`;
}
export async function fetchPlacementPlan(projectId: string): Promise<{
objective?: string;
domains: Array<{
domain_id: string;
power_nets: string[];
ic_refs: string[];
assemble_order: string[];
}>;
groups: Array<{
ref: string;
mpn?: string | null;
component_subtype?: string | null;
rank?: number;
satellites: Array<{ ref: string; role_hint?: string; hop?: number }>;
layout_rules?: unknown[];
assemble_order?: string[];
}>;
}> {
export async function fetchPlacementPlan(projectId: string): Promise<PlacementPlan> {
const res = await authFetch(`${BASE}/api/pipeline/${projectId}/placement/plan`);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Placement plan not found" }));
+45
View File
@@ -285,6 +285,51 @@ export interface Project {
placementState?: Record<string, unknown> | null;
}
export type RoleHint =
| "decoupling"
| "bulk"
| "load_cap"
| "filter"
| "pullup"
| "series"
| "divider"
| "bridge"
| "crystal"
| "other";
export interface PlacementSatellite {
ref: string;
component_type?: string;
component_subtype?: string | null;
nets?: string[];
hop?: number;
role_hint?: RoleHint;
}
export interface PlacementIcGroup {
ref: string;
mpn?: string | null;
component_subtype?: string | null;
rank?: number;
nets?: string[];
satellites: PlacementSatellite[];
layout_rules?: unknown[];
assemble_order?: string[];
}
export interface PlacementDomain {
domain_id: string;
power_nets: string[];
ic_refs: string[];
assemble_order: string[];
}
export interface PlacementPlan {
objective?: string;
domains: PlacementDomain[];
groups: PlacementIcGroup[];
}
// 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.