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.
423 lines
19 KiB
Python
423 lines
19 KiB
Python
#!/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 — peak in dB rel peak is 0;
|
||
# we want amplitude relative to broadside, so compute |E|² 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)
|
||
|
||
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(" Element pitch d = λ/2 → no real-space grating lobes at any scan ✓")
|
||
print(" Firmware's 4-elem broadcast → super-pitch d_super = 4d = 2λ")
|
||
print(" → 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()
|