PLFM_RADAR/5_Simulations/Antenna/probe_fed_array_aeris10_v3.py
Jason acfbbb1d4d sim(antenna): probe_fed_array v3 — multi-port DRIVEN_PORTS env override
Previously the array sim only excited a single inner element
(DRIVEN_X / DRIVEN_Y). Adds DRIVEN_PORTS env var accepting a
comma/semicolon-separated list of "i,j" pairs that are all excited
in-phase with equal amplitude — models a perfect 1:N corporate
splitter feeding an N-patch sub-array.

Example: DRIVEN_PORTS="0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1" excites a
4-cols × 2-rows sub-array anchored in the corner.

S-parameter post-processing reframed for the multi-driven case:
each excited port reports active-S11 (uf_ref/uf_inc with all driven
ports active); each non-excited port reports S relative to a
representative driven port's incident wave (all driven ports have
equal amplitude so any reference works).

Backwards-compatible: empty DRIVEN_PORTS reverts to single-port
DRIVEN_X / DRIVEN_Y behaviour.
2026-05-04 21:06:46 +05:45

386 lines
15 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_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)))
# DRIVEN_PORTS overrides DRIVEN_X/Y — comma/semicolon-separated list of "i,j"
# pairs all excited in-phase with equal amplitude. Models perfect 1:8 corporate
# splitter feeding an 8-patch sub-array. Example: "0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1"
# is a 4-cols × 2-rows sub-array anchored in the corner.
DRIVEN_PORTS_STR = os.environ.get("DRIVEN_PORTS", "")
if DRIVEN_PORTS_STR:
pairs = []
for tok in DRIVEN_PORTS_STR.replace(';', ',').split(','):
tok = tok.strip()
if tok:
pairs.append(int(tok))
if len(pairs) % 2 != 0:
raise ValueError("DRIVEN_PORTS must be even count of integers (i,j pairs)")
DRIVEN_SET = set((pairs[k], pairs[k+1]) for k in range(0, len(pairs), 2))
else:
DRIVEN_SET = {(DRIVEN_X, DRIVEN_Y)}
# 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, j) in DRIVEN_SET 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)
for (idx, p) in ports:
p.CalcPort(sim_path, freq)
# For each excited port: S = uf_ref/uf_inc (active reflection with all
# driven ports excited). For each non-excited port: S = uf_ref / <inc>
# where <inc> is the incident wave from any one driven port (used as
# reference; all driven ports have equal amplitude so any works).
ref_inc = next(p for (idx, p) in ports if idx in DRIVEN_SET).uf_inc
S = {}
for (idx, p) in ports:
if idx in DRIVEN_SET:
S[idx] = p.uf_ref / p.uf_inc # active S11
else:
S[idx] = p.uf_ref / ref_inc # coupling out
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)))
N_DRIVEN = len(DRIVEN_SET)
# Print coupling grid
print()
print("=" * 70)
if N_DRIVEN == 1:
dx, dy = list(DRIVEN_SET)[0]
print(f" {N_X}x{N_Y} probe-fed array — driven port at ({dx},{dy})")
else:
sub_str = ' '.join(f"({i},{j})" for (i, j) in sorted(DRIVEN_SET))
print(f" {N_X}x{N_Y} probe-fed array — {N_DRIVEN}-patch sub-array driven in-phase")
print(f" Sub-array: {sub_str}")
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| at {F0/1e9:.2f} GHz, dB (driven ports show active S11):")
print()
# Layout grid as visual array
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)
marker = "*" if (i, j) in DRIVEN_SET else " "
row += f"{marker}{dB:>6.1f}"
print(row)
print(" (* = driven port)")
print()
# For each driven port, report active S11 + Zin + per-port BW
print(f" Active S11 per driven port at {F0/1e9:.2f} GHz:")
S11_at_op = []
zin_at_op = []
for (i, j) in sorted(DRIVEN_SET):
s = S[(i, j)]
s_dB_op = 20*np.log10(abs(s[i_op]) + 1e-30)
zin_p = 50.0 * (1 + s) / (1 - s)
print(f" ({i},{j}) : S11 = {s_dB_op:>6.2f} dB Z = {zin_p[i_op].real:5.1f} + j{zin_p[i_op].imag:+5.1f} Ω")
S11_at_op.append(s_dB_op)
zin_at_op.append(zin_p[i_op])
if N_DRIVEN > 1:
print()
print(f" Sub-array uniformity:")
print(f" S11 min/max/avg : {min(S11_at_op):>6.2f} / {max(S11_at_op):>6.2f} / "
f"{sum(S11_at_op)/N_DRIVEN:>6.2f} dB")
R_vals = [z.real for z in zin_at_op]
X_vals = [z.imag for z in zin_at_op]
print(f" R min/max/avg : {min(R_vals):>5.1f} / {max(R_vals):>5.1f} / "
f"{sum(R_vals)/N_DRIVEN:>5.1f} Ω")
print(f" X min/max/avg : {min(X_vals):+5.1f} / {max(X_vals):+5.1f} / "
f"{sum(X_vals)/N_DRIVEN:+5.1f} Ω")
# Average port (what the ADAR channel "sees" through ideal 1:8 splitter)
Z_avg = sum(zin_at_op) / N_DRIVEN
print(f" Z avg (= what ADAR channel sees through ideal 1:8 splitter):")
print(f" Z = {Z_avg.real:.1f} + j{Z_avg.imag:+.1f} Ω, "
f"VSWR = {abs((Z_avg-50)/(Z_avg+50)) and (1+abs((Z_avg-50)/(Z_avg+50)))/(1-abs((Z_avg-50)/(Z_avg+50))):.2f}")
# Coupling out (top non-driven ports)
nondriven_couplings = [(idx, abs(S[idx][i_op])) for idx in S.keys()
if idx not in DRIVEN_SET]
nondriven_couplings.sort(key=lambda x: -x[1])
print()
print(f" Top-5 strongest couplings OUT of sub-array at {F0/1e9:.2f} GHz:")
for idx, val in nondriven_couplings[:5]:
dB = 20*np.log10(val + 1e-30)
print(f" ({idx[0]},{idx[1]}) |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)
# Coupling heatmap at 10.5 GHz
fig, ax = plt.subplots(figsize=(7, 6.5))
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)
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 {N_X}x{N_Y} probe-fed array — |S| at {F0/1e9:.2f} GHz')
for j in range(N_Y):
for i in range(N_X):
if (i, j) in DRIVEN_SET:
ax.text(i, j, f"DRIVEN\n{grid[j,i]:.1f} dB", ha='center', va='center',
color='red', fontsize=8, fontweight='bold')
else:
ax.text(i, j, f"{grid[j,i]:.1f}\ndB", ha='center', va='center',
color='white', fontsize=7)
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")