PLFM_RADAR/5_Simulations/Antenna/edge_fed_row_aeris10_v3.py
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

350 lines
14 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_row_aeris10_v3.py
#
# 1xN series-fed row sim for the 2-layer 0.508 mm RO4350B stackup.
# Extends edge_fed_aeris10_v3.py from a single element to a daisy chain.
#
# Topology:
# PORT (-y board edge) -> 50 Ω feed line (FEED_LEAD_L mm)
# -> patch_0 (-y edge connected to feed line)
# -> connecting line (CONN_LEN mm)
# -> patch_1 (edge-connected) -> connecting line -> ...
# -> patch_(N-1) (open at +y edge)
#
# The structure is a finite periodic array with a stopband centered at the
# patch self-resonance. The row exhibits an N-mode comb response — N=8 dips
# spanning ~3 GHz with ~0.5 GHz spacing. Operating frequency lands on the
# top-below-stopband mode (deepest dip just below the gap center).
#
# Verified design point (PROFILE=balanced, λ/25 mesh):
# W=7.854 mm L=6.95 mm CONN_LEN=8.15 mm pitch=15.10 mm
# INSET_DEPTH=0 (direct edge feed; inset on patch 0 drops Z to ~6 Ω which
# is unmatchable for N=8 — natural edge-fed Z at array resonance is ~80 Ω,
# close to 50 Ω so no input matching network is needed)
# FEED_W=1.16 mm FEED_LEAD=15.5 mm
#
# Verified result (operating-mode CONN_LEN swept to land dip on TX center):
# Operating mode at 10.520 GHz: S11 = -18.8 dB, Zin = 76.2 - j9.3 Ω
# -10 dB BW: 100 MHz (10.470 - 10.570 GHz)
# Across radar TX 10.510-10.530 GHz: S11 = -17.4 to -18.8 dB (symmetric)
# At 10.500 GHz (LO): S11 = -15.4 dB
#
# Sensitivity: df/dCONN_LEN ≈ -0.20 GHz/mm (longer CONN → lower op freq).
# To recenter on a different freq:
# CONN=8.25 → dip at 10.500 GHz (LO-centered, TX edge falls off)
# CONN=8.15 → dip at 10.520 GHz (TX-centered, recommended)
# CONN=8.20 → dip at 10.510 GHz (TX-low-edge centered)
# CONN=8.00 → dip at 10.560 GHz (above TX band)
#
# CRITICAL difference from edge_fed_aeris10_v3.py: single-element used inset
# (INSET_DEPTH=3.40) to match each patch to 50 Ω; row uses NO inset because
# 8 inset-matched patches in parallel would give Z_in ~ 6 Ω at the row port.
# Direct edge feed with N=8 naturally lands at ~80 Ω (close to 50).
#
# Run:
# cd /tmp && DYLD_LIBRARY_PATH=/Users/ganeshpanth/opt/openEMS/lib \
# PROFILE=balanced N_PATCHES=8 \
# /Users/ganeshpanth/radar_venv/bin/python \
# /Users/ganeshpanth/PLFM_RADAR/5_Simulations/Antenna/edge_fed_row_aeris10_v3.py
#
# Env overrides:
# N_PATCHES (default 8)
# PATCH_W_MM PATCH_L_MM
# FEED_W_MM (50 Ω microstrip on 0.508 mm RO4350B → 1.16 mm)
# INSET_DEPTH_MM (0 = edge feed; >0 = inset feed on patch 0 only)
# INSET_GAP_MM
# FEED_LEAD_MM (1·λ_g at f0, line transparent)
# CONN_LEN_MM (connecting line between patches)
# PROFILE (sanity | balanced; balanced is REQUIRED for accuracy)
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": 100000, "end_dB": -30},
"balanced": {"mesh_lambda_div": 25, "n_timesteps": 250000, "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
# ============================================================================
N_PATCHES = int(os.environ.get("N_PATCHES", "8"))
PATCH_W = float(os.environ.get("PATCH_W_MM", "7.854"))
PATCH_L = float(os.environ.get("PATCH_L_MM", "6.95"))
FEED_W = float(os.environ.get("FEED_W_MM", "1.16"))
INSET_DEPTH = float(os.environ.get("INSET_DEPTH_MM", "0.0"))
INSET_GAP = float(os.environ.get("INSET_GAP_MM", "0.30"))
FEED_LEAD_L = float(os.environ.get("FEED_LEAD_MM", "15.5"))
# Connecting line. CONN_LEN=8.15 lands the operating-mode dip at 10.520 GHz
# (radar TX center), giving symmetric -17 to -19 dB across the 10.510-10.530
# chirp band. Pitch = PATCH_L + CONN_LEN = 15.10 mm (vs old Gerber 15.01).
CONN_LEN = float(os.environ.get("CONN_LEN_MM", "8.15"))
PITCH = PATCH_L + CONN_LEN
# Array spans y from feed-board-edge to last-patch-top (asymmetric layout)
Y_FEED_BOARD_EDGE = -PATCH_L/2 - FEED_LEAD_L
Y_LAST_PATCH_TOP = (N_PATCHES - 1) * PITCH + PATCH_L/2
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_NEG = Y_FEED_BOARD_EDGE - GND_Y_MARGIN
GND_Y_POS = Y_LAST_PATCH_TOP + GND_Y_MARGIN
AIR_ABOVE = 14.3
AIR_BELOW = 14.3
AIR_X_HALF = GND_X_HALF + 8.0
AIR_Y_NEG = GND_Y_NEG - 8.0
AIR_Y_POS = GND_Y_POS + 8.0
OUT_DIR = "/tmp/aeris10_edgefed_row_v3"
os.makedirs(OUT_DIR, exist_ok=True)
# ============================================================================
# Build + run
# ============================================================================
def run_case(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)
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 (single slab spanning the whole row)
patch_sub.AddBox([-GND_X_HALF, GND_Y_NEG, Z_GND + T_CU],
[+GND_X_HALF, GND_Y_POS, Z_PATCH], priority=1)
# Ground plane (full footprint)
copper.AddBox([-GND_X_HALF, GND_Y_NEG, Z_GND],
[+GND_X_HALF, GND_Y_POS, Z_GND + T_CU], priority=10)
# ---- Patches ----
notch_half_w = FEED_W/2 + INSET_GAP
for i in range(N_PATCHES):
py0 = i * PITCH - PATCH_L/2 # patch -y edge
py1 = i * PITCH + PATCH_L/2 # patch +y edge
if i == 0 and INSET_DEPTH > 0.001:
# Patch 0: inset feed cut into -y edge
copper.AddBox([-PATCH_W/2, py0 + INSET_DEPTH, Z_PATCH],
[+PATCH_W/2, py1, Z_PATCH + T_CU], priority=10)
copper.AddBox([-PATCH_W/2, py0, Z_PATCH],
[-notch_half_w, py0 + INSET_DEPTH, Z_PATCH + T_CU],
priority=10)
copper.AddBox([+notch_half_w, py0, Z_PATCH],
[+PATCH_W/2, py0 + INSET_DEPTH, Z_PATCH + T_CU],
priority=10)
else:
# Patches 1..N-1 (or patch 0 if INSET_DEPTH=0): solid rectangle
copper.AddBox([-PATCH_W/2, py0, Z_PATCH],
[+PATCH_W/2, py1, Z_PATCH + T_CU], priority=10)
# ---- Connecting lines (between patch i +y edge and patch i+1 -y edge) ----
for i in range(N_PATCHES - 1):
cy0 = i * PITCH + PATCH_L/2
cy1 = (i + 1) * PITCH - PATCH_L/2
copper.AddBox([-FEED_W/2, cy0, Z_PATCH],
[+FEED_W/2, cy1, Z_PATCH + T_CU], priority=10)
# ---- Feed line (board edge → patch 0 inset, or edge if INSET_DEPTH=0) ----
feed_y_start = Y_FEED_BOARD_EDGE
feed_y_end = (-PATCH_L/2 + INSET_DEPTH) if INSET_DEPTH > 0.001 else -PATCH_L/2
copper.AddBox([-FEED_W/2, feed_y_start, Z_PATCH],
[+FEED_W/2, feed_y_end, Z_PATCH + 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, -PATCH_W/2, -notch_half_w, -FEED_W/2,
0, +FEED_W/2, +notch_half_w, +PATCH_W/2, +GND_X_HALF, +AIR_X_HALF]
ylines = [AIR_Y_NEG, GND_Y_NEG, feed_y_start]
for i in range(N_PATCHES):
ylines.append(i * PITCH - PATCH_L/2)
ylines.append(i * PITCH)
ylines.append(i * PITCH + PATCH_L/2)
ylines.append(-PATCH_L/2 + INSET_DEPTH)
ylines.append(GND_Y_POS)
ylines.append(AIR_Y_POS)
port_y_lines = list(np.linspace(feed_y_start, feed_y_start + PORT_LEN, 6))
ylines += 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(sorted(set(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)
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)
sim_path = os.path.join(OUT_DIR, label or "row")
print(f"[case {label}] N={N_PATCHES} patch={PATCH_W:.3f}x{PATCH_L:.3f}mm "
f"conn={CONN_LEN:.2f}mm pitch={PITCH:.2f}mm 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):
"""Find the dip nearest 10.5 GHz with S11 < -10 dB (the operating mode of
the row), plus its contiguous -10 dB BW."""
below = s11_dB <= -10.0
if not below.any():
# No -10 dB region anywhere; fall back to global min in 9.5-11.5
mask = (freq >= 9.5e9) & (freq <= 11.5e9)
idx = np.where(mask)[0]
i_min = idx[int(np.argmin(s11_dB[idx]))]
return freq[i_min], float(s11_dB[i_min]), 0.0, 0.0, 0.0
# Find local minima below -10 dB; pick the one nearest 10.5 GHz
minima = []
for i in range(2, len(s11_dB)-2):
if (below[i] and s11_dB[i] < s11_dB[i-1] and s11_dB[i] < s11_dB[i+1]):
minima.append(i)
if not minima:
i_pick = int(np.argmin(np.abs(freq - 10.5e9)))
else:
i_pick = min(minima, key=lambda i: abs(freq[i] - 10.5e9))
f_res = freq[i_pick]
s11_min = float(s11_dB[i_pick])
lo = i_pick
while lo > 0 and below[lo-1]:
lo -= 1
hi = i_pick
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
# ============================================================================
freq, s11_dB, zin, vswr, dt = run_case(cfg, label=f"N{N_PATCHES}")
f_res, s11_min, f_lo, f_hi, bw_pct = find_resonance(freq, s11_dB)
i_op = int(np.argmin(np.abs(freq - 10.5e9)))
i_res = int(np.argmin(np.abs(freq - f_res)))
print()
print("=" * 78)
print(f" Edge-fed series-fed row N={N_PATCHES} on 0.508 mm RO4350B")
print(f" W={PATCH_W} L={PATCH_L} inset={INSET_DEPTH}/{INSET_GAP} "
f"conn={CONN_LEN} pitch={PITCH:.2f}")
print(f" Operating mode (nearest 10.5 GHz): {f_res/1e9:.3f} GHz, {s11_min:.2f} dB")
print(f" Zin at op mode : {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("=" * 78)
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"min S11 @ {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 1×{N_PATCHES} Series-Fed Row — 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, f"S11_N{N_PATCHES}.png"), dpi=140)
plt.close(fig)
with open(os.path.join(OUT_DIR, f"S11_data_N{N_PATCHES}.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_N{N_PATCHES}.png")
print(f"[out] {OUT_DIR}/S11_data_N{N_PATCHES}.csv")