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.
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
@@ -15,6 +16,11 @@ from typing import Any, Literal
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.models import (
ComponentType,
@@ -87,6 +93,7 @@ class AntennaDesignRecipe(BaseModel):
feed_point: dict[str, Any] | None = None
feed_line: AntennaFeedLine | None = None
radiator: AntennaRadiator | None = None
geometry: AntennaGeometry | None = None
zone: AntennaZoneInfo | None = None
keepout_checklist: list[str] = []
detail: str = ""
@@ -111,6 +118,7 @@ def build_antenna_report(
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaReport:
verify = _verify(graph, layout, impedance_nets, target_z_ohm)
design = build_design_recipe(
@@ -118,6 +126,7 @@ def build_antenna_report(
f0_mhz=f0_mhz,
target_z_ohm=target_z_ohm,
h_mm=h_mm,
template=template,
er=er,
t_mm=t_mm,
)
@@ -133,6 +142,7 @@ def build_design_recipe(
h_mm: float | None = None,
er: float | None = None,
t_mm: float | None = None,
template: AntennaTemplate = "ifa",
) -> AntennaDesignRecipe:
marker = _find_marker(graph, 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.",
"Avoid long stubs and right angles on the 50 Ω feed.",
"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)
@@ -197,14 +209,36 @@ def build_design_recipe(
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(
status="ready",
feed_point=marker,
feed_line=feed,
radiator=radiator,
geometry=geometry,
zone=zone,
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
er: 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]:
@@ -190,6 +191,7 @@ async def post_project_antenna_design(
h_mm=body.h,
er=body.er,
t_mm=body.t,
template=body.template,
)
# Recompute design with explicit params (same as report.design but ensure POST body wins)
report.design = build_design_recipe(
@@ -200,5 +202,6 @@ async def post_project_antenna_design(
h_mm=body.h,
er=body.er,
t_mm=body.t,
template=body.template,
)
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** |
| 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** |
| 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 | — |
| 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** |
@@ -428,7 +428,7 @@ Passi:
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`).
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).
---
+8
View File
@@ -2,6 +2,14 @@
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
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 [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);
@@ -163,6 +164,7 @@ export function ImpedancePanel({
h: num(h),
er: num(er),
t: num(t),
template,
});
setAntenna(report);
} catch (e) {
@@ -177,6 +179,19 @@ export function ImpedancePanel({
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;
@@ -243,13 +258,29 @@ export function ImpedancePanel({
<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.
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-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>f0 (MHz)</Label>
<Input value={f0} onChange={(e) => setF0(e.target.value)} placeholder="2440" />
@@ -278,12 +309,22 @@ export function ImpedancePanel({
>
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 && (
@@ -299,6 +340,28 @@ export function ImpedancePanel({
{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}
+1
View File
@@ -747,6 +747,7 @@ export async function designAntenna(
h?: number | null;
er?: number | null;
t?: number | null;
template?: "ifa" | "meander" | "stub";
},
): Promise<AntennaReport> {
const res = await authFetch(
+15
View File
@@ -465,6 +465,20 @@ export interface AntennaVerifyRow {
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 {
status: "ready" | "need_pcb" | "need_stackup" | "need_marker";
feed_point?: Record<string, unknown> | null;
@@ -481,6 +495,7 @@ export interface AntennaDesignRecipe {
f0_mhz?: number | null;
note?: string;
} | null;
geometry?: AntennaGeometry | null;
zone?: {
net: 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.zone 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