Files
periscope/frontend/src/components/project/impedance-panel.tsx
T
micheleandCursor 8d2b85600f Rebrand Pinscope to Periscope across product and codebase.
Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 20:02:04 +02:00

559 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
analyzeImpedanceNets,
computeImpedance,
designAntenna,
fetchAntennaReport,
fetchImpedanceNets,
} from "@/lib/api";
import { PcbUploadButton } from "@/components/project/pcb-upload";
import type {
AntennaReport,
ImpedanceKind,
ImpedanceNetsReport,
ImpedanceStackupResult,
ImpedanceTraceResult,
} from "@/lib/types";
function num(v: string): number {
return Number.parseFloat(v);
}
function fmt(n: number | null | undefined, digits = 2): string {
if (n == null || Number.isNaN(n)) return "—";
return n.toFixed(digits);
}
export function ImpedancePanel({
projectId,
hasPcb,
onPcbUploaded,
}: {
projectId: string;
hasPcb: boolean;
onPcbUploaded?: () => void;
}) {
const [kind, setKind] = useState<ImpedanceKind>("microstrip");
const [h, setH] = useState("0.20");
const [er, setEr] = useState("4.5");
const [t, setT] = useState("0.035");
const [w, setW] = useState("0.35");
const [s, setS] = useState("0.20");
const [targetZ, setTargetZ] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [trace, setTrace] = useState<ImpedanceTraceResult | null>(null);
const [stackup, setStackup] = useState<ImpedanceStackupResult | null>(null);
const [boardNets, setBoardNets] = useState<ImpedanceNetsReport | null>(null);
const [extraNets, setExtraNets] = useState("");
const [antenna, setAntenna] = useState<AntennaReport | null>(null);
const [f0, setF0] = useState("2440");
const [template, setTemplate] = useState<"ifa" | "meander" | "stub">("ifa");
const [antBusy, setAntBusy] = useState(false);
const [antError, setAntError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchImpedanceNets(projectId)
.then((r) => {
if (!cancelled) setBoardNets(r);
})
.catch(() => {
if (!cancelled) setBoardNets(null);
});
fetchAntennaReport(projectId)
.then((r) => {
if (!cancelled) setAntenna(r);
})
.catch(() => {
if (!cancelled) setAntenna(null);
});
return () => {
cancelled = true;
};
}, [projectId]);
async function runTrace() {
setBusy(true);
setError(null);
try {
const tz = targetZ.trim() === "" ? null : num(targetZ);
const result = (await computeImpedance({
mode: "trace",
kind,
h: num(h),
er: num(er),
t: num(t),
w: tz != null ? null : num(w),
s: kind === "microstrip" || kind === "stripline" ? null : num(s),
target_z: tz,
})) as ImpedanceTraceResult;
setTrace(result);
if (result.w_mm != null) setW(result.w_mm.toFixed(4));
} catch (e) {
setTrace(null);
setError(e instanceof Error ? e.message : "Compute failed");
} finally {
setBusy(false);
}
}
async function runStackup() {
setBusy(true);
setError(null);
try {
const result = (await computeImpedance({
mode: "stackup",
h: num(h),
er: num(er),
t: num(t),
s: num(s),
})) as ImpedanceStackupResult;
setStackup(result);
} catch (e) {
setStackup(null);
setError(e instanceof Error ? e.message : "Stackup failed");
} finally {
setBusy(false);
}
}
function downloadDru() {
if (!stackup?.kicad_dru) return;
const blob = new Blob([stackup.kicad_dru], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "periscope.kicad_dru";
a.click();
URL.revokeObjectURL(url);
}
async function runSpecifiedNets() {
const names = extraNets
.split(/[\s,]+/)
.map((n) => n.trim())
.filter(Boolean);
if (names.length === 0) return;
setBusy(true);
setError(null);
try {
setBoardNets(await analyzeImpedanceNets(projectId, names));
} catch (e) {
setError(e instanceof Error ? e.message : "Net analysis failed");
} finally {
setBusy(false);
}
}
async function runAntennaDesign() {
setAntBusy(true);
setAntError(null);
try {
const f0n = f0.trim() === "" ? null : num(f0);
const report = await designAntenna(projectId, {
f0_mhz: f0n != null && !Number.isNaN(f0n) ? f0n : null,
target_z_ohm: 50,
h: num(h),
er: num(er),
t: num(t),
template,
});
setAntenna(report);
} catch (e) {
setAntError(e instanceof Error ? e.message : "Antenna design failed");
} finally {
setAntBusy(false);
}
}
function copyRecipe() {
if (!antenna?.design) return;
void navigator.clipboard.writeText(JSON.stringify(antenna.design, null, 2));
}
function downloadKicadMod() {
const mod = antenna?.design?.geometry?.kicad_mod;
const name = antenna?.design?.geometry?.footprint_name ?? "Antenna";
if (!mod) return;
const blob = new Blob([mod], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.kicad_mod`;
a.click();
URL.revokeObjectURL(url);
}
const needsGap = kind === "cpw" || kind === "diff";
const design = antenna?.design;
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Verify antenna / RF feed</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{antenna?.marker_help ??
"Matching from IC ANT/RF pins toward ANT* / ANT_FEED. Feed Z0 when PCB nets were analyzed."}
</p>
{antenna && antenna.verify.length === 0 && (
<p className="text-sm text-muted-foreground">
No RF ports detected on IC pins (ANT/RF). Modules with an internal
antenna may show nothing here use Progetta with an ANT* marker.
</p>
)}
{antenna && antenna.verify.length > 0 && (
<div className="space-y-2">
{antenna.verify.map((row) => (
<div
key={`${row.ic_ref}-${row.pin}-${row.net}`}
className="rounded-lg border p-3 text-sm space-y-1"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono font-medium">
{row.ic_ref}.{row.pin}
</span>
<code className="text-xs">{row.net}</code>
<Badge variant="outline">{row.topology}</Badge>
<Badge
variant={row.status === "warning" ? "destructive" : "secondary"}
>
{row.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{row.detail}</p>
{row.parts.length > 0 && (
<p className="text-xs text-muted-foreground">
Parts: {row.parts.join(", ")}
</p>
)}
{(row.feed_z0 != null || row.feed_length_mm != null) && (
<p className="text-xs tabular-nums">
Feed Z0 {fmt(row.feed_z0)} Ω · {fmt(row.feed_length_mm)} mm
{row.marker_ref ? ` · marker ${row.marker_ref}` : ""}
</p>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Progetta antenna (ricetta)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Mark the feed join in KiCad (<code className="text-xs">ANT*</code>{" "}
footprint or net <code className="text-xs">ANT_FEED</code>). Optional
zone net <code className="text-xs">antenna</code>. Choose a template
(IFA / meander / stub) to get polylines, SVG preview, and a{" "}
<code className="text-xs">.kicad_mod</code> parametric routing aid,
not an EM result.
</p>
{!hasPcb && (
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<label className="space-y-1">
<Label>Template</Label>
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
value={template}
onChange={(e) =>
setTemplate(e.target.value as "ifa" | "meander" | "stub")
}
>
<option value="ifa">IFA</option>
<option value="meander">Meander</option>
<option value="stub">Stub</option>
</select>
</label>
<label className="space-y-1">
<Label>f0 (MHz)</Label>
<Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" />
</label>
<label className="space-y-1">
<Label>h (mm)</Label>
<Input value={h} onChange={(e) => setH(e.target.value)} />
</label>
<label className="space-y-1">
<Label>εr</Label>
<Input value={er} onChange={(e) => setEr(e.target.value)} />
</label>
<label className="space-y-1">
<Label>t (mm)</Label>
<Input value={t} onChange={(e) => setT(e.target.value)} />
</label>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={runAntennaDesign} disabled={antBusy}>
{antBusy ? "Computing…" : "Compute recipe"}
</Button>
<Button
variant="outline"
onClick={copyRecipe}
disabled={!design || design.status !== "ready"}
>
Copy JSON
</Button>
<Button
variant="outline"
onClick={downloadKicadMod}
disabled={!design?.geometry?.kicad_mod}
>
Download .kicad_mod
</Button>
</div>
{antError && <p className="text-sm text-destructive">{antError}</p>}
{design && (
<div className="rounded-lg border p-3 text-sm space-y-2">
<div className="flex flex-wrap gap-2 items-center">
<Badge variant="secondary">{design.status}</Badge>
{design.geometry && (
<Badge variant="outline">{design.geometry.fit}</Badge>
)}
<span className="text-muted-foreground text-xs">{design.detail}</span>
</div>
{design.feed_line && (
<p className="tabular-nums">
Feed microstrip @ {design.feed_line.target_z_ohm} Ω w ={" "}
{fmt(design.feed_line.w_mm, 4)} mm (h={fmt(design.feed_line.h_mm)},
εr={fmt(design.feed_line.er)})
</p>
)}
{design.radiator?.length_mm_suggest != null && (
<p className="tabular-nums text-xs text-muted-foreground">
λ/4 suggest {fmt(design.radiator.length_mm_suggest)} mm at{" "}
{fmt(design.radiator.f0_mhz, 0)} MHz {design.radiator.note}
</p>
)}
{design.geometry?.svg && (
<div
className="overflow-auto rounded-md border bg-muted/30 p-2"
dangerouslySetInnerHTML={{ __html: design.geometry.svg }}
/>
)}
{design.geometry && design.geometry.fit !== "need_f0" && (
<p className="text-xs text-muted-foreground tabular-nums">
{design.geometry.template.toUpperCase()}
{design.geometry.total_length_mm != null
? ` · path ${fmt(design.geometry.total_length_mm)} mm`
: ""}
{design.geometry.length_ideal_mm != null
? ` · ideal λ/4 ${fmt(design.geometry.length_ideal_mm)} mm`
: ""}
{design.geometry.segments.length
? ` · ${design.geometry.segments.length} segment(s)`
: ""}
{" — "}
{design.geometry.note}
</p>
)}
{design.zone && (
<p className="text-xs text-muted-foreground">
Zone {design.zone.net} on {design.zone.layer}
{design.zone.bbox_mm
? ` · bbox [${design.zone.bbox_mm.map((n) => n.toFixed(1)).join(", ")}]`
: ""}
</p>
)}
{design.keepout_checklist.length > 0 && (
<ul className="list-disc pl-4 text-xs text-muted-foreground space-y-0.5">
{design.keepout_checklist.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Impedance calculator</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
ImpedenceFinder (HammerstadJensen). The calculator is advice only.
With a `.kicad_pcb` and stackup, a pipeline run samples routed signal
nets. CPWG is not implemented upstream.
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<label className="space-y-1">
<Label>Kind</Label>
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm"
value={kind}
onChange={(e) => setKind(e.target.value as ImpedanceKind)}
>
<option value="microstrip">Microstrip</option>
<option value="stripline">Stripline</option>
<option value="diff">Coupled diff</option>
</select>
</label>
<label className="space-y-1">
<Label>h (mm)</Label>
<Input value={h} onChange={(e) => setH(e.target.value)} />
</label>
<label className="space-y-1">
<Label>εr</Label>
<Input value={er} onChange={(e) => setEr(e.target.value)} />
</label>
<label className="space-y-1">
<Label>t (mm)</Label>
<Input value={t} onChange={(e) => setT(e.target.value)} />
</label>
<label className="space-y-1">
<Label>w (mm)</Label>
<Input value={w} onChange={(e) => setW(e.target.value)} />
</label>
<label className="space-y-1">
<Label>s gap (mm)</Label>
<Input
value={s}
onChange={(e) => setS(e.target.value)}
disabled={!needsGap && !stackup}
/>
</label>
<label className="space-y-1 col-span-2 sm:col-span-1">
<Label>Target Z0 (optional)</Label>
<Input
value={targetZ}
onChange={(e) => setTargetZ(e.target.value)}
placeholder="solve w"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={runTrace} disabled={busy}>
Compute Z
</Button>
<Button variant="outline" onClick={runStackup} disabled={busy}>
Stackup 50 / 90 / 100
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{trace && (
<p className="text-sm tabular-nums">
{trace.z0 != null && <>Z0 = {fmt(trace.z0)} Ω</>}
{trace.zdiff != null && (
<>
{" "}
Zdiff = {fmt(trace.zdiff)} Ω (odd {fmt(trace.zodd)}, even{" "}
{fmt(trace.zeven)})
</>
)}
{trace.w_mm != null && <> · w = {fmt(trace.w_mm, 4)} mm</>}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">PCB net Z0 (ImpedenceFinder)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{!hasPcb && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Upload a `.kicad_pcb`, then re-run the pipeline. Power/ground
nets are skipped; signal traces with stackup εr/h are sampled.
</p>
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
</div>
)}
{hasPcb && boardNets?.skipped && (
<p className="text-sm text-muted-foreground">{boardNets.skipped}</p>
)}
{boardNets && boardNets.nets.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground">
<th className="py-1">Net</th>
<th>Z0 avg Ω</th>
<th>minmax</th>
<th>mm</th>
<th>topology</th>
</tr>
</thead>
<tbody>
{boardNets.nets.map((row) => (
<tr key={row.net_name} className="border-t border-border">
<td className="py-1">{row.net_name}</td>
<td className="tabular-nums">
{row.error ?? fmt(row.z0_avg_ohms)}
</td>
<td className="tabular-nums">
{row.z0_min_ohms != null
? `${fmt(row.z0_min_ohms)}${fmt(row.z0_max_ohms)}`
: "—"}
</td>
<td className="tabular-nums">{fmt(row.length_mm, 2)}</td>
<td>{(row.topologies || []).join(", ") || "—"}</td>
</tr>
))}
</tbody>
</table>
)}
{hasPcb && (
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-40 flex-1 space-y-1">
<Label>Extra nets</Label>
<Input
value={extraNets}
onChange={(e) => setExtraNets(e.target.value)}
placeholder="/USB.D+ /USB.D-"
/>
</label>
<Button
variant="outline"
onClick={runSpecifiedNets}
disabled={busy}
>
Analyze named nets
</Button>
</div>
)}
</CardContent>
</Card>
{stackup && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Suggested widths (apply in KiCad)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-1 text-sm tabular-nums">
{Object.entries(stackup.targets).map(([k, v]) => (
<div key={k}>
{k}: w={fmt(v.w_mm, 4)} mm
{v.z0 != null && <> · Z0={fmt(v.z0)}</>}
{v.zdiff != null && <> · Zdiff={fmt(v.zdiff)}</>}
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={downloadDru}>
Download .kicad_dru advice
</Button>
</CardContent>
</Card>
)}
</div>
);
}