Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit33bcc8a

Browse files
777arcMarc Lichtman
and
Marc Lichtman
authored
misc DOA improvements (#86)
* start of 2d cal example, and some tweaks to existing 2d content* messing around still not working* x-y plane* cleanups* fix plotting* asdasd* fixed lms* example using mean of angle of x * x conj* beam pattern sampling* van trees* spelling* encoding---------Co-authored-by: Marc Lichtman <marc@projecteagle.net>
1 parent186f6db commit33bcc8a

File tree

10 files changed

+380
-29
lines changed

10 files changed

+380
-29
lines changed

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ figure-generating-scripts/fm_rds_250k_1Msamples.iq
4141
figure-generating-scripts/fm.wav
4242
_templates/patrons.html
4343
_spelling/
44+
Radar-2025-Labs/

‎_images/doa_lms_animation.gif‎

-590 KB
Loading

‎content/doa.rst‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,7 +1163,7 @@ In the example Python code below, we simulate an 8-element array with a SOI that
11631163
# Scenario
11641164
sample_rate=1e6
11651165
d=0.5# half wavelength spacing
1166-
N=10000# number of samples to simulate
1166+
N=100000# number of samples to simulate
11671167
Nr=8# elements
11681168
theta_soi=20/180* np.pi# convert to radians
11691169
theta2=60/180* np.pi
@@ -1191,8 +1191,7 @@ In the example Python code below, we simulate an 8-element array with a SOI that
11911191
r= r+0.5*n# 8xN
11921192
11931193
# LMS, not knowing the direction of SOI but knowing the SOI signal itself
1194-
mu=1e-4# LMS step size
1195-
gamma=0.995# knowledge decay rate
1194+
mu=0.5e-5# LMS step size
11961195
w_lms= np.zeros((Nr,1),dtype=np.complex128)# start with all zeros
11971196
11981197
# Loop through received samples
@@ -1204,8 +1203,9 @@ In the example Python code below, we simulate an 8-element array with a SOI that
12041203
y= y.squeeze()# make it a scalar
12051204
error= soi_sample- y
12061205
error_log.append(np.abs(error)**2)
1207-
w_lms= w_lms* gamma+ mu* np.conj(error)* r_sample# weights are still 8x1
1208-
w_lms/= np.linalg.norm(w_lms)# normalize weights
1206+
w_lms+= mu* np.conj(error)* r_sample# weights are still 8x1
1207+
1208+
w_lms/= np.linalg.norm(w_lms)# normalize weights
12091209
12101210
plt.plot(error_log)
12111211
plt.xlabel('Iteration')
@@ -1631,7 +1631,7 @@ This outputs 0 dB, which is what we expect because MVDR's goal is to achieve uni
16311631
* - A random direction
16321632
- -14.285 dB
16331633

1634-
Your results may vary due to the random noise being used to calculate the received samples, which get used to calculate:code:`R`. But the main take-away is that the jammers will be in a null and very low power, the 1 degree off from:code:`dir` will be slightly below 0 dB, but still in the main lobe, and then a random direction is going to be lower than 0 dB but higher than the jammers, and very different every run of the simulation.
1634+
Your results may vary due to the random noise being used to calculate the received samples, which get used to calculate:code:`R`. But the main take-away is that the jammers will be in a null and very low power, the 1 degree off from:code:`dir` will be slightly below 0 dB, but still in the main lobe, and then a random direction is going to be lower than 0 dB but higher than the jammers, and very different every run of the simulation. Note that with MVDR you get a gain of 0 dB for the main lobe, but if you were to use the conventional beamformer, you would get:math:`10\log_{10}(Nr)`, so about 12 dB for our 16-element array, showing one of the trade-offs of using MVDR.
16351635

16361636
*************************
16371637
Conclusion and References
@@ -1644,6 +1644,8 @@ All Python code, including code used to generate the figures/animations, can be
16441644

16451645
[1] Mailloux, Robert J. Phased Array Antenna Handbook. Second edition, Artech House, 2005
16461646

1647+
[2] Van Trees, Harry L. Optimum Array Processing: Part IV of Detection, Estimation, and Modulation Theory. Wiley, 2002.
1648+
16471649
.. |br|raw:: html
16481650

16491651
<br>

‎figure-generating-scripts/beamforming_slider_app.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,16 @@ def update(val):
4646

4747
ax1=fig.add_subplot(gs[0:5,3:5],projection='polar')
4848
polar_plot,=ax1.plot(theta_bins,np.zeros(N_fft))
49-
ax1.set_theta_zero_location('N')# make 0 degrees point up
50-
ax1.set_theta_direction(-1)# increase clockwise
51-
ax1.set_thetamin(-90)# only show top half
52-
ax1.set_thetamax(90)
53-
ax1.set_ylim([-30,10])
49+
ax1.set_theta_zero_location('N')#type: ignore #make 0 degrees point up
50+
ax1.set_theta_direction(-1)#type: ignore #increase clockwise
51+
ax1.set_thetamin(-90)#type: ignore #only show top half
52+
ax1.set_thetamax(90)# type: ignore
53+
ax1.set_ylim((-30,10))
5454
ax1.set_yticks(np.arange(-30,11,10))# Only label every 10 dB
5555

5656
ax2=fig.add_subplot(gs[5:9,3:5])
5757
rect_plot,=ax2.plot(theta_bins*180/np.pi,np.zeros(N_fft))
58-
plt.axis([-90,90,-40,10])
58+
plt.axis((-90,90,-40,10))
5959
plt.xlabel('Theta [Degrees]')
6060
plt.grid()
6161

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
importnumpyasnp
2+
importmatplotlib.pyplotasplt
3+
4+
sample_rate=1e6
5+
N=10000# number of samples to simulate
6+
d=0.5
7+
Nr=2
8+
theta=np.deg2rad(20)
9+
10+
# Create a tone to act as the transmitted signal
11+
s=np.exp(-2j*np.pi*d*np.arange(Nr)*np.sin(theta)).reshape(-1,1)# steering vector
12+
t=np.arange(N)/sample_rate
13+
f_tone=0.02e6
14+
tx=np.exp(2j*np.pi*f_tone*t).reshape(1,-1)# 1 x 10000
15+
tx=np.random.randn(1,N)+1j*np.random.randn(1,N)
16+
n=np.random.randn(Nr,N)+1j*np.random.randn(Nr,N)
17+
r=s @tx+0.05*n# Nr x 10000
18+
19+
# DOA loop
20+
theta_scan=np.linspace(-1*np.pi,np.pi,1000)
21+
results_conventional=np.zeros(len(theta_scan))
22+
results_mvdr=np.zeros(len(theta_scan))
23+
fortheta_iinrange(len(theta_scan)):
24+
s=np.exp(-2j*np.pi*d*np.arange(Nr)*np.sin(theta_scan[theta_i]))
25+
26+
# Conventional
27+
w_conventional=s
28+
r_weighted_conv=np.conj(w_conventional) @r# apply our weights corresponding to the direction theta_i
29+
results_conventional[theta_i]=10*np.log10(np.mean(np.var(r_weighted_conv)))# energy detector
30+
31+
# MVDR
32+
R= (r @r.conj().T)/r.shape[1]# Calc covariance matrix. gives a Nr x Nr covariance matrix of the samples
33+
Rinv=np.linalg.pinv(R)# 3x3. pseudo-inverse tends to work better than a true inverse
34+
results_mvdr[theta_i]=10*np.log10(1/(s.conj().T @Rinv @s).squeeze())
35+
36+
# Simple phase measurement using angle(x * x.conj)
37+
phase_diff=np.mean(np.angle(r[0, :]*np.conj(r[1, :])))
38+
theta_est=np.arcsin(phase_diff/ (2*np.pi*d))
39+
print("Estimated angle of arrival: ",theta_est*180/np.pi)# in degrees
40+
41+
# Plot
42+
fig,ax=plt.subplots(subplot_kw={'projection':'polar'})
43+
ax.plot(theta_scan,results_conventional)
44+
ax.plot(theta_scan,results_mvdr)
45+
ax.plot([theta_est]*2, [-20,10],'c')
46+
ax.plot([theta]*2, [-20,10],'g--')
47+
ax.legend(['Conventional','MVDR','np.angle(x x.conj)','Correct AoA'])
48+
ax.set_theta_zero_location('N')# type: ignore # make 0 degrees point up
49+
ax.set_theta_direction(-1)# type: ignore # increase clockwise
50+
ax.set_thetamin(-90)# type: ignore # only show top half
51+
ax.set_thetamax(90)# type: ignore
52+
ax.set_ylim((-20,10))# because there's no noise, only go down 30 dB
53+
plt.show()
54+
exit()

‎figure-generating-scripts/doa_2d.py‎

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ def steering_vector(pos, dir):
4242

4343
# The direction unit vector pointing towards theta is:
4444
dir=np.asmatrix([np.cos(theta),# x component, goes back to the geometry shown in the beginning of DOA chapter
45-
0,# y component
45+
np.sin(theta),# y component ends up being 0 because the element positions are all 0 in y
4646
0]# z component
47-
).T
47+
).T
4848
print("dir:\n",dir)# Remember that it's a unit vector representing a direction, it's not in meters
4949

5050
# Now let's use our generalized steering vector function to calculate the steering vector
@@ -96,15 +96,15 @@ def steering_vector(pos, dir):
9696
exit()
9797

9898
# We will introduce phi, the elevation angle. Let's point towards an arbitrary direction
99-
theta=np.deg2rad(60)# azimith angle
100-
phi=np.deg2rad(30)# elevation angle
99+
theta=np.deg2rad(0)# azimith angle
100+
phi=np.deg2rad(0)# elevation angle
101101

102102
# The direction unit vector in this direction now has two nonzero components:
103103
# Let's make a function out of it, because we will be using it a lot
104104
defget_unit_vector(theta,phi):
105105
returnnp.asmatrix([np.sin(theta)*np.sin(phi),# x component
106106
np.cos(theta)*np.sin(phi),# y component
107-
0]# z component
107+
np.cos(phi)]# z component, ends up being 0 because the element positions are all 0 in z
108108
).T
109109
dir=get_unit_vector(theta,phi)
110110
print("dir:\n",dir)# Remember that it's a unit vector representing a direction, it's not in meters
@@ -119,6 +119,91 @@ def get_unit_vector(theta, phi):
119119
# At this point it's worth pointing out that we didn't actually change dimensionality of anything, going from 1D to 2D, we just have a non-zero y component, the steering vector equation is still the same and the weights are still a 1D array
120120
# Some folks might assemble their weights as a 2D array so that visually it matches the array geometry, but it's not necessary and best to keep it 1D
121121

122+
# 2D contour plot from Tarun
123+
ifFalse:
124+
AZ_MIN=-180
125+
AZ_MAX=180
126+
AZ_RES=1
127+
EL_MIN=0
128+
EL_MAX=180
129+
EL_RES=1
130+
131+
az_range_rad=np.radians(np.arange(AZ_MIN,AZ_MAX,AZ_RES))[None,:]#1x360
132+
el_range_rad=np.radians(np.arange(EL_MIN,EL_MAX,EL_RES))[:,None]#180x1
133+
k=2*np.pi*np.asarray([fc]*Nr)/3e8#3x0,
134+
kx=k[0]*np.sin(el_range_rad)*np.cos(az_range_rad)#180*360
135+
ky=k[0]*np.sin(el_range_rad)*np.sin(az_range_rad)#180*360
136+
kz=k[0]*np.cos(el_range_rad)#180x1
137+
# print(np.arange(az_min, az_max, az_res)) #180x1
138+
# print(az_range_rad.shape)
139+
# print(el_range_rad.shape)
140+
print('kx shape:',kx.shape)
141+
print('ky shape:',ky.shape)
142+
print('kz shape:',kz.shape)
143+
scan_kx=np.exp(-1j* (kx[:,:,None]*np.arange(4)*d))[:,:,None,:]# 180x360x1x10
144+
scan_kz=np.exp(-1j* (kz[:,:,None]*np.arange(4)*d))[:, :,:,None]# 180x360x10x1
145+
print('scan_kx shape:',scan_kx.shape)
146+
print('scan_kz shape:',scan_kz.shape)
147+
148+
scan_overall=scan_kx*scan_kz
149+
print('scan_overall shape:',scan_overall.shape)
150+
151+
scan_overall=scan_overall.reshape((scan_overall.shape[0],scan_overall.shape[1],-1))#180x360x100
152+
# scan_overall = np.reshape(scan_overall, (180,360,100), order= 'C') #180x360x100
153+
154+
print('scan_overall shape:',scan_overall.shape)# (180, 360, 16)
155+
156+
weight_conj_t=w.conj().T
157+
# add another dimension to the weight vector to make it 1x1x16
158+
weight_conj_t=np.expand_dims(weight_conj_t,axis=0)# 1x1x16
159+
print('weight vector shape:',weight_conj_t.shape)# (1, 16)
160+
161+
results=np.sum(weight_conj_t*scan_overall,axis=2)#180x360
162+
results=np.abs(results)
163+
#results = results**2
164+
#results = 10 * np.log10(results) # Convert to dB
165+
166+
plt.figure(figsize=(8,6))
167+
im=plt.imshow(results,
168+
extent=(AZ_MIN,AZ_MAX,EL_MIN,EL_MAX),
169+
cmap='viridis',
170+
origin='lower')
171+
#vmax=15,
172+
#vmin=-30,
173+
#aspect='auto')
174+
plt.colorbar(im)
175+
plt.xlabel('Azimuth Angle (degrees)')
176+
plt.ylabel('Elevation Angle (degrees)')
177+
plt.grid()
178+
plt.show()
179+
180+
181+
# 2D plot that makes more sense
182+
# This seems to be making a UV plot
183+
ifTrue:
184+
resolution=100# number of points in each direction
185+
theta_scan=np.linspace(-np.pi,np.pi,resolution)# azimuth angles
186+
phi_scan=np.linspace(0,np.pi,resolution)# elevation angles
187+
results=np.zeros((resolution,resolution))# 2D array to store results
188+
fori,theta_iinenumerate(theta_scan):
189+
forj,phi_iinenumerate(phi_scan):
190+
a=steering_vector(pos,get_unit_vector(theta_i,phi_i))# array factor
191+
results[i,j]=np.abs(w.conj().T @a)[0,0]# power in signal, in dB
192+
193+
#results = 10*np.log10(results) # Convert to dB
194+
#results[results < -20] = -20
195+
196+
#X = np.sin(theta_scan[:,None]) * np.sin(phi_scan[None,:]) # doesnt make sense to convert to degrees at this point because its not a radian anymore
197+
#Y = np.cos(theta_scan[:,None]) * np.sin(phi_scan[None,:])
198+
#CS = plt.contour(X, Y, results)
199+
#plt.clabel(CS, inline=True, fontsize=8)
200+
plt.imshow(results,extent=(-180,180,0,90),origin='lower',aspect='auto',cmap='viridis')
201+
plt.colorbar(label='Power [dB]')
202+
plt.xlabel('Azimuth angle [degrees]')
203+
plt.ylabel('Elevation angle [degrees]')
204+
plt.show()
205+
206+
122207
# Visualize beam pattern when using these weights, but this time we need a 3D surface plot
123208
# Note that this is not a polar plot, it's using X and Y to represent the azimuth and elevation angles, and Z to represent the power in dB
124209
ifFalse:
@@ -134,6 +219,7 @@ def get_unit_vector(theta, phi):
134219
results[i,j]=10*np.log10(np.abs(resp)[0,0])# power in signal, in dB
135220
# plot_surface needs x,y,z form
136221
results[results<-10]=-10# crop the z axis to -10 dB
222+
print(np.max(results))# 12.04 dB because we have 16 elements 10*log10(16) = 12.04 dB
137223
fig,ax=plt.subplots(subplot_kw={"projection":"3d","computed_zorder":False})
138224
surf=ax.plot_surface(np.sin(theta_scan[:,None])*np.sin(phi_scan[None,:]),# x # type: ignore
139225
np.cos(theta_scan[:,None])*np.sin(phi_scan[None,:]),# y
@@ -214,3 +300,20 @@ def get_unit_vector(theta, phi):
214300
# So we end up getting a pretty low value towards the jammers
215301
# Slightly off from where we are pointing, we get a value a tad below 0 dB
216302
# Towards a random direction, we get a value higher than the jammers but way lower than 0 dB, most of the time
303+
304+
# Sanity check the max gain from MVDR
305+
resolution=200# number of points in each direction
306+
theta_scan=np.linspace(0,2*np.pi,resolution)# azimuth angles
307+
phi_scan=np.linspace(0,np.pi,resolution)# elevation angles
308+
results=np.zeros((resolution,resolution))# 2D array to store results
309+
fori,theta_iinenumerate(theta_scan):
310+
forj,phi_iinenumerate(phi_scan):
311+
dir_i=get_unit_vector(theta_i,phi_i)
312+
a=steering_vector(pos,dir_i)# array factor
313+
resp=w.conj().T @a# scalar
314+
results[i,j]=10*np.log10(np.abs(resp)[0,0])# power in signal, in dB
315+
print(np.max(results))# 1.3 dB
316+
# Print argmax of a 2d array
317+
max_idx=np.unravel_index(np.argmax(results,axis=None),results.shape)
318+
print(theta_scan[max_idx[0]]*180/np.pi)# theta doesnt really mean anything here because phi is close to 0
319+
print(phi_scan[max_idx[1]]*180/np.pi)# phi is sometimes +10 and sometimes -10 deg
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
importnumpyasnp
2+
importmatplotlib.pyplotasplt
3+
fromscipyimportsignal
4+
frommatplotlib.animationimportFuncAnimation
5+
6+
d=0.5
7+
Nr=16
8+
9+
# Using beam pattern sampling to come up with the beamforming weights
10+
# Van trees page 124
11+
B_theta=np.ones(Nr)# desired beam pattern
12+
phi= (np.arange(Nr)- (Nr-1)/2)*2*np.pi/Nr# eq 3.92
13+
B=np.conj(B_theta)*np.exp(-1j*phi* (Nr-1)/2)# eq 3.94
14+
b=np.fft.ifft(B)
15+
n=np.arange(Nr)
16+
w=b*np.exp(-1j*n*np.pi* (Nr-1)/Nr)# eq 3.101
17+
w=w/np.sum(w)# normalize weights
18+
19+
# Plot beam pattern
20+
N_fft=1024
21+
w=np.conj(w)# or else our answer will be negative/inverted
22+
w=w.squeeze()
23+
w_padded=np.concatenate((w,np.zeros(N_fft-Nr)))# zero pad to N_fft elements to get more resolution in the FFT
24+
w_fft_dB=10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2)# magnitude of fft in dB
25+
w_fft_dB-=np.max(w_fft_dB)# normalize to 0 dB at peak
26+
theta_bins=np.arcsin(np.linspace(-1,1,N_fft))# Map the FFT bins to angles in radians
27+
fig,ax=plt.subplots()
28+
ax.plot(theta_bins*180/np.pi,w_fft_dB)# MAKE SURE TO USE RADIAN FOR POLAR
29+
ax.set_xlabel("Theta [Degrees]")
30+
ax.set_ylabel("Beam Pattern [dB]")
31+
ax.set_ylim((-30,0))
32+
ax.grid()
33+
plt.show()

‎figure-generating-scripts/doa_lms.py‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
importnumpyasnp
22
importmatplotlib.pyplotasplt
3+
importimageio
34

4-
# OBS plus lots of ezgif.com was used to make the final animated gif
5+
# ezgif.com was used just to crop it
6+
7+
tmp='C:\\Users\\marclichtman\\AppData\\Local\\Temp'
58

6-
fig,ax=plt.subplots(subplot_kw={'projection':'polar'})
79
theta_sois_deg=np.linspace(-90,90,200)
10+
filenames= []
811
fortheta_soi_degintheta_sois_deg:
912
sample_rate=1e6
1013
d=0.5# half wavelength spacing
11-
N=10000# number of samples to simulate
14+
N=100000# number of samples to simulate
1215
t=np.arange(N)/sample_rate# time vector
1316

1417
Nr=8# elements
@@ -19,7 +22,6 @@
1922
s2=np.exp(-2j*np.pi*d*np.arange(Nr)*np.sin(theta2)).reshape(-1,1)
2023
s3=np.exp(-2j*np.pi*d*np.arange(Nr)*np.sin(theta3)).reshape(-1,1)
2124

22-
2325
ifFalse:
2426
# SOI is a tone
2527
soi=np.exp(2j*np.pi*0.01e6*t).reshape(1,-1)# 1xN
@@ -50,8 +52,7 @@
5052

5153
# LMS, not knowing the direction of SOI but knowing the soi signal itself
5254
# TODO add in a random time delay to start of soi
53-
mu=1e-4# LMS step size
54-
gamma=0.995# knowledge decay rate
55+
mu=0.5e-5# LMS step size
5556
#w_lms = np.random.randn(Nr, 1) + 1j*np.random.randn(Nr, 1) # random weights
5657
w_lms=np.zeros((Nr,1),dtype=np.complex128)# zeros
5758

@@ -64,8 +65,9 @@
6465
y=y.squeeze()# make it a scalar
6566
error=soi_sample-y
6667
error_log.append(np.abs(error)**2)
67-
w_lms=w_lms*gamma+mu*np.conj(error)*r_sample# weights are still 8x1
68-
w_lms/=np.linalg.norm(w_lms)# normalize
68+
w_lms+=mu*np.conj(error)*r_sample# weights are still 8x1
69+
70+
w_lms/=np.linalg.norm(w_lms)# normalize
6971

7072
'''
7173
plt.figure("Error Log")
@@ -84,6 +86,7 @@
8486
#w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak
8587
theta_bins=np.arcsin(np.linspace(-1,1,N_fft))# in radians
8688

89+
fig,ax=plt.subplots(subplot_kw={'projection':'polar'})
8790
ax.plot(theta_bins,w_fft_dB)
8891
ax.plot([theta_soi,theta_soi], [-30,10],'g--')
8992
ax.plot([theta2,theta2], [-30,10],'r--')
@@ -94,7 +97,17 @@
9497
ax.set_thetamin(-90)# type: ignore # only show top half
9598
ax.set_thetamax(90)# type: ignore
9699
ax.set_ylim((-30,10))# because there's no noise, only go down 30 dB
97-
plt.draw()
98-
plt.pause(0.001)# pause to update the plot
99-
plt.cla()
100+
#plt.draw()
101+
#plt.pause(0.001) # pause to update the plot
102+
#plt.cla()
103+
filename=tmp+'\\lms_animation'+str(theta_soi_deg)+'.png'
104+
print(theta_soi_deg)
105+
fig.savefig(filename,bbox_inches='tight')
106+
filenames.append(filename)
107+
plt.close(fig)
100108

109+
# Create animated gif
110+
images= []
111+
forfilenameinfilenames:
112+
images.append(imageio.imread(filename))
113+
imageio.mimsave(tmp+'\\lms_animation.gif',images,fps=10,loop=0)# loop=0 means loop forever

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp