Add IFA/meander/stub antenna templates with SVG and KiCad export.

Progetta now returns a parametric radiator geometry (segments + preview + .kicad_mod) so the layout can be replicated without inventing EM results.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-13 19:27:06 +02:00
co-authored by Cursor
parent 1d3cf12482
commit 556306bf4d
9 changed files with 529 additions and 8 deletions
+321
View File
@@ -0,0 +1,321 @@
"""Parametric PCB antenna templates → segments, SVG, KiCad footprint.
Templates (IFA / meander / stub) use a documented λ/4 electrical length with
εeff≈(εr+1)/2. This is a routing-first drawing aid — not an EM / VSWR result.
"""
from __future__ import annotations
import math
from typing import Literal
from pydantic import BaseModel, Field
_C_MPS = 299_792_458.0
AntennaTemplate = Literal["ifa", "meander", "stub"]
FitStatus = Literal["ok", "scaled", "overflow", "need_f0"]
_NOTE = (
"Parametric template from λ/4 (εeff≈(εr+1)/2) — routing aid only, "
"not an EM / VSWR result. Tune matching on the board."
)
class AntennaSegment(BaseModel):
points: list[tuple[float, float]] # local mm, origin = feed
width_mm: float
class AntennaGeometry(BaseModel):
template: AntennaTemplate
fit: FitStatus
segments: list[AntennaSegment] = Field(default_factory=list)
total_length_mm: float | None = None
length_ideal_mm: float | None = None
scale: float = 1.0
svg: str | None = None
kicad_mod: str | None = None
footprint_name: str | None = None
note: str = _NOTE
detail: str = ""
def quarter_wave_mm(f0_mhz: float, er: float) -> float:
"""Electrical λ/4 in mm using εeff≈(εr+1)/2."""
eeff = (er + 1.0) / 2.0
f_hz = f0_mhz * 1e6
return (_C_MPS / (4.0 * f_hz * math.sqrt(eeff))) * 1e3
def build_geometry(
template: AntennaTemplate,
*,
f0_mhz: float | None,
w_mm: float,
er: float,
zone_bbox_mm: tuple[float, float, float, float] | None = None,
feed_xy: tuple[float, float] | None = None,
) -> AntennaGeometry:
if f0_mhz is None or f0_mhz <= 0:
return AntennaGeometry(
template=template,
fit="need_f0",
detail="Set f0 (MHz) to generate radiator geometry.",
)
if w_mm <= 0:
return AntennaGeometry(
template=template,
fit="overflow",
detail="Feed width w_mm must be > 0.",
)
ideal = quarter_wave_mm(f0_mhz, er)
segs_local, length = _template_segments(template, ideal, w_mm)
fit: FitStatus = "ok"
scale = 1.0
detail = f"{template.upper()} template at {f0_mhz:g} MHz."
avail = _available_span(zone_bbox_mm, feed_xy)
if avail is not None:
need_w, need_h = _bbox_size(segs_local)
free_w, free_h = avail
max_span = max(free_w, free_h)
need_span = max(need_w, need_h)
if need_span > max_span + 1e-6 and max_span > 0:
scale = max_span / need_span
min_scale = 0.45
if scale < min_scale:
return AntennaGeometry(
template=template,
fit="overflow",
length_ideal_mm=round(ideal, 2),
total_length_mm=None,
scale=round(scale, 4),
detail=(
f"Zone too small for {template.upper()} "
f"(need ~{need_span:.1f} mm, have {max_span:.1f} mm)."
),
)
segs_local = _scale_segments(segs_local, scale)
length *= scale
fit = "scaled"
detail = (
f"Scaled to {scale:.2f}× to fit antenna zone "
f"({max_span:.1f} mm free). Retune matching."
)
name = f"Antenna_{template.upper()}_{int(round(f0_mhz))}"
svg = _segments_to_svg(segs_local, w_mm)
mod = _segments_to_kicad_mod(name, segs_local, w_mm, template)
return AntennaGeometry(
template=template,
fit=fit,
segments=segs_local,
total_length_mm=round(length, 2),
length_ideal_mm=round(ideal, 2),
scale=round(scale, 4),
svg=svg,
kicad_mod=mod,
footprint_name=name,
detail=detail,
)
def _template_segments(
template: AntennaTemplate,
length_mm: float,
w_mm: float,
) -> tuple[list[AntennaSegment], float]:
if template == "ifa":
return _ifa(length_mm, w_mm)
if template == "meander":
return _meander(length_mm, w_mm)
return _stub(length_mm, w_mm)
def _ifa(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Inverted-F: shorting stub + horizontal arm; feed on the arm at origin.
Local: feed (0,0) on the arm. Shorting at x=-d toward -Y (GND edge).
Arm runs to +X. Proportions: stub ≈ 0.12 L, feed offset ≈ 0.15 L.
"""
L = max(length_mm, 4.0 * w_mm)
stub_h = max(0.12 * L, 2.0 * w_mm)
d = max(0.15 * L, 2.0 * w_mm)
open_x = L - d
segs = [
AntennaSegment(points=[(-d, 0.0), (-d, -stub_h)], width_mm=w_mm),
AntennaSegment(points=[(-d, 0.0), (open_x, 0.0)], width_mm=w_mm),
]
path = stub_h + L
return segs, path
def _meander(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Serpentine that consumes ~length_mm inside a compact bbox."""
pitch = max(3.0 * w_mm, 1.2)
run = max(length_mm / 6.0, 4.0 * w_mm)
pts: list[tuple[float, float]] = [(0.0, 0.0)]
x = 0.0
y = 0.0
going_up = True
consumed = 0.0
target = max(length_mm, 4.0 * w_mm)
guard = 0
while consumed < target - 1e-6 and guard < 80:
guard += 1
dy = run if going_up else -run
remain = target - consumed
if remain < abs(dy):
dy = math.copysign(remain, dy)
y2 = y + dy
pts.append((x, y2))
consumed += abs(dy)
y = y2
if consumed >= target - 1e-6:
break
remain = target - consumed
dx = min(pitch, remain)
x2 = x + dx
pts.append((x2, y))
consumed += dx
x = x2
going_up = not going_up
segs = [AntennaSegment(points=pts, width_mm=w_mm)]
return segs, consumed
def _stub(length_mm: float, w_mm: float) -> tuple[list[AntennaSegment], float]:
"""Open L-stub monopole: short vertical then horizontal arm."""
L = max(length_mm, 4.0 * w_mm)
h = max(0.2 * L, 2.0 * w_mm)
arm = max(L - h, 2.0 * w_mm)
segs = [
AntennaSegment(points=[(0.0, 0.0), (0.0, -h)], width_mm=w_mm),
AntennaSegment(points=[(0.0, -h), (arm, -h)], width_mm=w_mm),
]
return segs, h + arm
def _available_span(
zone_bbox: tuple[float, float, float, float] | None,
feed_xy: tuple[float, float] | None,
) -> tuple[float, float] | None:
"""Free width/height from feed into the zone (mm)."""
if zone_bbox is None or feed_xy is None:
return None
xmin, ymin, xmax, ymax = zone_bbox
fx, fy = feed_xy
fx = min(max(fx, xmin), xmax)
fy = min(max(fy, ymin), ymax)
free_w = max(fx - xmin, xmax - fx)
free_h = max(fy - ymin, ymax - fy)
return free_w, free_h
def _bbox_size(segs: list[AntennaSegment]) -> tuple[float, float]:
xs: list[float] = []
ys: list[float] = []
for s in segs:
for x, y in s.points:
xs.append(x)
ys.append(y)
if not xs:
return 0.0, 0.0
return max(xs) - min(xs), max(ys) - min(ys)
def _scale_segments(
segs: list[AntennaSegment], scale: float,
) -> list[AntennaSegment]:
out: list[AntennaSegment] = []
for s in segs:
out.append(
AntennaSegment(
points=[(x * scale, y * scale) for x, y in s.points],
width_mm=s.width_mm,
)
)
return out
def _segments_to_svg(segs: list[AntennaSegment], default_w: float) -> str:
xs: list[float] = []
ys: list[float] = []
for s in segs:
for x, y in s.points:
xs.append(x)
ys.append(y)
if not xs:
return '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"/>'
pad = max(default_w * 2, 1.0)
xmin, xmax = min(xs) - pad, max(xs) + pad
ymin, ymax = min(ys) - pad, max(ys) + pad
bw = max(xmax - xmin, 1e-3)
bh = max(ymax - ymin, 1e-3)
paths: list[str] = []
for s in segs:
if len(s.points) < 2:
continue
d_parts = []
for i, (x, y) in enumerate(s.points):
cmd = "M" if i == 0 else "L"
d_parts.append(f"{cmd}{x:.3f},{-y:.3f}")
sw = s.width_mm
paths.append(
f'<path d="{" ".join(d_parts)}" fill="none" stroke="#1a1a1a" '
f'stroke-width="{sw:.3f}" stroke-linecap="round" '
f'stroke-linejoin="round"/>'
)
paths.append(
f'<circle cx="0" cy="0" r="{max(default_w, 0.3):.3f}" fill="#c45c26"/>'
)
vb = f"{xmin:.3f} {-ymax:.3f} {bw:.3f} {bh:.3f}"
body = "\n ".join(paths)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}" '
f'width="280" height="160" style="background:#f7f5f2">'
f"\n {body}\n</svg>"
)
def _segments_to_kicad_mod(
name: str,
segs: list[AntennaSegment],
w_mm: float,
template: AntennaTemplate,
) -> str:
lines = [
f'(footprint "{name}"',
" (version 20240108)",
' (generator "pinscope")',
' (layer "F.Cu")',
f' (descr "Pinscope {template.upper()} PCB antenna template '
f'— not EM-validated")',
" (attr smd)",
f' (pad "1" smd circle (at 0 0) (size {w_mm * 2:.4f} {w_mm * 2:.4f}) '
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000001))',
]
if template == "ifa" and segs:
tip = segs[0].points[-1]
lines.append(
f' (pad "2" smd circle (at {tip[0]:.4f} {tip[1]:.4f}) '
f"(size {w_mm * 2:.4f} {w_mm * 2:.4f}) "
f'(layers "F.Cu") (uuid 00000000-0000-4000-8000-000000000002))'
)
uid = 10
for s in segs:
pts = s.points
for i in range(len(pts) - 1):
x1, y1 = pts[i]
x2, y2 = pts[i + 1]
lines.append(
f" (fp_line (start {x1:.4f} {y1:.4f}) (end {x2:.4f} {y2:.4f}) "
f"(stroke (width {s.width_mm:.4f}) (type default)) "
f'(layer "F.Cu") (uuid 00000000-0000-4000-8000-{uid:012d}))'
)
uid += 1
lines.append(")")
return "\n".join(lines) + "\n"
+37 -3
View File
@@ -2,9 +2,10 @@
Verify: matching topology from IC ANT/RF pin toward ANT footprint / ANT_FEED. Verify: matching topology from IC ANT/RF pin toward ANT footprint / ANT_FEED.
Design: KiCad marker (ANT* footprint or ANT_FEED/RF_ANT net) → microstrip w Design: KiCad marker (ANT* footprint or ANT_FEED/RF_ANT net) → microstrip w
for target Z0 from stackup; optional λ/4 length if f0_mhz is given. for target Z0 from stackup; optional λ/4 length if f0_mhz is given;
parametric IFA / meander / stub geometry (segments + SVG + .kicad_mod).
No EM/VSWR. No CPWG clearance. Length suggestion is a documented estimate only. No EM/VSWR. No CPWG clearance. Geometry is a documented routing template only.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,6 +16,11 @@ from typing import Any, Literal
from pydantic import BaseModel from pydantic import BaseModel
from backend.pinscopex.antenna_geometry import (
AntennaGeometry,
AntennaTemplate,
build_geometry,
)
from backend.pinscopex.impedance import GeometryError, solve_width from backend.pinscopex.impedance import GeometryError, solve_width
from backend.pinscopex.models import ( from backend.pinscopex.models import (
ComponentType, ComponentType,
@@ -87,6 +93,7 @@ class AntennaDesignRecipe(BaseModel):
feed_point: dict[str, Any] | None = None feed_point: dict[str, Any] | None = None
feed_line: AntennaFeedLine | None = None feed_line: AntennaFeedLine | None = None
radiator: AntennaRadiator | None = None radiator: AntennaRadiator | None = None
geometry: AntennaGeometry | None = None
zone: AntennaZoneInfo | None = None zone: AntennaZoneInfo | None = None
keepout_checklist: list[str] = [] keepout_checklist: list[str] = []
detail: str = "" detail: str = ""
@@ -111,6 +118,7 @@ def build_antenna_report(
h_mm: float | None = None, h_mm: float | None = None,
er: float | None = None, er: float | None = None,
t_mm: float | None = None, t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaReport: ) -> AntennaReport:
verify = _verify(graph, layout, impedance_nets, target_z_ohm) verify = _verify(graph, layout, impedance_nets, target_z_ohm)
design = build_design_recipe( design = build_design_recipe(
@@ -118,6 +126,7 @@ def build_antenna_report(
f0_mhz=f0_mhz, f0_mhz=f0_mhz,
target_z_ohm=target_z_ohm, target_z_ohm=target_z_ohm,
h_mm=h_mm, h_mm=h_mm,
template=template,
er=er, er=er,
t_mm=t_mm, t_mm=t_mm,
) )
@@ -133,6 +142,7 @@ def build_design_recipe(
h_mm: float | None = None, h_mm: float | None = None,
er: float | None = None, er: float | None = None,
t_mm: float | None = None, t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaDesignRecipe: ) -> AntennaDesignRecipe:
marker = _find_marker(graph, layout) marker = _find_marker(graph, layout)
zone = _find_antenna_zone(layout) zone = _find_antenna_zone(layout)
@@ -141,6 +151,8 @@ def build_design_recipe(
"Short GND return from the matching network to the RF reference.", "Short GND return from the matching network to the RF reference.",
"Avoid long stubs and right angles on the 50 Ω feed.", "Avoid long stubs and right angles on the 50 Ω feed.",
"Place matching parts close to the RF pin / feed point.", "Place matching parts close to the RF pin / feed point.",
"IFA pad 2 (shorting tip) must connect to RF ground / pour edge.",
"Place the footprint with feed (pad 1) on the ANT* / ANT_FEED join.",
] ]
stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm) stack = _resolve_stackup(layout, h_mm=h_mm, er=er, t_mm=t_mm)
@@ -197,14 +209,36 @@ def build_design_recipe(
f0_mhz=f0_mhz, f0_mhz=f0_mhz,
) )
feed_xy = None
if marker.get("x") is not None and marker.get("y") is not None:
feed_xy = (float(marker["x"]), float(marker["y"]))
zone_bbox = zone.bbox_mm if zone else None
geometry = build_geometry(
template,
f0_mhz=f0_mhz,
w_mm=float(feed.w_mm or 0),
er=er_v,
zone_bbox_mm=zone_bbox,
feed_xy=feed_xy,
)
detail = "Recipe ready — feed at w_mm; geometry is a parametric template (not EM)."
if geometry.fit == "need_f0":
detail = "Feed w ready — set f0 to generate IFA / meander / stub geometry."
elif geometry.fit == "scaled":
detail = geometry.detail
elif geometry.fit == "overflow":
detail = geometry.detail
return AntennaDesignRecipe( return AntennaDesignRecipe(
status="ready", status="ready",
feed_point=marker, feed_point=marker,
feed_line=feed, feed_line=feed,
radiator=radiator, radiator=radiator,
geometry=geometry,
zone=zone, zone=zone,
keepout_checklist=checklist, keepout_checklist=checklist,
detail="Recipe ready — draw the feed at w_mm; radiator length is an estimate only.", detail=detail,
) )
+3
View File
@@ -133,6 +133,7 @@ class AntennaDesignRequest(BaseModel):
h: float | None = None h: float | None = None
er: float | None = None er: float | None = None
t: float | None = None t: float | None = None
template: Literal["ifa", "meander", "stub"] = "ifa"
def _load_graph_layout(storage, prefix: str) -> tuple[DesignGraph | None, LayoutGraph | None, dict | None]: def _load_graph_layout(storage, prefix: str) -> tuple[DesignGraph | None, LayoutGraph | None, dict | None]:
@@ -190,6 +191,7 @@ async def post_project_antenna_design(
h_mm=body.h, h_mm=body.h,
er=body.er, er=body.er,
t_mm=body.t, t_mm=body.t,
template=body.template,
) )
# Recompute design with explicit params (same as report.design but ensure POST body wins) # Recompute design with explicit params (same as report.design but ensure POST body wins)
report.design = build_design_recipe( report.design = build_design_recipe(
@@ -200,5 +202,6 @@ async def post_project_antenna_design(
h_mm=body.h, h_mm=body.h,
er=body.er, er=body.er,
t_mm=body.t, t_mm=body.t,
template=body.template,
) )
return report.model_dump(mode="json") return report.model_dump(mode="json")
+2 -2
View File
@@ -159,7 +159,7 @@ Passi:
| 4 Filtri | `check_filters` (solo con poli/numeri in specs) | Niente \(f_c\) inventata | **OK** | | 4 Filtri | `check_filters` (solo con poli/numeri in specs) | Niente \(f_c\) inventata | **OK** |
| 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** | | 5 Capacità PI | Decoupling sulla net; derating V; DC-bias/ESR se c’è il numero; mm sul PCB (`PS-PLC-001`) | — | **OK** |
| 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** | | 6 Elettrico | Pin mux; I2C/reset pull-up; LED; sequencing/IR/power margin se c’è il parametro | Senza numero in specs → skip | **OK** |
| 7 RF | Tab **RF / Impedance**: verify matching + design recipe (w 50 Ω, λ/4 se f0); Z0 feed se PCB | CPWG / auto-draw radiatore dopo | **Improved** | | 7 RF | Tab **RF / Impedance**: verify matching + template geometry (IFA/meander/stub → SVG + `.kicad_mod`); Z0 feed se PCB | CPWG / auto-place into `.kicad_pcb` dopo | **Improved** |
| 8 HV / isolation | — | Serve `layout_rules` + V/mm dal datasheet, non IEC inventato | — | | 8 HV / isolation | — | Serve `layout_rules` + V/mm dal datasheet, non IEC inventato | — |
| 9 Termico | `check_thermal` se θJA/I sono in specs; via courtyard vs `min_via_count` | Niente \(T_j\) senza parametro | **OK** | | 9 Termico | `check_thermal` se θJA/I sono in specs; via courtyard vs `min_via_count` | Niente \(T_j\) senza parametro | **OK** |
| 10 SI / DNP | DNP enable; `PS-SI-001` solo con `length_match` mm | Niente 3W/crosstalk inventati | **OK** | | 10 SI / DNP | DNP enable; `PS-SI-001` solo con `length_match` mm | Niente 3W/crosstalk inventati | **OK** |
@@ -428,7 +428,7 @@ Passi:
1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri). **OK** — sezione Verify in RF/Impedance. 1. Topologia π/T tra pin ANT e connettore/antenna (stesso matcher filtri). **OK** — sezione Verify in RF/Impedance.
2. Target 50 Ω come **intento**, non misura: WARNING se manca rete. **OK** (status warning su `missing`). 2. Target 50 Ω come **intento**, non misura: WARNING se manca rete. **OK** (status warning su `missing`).
3. Utility Progetta: marker KiCad `ANT*` / `ANT_FEED` + stackup → `w` microstrip; `f0`λ/4 stimata; zona `antenna`bbox. **OK** (auto-draw rame = dopo). 3. Utility Progetta: marker KiCad `ANT*` / `ANT_FEED` + stackup → `w` microstrip; `f0`template IFA / meander / stub (segmenti mm + SVG + `.kicad_mod`); zona `antenna`fit/scale. **OK** (auto-place rame nel PCB = dopo).
4. CPW clearance: **Wave G** (layout). 4. CPW clearance: **Wave G** (layout).
--- ---
+8
View File
@@ -2,6 +2,14 @@
What's new in Pinscope. What's new in Pinscope.
## 2.28.12 — 2026-09-13 — Antenna templates: IFA / meander / stub + .kicad_mod
Progetta antenna now returns a full parametric drawing: polyline segments (mm), SVG preview, and a downloadable KiCad footprint. Choose IFA, meander, or stub; geometry scales to the `antenna` zone when present. Still a routing template — no EM/VSWR invented.
- [New] `antenna_geometry.py` (IFA / meander / stub → segments + SVG + `.kicad_mod`).
- [Changed] `POST /antenna/design` accepts `template`; recipe includes `geometry`.
- [Changed] RF / Impedance Progetta UI: template select, SVG preview, download footprint.
## 2.28.11 — 2026-09-13 — RF / Impedance: verify antenna + design recipe ## 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. 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.
@@ -55,6 +55,7 @@ export function ImpedancePanel({
const [extraNets, setExtraNets] = useState(""); const [extraNets, setExtraNets] = useState("");
const [antenna, setAntenna] = useState<AntennaReport | null>(null); const [antenna, setAntenna] = useState<AntennaReport | null>(null);
const [f0, setF0] = useState("2440"); const [f0, setF0] = useState("2440");
const [template, setTemplate] = useState<"ifa" | "meander" | "stub">("ifa");
const [antBusy, setAntBusy] = useState(false); const [antBusy, setAntBusy] = useState(false);
const [antError, setAntError] = useState<string | null>(null); const [antError, setAntError] = useState<string | null>(null);
@@ -163,6 +164,7 @@ export function ImpedancePanel({
h: num(h), h: num(h),
er: num(er), er: num(er),
t: num(t), t: num(t),
template,
}); });
setAntenna(report); setAntenna(report);
} catch (e) { } catch (e) {
@@ -177,6 +179,19 @@ export function ImpedancePanel({
void navigator.clipboard.writeText(JSON.stringify(antenna.design, null, 2)); 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 needsGap = kind === "cpw" || kind === "diff";
const design = antenna?.design; const design = antenna?.design;
@@ -243,13 +258,29 @@ export function ImpedancePanel({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Mark the feed join in KiCad (<code className="text-xs">ANT*</code>{" "} Mark the feed join in KiCad (<code className="text-xs">ANT*</code>{" "}
footprint or net <code className="text-xs">ANT_FEED</code>). Optional footprint or net <code className="text-xs">ANT_FEED</code>). Optional
zone net <code className="text-xs">antenna</code>. Auto-draw in the zone net <code className="text-xs">antenna</code>. Choose a template
zone comes later this returns w / Z0 / length to draw by hand. (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> </p>
{!hasPcb && ( {!hasPcb && (
<PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} /> <PcbUploadButton projectId={projectId} onUploaded={onPcbUploaded} />
)} )}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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 className="space-y-1">
<Label>f0 (MHz)</Label> <Label>f0 (MHz)</Label>
<Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" /> <Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" />
@@ -278,12 +309,22 @@ export function ImpedancePanel({
> >
Copy JSON Copy JSON
</Button> </Button>
<Button
variant="outline"
onClick={downloadKicadMod}
disabled={!design?.geometry?.kicad_mod}
>
Download .kicad_mod
</Button>
</div> </div>
{antError && <p className="text-sm text-destructive">{antError}</p>} {antError && <p className="text-sm text-destructive">{antError}</p>}
{design && ( {design && (
<div className="rounded-lg border p-3 text-sm space-y-2"> <div className="rounded-lg border p-3 text-sm space-y-2">
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<Badge variant="secondary">{design.status}</Badge> <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> <span className="text-muted-foreground text-xs">{design.detail}</span>
</div> </div>
{design.feed_line && ( {design.feed_line && (
@@ -299,6 +340,28 @@ export function ImpedancePanel({
{fmt(design.radiator.f0_mhz, 0)} MHz {design.radiator.note} {fmt(design.radiator.f0_mhz, 0)} MHz {design.radiator.note}
</p> </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 && ( {design.zone && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Zone {design.zone.net} on {design.zone.layer} Zone {design.zone.net} on {design.zone.layer}
+1
View File
@@ -747,6 +747,7 @@ export async function designAntenna(
h?: number | null; h?: number | null;
er?: number | null; er?: number | null;
t?: number | null; t?: number | null;
template?: "ifa" | "meander" | "stub";
}, },
): Promise<AntennaReport> { ): Promise<AntennaReport> {
const res = await authFetch( const res = await authFetch(
+15
View File
@@ -465,6 +465,20 @@ export interface AntennaVerifyRow {
marker_ref?: string | null; marker_ref?: string | null;
} }
export interface AntennaGeometry {
template: "ifa" | "meander" | "stub";
fit: "ok" | "scaled" | "overflow" | "need_f0";
segments: { points: number[][]; width_mm: number }[];
total_length_mm?: number | null;
length_ideal_mm?: number | null;
scale?: number;
svg?: string | null;
kicad_mod?: string | null;
footprint_name?: string | null;
note?: string;
detail?: string;
}
export interface AntennaDesignRecipe { export interface AntennaDesignRecipe {
status: "ready" | "need_pcb" | "need_stackup" | "need_marker"; status: "ready" | "need_pcb" | "need_stackup" | "need_marker";
feed_point?: Record<string, unknown> | null; feed_point?: Record<string, unknown> | null;
@@ -481,6 +495,7 @@ export interface AntennaDesignRecipe {
f0_mhz?: number | null; f0_mhz?: number | null;
note?: string; note?: string;
} | null; } | null;
geometry?: AntennaGeometry | null;
zone?: { zone?: {
net: string; net: string;
layer: string; layer: string;
+76
View File
@@ -177,3 +177,79 @@ def test_design_recipe_ready_with_ant_and_stackup():
assert recipe.radiator.length_mm_suggest is not None assert recipe.radiator.length_mm_suggest is not None
assert recipe.zone is not None assert recipe.zone is not None
assert recipe.zone.bbox_mm is not None assert recipe.zone.bbox_mm is not None
assert recipe.geometry is not None
assert recipe.geometry.template == "ifa"
assert recipe.geometry.fit in ("ok", "scaled")
assert len(recipe.geometry.segments) >= 2
assert recipe.geometry.svg and "<svg" in recipe.geometry.svg
assert recipe.geometry.kicad_mod and '(footprint "' in recipe.geometry.kicad_mod
assert "fp_line" in recipe.geometry.kicad_mod
def test_geometry_templates_produce_export():
from backend.pinscopex.antenna_geometry import build_geometry
for tmpl in ("ifa", "meander", "stub"):
geo = build_geometry(tmpl, f0_mhz=2440.0, w_mm=0.4, er=4.5)
assert geo.fit == "ok"
assert geo.total_length_mm is not None and geo.total_length_mm > 10
assert geo.length_ideal_mm is not None
assert geo.total_length_mm >= geo.length_ideal_mm * 0.9
assert geo.svg and "path" in geo.svg
assert geo.kicad_mod and '(pad "1"' in geo.kicad_mod
assert len(geo.segments) >= 1
def test_geometry_overflow_tiny_zone():
from backend.pinscopex.antenna_geometry import build_geometry
geo = build_geometry(
"ifa",
f0_mhz=2440.0,
w_mm=0.4,
er=4.5,
zone_bbox_mm=(0.0, 0.0, 2.0, 1.0),
feed_xy=(0.0, 0.0),
)
assert geo.fit == "overflow"
assert not geo.segments
def test_design_recipe_meander_template():
g = DesignGraph(
components={
"ANT1": Component(
reference="ANT1", value="feed", footprint="",
component_type=ComponentType.CONNECTOR,
pins={"1": "ANT_FEED"},
),
},
nets={
"ANT_FEED": Net(
name="ANT_FEED", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="ANT1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={
"ANT1": LayoutFootprint(
reference="ANT1", x=0.0, y=0.0, layer="F.Cu",
pads=[LayoutPad(number="1", x=0.0, y=0.0, net="ANT_FEED")],
),
},
nets={"ANT_FEED": 1},
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="FR4", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
recipe = build_design_recipe(
g, layout, f0_mhz=2440.0, template="meander",
)
assert recipe.status == "ready"
assert recipe.geometry is not None
assert recipe.geometry.template == "meander"
assert recipe.geometry.fit == "ok"
assert recipe.geometry.kicad_mod is not None