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:
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.28.11 — 2026-09-13 — RF / Impedance: verify antenna + design recipe
|
||||
|
||||
Project tab renamed **RF / Impedance**. Verify matching from IC ANT/RF pins toward an `ANT*` / `ANT_FEED` marker; Progetta returns microstrip `w` for 50 Ω (stackup or UI h/εr), optional λ/4 length from `f0`, and antenna-zone bbox. Auto-draw copper comes later — no EM/VSWR invented.
|
||||
|
||||
- [New] `antenna_rf.py` + `GET/POST /api/projects/{id}/antenna`.
|
||||
- [New] Verify + Progetta cards on the impedance panel.
|
||||
- [Changed] Sidebar label **RF / Impedance**.
|
||||
|
||||
## 2.28.10 — 2026-09-13 — Classify strap/bootstrap satellites; hide other
|
||||
|
||||
Caps on EN/BOOT/REGN/PMID and bootstrap caps between IC pins get real roles. Unclassified `other` parts are dropped from satellites and assemble_order.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
Reference in New Issue
Block a user