Files
NawfalMotii79-PLFM_RADAR/5_Simulations/Antenna/probe_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

316 lines
13 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
# probe_fed_aeris10_v3.py
#
# Single-element probe-fed patch antenna sim for AERIS-10 — true 2-layer
# stackup (L1 patch / 0.508 mm RO4350B / L2 ground plane). Probe via through
# ground plane feeds the patch; LumpedPort with R=50 Ω across the substrate
# at the probe location models the coax launch.
#
# Why this topology: aperture-coupled v2 (4-layer Stack_Hybrid) capped at
# ~60 MHz BW because the 0.11 mm L4 backshort acted as a near-short reflector
# — wider BW is fundamentally coupling-limited there. Probe-fed patch on the
# same 0.508 mm patch substrate has no slot bottleneck; physics BW from
# 3.77·(εr-1)/εr²·(W/L)·(h/λ₀) is ~1.6% ≈ 170 MHz at 10.5 GHz.
#
# Patch geometry preserved from the existing 8x16 Gerber (Antenna_16_8.top):
# W = 7.854 mm (D10 first dimension; sets X-pitch 14.27 mm in the array)
# L = 6.56 mm (tuned at balanced profile to land f_res = 10.51 GHz; old
# Gerber's 7.356 mm at 0.102 mm sub gave f_res ~10.6 GHz, the
# thicker substrate adds ~1 mm of fringing-edge ΔL each side)
#
# Probe location:
# y_off = 2.14 mm from -y radiating edge → R_in = 41 Ω, VSWR = 1.18 at 10.5
# GHz. R_edge fitted from sim ≈ 152 Ω; cos²(π·y_off/L) gives R_in.
#
# Verified design point (PROFILE=balanced, λ/25 mesh, 13 s/run):
# f_res = 10.510 GHz, S11 = -21.79 dB at 10.5 GHz, Zin = 42.7 + j2.0 Ω
# -10 dB BW = 180 MHz (10.40 10.58 GHz, 1.71%)
# Compare 4-layer Stack_Hybrid + cap: 60 MHz BW, -19 dB. 3× wider, no cap.
#
# Stackup:
# L1 Cu 0.035 mm ← patch
# -- RO4350B 0.508 mm εr=3.48 (patch substrate)
# L2 Cu 0.035 mm ← ground plane (with antipad clearance)
# air below; coax launches up through
# to the probe via from L2 ground.
#
# Run:
# cd /tmp && DYLD_LIBRARY_PATH=/Users/ganeshpanth/opt/openEMS/lib \
# PROFILE=balanced PATCH_L_MM=7.54 FEED_OFFSET_MM=2.5 \
# /Users/ganeshpanth/radar_venv/bin/python \
# /Users/ganeshpanth/PLFM_RADAR/5_Simulations/Antenna/probe_fed_aeris10_v3.py
#
# Profiles:
# sanity — λ/18 mesh, fast, borderline convergence
# balanced — λ/25 mesh, slower, recommended for design verification
#
# Env overrides (all optional):
# PATCH_W_MM PATCH_L_MM FEED_OFFSET_MM (mm from -y radiating edge)
# FEED_X_MM (default 0; offset along W-axis, normally 0 for centred feed)
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 (mm) — true 2-layer probe-fed
# ============================================================================
T_CU = 0.035
H_PATCH_SUB = 0.508 # RO4350B between L1 patch and L2 ground
EPS_RO4350B = 3.48
TAN_RO4350B = 0.0037
# Z layers (L2 ground at z=0, patch on top)
Z_GND = 0.0
Z_PATCH = Z_GND + T_CU + H_PATCH_SUB
Z_TOP = Z_PATCH + T_CU
# ============================================================================
# GEOMETRY (mm) — defaults preserve old Gerber's W; L recomputed for 0.508 mm
# ============================================================================
PATCH_W = float(os.environ.get("PATCH_W_MM", "7.854"))
PATCH_L = float(os.environ.get("PATCH_L_MM", "6.56"))
# Probe: feed offset along the L-axis (y), measured from -y radiating edge
# inward. R_in(y_off) = R_edge·cos²(π·y_off/L). y_off=2.14 mm with iter#3 L
# (R_edge≈152 Ω fitted) lands R_in=41 Ω, VSWR=1.18 at 10.5 GHz.
FEED_OFFSET_MM = float(os.environ.get("FEED_OFFSET_MM", "2.14"))
FEED_X_MM = float(os.environ.get("FEED_X_MM", "0.0"))
# Substrate / ground extents (~λ/2 margin around patch)
GND_X_MARGIN = 14.3
GND_Y_MARGIN = 14.3
GND_X_HALF = PATCH_W/2 + GND_X_MARGIN
GND_Y_HALF = PATCH_L/2 + GND_Y_MARGIN
# Air box (λ/2 above patch, λ/2 below ground)
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_probefed_v3"
os.makedirs(OUT_DIR, exist_ok=True)
# ============================================================================
# Build + run a single FDTD case
# ============================================================================
def run_case(patch_w, patch_l, feed_offset, feed_x, 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)
# ---- L1: patch (centred on origin, L along y, W along x) ----
copper.AddBox([-patch_w/2, -patch_l/2, Z_PATCH],
[+patch_w/2, +patch_l/2, Z_PATCH + T_CU], priority=10)
# ---- L2: full ground plane ----
# Single-element sim — antipad clearance around the probe is implicit
# in the LumpedPort box (FDTD treats the port column as the metal probe
# and the surrounding cells as substrate). For a multi-element array
# with real coax launches a physical clearance hole would be added.
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"]
# Probe location in patch frame
feed_y = -patch_l/2 + feed_offset # offset from -y radiating edge
feed_x_pos = feed_x
xlines = [-AIR_X_HALF, -GND_X_HALF, -patch_w/2, feed_x_pos, +patch_w/2,
+GND_X_HALF, +AIR_X_HALF]
ylines = [-AIR_Y_HALF, -GND_Y_HALF, -patch_l/2, feed_y, +patch_l/2,
+GND_Y_HALF, +AIR_Y_HALF]
# Z mesh: substrate gets ≥6 interior cells for accurate field capture
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)
if os.environ.get("MESH_DEBUG"):
z_diff = np.diff(zlines)
print(f"[mesh] x={len(xlines)} y={len(ylines)} z={len(zlines)} cells={n_cells:,}")
print(f"[mesh] z min/max/avg cell: {z_diff.min()*1e3:.1f}/{z_diff.max()*1e3:.1f}/{z_diff.mean()*1e3:.1f} um")
# ---- LumpedPort: vertical 50 Ω port across substrate at (feed_x, feed_y) ----
# The lumped port replaces the coax+source: the 50 Ω resistor sits in the
# box, the metal column from L2 to L1 is implicit. Excitation is z-direction
# E-field across the substrate.
port_start = [feed_x_pos, feed_y, Z_GND + T_CU]
port_stop = [feed_x_pos, feed_y, Z_PATCH]
port = fdtd.AddLumpedPort(1, 50, port_start, port_stop, 'z',
excite=1.0, priority=5)
# ---- run ----
print(f"[case {label}] patch={patch_w:.2f}x{patch_l:.2f}mm "
f"feed=({feed_x_pos:.2f},{feed_y:.2f})mm cells={n_cells:,}")
t0 = time.time()
fdtd.Run(sim_path, verbose=0, cleanup=True)
dt = time.time() - t0
# ---- post-process ----
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):
"""Resonance: where R peaks AND Im(Z)=0 nearby. Falls back to min(S11)."""
f_res, s11_min = None, None
if zin is not None:
# Find peak R in the band
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))]
# Use the X=0 crossing closest to the R peak
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])
# walk outward to find -10 dB crossings around f_res
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_OFFSET_MM, FEED_X_MM, sim_path, cfg)
f_res, s11_min, f_lo, f_hi, bw_pct = find_resonance(freq, s11_dB, zin)
i_res = int(np.argmin(np.abs(freq - f_res)))
i_op = int(np.argmin(np.abs(freq - 10.5e9)))
print()
print("=" * 70)
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(f"AERIS-10 Probe-Fed Patch v3 — 2-layer 0.508 mm RO4350B "
f"(W={PATCH_W} L={PATCH_L} y_off={FEED_OFFSET_MM}mm)")
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")