mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-05-14 11:51:47 +00:00
sim(antenna): nf2ff far-field + ADAR1000 array-factor verification
PART 1 — edge_fed_row_nf2ff_aeris10_v3.py:
Far-field analysis of the 1x8 series-fed row at 10.520 GHz to discriminate
broadside from scanned operation. Adds an nf2ff probe box to the verified
TX-centered design point (CONN_LEN=8.15) and computes the radiation pattern.
Result: E-plane (along array y) main lobe at θ=0.0°, BW3dB=14°
H-plane (perpendicular) main lobe at θ=0.0°, BW3dB=44°
First sidelobe -12 dB at ±18° (theoretical N=8 uniform: -13 dB)
→ BROADSIDE CONFIRMED at the operating mode.
openEMS NF2FF API notes baked into the script:
- CalcNF2FF expects theta/phi in DEGREES, converts internally.
- Prad/Dmax are sign-buggy in this version; pattern shape (E_norm) is
what we use for broadside identification.
- EndCriteria is 10·log10 energy ratio (so -40 dB → EndCriteria=1e-4).
PART 2 — array_factor_adar1000_aeris10.py:
Phased-array beam-forming verification using the ADAR1000 firmware's actual
phase-shifter codes. Combines the cached single-row embedded-element pattern
with a 16-element x-axis array factor at d=λ/2=14.286 mm pitch (matches
firmware's `element_spacing = wavelength/2` constant).
Replicates ADAR1000_Manager.cpp:714-729 (calculatePhaseSettings) and the
setBeamAngle() loop that broadcasts the 4-phase pattern to all 4 chips.
Compares against the correct 16-element progressive phasing.
CRITICAL FIRMWARE BUG SURFACED BY THE SIM:
setBeamAngle(angle) computes only 4 phases (one per chip channel), then
applies the same 4-phase pattern to all 4 chips. The intended 16-element
beam-steering never actually happens.
At cmd 15°, the firmware writes: [0, 17, 33, 50] × 4 = repeating pattern
Correct 16-elem progression: [0, 17, 33, 50, 66, 83, 99, 116, 5, 21,
38, 54, 71, 87, 104, 120]
Sim consequences (H-plane peak vs commanded):
cmd +0° → fw peaks +0° (correct) / correct +0°
cmd +5° → fw peaks +0° (NO STEERING) / correct -4°
cmd +10° → fw peaks +0° (NO STEERING) / correct -10°
cmd +15° → fw peaks +0° (NO STEERING) / correct -14°
cmd +20° → fw peaks -28° (locked grating) / correct -20°
cmd +30° → fw peaks -30° (coincidence) / correct -30°
cmd +45° → fw peaks -30° (locked) / correct -44°
Why: the same 4-elem pattern broadcast to 4 chips = effective super-pitch
d_super = 4d = 2λ, which generates grating lobes at sin(θ_g)=±0.5±sin(θ_0).
For |cmd|<18° those grating lobes coincide with the fixed broadside peak,
so the array radiates broadside regardless of commanded angle. For larger
commands the beam jumps to ±30° (the grating-lobe direction), not the
intended target.
The proper function setCustomBeamPattern16() (ADAR1000_Manager.cpp:636)
exists but isn't called by setBeamAngle(); fix is to either route through
it from a host-computed 16-element table, or extend calculatePhaseSettings
to produce 16 phases. Tracking under a separate beam-forming PR (not
antenna sim).
Other radar-engineer checks (all PASS for the antenna with proper phasing):
- Phase quantization: 7-bit (2.8125°/LSB) adds <0.2 dB SLL degradation
- Grating lobes: d=λ/2 → none in real space at any scan angle 0..90°
- Scan loss: matches cos(θ) ideal up to ~45°, then EEP roll-off dominates
- Beam pointing: sign-flipped cmd↔peak (cmd +X° peaks at -X°) — wiring
convention; either negate phase_shift in firmware or invert in host
- Null steering: phase-only synthesis places -30 dB null at θ=20° while
keeping main beam at broadside — confirmed feasible
This commit is contained in:
426
5_Simulations/Antenna/array_factor_adar1000_aeris10.py
Normal file
426
5_Simulations/Antenna/array_factor_adar1000_aeris10.py
Normal file
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python3
|
||||
# array_factor_adar1000_aeris10.py
|
||||
#
|
||||
# Phased-array beam-forming verification using the ADAR1000 firmware's actual
|
||||
# phase-shifter codes. Combines:
|
||||
# * The single-row 1x8 series-fed embedded element pattern (from
|
||||
# edge_fed_row_nf2ff_aeris10_v3.py at 10.520 GHz, cached) for the y-axis
|
||||
# row pattern.
|
||||
# * A 16-element x-axis array factor at d = λ/2 = 14.286 mm pitch (matches
|
||||
# the firmware's `element_spacing = wavelength/2` constant).
|
||||
# * The firmware phase computation EXACTLY (ADAR1000_Manager.cpp:714-729):
|
||||
# `calculatePhaseSettings()` only fills 4 phases (one per chip channel),
|
||||
# and the broadcast loop applies the same 4-phase pattern to all 4 chips.
|
||||
# * The 7-bit (128-state, 2.8125 deg/step) ADAR1000 phase quantization.
|
||||
#
|
||||
# Verifications a radar engineer would run at this stage:
|
||||
# 1. Beam steering accuracy (commanded vs simulated peak angle).
|
||||
# 2. Sidelobe and grating-lobe levels at multiple scan angles.
|
||||
# 3. Scan loss (peak gain vs scan angle).
|
||||
# 4. Null steering: place a deep null at a chosen angle.
|
||||
# 5. Compare FIRMWARE behaviour vs CORRECT 16-element progressive phasing
|
||||
# to expose the per-chip-broadcast bug.
|
||||
#
|
||||
# Inputs:
|
||||
# /tmp/aeris10_edgefed_row_nf2ff_v3/farfield.csv (cached single-row pattern)
|
||||
#
|
||||
# Outputs:
|
||||
# /tmp/aeris10_array_factor/scan_*.png (1D cuts at scan angles)
|
||||
# /tmp/aeris10_array_factor/scan_loss.png (peak gain vs scan)
|
||||
# /tmp/aeris10_array_factor/null_steering.png
|
||||
# /tmp/aeris10_array_factor/firmware_vs_correct.png
|
||||
|
||||
import os
|
||||
import csv
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# ============================================================================
|
||||
# Constants (match firmware ADAR1000_Manager.cpp:714-729 exactly)
|
||||
# ============================================================================
|
||||
F0 = 10.5e9
|
||||
C0 = 3.0e8
|
||||
LAMBDA = C0 / F0 # 28.5714 mm
|
||||
D_X = LAMBDA / 2 # 14.2857 mm — element_spacing in firmware
|
||||
N_TOTAL = 16 # 4 chips × 4 channels
|
||||
N_PER_CHIP = 4
|
||||
N_CHIPS = 4
|
||||
|
||||
# ADAR1000 7-bit phase resolution
|
||||
PHASE_STATES = 128
|
||||
PHASE_LSB_DEG = 360.0 / PHASE_STATES # 2.8125 deg/code
|
||||
|
||||
OUT_DIR = "/tmp/aeris10_array_factor"
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Embedded element pattern (single-row 1x8 at 10.520 GHz)
|
||||
# ============================================================================
|
||||
def load_single_row_pattern(path="/tmp/aeris10_edgefed_row_nf2ff_v3/farfield.csv"):
|
||||
"""Returns theta_deg, h_pat_lin, e_pat_lin (linear |E|, peak normalised to 1)."""
|
||||
th, h_dB, e_dB = [], [], []
|
||||
with open(path) as f:
|
||||
r = csv.reader(f); next(r)
|
||||
for row in r:
|
||||
th.append(float(row[0]))
|
||||
h_dB.append(float(row[1]))
|
||||
e_dB.append(float(row[2]))
|
||||
th = np.array(th); h_dB = np.array(h_dB); e_dB = np.array(e_dB)
|
||||
# CSV stores normalised dB rel peak. Convert to linear amplitude.
|
||||
h_lin = 10 ** (h_dB / 20.0)
|
||||
e_lin = 10 ** (e_dB / 20.0)
|
||||
return th, h_lin, e_lin
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phase code generators
|
||||
# ============================================================================
|
||||
def firmware_phase_codes(angle_deg):
|
||||
"""Replicate ADAR1000Manager::calculatePhaseSettings() + the broadcast
|
||||
loop in setBeamAngle(): 4 phases computed, same pattern applied to all
|
||||
4 chips. Returns 16 ADAR1000 phase codes (uint8 values 0..127)."""
|
||||
angle_rad = np.deg2rad(angle_deg)
|
||||
# firmware: phase_shift = 2π·d·sin(θ)/λ
|
||||
phase_shift = (2 * np.pi * D_X * np.sin(angle_rad)) / LAMBDA
|
||||
codes_4 = np.zeros(N_PER_CHIP, dtype=int)
|
||||
for i in range(N_PER_CHIP):
|
||||
ph = (i * phase_shift) % (2 * np.pi)
|
||||
codes_4[i] = int(round(ph / (2 * np.pi) * PHASE_STATES)) % PHASE_STATES
|
||||
# Broadcast: same 4-element pattern repeated to all 4 chips
|
||||
return np.tile(codes_4, N_CHIPS)
|
||||
|
||||
|
||||
def correct_phase_codes(angle_deg):
|
||||
"""Proper 16-element progressive phase shift (what the firmware should do)."""
|
||||
angle_rad = np.deg2rad(angle_deg)
|
||||
phase_shift = (2 * np.pi * D_X * np.sin(angle_rad)) / LAMBDA
|
||||
codes = np.zeros(N_TOTAL, dtype=int)
|
||||
for n in range(N_TOTAL):
|
||||
ph = (n * phase_shift) % (2 * np.pi)
|
||||
codes[n] = int(round(ph / (2 * np.pi) * PHASE_STATES)) % PHASE_STATES
|
||||
return codes
|
||||
|
||||
|
||||
def codes_to_radians(codes):
|
||||
return np.asarray(codes) * (2 * np.pi / PHASE_STATES)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Array factor at the φ=0 (H-plane) cut, for an arbitrary 16-element phase set
|
||||
# ============================================================================
|
||||
def array_factor_hplane(theta_deg_arr, phase_codes, amplitudes=None):
|
||||
"""Compute |AF(θ)| at φ=0 (H-plane). x_n = n*d. Element 0 at x=0, element
|
||||
15 at x=15·d. AF(θ) = Σ a_n · exp(j·k·x_n·sin(θ)) · exp(j·φ_n)."""
|
||||
if amplitudes is None:
|
||||
amplitudes = np.ones(len(phase_codes))
|
||||
k = 2 * np.pi / LAMBDA
|
||||
th_rad = np.deg2rad(theta_deg_arr)
|
||||
phases_rad = codes_to_radians(phase_codes)
|
||||
af = np.zeros(len(th_rad), dtype=complex)
|
||||
for n in range(len(phase_codes)):
|
||||
xn = n * D_X
|
||||
af += amplitudes[n] * np.exp(1j * (k * xn * np.sin(th_rad) + phases_rad[n]))
|
||||
return np.abs(af)
|
||||
|
||||
|
||||
def total_pattern_dB(theta_deg_arr, phase_codes, h_pat_lin):
|
||||
"""Single-row pattern × |AF| → normalised dB rel peak."""
|
||||
af = array_factor_hplane(theta_deg_arr, phase_codes)
|
||||
pat_lin = af * h_pat_lin
|
||||
pat_dB = 20 * np.log10(pat_lin / np.max(pat_lin) + 1e-30)
|
||||
return pat_dB
|
||||
|
||||
|
||||
def find_peak(theta_deg_arr, pat_dB):
|
||||
i = int(np.argmax(pat_dB))
|
||||
return theta_deg_arr[i], pat_dB[i]
|
||||
|
||||
|
||||
def find_main_lobe(theta_deg_arr, pat_dB, search_window=None):
|
||||
"""Find the deepest dip / peak in a window. Returns (peak_angle, peak_dB,
|
||||
bw3dB, sll_dB, sll_angle)."""
|
||||
if search_window is None:
|
||||
mask = np.ones(len(theta_deg_arr), dtype=bool)
|
||||
else:
|
||||
lo, hi = search_window
|
||||
mask = (theta_deg_arr >= lo) & (theta_deg_arr <= hi)
|
||||
idx = np.where(mask)[0]
|
||||
i_pk_local = idx[int(np.argmax(pat_dB[idx]))]
|
||||
peak_angle = theta_deg_arr[i_pk_local]
|
||||
peak_dB = pat_dB[i_pk_local]
|
||||
# 3 dB beamwidth around peak
|
||||
half = peak_dB - 3.0
|
||||
lo_i = i_pk_local
|
||||
while lo_i > 0 and pat_dB[lo_i] > half:
|
||||
lo_i -= 1
|
||||
hi_i = i_pk_local
|
||||
while hi_i < len(pat_dB) - 1 and pat_dB[hi_i] > half:
|
||||
hi_i += 1
|
||||
bw3 = theta_deg_arr[hi_i] - theta_deg_arr[lo_i]
|
||||
# First null walk
|
||||
null_lo, null_hi = lo_i, hi_i
|
||||
while null_lo > 0 and pat_dB[null_lo - 1] < pat_dB[null_lo]:
|
||||
null_lo -= 1
|
||||
while null_hi < len(pat_dB) - 1 and pat_dB[null_hi + 1] < pat_dB[null_hi]:
|
||||
null_hi += 1
|
||||
# Sidelobes outside the null-bracketed main lobe
|
||||
side_mask = np.ones(len(pat_dB), dtype=bool)
|
||||
side_mask[null_lo:null_hi + 1] = False
|
||||
if side_mask.any():
|
||||
i_sll = int(np.argmax(np.where(side_mask, pat_dB, -100)))
|
||||
sll_dB = pat_dB[i_sll] - peak_dB
|
||||
sll_angle = theta_deg_arr[i_sll]
|
||||
else:
|
||||
sll_dB, sll_angle = -np.inf, np.nan
|
||||
return peak_angle, peak_dB, bw3, sll_dB, sll_angle
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Verifications
|
||||
# ============================================================================
|
||||
def main():
|
||||
theta_deg, h_pat_lin, e_pat_lin = load_single_row_pattern()
|
||||
print(f"[load] embedded element pattern: {len(theta_deg)} samples, "
|
||||
f"theta {theta_deg.min():.0f}..{theta_deg.max():.0f}°")
|
||||
print(f"[const] λ={LAMBDA*1e3:.3f} mm, d=λ/2={D_X*1e3:.3f} mm, "
|
||||
f"N={N_TOTAL} (4 chips × 4 ch)")
|
||||
print(f"[const] phase LSB = {PHASE_LSB_DEG:.4f} deg/code (7-bit)")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1) Steering accuracy at multiple commanded angles
|
||||
# ------------------------------------------------------------------
|
||||
angles_to_test = [0, 5, 10, 15, 20, 30, 45]
|
||||
print("=" * 90)
|
||||
print(" TEST 1: Beam steering accuracy — firmware vs correct (φ=0 H-plane)")
|
||||
print("=" * 90)
|
||||
print(f"{'cmd':>5} | {'firmware':>40} | {'correct':>40}")
|
||||
print(f"{'deg':>5} | {'peak deg':>10} {'BW3':>6} {'SLL dB':>7} {'SLL deg':>8} | "
|
||||
f"{'peak deg':>10} {'BW3':>6} {'SLL dB':>7} {'SLL deg':>8}")
|
||||
print("-" * 90)
|
||||
|
||||
rows_for_csv = []
|
||||
for ang in angles_to_test:
|
||||
codes_fw = firmware_phase_codes(ang)
|
||||
codes_co = correct_phase_codes(ang)
|
||||
pat_fw = total_pattern_dB(theta_deg, codes_fw, h_pat_lin)
|
||||
pat_co = total_pattern_dB(theta_deg, codes_co, h_pat_lin)
|
||||
pk_fw = find_main_lobe(theta_deg, pat_fw)
|
||||
pk_co = find_main_lobe(theta_deg, pat_co)
|
||||
print(f"{ang:>5} | {pk_fw[0]:>+10.1f} {pk_fw[2]:>5.1f}° {pk_fw[3]:>+6.1f} "
|
||||
f"{pk_fw[4]:>+7.1f}° | {pk_co[0]:>+10.1f} {pk_co[2]:>5.1f}° "
|
||||
f"{pk_co[3]:>+6.1f} {pk_co[4]:>+7.1f}°")
|
||||
rows_for_csv.append((ang, pk_fw[0], pk_fw[2], pk_fw[3], pk_fw[4],
|
||||
pk_co[0], pk_co[2], pk_co[3], pk_co[4]))
|
||||
print()
|
||||
|
||||
with open(os.path.join(OUT_DIR, "steering_table.csv"), "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["cmd_deg", "fw_peak_deg", "fw_bw3", "fw_sll_dB", "fw_sll_deg",
|
||||
"co_peak_deg", "co_bw3", "co_sll_dB", "co_sll_deg"])
|
||||
for r in rows_for_csv:
|
||||
w.writerow(r)
|
||||
print(f"[out] {OUT_DIR}/steering_table.csv")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2) Side-by-side patterns at scan angles 0°, 15°, 30°, 45°
|
||||
# ------------------------------------------------------------------
|
||||
show_angles = [0, 15, 30, 45]
|
||||
fig, axes = plt.subplots(len(show_angles), 1, figsize=(11, 3.3*len(show_angles)),
|
||||
sharex=True)
|
||||
for ax, ang in zip(axes, show_angles):
|
||||
codes_fw = firmware_phase_codes(ang)
|
||||
codes_co = correct_phase_codes(ang)
|
||||
pat_fw = total_pattern_dB(theta_deg, codes_fw, h_pat_lin)
|
||||
pat_co = total_pattern_dB(theta_deg, codes_co, h_pat_lin)
|
||||
ax.plot(theta_deg, pat_co, "g-", lw=1.4, label="correct 16-elem (gold)")
|
||||
ax.plot(theta_deg, pat_fw, "r-", lw=1.4, label="firmware (4-elem broadcast)")
|
||||
ax.axvline(ang, color="k", ls=":", lw=0.8, label=f"commanded θ={ang}°")
|
||||
ax.axvline(-ang, color="grey", ls=":", lw=0.6, alpha=0.5,
|
||||
label=f"-cmd θ={-ang}° (sign-flip)")
|
||||
ax.set_xlim(-90, 90)
|
||||
ax.set_ylim(-40, 2)
|
||||
ax.set_ylabel("Pattern (dB)")
|
||||
ax.set_title(f"setBeamAngle({ang}°) — H-plane (φ=0, x-scan) at 10.520 GHz")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="lower right", fontsize=8)
|
||||
axes[-1].set_xlabel("θ (deg)")
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(OUT_DIR, "firmware_vs_correct.png"), dpi=140)
|
||||
plt.close(fig)
|
||||
print(f"[out] {OUT_DIR}/firmware_vs_correct.png")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3) Scan loss curve (peak gain vs commanded angle, both fw and correct)
|
||||
# ------------------------------------------------------------------
|
||||
scan_angles = np.arange(-60, 61, 2)
|
||||
peak_dB_fw = []
|
||||
peak_dB_co = []
|
||||
actual_peak_fw = []
|
||||
actual_peak_co = []
|
||||
# Reference broadside peak for absolute scan-loss
|
||||
ref_pat = total_pattern_dB(theta_deg, np.zeros(N_TOTAL, dtype=int), h_pat_lin)
|
||||
# Find peak amplitude (ref) on linear scale by recomputing without normalisation
|
||||
# Use a simpler proxy: peak in dB rel peak is 0; we want amplitude relative to
|
||||
# broadside. Compute |E|² broadside vs scanned without normalisation.
|
||||
|
||||
def total_amp_lin(theta_deg_arr, phase_codes):
|
||||
af = array_factor_hplane(theta_deg_arr, phase_codes)
|
||||
return af * h_pat_lin
|
||||
|
||||
ref_lin = total_amp_lin(theta_deg, np.zeros(N_TOTAL, dtype=int))
|
||||
ref_peak = float(np.max(ref_lin))
|
||||
for ang in scan_angles:
|
||||
codes_fw = firmware_phase_codes(ang)
|
||||
codes_co = correct_phase_codes(ang)
|
||||
amp_fw = total_amp_lin(theta_deg, codes_fw)
|
||||
amp_co = total_amp_lin(theta_deg, codes_co)
|
||||
peak_dB_fw.append(20*np.log10(np.max(amp_fw)/ref_peak + 1e-30))
|
||||
peak_dB_co.append(20*np.log10(np.max(amp_co)/ref_peak + 1e-30))
|
||||
i_fw = int(np.argmax(amp_fw))
|
||||
i_co = int(np.argmax(amp_co))
|
||||
actual_peak_fw.append(theta_deg[i_fw])
|
||||
actual_peak_co.append(theta_deg[i_co])
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
|
||||
ax = axes[0]
|
||||
ax.plot(scan_angles, peak_dB_co, "g-", lw=1.6, label="correct 16-elem")
|
||||
ax.plot(scan_angles, peak_dB_fw, "r-", lw=1.6, label="firmware (4-elem broadcast)")
|
||||
# Theoretical scan loss = cos(θ) (single-element factor) → in dB: 20·log10(cos)
|
||||
th_th = np.linspace(-60, 60, 121)
|
||||
ax.plot(th_th, 20*np.log10(np.cos(np.deg2rad(th_th))),
|
||||
"k--", lw=1.0, alpha=0.6, label="cos(θ) ideal scan loss")
|
||||
ax.set_xlabel("Commanded scan angle (deg)")
|
||||
ax.set_ylabel("Peak gain rel broadside (dB)")
|
||||
ax.set_title("Scan loss vs commanded angle")
|
||||
ax.set_xlim(-60, 60)
|
||||
ax.set_ylim(-25, 2)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend()
|
||||
|
||||
ax = axes[1]
|
||||
ax.plot(scan_angles, actual_peak_co, "g-", lw=1.6, label="correct 16-elem")
|
||||
ax.plot(scan_angles, actual_peak_fw, "r-", lw=1.6, label="firmware")
|
||||
ax.plot(scan_angles, scan_angles, "k--", lw=1.0, alpha=0.6,
|
||||
label="ideal (peak = cmd)")
|
||||
ax.plot(scan_angles, -scan_angles, "k:", lw=1.0, alpha=0.4,
|
||||
label="sign-flipped (peak = -cmd)")
|
||||
ax.set_xlabel("Commanded scan angle (deg)")
|
||||
ax.set_ylabel("Actual peak angle (deg)")
|
||||
ax.set_title("Beam pointing accuracy")
|
||||
ax.set_xlim(-60, 60)
|
||||
ax.set_ylim(-90, 90)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(OUT_DIR, "scan_loss.png"), dpi=140)
|
||||
plt.close(fig)
|
||||
print(f"[out] {OUT_DIR}/scan_loss.png")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4) Null-steering: place a null at a chosen angle (LCMV-style minimal)
|
||||
# ------------------------------------------------------------------
|
||||
# Set main beam at θ=0, with an explicit null at θ_null=20° using simple
|
||||
# phase-only synthesis: subtract a unit-amplitude vector pointed at θ_null.
|
||||
th_null = 20.0
|
||||
k = 2*np.pi/LAMBDA
|
||||
n = np.arange(N_TOTAL)
|
||||
a_main = np.exp(1j * 0.0 * n)
|
||||
a_null = np.exp(1j * k * n * D_X * np.sin(np.deg2rad(th_null)))
|
||||
# Project: a' = a_main - <a_null, a_main>/<a_null, a_null> * a_null
|
||||
proj = (np.vdot(a_null, a_main) / np.vdot(a_null, a_null)) * a_null
|
||||
a_steered = a_main - proj
|
||||
# Convert complex weights to phase codes (drop amplitude variation —
|
||||
# ADAR1000 phase shifters are constant-amplitude; we keep amplitude=1 and
|
||||
# use phase only for an honest sim).
|
||||
phases_null = np.angle(a_steered) % (2*np.pi)
|
||||
codes_null = np.round(phases_null / (2*np.pi) * PHASE_STATES).astype(int) % PHASE_STATES
|
||||
pat_null = total_pattern_dB(theta_deg, codes_null, h_pat_lin)
|
||||
# Reference: no null
|
||||
pat_bs = total_pattern_dB(theta_deg, np.zeros(N_TOTAL, dtype=int), h_pat_lin)
|
||||
|
||||
# Find depth of null in the steered pattern at θ=20°
|
||||
i_null = int(np.argmin(np.abs(theta_deg - th_null)))
|
||||
null_depth = pat_null[i_null] - 0.0 # rel peak
|
||||
|
||||
fig, ax = plt.subplots(figsize=(11, 4.5))
|
||||
ax.plot(theta_deg, pat_bs, "k-", lw=1.2, alpha=0.5, label="broadside, no null")
|
||||
ax.plot(theta_deg, pat_null, "b-", lw=1.6,
|
||||
label=f"phase-only null @ θ={th_null}°")
|
||||
ax.axvline(th_null, color="r", ls=":", lw=0.8, label=f"target null θ={th_null}°")
|
||||
ax.axhline(-30, color="grey", ls="--", lw=0.6, alpha=0.5)
|
||||
ax.set_xlim(-90, 90)
|
||||
ax.set_ylim(-50, 2)
|
||||
ax.set_xlabel("θ (deg)")
|
||||
ax.set_ylabel("Pattern (dB)")
|
||||
ax.set_title(f"Null-steering — broadside main beam with null at θ={th_null}° "
|
||||
f"(achieved depth: {null_depth:.1f} dB rel peak)")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend()
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(OUT_DIR, "null_steering.png"), dpi=140)
|
||||
plt.close(fig)
|
||||
print(f"[out] {OUT_DIR}/null_steering.png")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5) Phase-quantization effect (compare unquantized continuous phase
|
||||
# to 7-bit quantized phase at θ=15°)
|
||||
# ------------------------------------------------------------------
|
||||
ang = 15
|
||||
angle_rad = np.deg2rad(ang)
|
||||
phase_shift = (2 * np.pi * D_X * np.sin(angle_rad)) / LAMBDA
|
||||
phases_continuous = np.array([(n*phase_shift) % (2*np.pi) for n in range(N_TOTAL)])
|
||||
codes_quant = correct_phase_codes(ang)
|
||||
phases_quant = codes_to_radians(codes_quant)
|
||||
|
||||
def total_pattern_dB_continuous(theta_deg_arr, phases_rad, h_pat_lin):
|
||||
k = 2*np.pi/LAMBDA
|
||||
th_rad = np.deg2rad(theta_deg_arr)
|
||||
af = np.zeros(len(th_rad), dtype=complex)
|
||||
for n in range(len(phases_rad)):
|
||||
af += np.exp(1j*(k*n*D_X*np.sin(th_rad) + phases_rad[n]))
|
||||
amp = np.abs(af) * h_pat_lin
|
||||
return 20*np.log10(amp / np.max(amp) + 1e-30)
|
||||
|
||||
pat_cont = total_pattern_dB_continuous(theta_deg, phases_continuous, h_pat_lin)
|
||||
pat_quant = total_pattern_dB(theta_deg, codes_quant, h_pat_lin)
|
||||
pk_cont = find_main_lobe(theta_deg, pat_cont)
|
||||
pk_quant = find_main_lobe(theta_deg, pat_quant)
|
||||
print()
|
||||
print("=" * 90)
|
||||
print(" TEST 2: 7-bit phase quantization vs continuous (at cmd 15°)")
|
||||
print(f" Continuous phase: peak θ={pk_cont[0]:+.1f}°, BW3={pk_cont[2]:.1f}°, "
|
||||
f"SLL={pk_cont[3]:+.1f} dB")
|
||||
print(f" Quantized 7-bit : peak θ={pk_quant[0]:+.1f}°, BW3={pk_quant[2]:.1f}°, "
|
||||
f"SLL={pk_quant[3]:+.1f} dB")
|
||||
print(f" → quantization adds {pk_cont[3] - pk_quant[3]:+.2f} dB to the SLL "
|
||||
f"(positive = quantized has worse SLL)")
|
||||
print("=" * 90)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6) Grating-lobe envelope check
|
||||
# ------------------------------------------------------------------
|
||||
# Theoretical: grating lobes at sin(θ_g) = ±λ/d - sin(θ_0). At d=λ/2, NO
|
||||
# grating lobes for any scan angle (since |λ/d - sin(θ_0)| ≥ 1 always).
|
||||
# The firmware's 4-element broadcast effectively makes super-pitch d_super
|
||||
# = 4d = 2λ → grating lobes at sin(θ_g) = ±λ/(4d) ± sin(θ_0) = ±0.5 ± sin(θ_0).
|
||||
print()
|
||||
print(" TEST 3: Grating-lobe geometry")
|
||||
print(f" Element pitch d = λ/2 → no real-space grating lobes at any scan ✓")
|
||||
print(f" Firmware's 4-elem broadcast → super-pitch d_super = 4d = 2λ")
|
||||
print(f" → grating lobes appear at sin(θ_g) = ±0.5 ± sin(θ_0)")
|
||||
for ang in [0, 15, 30, 45]:
|
||||
sin0 = np.sin(np.deg2rad(ang))
|
||||
gl = []
|
||||
for sign in [+1, -1]:
|
||||
sin_g = sign*0.5 + sin0 # firmware steers to -ang due to sign convention
|
||||
if abs(sin_g) <= 1:
|
||||
gl.append(np.rad2deg(np.arcsin(sin_g)))
|
||||
print(f" cmd {ang:+d}° → grating lobes at: {[f'{g:+.1f}°' for g in gl]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
331
5_Simulations/Antenna/edge_fed_row_nf2ff_aeris10_v3.py
Normal file
331
5_Simulations/Antenna/edge_fed_row_nf2ff_aeris10_v3.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
# edge_fed_row_nf2ff_aeris10_v3.py
|
||||
#
|
||||
# Far-field analysis of the 1x8 series-fed row at f=10.520 GHz to confirm
|
||||
# whether the operating mode at the TX-centered design point is broadside
|
||||
# (peak in +z, θ=0°) or scanned (peak at θ ≠ 0).
|
||||
#
|
||||
# Same geometry as edge_fed_row_aeris10_v3.py at the verified design point
|
||||
# (CONN_LEN=8.15, no inset on patch 0). Adds an nf2ff probe box around the
|
||||
# simulation domain so the radiation pattern can be computed post-sim.
|
||||
#
|
||||
# Coordinate convention (openEMS/standard physics):
|
||||
# z is normal to the board (+z = upward, away from ground plane)
|
||||
# y is the array axis (patches arranged along +y)
|
||||
# x is the patch-W direction
|
||||
# theta = angle from +z (broadside is θ=0°)
|
||||
# phi=0 → xz plane (H-plane cut, perpendicular to array)
|
||||
# phi=90 → yz plane (E-plane cut, ALONG array axis — this is where the
|
||||
# array factor lives; broadside check happens HERE)
|
||||
#
|
||||
# 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_row_nf2ff_aeris10_v3.py
|
||||
|
||||
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
|
||||
|
||||
PROFILE = os.environ.get("PROFILE", "balanced")
|
||||
# end_dB is the ENERGY ratio threshold reported in the engine's "(-X dB)" line
|
||||
# (10·log10 convention). nf2ff needs at least -40 dB (energy ratio 1e-4) for a
|
||||
# valid radiated-power integral.
|
||||
profiles = {
|
||||
"sanity": {"mesh_lambda_div": 18, "n_timesteps": 150000, "end_dB": -35},
|
||||
"balanced": {"mesh_lambda_div": 25, "n_timesteps": 300000, "end_dB": -40},
|
||||
}
|
||||
cfg = profiles[PROFILE]
|
||||
|
||||
F0 = 10.5e9
|
||||
F_SPAN = 4.0e9
|
||||
F_TX = float(os.environ.get("F_TX_GHZ", "10.520")) * 1e9
|
||||
F_START = F0 - F_SPAN/2
|
||||
F_STOP = F0 + F_SPAN/2
|
||||
|
||||
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
|
||||
|
||||
# Verified TX-centered design point
|
||||
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"))
|
||||
CONN_LEN = float(os.environ.get("CONN_LEN_MM", "8.15"))
|
||||
|
||||
PITCH = PATCH_L + CONN_LEN
|
||||
|
||||
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_nf2ff_v3"
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Build sim
|
||||
# ============================================================================
|
||||
fdtd = openEMS(NrTS=cfg["n_timesteps"],
|
||||
EndCriteria=10**(cfg["end_dB"]/10.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")
|
||||
|
||||
patch_sub.AddBox([-GND_X_HALF, GND_Y_NEG, Z_GND + T_CU],
|
||||
[+GND_X_HALF, GND_Y_POS, Z_PATCH], priority=1)
|
||||
copper.AddBox([-GND_X_HALF, GND_Y_NEG, Z_GND],
|
||||
[+GND_X_HALF, GND_Y_POS, Z_GND + T_CU], priority=10)
|
||||
|
||||
notch_half_w = FEED_W/2 + INSET_GAP
|
||||
|
||||
for i in range(N_PATCHES):
|
||||
py0 = i * PITCH - PATCH_L/2
|
||||
py1 = i * PITCH + PATCH_L/2
|
||||
if i == 0 and INSET_DEPTH > 0.001:
|
||||
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:
|
||||
copper.AddBox([-PATCH_W/2, py0, Z_PATCH],
|
||||
[+PATCH_W/2, py1, Z_PATCH + T_CU], priority=10)
|
||||
|
||||
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_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 / 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 += [GND_Y_POS, 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)
|
||||
|
||||
# NF2FF box (created AFTER mesh is set up, BEFORE running)
|
||||
nf2ff = fdtd.CreateNF2FFBox()
|
||||
|
||||
sim_path = os.path.join(OUT_DIR, "sim")
|
||||
print(f"[run] N={N_PATCHES} patch={PATCH_W}x{PATCH_L} CONN={CONN_LEN} "
|
||||
f"PROFILE={PROFILE} cells={n_cells:,}")
|
||||
t0 = time.time()
|
||||
fdtd.Run(sim_path, verbose=0, cleanup=True)
|
||||
dt_sim = time.time() - t0
|
||||
print(f"[run] sim time {dt_sim:.1f} s")
|
||||
|
||||
# Verify S11 dip is where we expect
|
||||
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)
|
||||
i_tx = int(np.argmin(np.abs(freq - F_TX)))
|
||||
print(f"[s11] S11 @ {F_TX/1e9:.3f} GHz = {s11_dB[i_tx]:.2f} dB")
|
||||
|
||||
# ============================================================================
|
||||
# Far-field
|
||||
# ============================================================================
|
||||
print(f"[ff] Computing far-field at {F_TX/1e9:.3f} GHz")
|
||||
# CalcNF2FF expects theta/phi in DEGREES (it converts internally).
|
||||
# NOTE: Prad and Dmax are buggy in this openEMS version (Prad ≈ 0,
|
||||
# sign-dependent). E_norm pattern shape is valid — that's enough to identify
|
||||
# broadside vs scanned mode. Plot NORMALIZED pattern (peak at 0 dB).
|
||||
theta_deg_arr = np.arange(-180, 181, 2.0)
|
||||
phi_deg_arr = np.array([0.0, 90.0])
|
||||
y_array_center = ((N_PATCHES - 1) * PITCH) / 2
|
||||
center = [0.0, y_array_center, Z_PATCH/2]
|
||||
|
||||
t0 = time.time()
|
||||
nf2ff_res = nf2ff.CalcNF2FF(sim_path, F_TX, theta_deg_arr, phi_deg_arr,
|
||||
center=center, verbose=0)
|
||||
dt_ff = time.time() - t0
|
||||
print(f"[ff] far-field calc time {dt_ff:.1f} s")
|
||||
|
||||
# Result shape: E_norm[freq] is [n_theta, n_phi]
|
||||
E_norm = np.array(nf2ff_res.E_norm[0])
|
||||
|
||||
# Normalised pattern (peak = 0 dB). Pattern shape is what matters for
|
||||
# broadside vs scanned identification.
|
||||
E_max_h = float(np.max(np.abs(E_norm[:, 0])))
|
||||
E_max_e = float(np.max(np.abs(E_norm[:, 1])))
|
||||
E_max_global = max(E_max_h, E_max_e)
|
||||
|
||||
pat_dB_h_norm = 20*np.log10(np.abs(E_norm[:, 0]) / E_max_global + 1e-30)
|
||||
pat_dB_e_norm = 20*np.log10(np.abs(E_norm[:, 1]) / E_max_global + 1e-30)
|
||||
|
||||
theta_deg = theta_deg_arr
|
||||
i_bs = int(np.argmin(np.abs(theta_deg)))
|
||||
i_pk_h = int(np.argmax(np.abs(E_norm[:, 0])))
|
||||
i_pk_e = int(np.argmax(np.abs(E_norm[:, 1])))
|
||||
|
||||
# -3 dB beamwidth in E-plane (array axis — main lobe)
|
||||
def beamwidth_3dB(theta_deg, pat_dB, i_peak):
|
||||
half_db = pat_dB[i_peak] - 3.0
|
||||
# Walk left
|
||||
lo = i_peak
|
||||
while lo > 0 and pat_dB[lo] > half_db:
|
||||
lo -= 1
|
||||
# Walk right
|
||||
hi = i_peak
|
||||
while hi < len(pat_dB) - 1 and pat_dB[hi] > half_db:
|
||||
hi += 1
|
||||
return theta_deg[hi] - theta_deg[lo]
|
||||
|
||||
bw_e = beamwidth_3dB(theta_deg, pat_dB_e_norm, i_pk_e)
|
||||
bw_h = beamwidth_3dB(theta_deg, pat_dB_h_norm, i_pk_h)
|
||||
|
||||
print()
|
||||
print("=" * 78)
|
||||
print(f" Far-field NORMALISED pattern at f = {F_TX/1e9:.3f} GHz")
|
||||
print(f" ── E-plane (φ=90°, along array axis y — array factor lives here) ──")
|
||||
print(f" Broadside (θ=0°) level : {pat_dB_e_norm[i_bs]:.2f} dB (rel peak)")
|
||||
print(f" Peak direction : θ = {theta_deg[i_pk_e]:+.1f}° (peak = {pat_dB_e_norm[i_pk_e]:.2f} dB)")
|
||||
print(f" -3 dB beamwidth : {bw_e:.1f}°")
|
||||
print(f" ── H-plane (φ=0°, perpendicular to array) ──")
|
||||
print(f" Broadside (θ=0°) level : {pat_dB_h_norm[i_bs]:.2f} dB (rel peak)")
|
||||
print(f" Peak direction : θ = {theta_deg[i_pk_h]:+.1f}° (peak = {pat_dB_h_norm[i_pk_h]:.2f} dB)")
|
||||
print(f" -3 dB beamwidth : {bw_h:.1f}°")
|
||||
print()
|
||||
if abs(theta_deg[i_pk_e]) < 5.0:
|
||||
print(f" → BROADSIDE CONFIRMED: E-plane main lobe at θ={theta_deg[i_pk_e]:+.1f}° (within ±5° of normal)")
|
||||
else:
|
||||
print(f" → SCANNED MODE: E-plane main lobe at θ={theta_deg[i_pk_e]:+.1f}° (NOT broadside)")
|
||||
print("=" * 78)
|
||||
|
||||
# Plot (normalized to peak = 0 dB)
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
for ax, pat_dB, title, peak_deg in [
|
||||
(axes[0], pat_dB_h_norm, f"H-plane (φ=0°, xz cut, perp. to array)",
|
||||
theta_deg[i_pk_h]),
|
||||
(axes[1], pat_dB_e_norm, f"E-plane (φ=90°, yz cut, ALONG array — array factor)",
|
||||
theta_deg[i_pk_e]),
|
||||
]:
|
||||
ax.plot(theta_deg, pat_dB, "b-", lw=1.6)
|
||||
ax.axvline(0, color="r", ls=":", lw=0.8, label=f"broadside (θ=0°)")
|
||||
if abs(peak_deg) > 1.0:
|
||||
ax.axvline(peak_deg, color="g", ls=":", lw=0.8,
|
||||
label=f"peak at θ={peak_deg:+.1f}°")
|
||||
ax.set_xlabel("θ (deg)")
|
||||
ax.set_ylabel("Pattern (dB rel peak)")
|
||||
ax.set_title(f"{title}\nat {F_TX/1e9:.3f} GHz")
|
||||
ax.set_xlim(-180, 180)
|
||||
ax.set_ylim(-40, 2)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="lower center")
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(OUT_DIR, "farfield_cuts.png"), dpi=140)
|
||||
plt.close(fig)
|
||||
print(f"[out] {OUT_DIR}/farfield_cuts.png")
|
||||
|
||||
# Polar plot (top half-sphere only, θ from -90 to +90)
|
||||
fig = plt.figure(figsize=(12, 5))
|
||||
ax_h = fig.add_subplot(121, projection="polar")
|
||||
ax_e = fig.add_subplot(122, projection="polar")
|
||||
mask = (theta_deg >= -90) & (theta_deg <= 90)
|
||||
for ax, pat_dB, title in [
|
||||
(ax_h, pat_dB_h_norm, "H-plane (φ=0°)"),
|
||||
(ax_e, pat_dB_e_norm, "E-plane (φ=90°, array axis)"),
|
||||
]:
|
||||
th_pol = np.deg2rad(theta_deg[mask])
|
||||
g_norm = np.clip(pat_dB[mask] - np.max(pat_dB[mask]), -40, 0)
|
||||
ax.plot(th_pol, g_norm, "b-", lw=1.6)
|
||||
ax.set_theta_zero_location("N") # θ=0 at top (broadside)
|
||||
ax.set_theta_direction(-1) # clockwise
|
||||
ax.set_rlim(-40, 0)
|
||||
ax.set_rticks([-30, -20, -10, 0])
|
||||
ax.set_title(f"{title}\n(normalised to peak)")
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(OUT_DIR, "farfield_polar.png"), dpi=140)
|
||||
plt.close(fig)
|
||||
print(f"[out] {OUT_DIR}/farfield_polar.png")
|
||||
|
||||
with open(os.path.join(OUT_DIR, "farfield.csv"), "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["theta_deg", "pattern_hplane_dB_norm", "pattern_eplane_dB_norm"])
|
||||
for k in range(len(theta_deg)):
|
||||
w.writerow([theta_deg[k], pat_dB_h_norm[k], pat_dB_e_norm[k]])
|
||||
print(f"[out] {OUT_DIR}/farfield.csv")
|
||||
Reference in New Issue
Block a user