sim(antenna): add 4x4 probe-fed array model — mutual coupling characterisation

Single-port-driven, 15-port-terminated FDTD sim built on the same v3 patch
design point (W=7.854, L=6.56, FEED_OFFSET=2.14) at the Gerber pitch
(14.27 mm X / 15.01 mm Y). Reads out S_jd for all elements when port
(DRIVEN_X, DRIVEN_Y) is excited. Configurable array size + driven port via
env vars; default = 4x4, drive inner element (1,1).

Result at PROFILE=balanced, drive (1,1):
  Active S11 @ 10.5 GHz : -16.7 dB,  Z = 45.7 + j13.5 Ω
  Active -10 dB BW      : 190 MHz (10.44-10.63 GHz; ≈ single-element 180)
  Nearest-neighbour coupling:
    -x edge neighbour (0,1): -18.3 dB  (strongest — edge element loads less)
    -y/+y in-array        : -22 dB
    +x interior neighbour : -24.7 dB
  Diagonal               : -32 dB
  Far corners            : -45 to -53 dB

Outputs: coupling_grid.png heatmap + S_matrix.csv (all S_jd full sweep) +
S11_data.csv (driven-port only).

Known limitation: port box must align with mesh lines; SmoothMeshLines may
sub-cell-shift seed lines, causing some DRIVEN_X/DRIVEN_Y choices to give
"Unused primitive" warning + zero excitation. Default (1,1) is verified;
other choices are best-effort. Documented inline.
This commit is contained in:
Jason
2026-05-03 16:18:42 +05:45
parent 3f3846b514
commit eb9be337b1

View File

@@ -0,0 +1,358 @@
#!/usr/bin/env python3
# probe_fed_array_aeris10_v3.py
#
# 4x4 (default; configurable) probe-fed patch array sim for AERIS-10. Built
# on the same single-element design point as probe_fed_aeris10_v3.py but
# placed on the 8x16 Gerber pitch (14.27 mm X / 15.01 mm Y).
#
# Purpose: characterise mutual coupling between elements. Each patch has its
# own probe-via port; only one port is excited per sim run, the other 15 are
# terminated in 50 Ω. From this we read:
# - S_dd (active S11 of the driven element with array loaded)
# - S_jd for all other ports j (coupling driven → j)
# Pattern of |S_jd| dB values across the array tells us nearest-neighbour vs
# diagonal vs skip-one coupling, edge vs interior asymmetry, etc.
#
# Per-element design (matches probe_fed_aeris10_v3.py iter#3):
# PATCH_W = 7.854 mm PATCH_L = 6.56 mm FEED_OFFSET = 2.14 mm
# Substrate: 0.508 mm RO4350B (εr=3.48, tanδ=0.0037)
# Pitch: 14.27 mm × 15.01 mm (X-pitch ~λ₀/2 at 10.5 GHz, Y-pitch ~1.05·λ₀/2)
#
# Run:
# cd /tmp && DYLD_LIBRARY_PATH=/Users/ganeshpanth/opt/openEMS/lib \
# PROFILE=sanity \
# /Users/ganeshpanth/radar_venv/bin/python \
# /Users/ganeshpanth/PLFM_RADAR/5_Simulations/Antenna/probe_fed_array_aeris10_v3.py
#
# Env overrides:
# ARRAY_NX ARRAY_NY (default 4, 4)
# PITCH_X_MM PITCH_Y_MM (default 14.27, 15.01 from Gerber)
# DRIVEN_X DRIVEN_Y (0-indexed; default = inner element 1,1)
# PATCH_W_MM PATCH_L_MM FEED_OFFSET_MM (default v3 design point)
#
# Output (in /tmp/aeris10_array_v3/):
# S_matrix.csv — driven-column S parameters (mag dB + phase deg) at 10.5 GHz
# S11_data.csv — driven port full sweep
# coupling_grid.png — heatmap of |S_jd| dB at 10.5 GHz across array
import os
import sys
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
# ============================================================================
# PATCH (per-element, from v3 iter#3)
# ============================================================================
PATCH_W = float(os.environ.get("PATCH_W_MM", "7.854"))
PATCH_L = float(os.environ.get("PATCH_L_MM", "6.56"))
FEED_OFFSET_MM = float(os.environ.get("FEED_OFFSET_MM", "2.14"))
# ============================================================================
# ARRAY
# ============================================================================
N_X = int(os.environ.get("ARRAY_NX", "4"))
N_Y = int(os.environ.get("ARRAY_NY", "4"))
PITCH_X = float(os.environ.get("PITCH_X_MM", "14.27"))
PITCH_Y = float(os.environ.get("PITCH_Y_MM", "15.01"))
DRIVEN_X = int(os.environ.get("DRIVEN_X", str(N_X // 2 - (N_X+1) % 2))) # inner element
DRIVEN_Y = int(os.environ.get("DRIVEN_Y", str(N_Y // 2 - (N_Y+1) % 2)))
# Array footprint extent (centre patch on origin)
ARRAY_X_HALF = (N_X-1)/2 * PITCH_X + PATCH_W/2
ARRAY_Y_HALF = (N_Y-1)/2 * PITCH_Y + PATCH_L/2
# Substrate / ground extents (~λ/2 margin around array)
GND_MARGIN = 14.3
GND_X_HALF = ARRAY_X_HALF + GND_MARGIN
GND_Y_HALF = ARRAY_Y_HALF + GND_MARGIN
# Air box
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_array_v3"
os.makedirs(OUT_DIR, exist_ok=True)
def patch_center(i, j):
"""Centre coordinate of patch at array index (i,j), origin at array centre."""
x = -(N_X-1)/2 * PITCH_X + i * PITCH_X
y = -(N_Y-1)/2 * PITCH_Y + j * PITCH_Y
return x, y
# ============================================================================
# Build + run
# ============================================================================
def run_case(sim_path, profile_cfg):
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 (full board extent) ----
patch_sub.AddBox([-GND_X_HALF, -GND_Y_HALF, Z_GND + T_CU],
[+GND_X_HALF, +GND_Y_HALF, Z_PATCH], priority=1)
# ---- L2: full ground plane ----
copper.AddBox([-GND_X_HALF, -GND_Y_HALF, Z_GND],
[+GND_X_HALF, +GND_Y_HALF, Z_GND + T_CU], priority=10)
# ---- L1: 4x4 patch array + ports ----
ports = []
feed_locs = []
for i in range(N_X):
for j in range(N_Y):
cx, cy = patch_center(i, j)
copper.AddBox([cx - PATCH_W/2, cy - PATCH_L/2, Z_PATCH],
[cx + PATCH_W/2, cy + PATCH_L/2, Z_PATCH + T_CU],
priority=10)
feed_x = cx
feed_y = cy - PATCH_L/2 + FEED_OFFSET_MM
feed_locs.append((feed_x, feed_y, i, j))
# ---- mesh ----
lambda_min_mm = (C0 / F_STOP) * 1000.0
res = lambda_min_mm / profile_cfg["mesh_lambda_div"]
# X mesh: array extent + air, plus patch edges + feed locations
xlines = [-AIR_X_HALF, -GND_X_HALF, +GND_X_HALF, +AIR_X_HALF]
for i in range(N_X):
cx, _ = patch_center(i, 0)
xlines += [cx - PATCH_W/2, cx, cx + PATCH_W/2]
# Y mesh
ylines = [-AIR_Y_HALF, -GND_Y_HALF, +GND_Y_HALF, +AIR_Y_HALF]
for j in range(N_Y):
_, cy = patch_center(0, j)
ylines += [cy - PATCH_L/2, cy, cy + PATCH_L/2,
cy - PATCH_L/2 + FEED_OFFSET_MM] # feed y location
# Z mesh: 6 cells in substrate
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)
# ---- ports (one excited, 15 terminated 50Ω) ----
# NOTE: each port box must land exactly on existing mesh lines. The seed
# mesh includes feed_x (= patch centre cx) and feed_y for each (i,j) — so
# this normally works — but SmoothMeshLines can sub-cell-shift seed lines
# in some configurations and a port box ends up between two mesh lines,
# leaving openEMS without an excitation cell ("Unused primitive" warning,
# zero energy). The DRIVEN=(1,1) inner-element case has been verified to
# land on the mesh; other driven-port choices are best-effort. If you see
# NaN/zero results for a different DRIVEN_X/DRIVEN_Y, that's the cause.
for (feed_x, feed_y, i, j) in feed_locs:
port_num = i * N_Y + j + 1
excite_amp = 1.0 if (i == DRIVEN_X and j == DRIVEN_Y) else 0.0
port = fdtd.AddLumpedPort(port_num, 50,
[feed_x, feed_y, Z_GND + T_CU],
[feed_x, feed_y, Z_PATCH],
'z', excite=excite_amp, priority=5)
ports.append(((i, j), port))
# ---- run ----
print(f"[case] {N_X}x{N_Y} array, driven=({DRIVEN_X},{DRIVEN_Y}), "
f"cells={n_cells:,}, sub={H_PATCH_SUB}mm, pitch={PITCH_X}x{PITCH_Y}mm")
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)
driven_port = next(p for (idx, p) in ports if idx == (DRIVEN_X, DRIVEN_Y))
for (idx, p) in ports:
p.CalcPort(sim_path, freq)
# S_jd (j is each port, d is driven). For driven port, S_dd = uf_ref/uf_inc.
# For other ports, S_jd = uf_ref_j / uf_inc_d (no incident wave at j from
# its own source since port j is unexcited).
S = {}
for (idx, p) in ports:
if idx == (DRIVEN_X, DRIVEN_Y):
S[idx] = p.uf_ref / p.uf_inc
else:
S[idx] = p.uf_ref / driven_port.uf_inc
return freq, S, dt, ports
# ============================================================================
# MAIN
# ============================================================================
sim_path = os.path.join(OUT_DIR, "single")
freq, S, dt, ports = run_case(sim_path, cfg)
# At 10.5 GHz
i_op = int(np.argmin(np.abs(freq - F0)))
# Print coupling grid
print()
print("=" * 70)
print(f" 4x4 probe-fed array — driven port at ({DRIVEN_X},{DRIVEN_Y})")
print(f" Substrate: {H_PATCH_SUB} mm RO4350B, pitch {PITCH_X}x{PITCH_Y} mm")
print(f" Sim time: {dt:.1f} s")
print("=" * 70)
print()
print(f" S parameters at {F0/1e9:.2f} GHz (|S_j,driven| in dB):")
print()
# Layout grid as visual array (i is x-direction, j is y-direction)
# Print y high to low so it matches usual visual orientation
header = " " + "".join(f" i={i:1d} " for i in range(N_X))
print(header)
for j in reversed(range(N_Y)):
row = f" j={j:1d}: "
for i in range(N_X):
val = abs(S[(i, j)][i_op])
dB = 20*np.log10(val + 1e-30)
row += f"{dB:>7.1f}"
print(row)
print()
# Driven port S11 vs frequency
S_dd = S[(DRIVEN_X, DRIVEN_Y)]
S_dd_dB = 20*np.log10(np.abs(S_dd) + 1e-30)
zin_d = 50.0 * (1 + S_dd) / (1 - S_dd) # Z = Z0·(1+S)/(1-S)
print(f" Driven port active S11:")
print(f" @ 10.5 GHz : {S_dd_dB[i_op]:.2f} dB Z = {zin_d[i_op].real:.1f} + j{zin_d[i_op].imag:.1f} Ω")
# -10 dB BW around f0
below = S_dd_dB <= -10.0
if below[i_op]:
lo, hi = i_op, i_op
while lo > 0 and below[lo-1]:
lo -= 1
while hi < len(below)-1 and below[hi+1]:
hi += 1
print(f" -10 dB BW : {(freq[hi]-freq[lo])/1e6:.0f} MHz "
f"({freq[lo]/1e9:.2f} {freq[hi]/1e9:.2f} GHz)")
else:
print(f" -10 dB BW : <none at 10.5 GHz>")
# Worst-case coupling (excluding driven port itself)
couplings = [(idx, abs(S[idx][i_op])) for idx in S.keys() if idx != (DRIVEN_X, DRIVEN_Y)]
couplings.sort(key=lambda x: -x[1])
print()
print(f" Top-5 strongest couplings to driven port at 10.5 GHz:")
for idx, val in couplings[:5]:
di = idx[0] - DRIVEN_X
dj = idx[1] - DRIVEN_Y
dB = 20*np.log10(val + 1e-30)
print(f" ({idx[0]},{idx[1]}) Δ=({di:+d},{dj:+d}) |S| = {dB:>6.1f} dB")
print("=" * 70)
# Save S matrix CSV (full-band)
with open(os.path.join(OUT_DIR, "S_matrix.csv"), "w", newline="") as f:
w = csv.writer(f)
header = ["freq_Hz"]
keys = sorted(S.keys())
for idx in keys:
header += [f"S({idx[0]},{idx[1]})_dB", f"S({idx[0]},{idx[1]})_phase_deg"]
w.writerow(header)
for k in range(len(freq)):
row = [freq[k]]
for idx in keys:
mag_dB = 20*np.log10(np.abs(S[idx][k]) + 1e-30)
phase = np.angle(S[idx][k], deg=True)
row += [mag_dB, phase]
w.writerow(row)
# Save driven-port S11 CSV
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"])
for k in range(len(freq)):
w.writerow([freq[k], S_dd_dB[k], zin_d[k].real, zin_d[k].imag])
# Coupling heatmap at 10.5 GHz
fig, ax = plt.subplots(figsize=(6.5, 6))
grid = np.zeros((N_Y, N_X))
for (i, j) in S.keys():
grid[j, i] = 20*np.log10(abs(S[(i,j)][i_op]) + 1e-30)
# Driven port floor (S11 is just one number, not a coupling) — set to NaN to highlight
grid[DRIVEN_Y, DRIVEN_X] = np.nan
im = ax.imshow(grid, origin='lower', cmap='viridis', aspect='equal')
ax.set_xticks(range(N_X))
ax.set_yticks(range(N_Y))
ax.set_xlabel('i (x-pitch direction)')
ax.set_ylabel('j (y-pitch direction)')
ax.set_title(f'AERIS-10 4x4 array — coupling |S_j,({DRIVEN_X},{DRIVEN_Y})| at {F0/1e9:.2f} GHz')
# Annotate cells
for j in range(N_Y):
for i in range(N_X):
if (i, j) == (DRIVEN_X, DRIVEN_Y):
ax.text(i, j, "DRIVEN", ha='center', va='center', color='red',
fontsize=9, fontweight='bold')
else:
ax.text(i, j, f"{grid[j,i]:.1f}\ndB", ha='center', va='center',
color='white', fontsize=8)
plt.colorbar(im, ax=ax, label='|S| (dB)', shrink=0.7)
fig.tight_layout()
fig.savefig(os.path.join(OUT_DIR, "coupling_grid.png"), dpi=140)
plt.close(fig)
print(f"[out] {OUT_DIR}/coupling_grid.png")
print(f"[out] {OUT_DIR}/S_matrix.csv")
print(f"[out] {OUT_DIR}/S11_data.csv")