nutshell/plots/09_beamforming.py
2025-03-23 01:50:03 -06:00

196 lines
6.3 KiB
Python

# %% imports
import numpy as np
from utils import AnimatedPlot, db10, db20, dir_assets, wrap_phase
# %%
class PcolorPlot(AnimatedPlot):
def __init__(self, elements: int, angle_deg: float = 0, spacing_lambda: float = 0.5, **kwargs):
super().__init__(**kwargs)
self.ax.axes.xaxis.set_visible(False)
self.ax.axes.yaxis.set_visible(False)
self.ax.set_aspect("equal")
self.spacing_lambda = spacing_lambda
x_range = spacing_lambda * (elements - 1)
self.element_x = np.linspace(-x_range / 2, x_range / 2, elements)
self.element_y = np.zeros(elements)
self.angle = np.deg2rad(angle_deg)
self.element_phase = (
np.dot(np.array([self.element_x, self.element_y]).T, [np.sin(self.angle), np.cos(self.angle)]) * 2 * np.pi
)
x_max = np.max([x_range * 1.5, 10])
x_pixels = 200
self.x = np.linspace(-1, 1, x_pixels) * x_max
self.y = np.linspace(-0.2, 1, x_pixels) * x_max
def update(self, t: float):
self.ax.clear()
x = self.x
y = self.y
xx, yy = np.meshgrid(x, y)
cdata = []
for x, y, phase in zip(self.element_x, self.element_y, self.element_phase):
distance = np.sqrt((xx - x) ** 2 + (yy - y) ** 2) + float(np.finfo(float).tiny)
voltage = np.exp(1j * (phase + 2 * np.pi * distance - 2 * np.pi * t)) # * (1 / distance**2)
cdata.append(voltage)
cc = np.array(cdata).sum(axis=0)
self.ax.scatter(self.element_x, self.element_y, marker="x", color="k")
x_max = np.max(self.x)
self.ax.arrow(
x=np.sin(self.angle) * 0.05 * x_max,
y=np.cos(self.angle) * 0.05 * x_max,
dx=np.sin(self.angle) * 0.9 * x_max,
dy=np.cos(self.angle) * 0.9 * x_max,
head_width=0.05 * x_max,
head_length=0.05 * x_max,
width=0.01 * x_max,
fc="k",
ec="k",
)
return xx, yy, cc
class PcolorPhasePlot(PcolorPlot):
def update(self, t: float):
xx, yy, cc = super().update(t)
self.ax.set_title(
"Nearfield Phase\n"
f"{len(self.element_x)} Element{'s' if len(self.element_x) > 1 else ''}, "
f"{self.spacing_lambda}$\\lambda$ Spacing, "
f"{np.rad2deg(self.angle):.0f}° Steer"
)
self.ax.pcolormesh(
xx,
yy,
np.angle(cc, deg=False),
clim=(-np.pi, np.pi),
cmap="twilight",
zorder=0,
)
# TODO: don't animate this, it is constant
class PcolorMagPlot(PcolorPlot):
def update(self, t: float):
xx, yy, cc = super().update(t)
self.ax.set_title(
# Note that this ignores 1/r**2 losses
"Nearfield Power Density\n"
f"{len(self.element_x)} Element{'s' if len(self.element_x) > 1 else ''}, "
f"{self.spacing_lambda}$\\lambda$ Spacing, "
f"{np.rad2deg(self.angle):.0f}° Steer"
)
self.ax.pcolormesh(
xx,
yy,
np.abs(cc) ** 2,
# 20 * np.log10(np.abs(cc / len(self.element_x))),
clim=(0, len(self.element_x) ** 2),
# clim=(-30, 0),
cmap="viridis",
zorder=0,
)
class Steer(AnimatedPlot):
def __init__(self, elements: int, spacing_lambda: float = 0.5, **kwargs):
super().__init__(**kwargs)
self.ax.remove()
self.axs = [
self.fig.add_subplot(3, 1, 1),
self.fig.add_subplot(3, 1, 2),
self.fig.add_subplot(3, 1, 3),
]
self.spacing_lambda = spacing_lambda
self.elements = elements
def update(self, t: float):
for ax in self.axs:
ax.clear()
# rewind, don't reset
if t > 0.5:
t = 1 - t
t *= 2
angle = np.deg2rad((t * 2 - 1) * 90) # steering angle
def get_phase(position):
return wrap_phase(np.sin(angle) * position * 2 * np.pi, deg=False)
ideal_position = self.spacing_lambda * np.linspace(-(self.elements - 1) / 2, (self.elements - 1) / 2, 1001)
ideal_phase = get_phase(ideal_position)
element_position = self.spacing_lambda * np.linspace(
-(self.elements - 1) / 2, (self.elements - 1) / 2, self.elements
)
element_phase = get_phase(element_position)
element_taper = np.ones(self.elements)
element_excitation = element_taper * np.exp(1j * element_phase)
theta = np.linspace(-90, 90, 361)
fft_points = 128
fft_period = 90 / self.spacing_lambda
theta_fft = np.linspace(0, fft_period, fft_points, endpoint=False)
ff_pattern = np.interp(
theta,
theta_fft,
np.fft.fft(np.concat([element_excitation, np.zeros(fft_points - self.elements)]), norm="backward")
/ self.elements,
period=fft_period,
)
# self.fig.suptitle(f"{np.rad2deg(angle):+5.1f}° Steer")
rolloff = np.cos(np.deg2rad(theta))
self.axs[0].set_ylabel("Farfield Magnitude [dB]")
self.axs[0].plot(theta, db20(rolloff), color="gray")
self.axs[0].plot(theta, db20(ff_pattern * rolloff))
self.axs[0].set_xlim(-90, 90)
self.axs[0].set_ylim(-30, 5)
self.axs[0].set_xlabel("Theta [°]")
self.axs[0].grid(True)
# self.axs[0].axvline(np.rad2deg(angle))
self.axs[1].set_ylabel("Excitation Phase")
self.axs[1].stem(element_position, np.rad2deg(element_phase))
self.axs[1].plot(ideal_position, np.rad2deg(ideal_phase), linestyle="--")
self.axs[1].set_ylim(-200, 200)
self.axs[2].set_ylabel("Excitation Magnitude")
self.axs[2].stem(element_position, element_taper)
# self.axs[2].set_xlabel("Element")
# %%
def generate():
# for elements in [
# 1,
# 2,
# 4,
# 10,
# ]:
# PcolorPhasePlot(elements=elements, angle_deg=45).save(dir_assets / "beamforming" / f"phase_xz_{elements}el.gif")
# PcolorMagPlot(elements=elements, angle_deg=45).save(
# dir_assets / "beamforming" / f"magnitude_xz_{elements}el.gif"
# )
Steer(elements=16, spacing_lambda=0.5, frames=200).save(dir_assets / "beamforming" / "steering.gif", framerate=15)
# %%
if __name__ == "__main__":
generate()