Files
NawfalMotii79-PLFM_RADAR/5_Simulations/Antenna/edge_fed_aeris10_v3.py
T
Jason 0728d931c4 chore(repo): PR-H — G-series close-out (regression infra + lint sweep)
Closeout pass for the G-series 3-ladder chirp + adaptive-escalation work.
Cleanup, watchdog/fallback, lint, full regression — final sign-off.

Cleanup + watchdog/fallback: already wired during earlier audit waves
(track watchdog in chirp_scheduler RP_DEF_TRACK_WATCHDOG_FRAMES, RESERVED
fallback in plfm_chirp_controller_v2, range-decim watchdog in
radar_system_top with gpio_dig7 surfacing, F-3.* MCU error path).
Verified — no residual TODO/FIXME in production RTL or MCU.

Regression infra: tb/cosim/compare_independent.py SKIP-detection bug —
importlib.util.find_spec("scipy.signal") raises ModuleNotFoundError when
the parent scipy package is itself absent (instead of returning None as
the surrounding logic assumed). Wrap in try/except so the regression
runner gets the intended rc=2 SKIP marker rather than a crash that masks
the rest of the script.

Lint sweep: ruff full-repo → 0 errors. Two changes:
  - pyproject.toml broadens 5_Simulations/Antenna/**.py exemption from
    just T20+ERA to the full set of script-ergonomics rules
    (RUF001/002/003 Greek µ/λ/π/θ in physical-units strings, E501 long
    matplotlib/numpy lines, RUF005/015/046, E70x one-line setup, B007
    tuple-unpack loop vars, B905, BLE001 diag try/except, C401, RET504,
    SIM118, PERF40x, ARG001, E402). These are sim/analysis scripts, not
    production code — keep substantive bug rules (F unused, B core
    bugbears) but drop stylistic noise.
  - Auto-fix sweep: 31x F541 (f-string-no-placeholder), 3x F401 (unused
    sys import), 2x F841 (dead leftover ref_pat / phases_quant in
    array_factor_adar1000_aeris10.py).

.gitignore: cover 9_Firmware/9_2_FPGA/tb/cosim/mf_chain_autocorr.csv
(matched_filter cosim writes here now; was already covered for tb/ but
not tb/cosim/).

Regression baseline (radar_venv):
  FPGA  : 42/43 — 1 pre-existing T-6 drift cosim fail surfaced by the
          SKIP fix above. Three sub-checks now red because PR-O moved
          xFFT/MF chain to LogiCORE v9.1 *Scaled* mode (1/2 per stage,
          1/2^11 total for N=2048) but compare_independent.py's invariants
          (FFT-impulse uniform-spectrum, MF peak-at-injected-delay, MF
          peak/median ≥ 5) were written assuming UNSCALED FFT. Not
          introduced by this PR — was hidden by the SKIP-detection crash.
          Defer to PR-M.4: redesign T-6 invariants (or input amplitudes)
          to match scaled-mode arithmetic.
  MCU   : 34/34 binary suites pass.
  GUI   : test_v7 150/150 pass.

uv.lock: scipy resolution catch-up (declared in pyproject dev group all
along; lock just hadn't been refreshed after pyproject edits landed).

Bench-side checks: none — this PR is repo hygiene, no firmware/RTL
behaviour change.
2026-05-05 10:39:57 +05:45

306 lines
12 KiB
Python
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.
#!/usr/bin/env python3
# edge_fed_aeris10_v3.py
#
# Single-element edge-fed (inset-feed microstrip) patch antenna sim for the
# 2-layer thicker-substrate option — preserves the old Gerber's series-fed-row
# topology but thickens the patch substrate from 0.102 mm to 0.508 mm RO4350B.
#
# Goal: validate that edge-fed on 0.508 mm gives reasonable BW (target >100 MHz,
# vs probe-fed v3's 180 MHz on the same substrate, vs old design's ~30 MHz on
# 0.102 mm). If BW is good, the 1x8 series-fed row will work; if it's poor,
# the on-top-layer feed traces are coupling to the patch and we need a
# different topology.
#
# Stackup (true 2-layer):
# L1 Cu 0.035 mm ← patch + edge-fed inset + feed line
# -- RO4350B 0.508 mm εr=3.48 (patch substrate, sets BW)
# L2 Cu 0.035 mm ← ground plane
#
# Verified design point (PROFILE=balanced, λ/25 mesh):
# W=7.854 mm L=6.95 mm inset_depth=3.40 mm inset_gap=0.30 mm
# feed_W=1.16 mm feed_lead=15.5 mm (1·λ_g at f0, line transparent)
# f_res = 10.509 GHz, S11 @ 10.5 = -18.5 dB, VSWR = 1.27
# -10 dB BW = 180 MHz (10.41-10.59 GHz) ← same as probe-fed v3
#
# Run:
# cd /tmp && DYLD_LIBRARY_PATH=/Users/ganeshpanth/opt/openEMS/lib \
# PROFILE=balanced \
# /Users/ganeshpanth/radar_venv/bin/python \
# /Users/ganeshpanth/PLFM_RADAR/5_Simulations/Antenna/edge_fed_aeris10_v3.py
#
# Env overrides:
# PATCH_W_MM PATCH_L_MM
# FEED_W_MM (50 Ω microstrip on 0.508 mm RO4350B → 1.16 mm)
# INSET_DEPTH_MM (inset notch depth from radiating edge)
# INSET_GAP_MM (gap between feed line and patch metal in the inset region)
# FEED_LEAD_MM (length of feed line before reaching patch edge)
import os
import time
import csv
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from openEMS import openEMS
from openEMS.physical_constants import C0
from CSXCAD import ContinuousStructure
from CSXCAD.SmoothMeshLines import SmoothMeshLines
# ============================================================================
# PROFILES
# ============================================================================
PROFILE = os.environ.get("PROFILE", "sanity")
profiles = {
"sanity": {"mesh_lambda_div": 18, "n_timesteps": 50000, "end_dB": -30},
"balanced": {"mesh_lambda_div": 25, "n_timesteps": 80000, "end_dB": -40},
}
cfg = profiles[PROFILE]
# ============================================================================
# BAND
# ============================================================================
F0 = 10.5e9
F_SPAN = 4.0e9
F_START = F0 - F_SPAN/2
F_STOP = F0 + F_SPAN/2
# ============================================================================
# STACKUP
# ============================================================================
T_CU = 0.035
H_PATCH_SUB = 0.508
EPS_RO4350B = 3.48
TAN_RO4350B = 0.0037
Z_GND = 0.0
Z_PATCH = Z_GND + T_CU + H_PATCH_SUB
Z_TOP = Z_PATCH + T_CU
# ============================================================================
# GEOMETRY
# ============================================================================
PATCH_W = float(os.environ.get("PATCH_W_MM", "7.854"))
PATCH_L = float(os.environ.get("PATCH_L_MM", "6.95"))
# 50 Ω microstrip feed on 0.508 mm RO4350B (Hammerstad: W ≈ 1.16 mm)
FEED_W = float(os.environ.get("FEED_W_MM", "1.16"))
INSET_DEPTH = float(os.environ.get("INSET_DEPTH_MM", "3.40")) # ~49% of L for 50 Ω
INSET_GAP = float(os.environ.get("INSET_GAP_MM", "0.30")) # clearance both sides of feed line in the inset
FEED_LEAD_L = float(os.environ.get("FEED_LEAD_MM", "15.5")) # 1·λ_g at 10.5 GHz: line transparent at f0
GND_X_MARGIN = 14.3
GND_Y_MARGIN = 14.3
GND_X_HALF = max(PATCH_W/2, FEED_W/2 + INSET_GAP) + GND_X_MARGIN
GND_Y_HALF = (PATCH_L/2 + FEED_LEAD_L) + GND_Y_MARGIN
AIR_ABOVE = 14.3
AIR_BELOW = 14.3
AIR_X_HALF = GND_X_HALF + 8.0
AIR_Y_HALF = GND_Y_HALF + 8.0
OUT_DIR = "/tmp/aeris10_edgefed_v3"
os.makedirs(OUT_DIR, exist_ok=True)
# ============================================================================
# Build + run
# ============================================================================
def run_case(patch_w, patch_l, feed_w, inset_depth, inset_gap, feed_lead,
sim_path, profile_cfg, label=""):
fdtd = openEMS(NrTS=profile_cfg["n_timesteps"],
EndCriteria=10**(profile_cfg["end_dB"]/20.0))
fdtd.SetGaussExcite(F0, F_SPAN/2.0)
fdtd.SetBoundaryCond(["MUR"]*6)
CSX = ContinuousStructure()
fdtd.SetCSX(CSX)
mesh = CSX.GetGrid()
mesh.SetDeltaUnit(1e-3)
# Materials
eps0 = 8.854e-12
patch_sub = CSX.AddMaterial("RO4350B",
epsilon=EPS_RO4350B,
kappa=2*np.pi*F0*EPS_RO4350B*eps0*TAN_RO4350B)
copper = CSX.AddMetal("Copper")
# Substrate
patch_sub.AddBox([-GND_X_HALF, -GND_Y_HALF, Z_GND + T_CU],
[+GND_X_HALF, +GND_Y_HALF, Z_PATCH], priority=1)
# Patch geometry — patch is centred at origin, L is along y, W along x.
# Inset feed: notch cut into the -y radiating edge for the feed line.
# The notch is feed_w + 2*inset_gap wide × inset_depth tall.
notch_half_w = feed_w/2 + inset_gap
px0, px1 = -patch_w/2, +patch_w/2
py0, py1 = -patch_l/2, +patch_l/2
# Patch in 3 boxes around the notch (which is at -y edge, centred on x=0):
# Box A: full width above the notch (y from py0+inset_depth to py1)
copper.AddBox([px0, py0 + inset_depth, Z_PATCH],
[px1, py1, Z_PATCH + T_CU], priority=10)
# Box B: left of notch, between patch -y edge and notch top
copper.AddBox([px0, py0, Z_PATCH],
[-notch_half_w, py0 + inset_depth, Z_PATCH + T_CU],
priority=10)
# Box C: right of notch, between patch -y edge and notch top
copper.AddBox([+notch_half_w, py0, Z_PATCH],
[px1, py0 + inset_depth, Z_PATCH + T_CU], priority=10)
# Feed line: 50 Ω microstrip from board edge (at -y) up into the inset
# notch. Feed line top reaches inside the notch by `inset_depth` so the
# feed-trace tip touches the patch at the inset bottom.
feed_y_start = -GND_Y_HALF + GND_Y_MARGIN # at edge of ground
feed_y_end = py0 + inset_depth # tip inside inset
copper.AddBox([-feed_w/2, feed_y_start, Z_PATCH],
[+feed_w/2, feed_y_end, Z_PATCH + T_CU], priority=10)
# Ground plane (full)
copper.AddBox([-GND_X_HALF, -GND_Y_HALF, Z_GND],
[+GND_X_HALF, +GND_Y_HALF, Z_GND + T_CU], priority=10)
# Mesh
lambda_min_mm = (C0 / F_STOP) * 1000.0
res = lambda_min_mm / profile_cfg["mesh_lambda_div"]
PORT_LEN = 2.0
xlines = [-AIR_X_HALF, -GND_X_HALF, px0, -notch_half_w, -feed_w/2, 0,
+feed_w/2, +notch_half_w, px1, +GND_X_HALF, +AIR_X_HALF]
port_y_lines = list(np.linspace(feed_y_start, feed_y_start + PORT_LEN, 6))
ylines = [-AIR_Y_HALF, -GND_Y_HALF, py0, py0 + inset_depth, 0, py1,
+GND_Y_HALF, +AIR_Y_HALF] + port_y_lines
air_below = list(np.arange(Z_GND - T_CU - AIR_BELOW, Z_GND - T_CU, res))
air_above = list(np.arange(Z_TOP + res, Z_TOP + AIR_ABOVE + res, res))
sub_interior = list(np.linspace(Z_GND + T_CU, Z_PATCH, 7)[1:-1])
zlines = sorted(set(air_below + [
Z_GND - T_CU, Z_GND, Z_GND + T_CU,
Z_PATCH, Z_PATCH + T_CU,
] + sub_interior + air_above))
xlines = SmoothMeshLines(np.array(xlines), res)
ylines = SmoothMeshLines(np.array(ylines), res)
zlines = np.array(zlines)
mesh.AddLine("x", xlines)
mesh.AddLine("y", ylines)
mesh.AddLine("z", zlines)
n_cells = len(xlines) * len(ylines) * len(zlines)
# MSLPort: at -y edge of board, on the feed line. 50 Ω microstrip,
# propagation along +y, excitation in z.
port = fdtd.AddMSLPort(1, copper,
start=[-feed_w/2, feed_y_start, Z_GND + T_CU],
stop= [+feed_w/2, feed_y_start + PORT_LEN, Z_PATCH + T_CU],
prop_dir='y', exc_dir='z',
excite=1.0,
FeedShift=0.4, MeasPlaneShift=1.6,
Feed_R=50)
print(f"[case {label}] patch={patch_w:.2f}x{patch_l:.2f}mm "
f"feed={feed_w:.2f}mm inset={inset_depth:.2f}/{inset_gap:.2f}mm "
f"cells={n_cells:,}")
t0 = time.time()
fdtd.Run(sim_path, verbose=0, cleanup=True)
dt = time.time() - t0
freq = np.linspace(F_START, F_STOP, 401)
port.CalcPort(sim_path, freq)
s11 = port.uf_ref / port.uf_inc
s11_dB = 20.0 * np.log10(np.abs(s11) + 1e-30)
zin = port.uf_tot / port.if_tot
vswr = (1 + np.abs(s11)) / (1 - np.abs(s11) + 1e-30)
return freq, s11_dB, zin, vswr, dt
def find_resonance(freq, s11_dB, zin=None):
f_res, s11_min = None, None
if zin is not None:
mask = (freq >= 9.0e9) & (freq <= 11.5e9)
idx_band = np.where(mask)[0]
if len(idx_band) > 1:
r_band = np.real(zin[idx_band])
i_pk = idx_band[int(np.argmax(r_band))]
x = np.imag(zin)
sign = np.sign(x)
crossings = np.where(np.diff(sign) != 0)[0]
crossings_in_band = [c for c in crossings if mask[c]]
if crossings_in_band:
k = min(crossings_in_band, key=lambda c: abs(c - i_pk))
t = -x[k] / (x[k+1] - x[k]) if x[k+1] != x[k] else 0
f_res = freq[k] + t * (freq[k+1] - freq[k])
s11_min = s11_dB[k] + t * (s11_dB[k+1] - s11_dB[k])
if f_res is None:
imin = int(np.argmin(s11_dB))
f_res = freq[imin]
s11_min = float(s11_dB[imin])
below = s11_dB <= -10.0
if not below.any():
return f_res, s11_min, 0.0, 0.0, 0.0
i_f = int(np.argmin(np.abs(freq - f_res)))
if not below[i_f]:
return f_res, s11_min, 0.0, 0.0, 0.0
lo = i_f
while lo > 0 and below[lo-1]:
lo -= 1
hi = i_f
while hi < len(below)-1 and below[hi+1]:
hi += 1
f_lo, f_hi = freq[lo], freq[hi]
bw = f_hi - f_lo
bw_pct = bw / f_res * 100.0
return f_res, s11_min, f_lo, f_hi, bw_pct
# Main
sim_path = os.path.join(OUT_DIR, "single")
freq, s11_dB, zin, vswr, dt = run_case(
PATCH_W, PATCH_L, FEED_W, INSET_DEPTH, INSET_GAP, FEED_LEAD_L,
sim_path, cfg)
f_res, s11_min, f_lo, f_hi, bw_pct = find_resonance(freq, s11_dB, zin)
i_op = int(np.argmin(np.abs(freq - 10.5e9)))
i_res = int(np.argmin(np.abs(freq - f_res)))
print()
print("=" * 70)
print(f" Edge-fed (inset) on 0.508 mm RO4350B (W={PATCH_W} L={PATCH_L} inset={INSET_DEPTH})")
print(f" Resonance (R peak + Im=0): {f_res/1e9:.3f} GHz (target 10.5 GHz)")
print(f" S11 at resonance : {s11_min:.2f} dB")
print(f" Zin at resonance : {zin[i_res].real:.1f} + j{zin[i_res].imag:+.1f} Ω")
print(" ── at 10.500 GHz exactly:")
print(f" S11 @ 10.5GHz : {s11_dB[i_op]:.2f} dB")
print(f" Zin @ 10.5GHz : {zin[i_op].real:.1f} + j{zin[i_op].imag:+.1f} Ω")
print(f" VSWR @ 10.5GHz : {vswr[i_op]:.2f}")
print(f" -10 dB bandwidth : {(f_hi-f_lo)/1e6:.0f} MHz "
f"({f_lo/1e9:.3f} {f_hi/1e9:.3f} GHz, {bw_pct:.2f}%)")
print(f" Sim time : {dt:.1f} s")
print("=" * 70)
fig, ax = plt.subplots(figsize=(8.5, 4.5))
ax.plot(freq/1e9, s11_dB, "b-", lw=1.6, label="S11")
ax.axhline(-10, color="r", ls="--", lw=0.8, label="-10 dB")
ax.axvline(f_res/1e9, color="g", ls=":", lw=0.8,
label=f"resonance {f_res/1e9:.3f} GHz")
if (f_hi-f_lo) > 0:
ax.axvspan(f_lo/1e9, f_hi/1e9, color="g", alpha=0.10,
label=f"BW {(f_hi-f_lo)/1e6:.0f} MHz ({bw_pct:.2f}%)")
ax.set_xlabel("Frequency (GHz)")
ax.set_ylabel("S11 (dB)")
ax.set_title("AERIS-10 Edge-Fed (inset) — 2-layer 0.508 mm RO4350B")
ax.set_xlim(F_START/1e9, F_STOP/1e9)
ax.set_ylim(-40, 0)
ax.grid(True, alpha=0.3)
ax.legend(loc="lower right")
fig.tight_layout()
fig.savefig(os.path.join(OUT_DIR, "S11.png"), dpi=140)
plt.close(fig)
with open(os.path.join(OUT_DIR, "S11_data.csv"), "w", newline="") as f:
w = csv.writer(f)
w.writerow(["freq_Hz", "S11_dB", "Zin_real", "Zin_imag", "VSWR"])
for k in range(len(freq)):
w.writerow([freq[k], s11_dB[k], zin[k].real, zin[k].imag, vswr[k]])
print(f"[out] {OUT_DIR}/S11.png")
print(f"[out] {OUT_DIR}/S11_data.csv")