Add RF antenna verify and design recipe to Impedance tab.

Detect matching toward ANT*/ANT_FEED, compute 50 Ω feed width from stackup, and optional λ/4 length from f0 — before auto-draw or F2 packing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 18:57:59 +02:00
co-authored by Cursor
parent 68af0fd432
commit 1d3cf12482
9 changed files with 1055 additions and 29 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ const PROJECT_NAV_ITEMS: NavItem[] = [
{ 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: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
@@ -5,13 +5,17 @@ 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,
@@ -49,6 +53,10 @@ export function ImpedancePanel({
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 [antBusy, setAntBusy] = useState(false);
const [antError, setAntError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -59,6 +67,13 @@ export function ImpedancePanel({
.catch(() => {
if (!cancelled) setBoardNets(null);
});
fetchAntennaReport(projectId)
.then((r) => {
if (!cancelled) setAntenna(r);
})
.catch(() => {
if (!cancelled) setAntenna(null);
});
return () => {
cancelled = true;
};
@@ -137,10 +152,173 @@ export function ImpedancePanel({
}
}
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),
});
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));
}
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>. Auto-draw in the
zone comes later this returns w / Z0 / length to draw by hand.
</p>
{!hasPcb && (
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<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>
</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>
<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.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>
@@ -297,30 +475,17 @@ export function ImpedancePanel({
<CardTitle className="text-sm">Suggested widths (apply in KiCad)</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground">
<th className="py-1">Target</th>
<th>w mm</th>
<th>s mm</th>
<th>Z</th>
</tr>
</thead>
<tbody>
{Object.entries(stackup.targets).map(([key, row]) => (
<tr key={key} className="border-t border-border">
<td className="py-1">{key}</td>
<td className="tabular-nums">{fmt(row.w_mm, 4)}</td>
<td className="tabular-nums">{fmt(row.s_mm, 4)}</td>
<td className="tabular-nums">
{row.z0 != null ? `${fmt(row.z0)} Ω` : `${fmt(row.zdiff)} Ω diff`}
</td>
</tr>
))}
</tbody>
</table>
<Button variant="outline" onClick={downloadDru}>
Download pinscope.kicad_dru
<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>
+41
View File
@@ -14,6 +14,7 @@ import type {
ImpedanceNetsReport,
ImpedanceStackupResult,
ImpedanceTraceResult,
AntennaReport,
EdifSubDesign,
FindingComment,
FindingReview,
@@ -725,6 +726,46 @@ export async function analyzeImpedanceNets(
return res.json();
}
export async function fetchAntennaReport(
projectId: string,
): Promise<AntennaReport> {
const res = await authFetch(`${BASE}/api/projects/${projectId}/antenna`);
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Antenna report failed",
);
}
return res.json();
}
export async function designAntenna(
projectId: string,
body: {
f0_mhz?: number | null;
target_z_ohm?: number;
h?: number | null;
er?: number | null;
t?: number | null;
},
): Promise<AntennaReport> {
const res = await authFetch(
`${BASE}/api/projects/${projectId}/antenna/design`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(
typeof detail.detail === "string" ? detail.detail : "Antenna design failed",
);
}
return res.json();
}
export async function fetchReport(
projectId: string,
): Promise<ValidationReport> {
+46
View File
@@ -451,6 +451,52 @@ export interface ImpedanceNetsReport {
skipped: string | null;
}
export interface AntennaVerifyRow {
ic_ref: string;
pin: string;
net: string;
topology: string;
parts: string[];
target_z_ohm: number;
status: "ok" | "warning" | "info";
detail: string;
feed_z0?: number | null;
feed_length_mm?: number | null;
marker_ref?: string | null;
}
export interface AntennaDesignRecipe {
status: "ready" | "need_pcb" | "need_stackup" | "need_marker";
feed_point?: Record<string, unknown> | null;
feed_line?: {
kind: string;
target_z_ohm: number;
w_mm?: number | null;
h_mm?: number | null;
er?: number | null;
t_mm?: number | null;
} | null;
radiator?: {
length_mm_suggest?: number | null;
f0_mhz?: number | null;
note?: string;
} | null;
zone?: {
net: string;
layer: string;
bbox_mm?: number[] | null;
area_mm2?: number | null;
} | null;
keepout_checklist: string[];
detail: string;
}
export interface AntennaReport {
verify: AntennaVerifyRow[];
design: AntennaDesignRecipe | null;
marker_help: string;
}
export interface NetlistPreviewDesignator {
ref: string;
pins: { number: string; net_name: string }[];