diff --git a/content-nl/2d_beamforming.rst b/content-nl/2d_beamforming.rst new file mode 100644 index 00000000..f49560ab --- /dev/null +++ b/content-nl/2d_beamforming.rst @@ -0,0 +1,565 @@ +.. _2d-beamforming-chapter: + +################# +2D-bundelvorming +################# + +Dit hoofdstuk breidt het 1D-hoofdstuk over bundelvorming/DOA uit naar 2D-arrays. We starten met een eenvoudige rechthoekige array en leiden de stuurvectorvergelijking en MVDR-bundelvormer af, daarna werken we met echte data van een 3x5-array. Tot slot gebruiken we de interactieve tool om de effecten van verschillende arraygeometrieen en elementafstand te verkennen. + +**************************************** +Rechthoekige Arrays en 2D-bundelvorming +**************************************** + +Rechthoekige arrays (ook wel planaire arrays) bestaan uit een 2D-array van elementen. Met een extra dimensie komt wat extra complexiteit, maar dezelfde basisprincipes blijven gelden, en het lastigste deel is het visualiseren van de resultaten (geen eenvoudige polaire grafieken meer, maar 3D-oppervlakteplots). Ook al is onze array nu 2D, dat betekent niet dat we aan elke datastructuur een extra dimensie moeten toevoegen. Zo houden we de gewichten gewoon als een 1D-array van complexe getallen. Wel moeten we de posities van onze elementen in 2D representeren. We blijven :code:`theta` gebruiken voor de azimuthoek, maar introduceren nu ook :code:`phi`, de elevatiehoek. Er bestaan meerdere conventies voor bolcoordinaten, maar wij gebruiken de volgende: + +.. image:: ../_images/Spherical_Coordinates.svg + :align: center + :target: ../_images/Spherical_Coordinates.svg + :alt: Bolcoordinatenstelsel met theta en phi + +Dat komt overeen met: + +.. math:: + + x = \sin(\theta) \cos(\phi) + + y = \cos(\theta) \cos(\phi) + + z = \sin(\phi) + +We stappen ook over op een gegeneraliseerde stuurvectorvergelijking, die niet aan een specifieke arraygeometrie is gebonden: + +.. math:: + + s = e^{2j \pi \boldsymbol{p} u / \lambda} + +waarbij :math:`\boldsymbol{p}` de verzameling x/y/z-posities van de elementen in meter is (grootte :code:`Nr` x 3) en :math:`u` de richting is waar we naartoe willen wijzen als een eenheidsvector in x/y/z (grootte 3x1). In Python ziet dat er zo uit: + +.. code-block:: python + + def steering_vector(pos, dir): + # Nrx3 3x1 + return np.exp(2j * np.pi * pos @ dir / wavelength) # outputs Nr x 1 (column vector) + +Laten we deze gegeneraliseerde stuurvectorvergelijking toepassen op een eenvoudige ULA met 4 elementen, zodat de koppeling met eerdere stof duidelijk blijft. We drukken :code:`d` nu uit in meters in plaats van relatief ten opzichte van de golflengte. We plaatsen de elementen langs de y-as: + +.. code-block:: python + + Nr = 4 + fc = 5e9 + wavelength = 3e8 / fc + d = 0.5 * wavelength # in meters + + # We will store our element positions in a list of (x,y,z)'s, even though it's just a ULA along the y-axis + pos = np.zeros((Nr, 3)) # Element positions, as a list of x,y,z coordinates in meters + for i in range(Nr): + pos[i,0] = 0 # x position + pos[i,1] = d * i # y position + pos[i,2] = 0 # z position + +De onderstaande afbeelding toont een bovenaanzicht van de ULA, met als voorbeeld een theta van 20 graden. + +.. image:: ../_images/2d_beamforming_ula.svg + :align: center + :target: ../_images/2d_beamforming_ula.svg + :alt: ULA met theta van 20 graden + +Het enige dat nog rest is het koppelen van onze oude :code:`theta` aan deze nieuwe aanpak met eenheidsvectoren. We kunnen :code:`dir` eenvoudig uit :code:`theta` berekenen: de x- en z-component van de eenheidsvector zijn 0 omdat we nog in 1D werken, en volgens onze bolcoordinatenconventie is de y-component :code:`np.cos(theta)`, dus de volledige code is :code:`dir = np.asmatrix([0, np.cos(theta_i), 0]).T`. Op dit punt kun je de gegeneraliseerde stuurvectorvergelijking koppelen aan de ULA-stuurvectorvergelijking die we al gebruikten. Probeer deze nieuwe code uit, kies een :code:`theta` tussen 0 en 360 graden (vergeet niet om naar radialen om te rekenen!), en de stuurvector moet een 4x1-array zijn. + +Laten we nu naar het 2D-geval gaan. We plaatsen onze array in het X-Z-vlak, met kijkrichting horizontaal gericht naar de positieve y-as (:math:`\theta = 0`, :math:`\phi = 0`). We gebruiken dezelfde elementafstand als eerder, maar nu hebben we in totaal 16 elementen: + +.. code-block:: python + + # Now let's switch to 2D, using a 4x4 array with half wavelength spacing, so 16 elements total + Nr = 16 + + # Element positions, still as a list of x,y,z coordinates in meters, we'll place the array in the X-Z plane + pos = np.zeros((Nr,3)) + for i in range(Nr): + pos[i,0] = d * (i % 4) # x position + pos[i,1] = 0 # y position + pos[i,2] = d * (i // 4) # z position + +Bovenaanzicht van onze rechthoekige 4x4-array: + +.. image:: ../_images/2d_beamforming_element_pos.svg + :align: center + :target: ../_images/2d_beamforming_element_pos.svg + :alt: Elementposities van rechthoekige array + +Om naar een bepaalde theta en phi te wijzen, moeten we die hoeken omzetten naar een eenheidsvector. We gebruiken dezelfde gegeneraliseerde stuurvectorvergelijking als eerder, maar nu berekenen we de eenheidsvector op basis van zowel theta als phi, met de vergelijkingen uit het begin van dit hoofdstuk: + +.. code-block:: python + + # Let's point towards an arbitrary direction + theta = np.deg2rad(60) # azimith angle + phi = np.deg2rad(30) # elevation angle + + # Using our spherical coordinate convention, we can calculate the unit vector: + def get_unit_vector(theta, phi): # angles are in radians + return np.asmatrix([np.sin(theta) * np.cos(phi), # x component + np.cos(theta) * np.cos(phi), # y component + np.sin(phi)]).T # z component + + dir = get_unit_vector(theta, phi) + # dir is a 3x1 + # [[0.75 ] + # [0.4330127] + # [0.5 ]] + +Laten we nu onze gegeneraliseerde stuurvectorfunctie gebruiken om de stuurvector te berekenen: + +.. code-block:: python + + s = steering_vector(pos, dir) + + # Use the conventional beamformer, which is simply the weights equal to the steering vector, plot the beam pattern + w = s # 16x1 vector of weights + +Het is belangrijk om op te merken dat we bij de stap van 1D naar 2D de dimensies van de datastructuren niet echt hebben aangepast: we hebben nu alleen niet-nul x/y/z-componenten. De stuurvectorvergelijking blijft hetzelfde en de gewichten blijven een 1D-array. Het kan verleidelijk zijn om gewichten als 2D-array op te slaan zodat dit visueel bij de arraygeometrie past, maar dat is niet nodig en 1D is doorgaans beter. Voor elk element bestaat er een corresponderend gewicht, en de volgorde van de gewichten is dezelfde als die van de elementposities. + +Het bundelpatroon dat bij deze gewichten hoort visualiseren is wat complexer, omdat we een 3D-plot of een 2D-heatmap nodig hebben. We scannen :code:`theta` en :code:`phi` om een 2D-array met vermogensniveaus te krijgen, en plotten die vervolgens met :code:`imshow()`. De code hieronder doet precies dat, en het resultaat staat in de figuur eronder, inclusief een punt op de eerder gekozen hoek: + +.. code-block:: python + + resolution = 100 # number of points in each direction + theta_scan = np.linspace(-np.pi/2, np.pi/2, resolution) # azimuth angles + phi_scan = np.linspace(-np.pi/4, np.pi/4, resolution) # elevation angles + results = np.zeros((resolution, resolution)) # 2D array to store results + for i, theta_i in enumerate(theta_scan): + for j, phi_i in enumerate(phi_scan): + a = steering_vector(pos, get_unit_vector(theta_i, phi_i)) # array factor + results[i, j] = np.abs(w.conj().T @ a)[0,0] # power in signal, looks better as linear + plt.imshow(results.T, extent=(theta_scan[0]*180/np.pi, theta_scan[-1]*180/np.pi, phi_scan[0]*180/np.pi, phi_scan[-1]*180/np.pi), origin='lower', aspect='auto', cmap='viridis') + plt.colorbar(label='Power [linear]') + plt.scatter(theta*180/np.pi, phi*180/np.pi, color='red', s=50) # Add a dot at the correct theta/phi + plt.xlabel('Azimuth angle [degrees]') + plt.ylabel('Elevation angle [degrees]') + plt.show() + +.. image:: ../_images/2d_beamforming_2dplot.svg + :align: center + :target: ../_images/2d_beamforming_2dplot.svg + :alt: 3D-plot van het bundelpatroon + +Laten we nu echte samples simuleren; we voegen twee toon-stoorzenders toe die uit verschillende richtingen aankomen: + +.. code-block:: python + + N = 10000 # number of samples to simulate + + jammer1_theta = np.deg2rad(-30) + jammer1_phi = np.deg2rad(10) + jammer1_dir = get_unit_vector(jammer1_theta, jammer1_phi) + jammer1_s = steering_vector(pos, jammer1_dir) # Nr x 1 + jammer1_tone = np.exp(2j*np.pi*0.1*np.arange(N)).reshape(1,-1) # make a row vector + + jammer2_theta = np.deg2rad(10) + jammer2_phi = np.deg2rad(50) + jammer2_dir = get_unit_vector(jammer2_theta, jammer2_phi) + jammer2_s = steering_vector(pos, jammer2_dir) + jammer2_tone = np.exp(2j*np.pi*0.2*np.arange(N)).reshape(1,-1) # make a row vector + + noise = np.random.normal(0, 1, (Nr, N)) + 1j * np.random.normal(0, 1, (Nr, N)) # complex Gaussian noise + r = jammer1_s @ jammer1_tone + jammer2_s @ jammer2_tone + noise # produces 16 x 10000 matrix of samples + +Voor de volledigheid berekenen we nu de MVDR-bundelvormergewichten richting de theta en phi die we eerder gebruikten (een eenheidsvector in die richting staat nog steeds in :code:`dir`): + +.. code-block:: python + + s = steering_vector(pos, dir) # 16 x 1 + R = np.cov(r) # Covariance matrix, 16 x 16 + Rinv = np.linalg.pinv(R) + w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # MVDR/Capon equation + +In plaats van naar een matige 3D-plot van het bundelpatroon te kijken, gebruiken we een alternatieve methode om te controleren of deze gewichten logisch zijn: we evalueren de respons van de gewichten voor verschillende richtingen en berekenen het vermogen in dB. We beginnen met de richting waarnaar we wijzen: + +.. code-block:: python + + # Power in the direction we are pointing (theta=60, phi=30, which is still saved as dir): + a = steering_vector(pos, dir) # array factor + resp = w.conj().T @ a # scalar + print("Power in direction we are pointing:", 10*np.log10(np.abs(resp)[0,0]), 'dB') + +Dit geeft 0 dB, wat we verwachten omdat het doel van MVDR is om eenheidsvermogen in de gewenste richting te realiseren. Laten we nu ook het vermogen controleren in de richtingen van de twee jammers, plus een willekeurige richting en een richting die een graad afwijkt van de gewenste richting (dezelfde code, alleen :code:`dir` wijzigen). De resultaten staan in de tabel hieronder: + +.. list-table:: + :widths: 70 30 + :header-rows: 1 + + * - Direction Pointed + - Gain + * - :code:`dir` (direction used to find MVDR weights) + - 0 dB + * - Jammer 1 + - -17.488 dB + * - Jammer 2 + - -18.551 dB + * - 1 degree off from :code:`dir` in both :math:`\theta` and :math:`\phi` + - -0.00683 dB + * - Een willekeurige richting + - -10.591 dB + +Je resultaten kunnen verschillen door de willekeurige ruis die wordt gebruikt om de ontvangen samples te berekenen, waarmee vervolgens :code:`R` wordt bepaald. De hoofdboodschap is echter dat de jammers in een null terechtkomen met zeer laag vermogen, de richting die 1 graad afwijkt van :code:`dir` net onder 0 dB zit maar nog in de hoofdlob, en dat een willekeurige richting meestal lager is dan 0 dB maar hoger dan de jammers, en sterk kan variëren per simulatie-run. Let op dat MVDR een versterking van 0 dB in de hoofdlob geeft; bij de conventionele bundelvormer krijg je :math:`10 \log_{10}(Nr)`, dus ongeveer 12 dB voor onze 16-element-array. Dat laat een van de afwegingen van MVDR zien. + +De code voor dit onderdeel staat `hier `_. + +********************************************** +Signalen Verwerken van een Echte 2D-array +********************************************** + +In dit onderdeel werken we met echte data die is opgenomen met een 3x5-array gebouwd op een `QUAD-MxFE `_-platform van Analog Devices, dat tot 16 zend- en ontvangstkanalen ondersteunt (wij gebruikten er 15, alleen in ontvangstmodus). Er zijn twee opnames beschikbaar: de eerste bevat een enkele zender op kijkrichting van de array, die we voor calibratie gebruiken. De tweede opname bevat twee zenders uit verschillende richtingen, die we voor bundelvorming en DOA-testen gebruiken. + +- `IQ-opname van alleen C `_ (gebruikt voor calibratie, omdat C op kijkrichting staat) +- `IQ-opname van B en D `_ (gebruikt voor bundelvorming/DOA-testen) + +De QUAD-MxFE was afgestemd op 2,8 GHz en alle zenders gebruikten een eenvoudige toon binnen de observatiebandbreedte. Interessant aan deze DSP is dat de sample rate hier niet doorslaggevend is: geen van de arrayverwerkingstechnieken die we gebruiken hangt ervan af, zolang het signaal maar ergens in de basisband zit. De DSP hangt wel af van de centerfrequentie, omdat de faseverschuiving tussen elementen afhangt van frequentie en aankomstrichting. Dat is het omgekeerde van veel andere signaalverwerking, waar sample rate cruciaal is en centerfrequentie meestal niet. + +We kunnen deze opnames in Python laden met de volgende code: + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + + r = np.load("DandB_capture1.npy")[0:15] # 16th element is not connected but was still recorded + r_cal = np.load("C_only_capture1.npy")[0:15] # only the calibration signal (at kijkrichting) on + +De afstand tussen de antennes was 0,051 meter. We representeren de elementposities als een lijst met x,y,z-coordinaten in meter. We plaatsen de array in het X-Z-vlak, omdat de array verticaal gemonteerd was (met kijkrichting horizontaal gericht). + +.. code-block:: python + + fc = 2.8e9 # center frequency in Hz + d = 0.051 # spacing between antennas in meters + wavelength = 3e8 / fc + Nr = 15 + rows = 3 + cols = 5 + + # Element positions, as a list of x,y,z coordinates in meters + pos = np.zeros((Nr, 3)) + for i in range(Nr): + pos[i,0] = d * (i % cols) # x position + pos[i,1] = 0 # y position + pos[i,2] = d * (i // cols) # z position + + # Plot and label positions of elements + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(pos[:,0], pos[:,1], pos[:,2], 'o') + # Label indices + for i in range(Nr): + ax.text(pos[i,0], pos[i,1], pos[i,2], str(i), fontsize=10) + plt.xlabel("X Position [m]") + plt.ylabel("Y Position [m]") + ax.set_zlabel("Z Position [m]") + plt.grid() + plt.show() + +De plot labelt elk element met zijn index, overeenkomend met de volgorde van de elementen in de opgenomen :code:`r`- en :code:`r_cal`-IQ-samples. + +.. image:: ../_images/2d_array_element_positions.svg + :align: center + :target: ../_images/2d_array_element_positions.svg + :alt: Elementposities van 2D-array + +Calibratie gebeurt met alleen de :code:`r_cal`-samples, die zijn opgenomen terwijl enkel de zender op kijkrichting actief was. Het doel is om voor elk element de fase- en amplitude-offset te vinden. Bij perfecte calibratie, en als de zender exact op kijkrichting staat, zouden alle afzonderlijke ontvangstkanalen hetzelfde signaal moeten ontvangen: onderling in fase en met gelijke amplitude. Door onvolkomenheden in array/kabels/antennes heeft elk element echter een andere fase- en amplitude-offset. In het calibratieproces bepalen we deze offsets, die we later op de :code:`r`-samples toepassen voordat we arrayverwerking uitvoeren. + +Er zijn veel manieren om te calibreren, maar wij gebruiken een methode op basis van eigenwaardedecompositie van de covariantiematrix. De covariantiematrix is een vierkante matrix met grootte :code:`Nr x Nr`, waarbij :code:`Nr` het aantal ontvangstkanalen is. De eigenvector die hoort bij de grootste eigenwaarde representeert idealiter het ontvangen signaal; die gebruiken we om fase-offsets te bepalen door van elk element in de eigenvector de fase te nemen en te normaliseren op het eerste element, dat als referentie dient. De amplitudecalibratie gebruikt de eigenvector niet, maar de gemiddelde amplitude van het ontvangen signaal per element. + +.. code-block:: python + + # Calc covariance matrix, it's Nr x Nr + R_cal = r_cal @ r_cal.conj().T + + # eigenvalue decomposition, v[:,i] is the eigenvector corresponding to the eigenvalue w[i] + w, v = np.linalg.eig(R_cal) + + # Plot eigenvalues to make sure we have just one large one + w_dB = 10*np.log10(np.abs(w)) + w_dB -= np.max(w_dB) # normalize + fig, (ax1) = plt.subplots(1, 1, figsize=(7, 3)) + ax1.plot(w_dB, '.-') + ax1.set_xlabel('Index') + ax1.set_ylabel('Eigenvalue [dB]') + plt.show() + + # Use max eigenvector to calibrate + v_max = v[:, np.argmax(np.abs(w))] + mags = np.mean(np.abs(r_cal), axis=1) + mags = mags[0] / mags # normalize to first element + phases = np.angle(v_max) + phases = phases[0] - phases # normalize to first element + cal_table = mags * np.exp(1j * phases) + print("cal_table", cal_table) + +Hieronder staat de plot van de eigenwaardeverdeling. We willen zien dat er slechts een grote waarde is en de rest klein, wat overeenkomt met een enkel ontvangen signaal. Eventuele interferers of multipad verslechteren het calibratieproces. + +.. image:: ../_images/2d_array_eigenvalues.svg + :align: center + :target: ../_images/2d_array_eigenvalues.svg + :alt: Eigenwaardeverdeling van 2D-array + +De calibratietabel is een lijst met complexe getallen, een per element, die de fase- en amplitude-offsets representeren (rechthoekige notatie is hier praktischer dan polaire notatie). Het eerste element is het referentie-element en is altijd 1.0 + 0.j. De overige elementen zijn de offsets per element in dezelfde volgorde als in :code:`pos`. + +.. code-block:: python + + [1. +0.j 0.99526771+0.76149029j -0.91754588-0.66825262j + -0.96840297+0.37251012j 0.87866849+0.40446665j 0.56040169+1.50499875j + -0.80109196-1.29299264j -1.28464742-0.31133052j 1.26622038+0.46047599j + 2.01855809+9.77121302j -0.29249322-1.09413205j -1.0372309 -0.17983522j + -0.70614339+0.78682873j -0.75612972+5.67234809j 1.00032754-0.60824109j] + + +We kunnen deze offsets op elke sample-set van de array toepassen door elk samplekanaal te vermenigvuldigen met het corresponderende element uit de calibratietabel: + +.. code-block:: python + + # Apply cal offsets to r + for i in range(Nr): + r[i, :] *= cal_table[i] + +Terzijde: daarom berekenden we de offsets met :code:`mags[0] / mags` en :code:`phases[0] - phases`. Met de omgekeerde volgorde zouden we bij toepassing moeten delen in plaats van vermenigvuldigen, en vermenigvuldigen is hier handiger. + +Vervolgens voeren we DOA-schatting uit met het MUSIC-algoritme. We gebruiken de functies :code:`steering_vector()` en :code:`get_unit_vector()` die we eerder definieerden om voor elk array-element de stuurvector te berekenen, en gebruiken daarna MUSIC om de DOA van de twee zenders in de :code:`r`-samples te schatten. Het MUSIC-algoritme is in het vorige hoofdstuk behandeld. + +.. code-block:: python + + # DOA using MUSIC + resolution = 400 # number of points in each direction + theta_scan = np.linspace(-np.pi/2, np.pi/2, resolution) # azimuth angles + phi_scan = np.linspace(-np.pi/4, np.pi/4, resolution) # elevation angles + results = np.zeros((resolution, resolution)) # 2D array to store results + R = np.cov(r) # Covariance matrix, 15 x 15 + Rinv = np.linalg.pinv(R) + expected_num_signals = 4 + w, v = np.linalg.eig(R) # eigenvalue decomposition, v[:,i] is the eigenvector corresponding to the eigenvalue w[i] + eig_val_order = np.argsort(np.abs(w)) + v = v[:, eig_val_order] # sort eigenvectors using this order + V = np.zeros((Nr, Nr - expected_num_signals), dtype=np.complex64) # Noise subspace is the rest of the eigenvalues + for i in range(Nr - expected_num_signals): + V[:, i] = v[:, i] + for i, theta_i in enumerate(theta_scan): + for j, phi_i in enumerate(phi_scan): + dir_i = get_unit_vector(-1*theta_i, phi_i) # TODO figure out why -1* was needed to match reality + s = steering_vector(pos, dir_i) # 15 x 1 + music_metric = 1 / (s.conj().T @ V @ V.conj().T @ s) + music_metric = np.abs(music_metric).squeeze() + music_metric = np.clip(music_metric, 0, 2) # Useful for ABCD one + results[i, j] = music_metric + +Onze resultaten zijn 2D, omdat de array 2D is, dus we moeten een 3D-plot of een 2D-heatmap gebruiken. We doen beide. Eerst een 3D-plot met elevatie op de ene as en azimuth op de andere: + +.. code-block:: python + + # 3D az-el DOA results + results = 10*np.log10(results) # convert to dB + results[results < -20] = -20 # crop the z axis to some level of dB + fig, ax = plt.subplots(subplot_kw={"projection": "3d", "computed_zorder": False}) + surf = ax.plot_surface(np.rad2deg(theta_scan[:,None]), # type: ignore + np.rad2deg(phi_scan[None,:]), + results, + cmap='viridis') + #ax.set_zlim(-10, results[max_idx]) + ax.set_xlabel('Azimuth (theta)') + ax.set_ylabel('Elevation (phi)') + ax.set_zlabel('Power [dB]') # type: ignore + fig.savefig('../_images/2d_array_3d_doa_plot.svg', bbox_inches='tight') + plt.show() + +.. image:: ../_images/2d_array_3d_doa_plot.png + :align: center + :scale: 30% + :target: ../_images/2d_array_3d_doa_plot.png + :alt: 3D-DOA-plot + +Afhankelijk van de situatie kan het lastig zijn om waarden uit een 3D-plot af te lezen, dus we kunnen ook een 2D-heatmap met :code:`imshow()` maken: + +.. code-block:: python + + # 2D, az-el heatmap (same as above, but 2D) + extent=(np.min(theta_scan)*180/np.pi, + np.max(theta_scan)*180/np.pi, + np.min(phi_scan)*180/np.pi, + np.max(phi_scan)*180/np.pi) + plt.imshow(results.T, extent=extent, origin='lower', aspect='auto', cmap='viridis') # type: ignore + plt.colorbar(label='Power [linear]') + plt.xlabel('Theta (azimuth, degrees)') + plt.ylabel('Phi (elevation, degrees)') + plt.savefig('../_images/2d_array_2d_doa_plot.svg', bbox_inches='tight') + plt.show() + +.. image:: ../_images/2d_array_2d_doa_plot.svg + :align: center + :target: ../_images/2d_array_2d_doa_plot.svg + :alt: 2D-DOA-plot + +Met deze 2D-plot kunnen we de geschatte azimuth en elevatie van de twee zenders eenvoudig aflezen (en zien dat het er inderdaad twee zijn). Op basis van de testopstelling die voor deze opname is gebruikt, komen deze resultaten overeen met de werkelijkheid. De *exacte* azimuth en elevatie zijn nooit gemeten, omdat daarvoor zeer specialistische apparatuur nodig is. + +Als oefening kun je zowel de conventionele bundelvormer als MVDR proberen en de resultaten vergelijken met MUSIC. + +De volledige code van dit onderdeel staat `hier `_. + +************************ +Interactieve Ontwerptool +************************ + +De onderstaande interactieve tool is gemaakt door `Jason Durbin `_, een freelance phased-array-engineer, die toestemming gaf om de tool in PySDR op te nemen. Bekijk gerust het `volledige project `_ of zijn `adviesbureau `_. Met deze tool kun je de geometrie van een phased array aanpassen, elementafstanden wijzigen, de stuurpositie veranderen, sidelobe-tapering toevoegen en meer. + +Enkele details over deze tool: antenne-elementen worden als isotroop aangenomen. De directiviteitsberekening gaat echter uit van straling over een halve hemisfeer (dus zonder achterlobben). Daardoor is de berekende directiviteit 3 dBi hoger dan bij volledig isotroop (de individuele elementgain is dus +3,0 dBi). De mesh kan fijner worden gemaakt door theta/phi-, u/v- of azimuth/elevatiepunten te verhogen. Door in de fase-/attenuatieplots op elementen te klikken (of lang te drukken) kun je fase/attenuatie handmatig instellen (let op: kies dan "enable override"). In de attenuatie-popup kun je elementen ook uitschakelen. Door met de muis over de 2D far-field- of geometrieplots te bewegen (of aan te raken) zie je de plotwaarde onder de cursor. + +.. raw:: html + + + +
+
+
+

Geometry

+
+
+

Steering

+ +
+ + +
+
+ + +
+
+
+

Taper(s)

+
+ + +
+
+
+
+
+

Quantization

+
+ + +
+
+ + +
+
+ + +
+
+ 0 bits would be no quantization. +
+
+
+
+
+ +
Loading...
+
+
+
+
+

Element
Phase

 
+
+ +
+ +
+
+

Element Attenuation

 
+
+ +
+ +
+
+

2-D Radiation Pattern

 
+
+ +
+ +
+
+
+
+

1-D Pattern Cuts

+
+ +
+ +
+
+
+
+

Taper

+
+ +
+ +
+
diff --git a/content-nl/about_author.rst b/content-nl/about_author.rst index 7e1ef167..12bec106 100644 --- a/content-nl/about_author.rst +++ b/content-nl/about_author.rst @@ -4,11 +4,11 @@ Over de auteur ################## -Dr. Marc Lichtman is een onderzoeker in draadloze communicatie die is gespecialiseerd in SDR, machine learning, LTE/5G-NR en spectrum sensing. Hij is een Adjunct-Professor op de Universiteit van Maryland. Hier heeft hij een cursus gemaakt en onderwezen wat als basis heeft gediend voor dit boek. Zijn cursus was een keuzevak als basis voor studenten die zich willen gaan specialiseren in SDR/DSP. Het heeft hem geholpen om de immens zware stof toegankelijk en activerend te maken voor studenten die konden programmeren, maar weinig-tot-niets wisten over de fysieke (PHY) laag. Het was niet ongewoon om een klas te starten met een mini-hackathon waar studenten een (door Marc verzonden) verborgen signaal moesten vinden of decoderen op basis van wat ze zojuist hadden geleerd. +Dr. Marc Lichtman is een onderzoeker in draadloze communicatie die is gespecialiseerd in SDR, machine learning, LTE/5G-NR en spectrum sensing. Hij is een Adjunct-Professor op de Universiteit van Maryland. Hier heeft hij een cursus gemaakt en onderwezen wat als basis heeft gediend voor dit boek. Zijn cursus was een keuzevak als basis voor studenten die zich willen gaan specialiseren in SDR/DSP. Het heeft hem geholpen om de immens zware stof toegankelijk en activerend te maken voor studenten die konden programmeren, maar weinig-tot-niets wisten over draadloze communicatie. Het was niet ongewoon om een klas te starten met een mini-hackathon waar studenten een (door Marc verzonden) verborgen signaal moesten vinden of decoderen op basis van wat ze zojuist hadden geleerd. -Marc is ook een van de hoofdpersonen van het `GNU Radio project `_, een open source SDR framework wat veel gebruikt wordt in de Academische wereld en defensie-gerelateerd onderzoek. Terwijl Python geweldig is om te leren, snel dingen uit te proberen en te ontwikkelen, leent het zich niet goed voor grote en complexe applicaties. GNU Radio kan gebruikt worden om complexere DSP-applicaties te implementeren. Daarnaast is een GNU Radio applicatie of een enkel blok erg gemakkelijk te delen met anderen. +Marc is ook een van de hoofdpersonen van het `GNU Radio project `_, een open source SDR framework wat veel gebruikt wordt in de Academische wereld en defensie-gerelateerd onderzoek. GNU Radio kan gebruikt worden om complexere DSP-applicaties te implementeren. Daarnaast is een GNU Radio applicatie of een enkel blok erg gemakkelijk te delen met anderen. -Marc leeft momenteel in de Washington DC omgeving met zijn vrouw Lindsey en hun vele katten en honden. Zijn hobby’s zijn houtbewerking, lasersnijden, de klarinet/saxofoon spelen, zeilen, tuinieren, drones bouwen/vliegen, elektrische skateboards bouwen/rijden en geavanceerd jojoën. +Marc leeft momenteel in de Washington DC omgeving met zijn vrouw Lindsey en hun vele katten en honden. Zijn hobby’s zijn houtbewerking, lasersnijden, de klarinet/saxofoon, zeilen, tuinieren en pinbal spelen. Email: marc@pysdr.org diff --git a/content-nl/cyclostationary.rst b/content-nl/cyclostationary.rst new file mode 100644 index 00000000..4b0befb8 --- /dev/null +++ b/content-nl/cyclostationary.rst @@ -0,0 +1,975 @@ +.. _freq-domain-chapter: + +################################### +Cyclostationaire Signaalverwerking +################################### + +.. raw:: html + + Mede-auteur: Sam Brown + +In dit hoofdstuk maken we cyclostationaire signaalverwerking (CSP) inzichtelijker. Dit is een relatief nichegebied binnen RF-signaalverwerking dat wordt gebruikt om signalen met cyclostationaire eigenschappen te analyseren of te detecteren (vaak bij zeer lage SNR), zoals de meeste moderne digitale modulatieschema's. We behandelen de Cyclic Autocorrelation Function (CAF), Spectral Correlation Function (SCF), Spectral Coherence Function (COH), de geconjugeerde varianten ervan, en hoe je ze toepast. Het hoofdstuk bevat meerdere volledige Python-implementaties met voorbeelden voor BPSK, QPSK, OFDM en combinaties van meerdere signalen. + +**************** +Introductie +**************** + +Cyclostationaire signaalverwerking (CSP) is een verzameling technieken die de cyclostationaire eigenschap van veel echte communicatiesignalen benut. Denk aan gemoduleerde signalen zoals AM/FM/TV-uitzendingen, cellulair verkeer, WiFi, radarsignalen en andere signalen waarvan statistische eigenschappen periodiek veranderen. Veel traditionele signaalverwerking gaat uit van stationariteit: gemiddelde, variantie en hogere orde momenten veranderen dan niet in de tijd. In de praktijk zijn veel RF-signalen echter cyclostationair: hun statistiek verandert *periodiek* in de tijd. CSP benut dit en kan worden gebruikt om signalen in ruis te detecteren, modulatie te herkennen en signalen te scheiden die zowel in tijd als in frequentie overlappen. + +Als je na dit hoofdstuk en wat experimenteren in Python dieper in CSP wilt duiken, bekijk dan William Gardner's leerboek uit 1994 `Cyclostationarity in Communications and Signal Processing `_, zijn boek uit 1987 `Statistical Spectral Analysis `_, of Chad Spooner's `verzameling blogposts `_. + +Een bron die je hier vindt en vrijwel nergens anders: aan het einde van het SCF-deel staat een interactieve JavaScript-app waarmee je in je browser met de SCF van een voorbeeldsignaal kunt spelen en direct ziet hoe de SCF verandert bij andere signaal- en SCF-parameters. Deze interactieve demo's zijn gratis voor iedereen en worden in belangrijke mate mogelijk gemaakt door de steun van PySDR's `Patreon `_-leden. + +***************************** +Herhaling van Autocorrelatie +***************************** + +Zelfs als je de autocorrelatiefunctie al kent, is een korte herhaling nuttig omdat dit de basis van CSP is. De autocorrelatiefunctie meet de overeenkomst (correlatie) tussen een signaal en een in de tijd verschoven versie van zichzelf. Intuitief geeft ze aan in welke mate een signaal repetitief gedrag vertoont. De autocorrelatie van :math:`x(t)` is: + +.. math:: + R_x(\tau) = E[x(t)x^*(t-\tau)] + +waar :math:`E` de verwachtingsoperator is, :math:`\tau` de tijdsvertraging, en :math:`*` het complex geconjugeerde teken. In discrete tijd met een eindig aantal samples (ons praktische geval) wordt dit: + +.. math:: + R_x(\tau) = \frac{1}{N} \sum_{n=-N/2}^{N/2} x\left[ n+\frac{\tau}{2} \right] x^*\left[ n-\frac{\tau}{2} \right] + +waar :math:`N` het aantal samples in het signaal is. + +Als een signaal op een bepaalde manier periodiek is, zoals de herhalende symboolvorm van een QPSK-signaal, dan zal de autocorrelatie over een bereik van tau ook periodiek zijn. Als een QPSK-signaal bijvoorbeeld 8 samples per symbool heeft, dan is bij tau als geheel veelvoud van 8 de overeenkomst veel sterker dan bij andere tau-waarden. Deze periodiciteit in de autocorrelatie is precies wat we met CSP-technieken willen detecteren. + +************************************************ +De Cyclic Autocorrelation Function (CAF) +************************************************ + +Zoals in de vorige sectie besproken, willen we bepalen wanneer periodiciteit in de autocorrelatie aanwezig is. Herinner de Fouriertransformatie: als we willen testen hoe sterk een frequentie :math:`f` in een willekeurig signaal :math:`x(t)` aanwezig is, gebruiken we: + +.. math:: + X(f) = \int x(t) e^{-j2\pi ft} dt + +Als we periodiciteit in de autocorrelatie willen vinden, berekenen we dus: + +.. math:: + R_x(\tau, \alpha) = \lim_{T\rightarrow\infty} \frac{1}{T} \int_{-T/2}^{T/2} x(t + \tau/2)x^*(t - \tau/2)e^{-j2\pi \alpha t}dt. + +of in discrete tijd: + +.. math:: + R_x(\tau, \alpha) = \frac{1}{N} \sum_{n=-N/2}^{N/2} x\left[ n+\frac{\tau}{2} \right] x^*\left[ n-\frac{\tau}{2} \right] e^{-j2\pi \alpha n} + +waarmee we testen hoe sterk frequentie :math:`\alpha` aanwezig is. Deze vergelijking noemen we de Cyclic Autocorrelation Function (CAF). Je kunt de CAF ook zien als een set Fourier-reekscoefficienten die de periodiciteit beschrijven. Met andere woorden: de CAF bevat amplitude en fase van harmonischen in de autocorrelatie van een signaal. We noemen signalen "cyclostationair" wanneer ze een periodieke of bijna periodieke autocorrelatie hebben. De CAF is daarmee een uitbreiding van de klassieke autocorrelatie voor cyclostationaire signalen. + +De CAF is een functie van twee variabelen: vertraging :math:`\tau` (tau) en cyclische frequentie :math:`\alpha`. Cyclische frequenties in CSP representeren de snelheid waarmee signaalstatistiek verandert, in het geval van de CAF vooral het tweede-ordemoment/variantiegedrag. Daarom corresponderen cyclische frequenties vaak met duidelijke periodiciteit zoals gemoduleerde symbolen in communicatiesignalen. We gaan zien hoe de symboolsnelheid van een BPSK-signaal en de gehele veelvouden daarvan (harmonischen) zichtbaar worden als cyclische frequenties in de CAF. + +In Python kan de CAF van basisbandsignaal :code:`samples` voor gegeven :code:`alpha` en :code:`tau` zo worden berekend (de omliggende code vullen we zo aan): + +.. code-block:: python + + CAF = (np.exp(1j * np.pi * alpha * tau) * + np.sum(samples * np.conj(np.roll(samples, tau)) * + np.exp(-2j * np.pi * alpha * np.arange(N)))) + +We gebruiken :code:`np.roll()` om een van de sample-sets met tau te verschuiven, omdat verschuiving in gehele aantallen samples moet gebeuren. Als we beide sets tegengesteld zouden verschuiven, slaan we om-en-om verschuivingen over. Daarnaast voegen we een frequentieverschuiving toe, omdat we telkens 1 sample verschuiven en slechts aan een kant (in plaats van een halve sample aan beide kanten zoals in de basis-CAF). De frequentie van die correctie is :code:`alpha/2`. + +Om met de CAF in Python te spelen, simuleren we eerst een voorbeeldsignaal. We gebruiken een rechthoekig BPSK-signaal (dus zonder pulse shaping) met 20 samples per symbool, plus witte Gaussische ruis (AWGN). We voegen een frequentie-offset toe aan het BPSK-signaal, zodat we later laten zien hoe cyclostationaire verwerking zowel frequentie-offset als cyclische frequentie kan schatten. Deze offset is vergelijkbaar met een radio die een signaal ontvangt zonder precies op de middenfrequentie afgestemd te zijn. + +De volgende code simuleert de IQ-samples die we in de volgende twee secties gebruiken: + +.. code-block:: python + + N = 100000 # number of samples to simulate + f_offset = 0.2 # Hz normalized + sps = 20 # cyclic freq (alpha) will be 1/sps or 0.05 Hz normalized + + symbols = np.random.randint(0, 2, int(np.ceil(N/sps))) * 2 - 1 # random 1's and -1's + bpsk = np.repeat(symbols, sps) # repeat each symbol sps times to make rectangular BPSK + bpsk = bpsk[:N] # clip off the extra samples + bpsk = bpsk * np.exp(2j * np.pi * f_offset * np.arange(N)) # Freq shift up the BPSK, this is also what makes it complex + noise = np.random.randn(N) + 1j*np.random.randn(N) # complex white Gaussian noise + samples = bpsk + 0.1*noise # add noise to the signal + +Omdat absolute sample rate en symboolsnelheid in dit hoofdstuk niet doorslaggevend zijn, gebruiken we genormaliseerde frequentie. Dat komt neer op sample rate = 1 Hz. Het signaal moet dan tussen -0.5 en +0.5 Hz liggen. Daarom zul je de variabele :code:`sample_rate` bewust niet in de code-snippets zien; we werken met samples per symbool (:code:`sps`). + +Ter illustratie kijken we eerst naar de power spectral density (FFT) van het signaal zelf, *voordat* CSP wordt toegepast: + +.. image:: ../_images/psd_of_bpsk_used_for_caf.svg + :align: center + :target: ../_images/psd_of_bpsk_used_for_caf.svg + :alt: PSD van BPSK gebruikt voor CAF + +Je ziet de toegepaste frequentieverschuiving van 0.2 Hz. Door 20 samples per symbool is het signaal relatief smal, maar zonder pulse shaping valt het in frequentie langzaam af. + +Nu berekenen we de CAF bij de juiste alpha en over een bereik aan tau-waarden (als start nemen we tau van -50 tot +50). De juiste alpha is hier simpelweg de inverse van samples per symbool: 1/20 = 0.05 Hz. In Python genereren we de CAF door over tau te itereren: + +.. code-block:: python + + # CAF only at the correct alpha + alpha_of_interest = 1/sps # equates to 0.05 Hz + taus = np.arange(-50, 51) + CAF = np.zeros(len(taus), dtype=complex) + for i in range(len(taus)): + CAF[i] = (np.exp(1j * np.pi * alpha_of_interest * taus[i]) * # This term is to make up for the fact we're shifting by 1 sample at a time, and only on one side + np.sum(samples * np.conj(np.roll(samples, taus[i])) * + np.exp(-2j * np.pi * alpha_of_interest * np.arange(N)))) + +Laten we het reele deel van :code:`CAF` plotten met :code:`plt.plot(taus, np.real(CAF))`: + +.. image:: ../_images/caf_at_correct_alpha.svg + :align: center + :target: ../_images/caf_at_correct_alpha.svg + :alt: CAF bij correcte alpha + +Dit ziet er misschien wat vreemd uit, maar onthoud dat tau het tijddomein representeert. Het belangrijkste is dat er veel energie in de CAF zit bij deze alpha, omdat deze alpha overeenkomt met een cyclische frequentie in ons signaal. Ter vergelijking bekijken we de CAF bij een onjuiste alpha, bijvoorbeeld 0.08 Hz: + +.. image:: ../_images/caf_at_incorrect_alpha.svg + :align: center + :target: ../_images/caf_at_incorrect_alpha.svg + :alt: CAF bij onjuiste alpha + +Let op de y-as: er zit nu veel minder energie in de CAF. De precieze patronen zijn op dit moment minder belangrijk en worden duidelijker na de SCF in de volgende sectie. + +Wat we ook kunnen doen is de CAF over een bereik van alpha's berekenen en per alpha het vermogen in de CAF bepalen via de magnitude en vervolgens som of gemiddelde (in dit geval maakt dat weinig uit). Als we deze vermogens over alpha plotten, verwachten we pieken op de cyclische frequenties in het signaal. De volgende code voegt een :code:`for`-loop toe en gebruikt een alpha-stap van 0.005 Hz (dit kan lang duren): + +.. code-block:: python + + alphas = np.arange(0, 0.5, 0.005) + CAF = np.zeros((len(alphas), len(taus)), dtype=complex) + for j in range(len(alphas)): + for i in range(len(taus)): + CAF[j, i] = (np.exp(1j * np.pi * alphas[j] * taus[i]) * + np.sum(samples * np.conj(np.roll(samples, taus[i])) * + np.exp(-2j * np.pi * alphas[j] * np.arange(N)))) + CAF_magnitudes = np.average(np.abs(CAF), axis=1) # at each alpha, calc power in the CAF + plt.plot(alphas, CAF_magnitudes) + plt.xlabel('Alpha') + plt.ylabel('CAF Power') + +.. image:: ../_images/caf_avg_over_alpha.svg + :align: center + :target: ../_images/caf_avg_over_alpha.svg + :alt: Gemiddelde CAF over alpha + +We zien niet alleen de verwachte piek op 0.05 Hz, maar ook pieken op gehele veelvouden daarvan. Dat komt doordat de CAF een Fourier-reeks is en harmonischen van de grondfrequentie zichtbaar zijn, zeker bij PSK/QAM zonder pulse shaping. De energie bij alpha = 0 is het totale vermogen in de PSD. Meestal nullen we die component uit omdat 1) we de PSD vaak al apart plotten en 2) deze anders het dynamisch bereik van de colormap verstoort bij 2D-plots. + +Hoewel de CAF interessant is, willen we vaak cyclische frequentie *als functie van RF-frequentie* bekijken, in plaats van alleen cyclische frequentie op zichzelf. Dat brengt ons bij de Spectral Correlation Function (SCF). + +************************************************ +De Spectral Correlation Function (SCF) +************************************************ + +Net zoals de CAF periodiciteit in de autocorrelatie laat zien, laat de SCF periodiciteit in de PSD zien. Autocorrelatie en PSD vormen een Fouriertransformatie-paar; daarom is het logisch dat CAF en SCF dat ook doen. Dit heet de *Cyclic Wiener Relationship*. Dit wordt nog duidelijker als je bedenkt dat CAF en SCF bij :math:`\alpha=0` respectievelijk de gewone autocorrelatie en PSD zijn. + +Je kunt de SCF verkrijgen door de Fouriertransformatie van de CAF te nemen. Voor ons BPSK-signaal met 20 samples per symbool bekijken we de SCF bij de juiste alpha (0.05 Hz). Dat vereist alleen de FFT van de CAF en een magnitudeplot. De volgende code sluit aan op de eerdere CAF-code met een enkele alpha: + +.. code-block:: python + + f = np.linspace(-0.5, 0.5, len(taus)) + SCF = np.fft.fftshift(np.fft.fft(CAF)) + plt.plot(f, np.abs(SCF)) + plt.xlabel('Frequency') + plt.ylabel('SCF') + +.. image:: ../_images/fft_of_caf.svg + :align: center + :target: ../_images/fft_of_caf.svg + :alt: FFT van CAF + +Let op dat we de toegepaste 0.2 Hz frequentie-offset terugzien van de BPSK-simulatie (dit staat los van cyclische frequentie en samples per symbool). Daarom zag de CAF er sinusvormig uit in het tau-domein: dat werd vooral bepaald door de relatief hoge RF-frequentie in dit voorbeeld. + +Helaas is dit voor duizenden of miljoenen alpha's extreem rekenintensief. Een tweede nadeel van direct FFT op de CAF is dat er geen averaging plaatsvindt. Efficiënte/praktische SCF-berekening gebruikt meestal een vorm van averaging, op tijd- of frequentiebasis, zoals in de volgende twee secties. + +Hieronder staat een interactieve JavaScript-app met SCF-implementatie, zodat je met verschillende signaal- en SCF-parameters intuïtie kunt opbouwen. De signaalfrequentie is een vrij directe regelaar en laat zien hoe goed de SCF RF-frequentie identificeert. Probeer pulse shaping door de optie Rectangular Pulse uit te zetten en varieer de roll-off. Let op: met de standaard alpha-stap geeft niet elke samples-per-symbool-waarde een zichtbare SCF-piek. Een kleinere alpha-stap helpt vaak, maar kost meer rekentijd. + +.. raw:: html + +
+ + +
+ + + 0.2 +
+ + + 20 +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+ +
+ + + + + +******************************** +Frequentie-smoothingmethode (FSM) +******************************** + +Nu we conceptueel begrijpen wat de SCF doet, kijken we naar een efficiente berekeningsmethode. We starten met het periodogram, de gekwadrateerde magnitude van de Fouriertransformatie van een signaal: + +.. math:: + + I(u,f) = \frac{1}{N}\left|X(u,f)\right|^2 + +Het cyclische periodogram krijgen we uit het product van twee in frequentie verschoven Fouriertransformaties: + +.. math:: + + I(u,f,\alpha) = \frac{1}{N}X(u,f + \alpha/2) X^*(u,f - \alpha/2) + +Beide zijn schattingen van PSD en SCF, maar voor een betrouwbare SCF moet je middelen over tijd of frequentie. Middelen over tijd heet de Time Smoothing Method (TSM): + +.. math:: + S_X(f, \alpha) = \lim_{T\rightarrow\infty} \frac{1}{T} \lim_{U\rightarrow\infty} \frac{1}{U} \int_{-U/2}^{U/2} X(t,f + \alpha/2) X^*(t,f - \alpha/2) dt + +middelen over frequentie heet de Frequency Smoothing Method (FSM): + +.. math:: + S_X(f, \alpha) = \lim_{\Delta\rightarrow 0} \lim_{T\rightarrow \infty} \frac{1}{T} g_{\Delta}(f) \otimes \left[X(t,f + \alpha/2) X^*(t,f - \alpha/2)\right] + +waar de functie :math:`g_{\Delta}(f)` een frequentiesmoothingfunctie is die over een klein frequentiebereik middelt. + +Hieronder staat een minimale Python-implementatie van FSM, een op frequentiemiddeling gebaseerde methode om de SCF van een signaal te berekenen. Eerst wordt het cyclische periodogram berekend door twee verschoven FFT-versies te vermenigvuldigen, daarna wordt elke slice gefilterd met een vensterfunctie waarvan de lengte de resolutie van de SCF-schatting bepaalt. Langere vensters geven gladdere resultaten met lagere resolutie; kortere vensters doen het omgekeerde. + +.. code-block:: python + + alphas = np.arange(0, 0.3, 0.001) + Nw = 256 # window length + N = len(samples) # signal length + window = np.hanning(Nw) + + X = np.fft.fftshift(np.fft.fft(samples)) # FFT of entire signal + + num_freqs = int(np.ceil(N/Nw)) # freq resolution after decimation + SCF = np.zeros((len(alphas), num_freqs), dtype=complex) + for i in range(len(alphas)): + shift = int(alphas[i] * N/2) + SCF_slice = np.roll(X, -shift) * np.conj(np.roll(X, shift)) + SCF[i, :] = np.convolve(SCF_slice, window, mode='same')[::Nw] # apply window and decimate by Nw + SCF = np.abs(SCF) + SCF[0, :] = 0 # null out alpha=0 which is just the PSD of the signal, it throws off the dynamic range + + extent = (-0.5, 0.5, float(np.max(alphas)), float(np.min(alphas))) + plt.imshow(SCF, aspect='auto', extent=extent, vmax=np.max(SCF)/2) + plt.xlabel('Frequency [Normalized Hz]') + plt.ylabel('Cyclic Frequency [Normalized Hz]') + plt.show() + +Let op dat door de manier waarop de verschuiving als geheel aantal samples wordt berekend en afgerond, het helpt om minstens :code:`2 / alpha_resolution` samples tegelijk te verwerken. + +Laten we de SCF berekenen voor het rechthoekige BPSK-signaal van eerder, met 20 samples per symbool en cyclische frequenties van 0 tot 0.3 met stapgrootte 0.001: + +.. image:: ../_images/scf_freq_smoothing.svg + :align: center + :target: ../_images/scf_freq_smoothing.svg + :alt: SCF met de Frequency Smoothing Method (FSM), cyclostationaire verwerking + +Deze methode heeft als voordeel dat maar een grote FFT nodig is, maar als nadeel dat voor smoothing veel convoluties nodig zijn. Let op de decimatie na de convolve via :code:`[::Nw]`; dit is optioneel maar sterk aanbevolen om het aantal weer te geven pixels te beperken, en door de opbouw van de SCF gooi je hiermee in de praktijk geen bruikbare informatie weg. + +*************************** +Tijd-smoothingmethode (TSM) +*************************** + +Nu bekijken we een TSM-implementatie in Python. De code hieronder splitst het signaal in *num_windows* blokken, elk van lengte *Nw* met overlap *Noverlap*. Overlap is niet verplicht, maar geeft vaak een netter resultaat. Daarna wordt per blok een vensterfunctie toegepast (hier Hanning, maar andere vensters kunnen ook) en een FFT genomen. De SCF ontstaat vervolgens door over blokken te middelen. De vensterlengte bepaalt, net als bij FSM, de afweging tussen resolutie en gladheid. + + +.. code-block:: python + + alphas = np.arange(0, 0.3, 0.001) + Nw = 256 # window length + N = len(samples) # signal length + Noverlap = int(2/3*Nw) # block overlap + num_windows = int((N - Noverlap) / (Nw - Noverlap)) # Number of windows + window = np.hanning(Nw) + + SCF = np.zeros((len(alphas), Nw), dtype=complex) + for ii in range(len(alphas)): # Loop over cyclic frequencies + neg = samples * np.exp(-1j*np.pi*alphas[ii]*np.arange(N)) + pos = samples * np.exp( 1j*np.pi*alphas[ii]*np.arange(N)) + for i in range(num_windows): + pos_slice = window * pos[i*(Nw-Noverlap):i*(Nw-Noverlap)+Nw] + neg_slice = window * neg[i*(Nw-Noverlap):i*(Nw-Noverlap)+Nw] + SCF[ii, :] += np.fft.fft(neg_slice) * np.conj(np.fft.fft(pos_slice)) # Cross Cyclic Power Spectrum + SCF = np.fft.fftshift(SCF, axes=1) # shift the RF freq axis + SCF = np.abs(SCF) + SCF[0, :] = 0 # null out alpha=0 which is just the PSD of the signal, it throws off the dynamic range + + extent = (-0.5, 0.5, float(np.max(alphas)), float(np.min(alphas))) + plt.imshow(SCF, aspect='auto', extent=extent, vmax=np.max(SCF)/2) + plt.xlabel('Frequency [Normalized Hz]') + plt.ylabel('Cyclic Frequency [Normalized Hz]') + plt.show() + +.. image:: ../_images/scf_time_smoothing.svg + :align: center + :target: ../_images/scf_time_smoothing.svg + :alt: SCF met de Time Smoothing Method (TSM), cyclostationaire verwerking + +Ziet er grofweg hetzelfde uit als FSM. + +***************** +Pulse-shaped BPSK +***************** + +Tot nu toe onderzochten we CSP alleen voor een *rechthoekig* BPSK-signaal. In echte RF-systemen zien we echter bijna nooit rechthoekige pulsen; een uitzondering is de BPSK-chippingsequentie in direct-sequence spread spectrum (DSSS), die vaak ongeveer rechthoekig is. + +Laten we nu kijken naar een BPSK-signaal met raised-cosine (RC) pulse shaping, een veelgebruikte vorm in digitale communicatie om de bezette bandbreedte te verkleinen ten opzichte van rechthoekig BPSK. Zoals besproken in :ref:`pulse-shaping-chapter` is de RC-pulsvorm in het tijddomein: + +.. math:: + h(t) = \mathrm{sinc}\left( \frac{t}{T} \right) \frac{\cos\left(\frac{\pi\beta t}{T}\right)}{1 - \left( \frac{2 \beta t}{T} \right)^2} + +De parameter :math:`\beta` bepaalt hoe snel het filter in het tijddomein afvalt, wat omgekeerd samenhangt met het afvallen in frequentie: + +.. image:: ../_images/raised_cosine_freq.svg + :align: center + :target: ../_images/raised_cosine_freq.svg + :alt: Raised-cosinefilter in het frequentiedomein met verschillende roll-offwaarden + +Let op dat :math:`\beta=0` overeenkomt met een oneindig lange pulsvorm en dus niet praktisch is. Ook geldt dat :math:`\beta=1` *niet* overeenkomt met een rechthoekige pulsvorm. In de praktijk ligt roll-off vaak tussen 0.2 en 0.4. + +We kunnen een BPSK-signaal met raised-cosine pulse shaping simuleren met onderstaande code; de eerste 5 en laatste 4 regels zijn gelijk aan rechthoekig BPSK: + +.. code-block:: python + + N = 100000 # number of samples to simulate + f_offset = 0.2 # Hz normalized + sps = 20 # cyclic freq (alpha) will be 1/sps or 0.05 Hz normalized + num_symbols = int(np.ceil(N/sps)) + symbols = np.random.randint(0, 2, num_symbols) * 2 - 1 # random 1's and -1's + + pulse_train = np.zeros(num_symbols * sps) + pulse_train[::sps] = symbols # easier explained by looking at an example output + print(pulse_train[0:96].astype(int)) + + # Raised-Cosine Filter for Pulse Shaping + beta = 0.3 # roll-off parameter (avoid exactly 0.2, 0.25, 0.5, and 1.0) + num_taps = 101 # somewhat arbitrary + t = np.arange(num_taps) - (num_taps-1)//2 + h = np.sinc(t/sps) * np.cos(np.pi*beta*t/sps) / (1 - (2*beta*t/sps)**2) # RC equation + bpsk = np.convolve(pulse_train, h, 'same') # apply the pulse shaping + + bpsk = bpsk[:N] # clip off the extra samples + bpsk = bpsk * np.exp(2j * np.pi * f_offset * np.arange(N)) # Freq shift up the BPSK, this is also what makes it complex + noise = np.random.randn(N) + 1j*np.random.randn(N) # complex white Gaussian noise + samples = bpsk + 0.1*noise # add noise to the signal + +Let op dat :code:`pulse_train` simpelweg onze symbolen zijn met :code:`sps - 1` nullen na elk symbool, dus in volgorde bijvoorbeeld: + +.. code-block:: bash + + [ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0... + +De onderstaande plot toont het pulse-shaped BPSK-signaal in het tijddomein, nog zonder ruis en zonder frequentieverschuiving: + +.. image:: ../_images/pulse_shaped_BSPK.svg + :align: center + :target: ../_images/pulse_shaped_BSPK.svg + :alt: Pulse-shaped BPSK-signaal met raised-cosine pulsvorm + +Laten we nu de SCF van dit pulse-shaped BPSK-signaal berekenen met roll-off 0.3, 0.6 en 0.9. We gebruiken dezelfde frequentieverschuiving van 0.2 Hz, dezelfde FSM-implementatie, FSM-parameters en symboollengte als in het rechthoekige BPSK-voorbeeld voor een eerlijke vergelijking: + +:code:`beta = 0.3`: + +.. image:: ../_images/scf_freq_smoothing_pulse_shaped_bpsk.svg + :align: center + :target: ../_images/scf_freq_smoothing_pulse_shaped_bpsk.svg + :alt: SCF van pulse-shaped BPSK met de Frequency Smoothing Method (FSM), beta 0.3 + +:code:`beta = 0.6`: + +.. image:: ../_images/scf_freq_smoothing_pulse_shaped_bpsk2.svg + :align: center + :target: ../_images/scf_freq_smoothing_pulse_shaped_bpsk2.svg + :alt: SCF van pulse-shaped BPSK met de Frequency Smoothing Method (FSM), beta 0.6 + +:code:`beta = 0.9`: + +.. image:: ../_images/scf_freq_smoothing_pulse_shaped_bpsk3.svg + :align: center + :target: ../_images/scf_freq_smoothing_pulse_shaped_bpsk3.svg + :alt: SCF van pulse-shaped BPSK met de Frequency Smoothing Method (FSM), beta 0.9 + +In alle drie gevallen verdwijnen de sterke sidelobes op de frequentie-as, en op de cyclische frequentie-as zien we niet dezelfde krachtige harmonischen van de grondfrequentie. Dat komt doordat raised-cosine pulse shaping de energie spectraal veel beter begrenst dan rechthoekige pulsenvormen. Hierdoor hebben pulse-shaped signalen doorgaans een "schonere" SCF dan rechthoekige signalen, vaak als een dominante piek met smeer erboven. Dit geldt voor alle enkelvoudige draaggolfsignalen met digitale modulatie, niet alleen BPSK. Bij grotere beta wordt de piek op de frequentie-as breder doordat het signaal meer bandbreedte gebruikt. + +******************************** +SNR en Aantal Symbolen +******************************** + +Komt binnenkort. We behandelen dan waarom boven een bepaald punt hogere SNR niet meer helpt, maar meer symbolen wel, en hoe packet-gebaseerde golfvormen leiden tot een beperkt aantal symbolen per transmissie. + +******************************** +QPSK en Hogere-orde Modulatie +******************************** + +Komt binnenkort. Dit deel zal QPSK, hogere orde PSK, QAM en een korte introductie tot hogere-orde cyclische momenten en cumulanten bevatten. + +******************************** +Meerdere Overlappende Signalen +******************************** + +Tot nu toe keken we naar een signaal tegelijk. Maar wat als het ontvangen signaal meerdere individuele signalen bevat die in frequentie, tijd en zelfs cyclische frequentie overlappen (dus hetzelfde aantal samples per symbool hebben)? Als signalen niet in frequentie overlappen, kun je ze met simpele filtering scheiden en met een PSD detecteren, mits boven de ruisvloer. Overlappen ze niet in de tijd, dan kun je stijg- en daalflanken per transmissie detecteren en met time-gating per signaal verwerken. In CSP richten we ons vaak op detectie van signalen met verschillende cyclische frequenties die in zowel tijd als frequentie overlappen. + +Laten we drie signalen simuleren met verschillende eigenschappen: + +* Signaal 1: rechthoekig BPSK met 20 samples per symbool en 0.2 Hz frequentie-offset +* Signaal 2: pulse-shaped BPSK met 20 samples per symbool, -0.1 Hz frequentie-offset en 0.35 roll-off +* Signaal 3: pulse-shaped QPSK met 4 samples per symbool, 0.2 Hz frequentie-offset en 0.21 roll-off + +Zoals je ziet hebben twee signalen dezelfde cyclische frequentie en twee dezelfde RF-frequentie. Daarmee kunnen we met verschillende graden van parameteroverlap experimenteren. + +Op elk signaal passen we een fractional-delay-filter toe met een willekeurige (niet-gehele) vertraging, zodat geen artefacten ontstaan doordat gesimuleerde samples exact uitgelijnd zijn (meer hierover in :ref:`sync-chapter`). Het rechthoekige BPSK-signaal verlagen we in vermogen ten opzichte van de andere twee, omdat rechthoekige pulsen zeer sterke cyclostationaire eigenschappen hebben en anders de SCF domineren. + +.. raw:: html + +
+ Klap open voor Python-code die de drie signalen simuleert + +.. code-block:: python + + N = 1000000 # number of samples to simulate + + def fractional_delay(x, delay): + N = 21 # number of taps + n = np.arange(-N//2, N//2) # ...-3,-2,-1,0,1,2,3... + h = np.sinc(n - delay) # calc filter taps + h *= np.hamming(N) # window the filter to make sure it decays to 0 on both sides + h /= np.sum(h) # normalize to get unity gain, we don't want to change the amplitude/power + return np.convolve(x, h, 'same') # apply filter + + # Signal 1, Rect BPSK + sps = 20 + f_offset = 0.2 + signal1 = np.repeat(np.random.randint(0, 2, int(np.ceil(N/sps))) * 2 - 1, sps) + signal1 = signal1[:N] * np.exp(2j * np.pi * f_offset * np.arange(N)) + signal1 = fractional_delay(signal1, 0.12345) + + # Signal 2, Pulse-shaped BPSK + sps = 20 + f_offset = -0.1 + beta = 0.35 + symbols = np.random.randint(0, 2, int(np.ceil(N/sps))) * 2 - 1 + pulse_train = np.zeros(int(np.ceil(N/sps)) * sps) + pulse_train[::sps] = symbols + t = np.arange(101) - (101-1)//2 + h = np.sinc(t/sps) * np.cos(np.pi*beta*t/sps) / (1 - (2*beta*t/sps)**2) + signal2 = np.convolve(pulse_train, h, 'same') + signal2 = signal2[:N] * np.exp(2j * np.pi * f_offset * np.arange(N)) + signal2 = fractional_delay(signal2, 0.52634) + + # Signal 3, Pulse-shaped QPSK + sps = 4 + f_offset = 0.2 + beta = 0.21 + data = x_int = np.random.randint(0, 4, int(np.ceil(N/sps))) # 0 to 3 + data_degrees = data*360/4.0 + 45 # 45, 135, 225, 315 degrees + symbols = np.cos(data_degrees*np.pi/180.0) + 1j*np.sin(data_degrees*np.pi/180.0) + pulse_train = np.zeros(int(np.ceil(N/sps)) * sps, dtype=complex) + pulse_train[::sps] = symbols + t = np.arange(101) - (101-1)//2 + h = np.sinc(t/sps) * np.cos(np.pi*beta*t/sps) / (1 - (2*beta*t/sps)**2) + signal3 = np.convolve(pulse_train, h, 'same') + signal3 = signal3[:N] * np.exp(2j * np.pi * f_offset * np.arange(N)) + signal3 = fractional_delay(signal3, 0.3526) + + # Add noise + noise = np.random.randn(N) + 1j*np.random.randn(N) + samples = 0.5*signal1 + signal2 + 1.5*signal3 + 0.1*noise + +.. raw:: html + +
+ +Voordat we in de CSP-resultaten duiken, bekijken we eerst de PSD van dit signaal: + +.. image:: ../_images/psd_of_multiple_signals.svg + :align: center + :target: ../_images/psd_of_multiple_signals.svg + :alt: PSD van drie verschillende signalen + +Signalen 1 en 3, die aan de positieve kant van de PSD liggen, overlappen, en je ziet signaal 1 (smaller) maar net uitsteken. Je krijgt hier ook een indruk van het ruisniveau. + +We gebruiken nu FSM om de SCF van deze gecombineerde signalen te berekenen: + +.. image:: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg + :align: center + :target: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg + :alt: SCF van drie verschillende signalen met de Frequency Smoothing Method (FSM) + +Let op dat signaal 1, ondanks de rechthoekige pulsvorm, zijn harmonischen grotendeels verborgen ziet onder de "kegel" boven signaal 3. In de PSD zagen we al dat signaal 1 als het ware achter signaal 3 zat. Met CSP kunnen we toch detecteren dat signaal 1 aanwezig is en de cyclische frequentie redelijk nauwkeurig benaderen, wat vervolgens voor synchronisatie bruikbaar is. Dat is precies de kracht van cyclostationaire signaalverwerking. + +************************ +Alternatieve CSP-kenmerken +************************ + +SCF is niet de enige manier om cyclostationariteit te detecteren, zeker niet als je cyclische frequentie niet per se over RF-frequentie hoeft te bekijken. Een eenvoudige methode (zowel conceptueel als qua rekentijd) is de **FFT van de magnitude** van het signaal te nemen en op pieken te zoeken. In Python is dat erg simpel: + +.. code-block:: python + + samples_mag = np.abs(samples) + #samples_mag = samples * np.conj(samples) # pretty much the same as line above + magnitude_metric = np.abs(np.fft.fft(samples_mag)) + +Let op dat deze methode in essentie gelijk is aan het signaal vermenigvuldigen met zijn complex geconjugeerde en daarna een FFT nemen. + +Voordat we de metric plotten, nullen we de DC-component uit omdat die veel energie bevat en het dynamisch bereik verstoort. We halen ook de helft van de FFT-output weg, omdat de FFT-invoer reeel is en de output dus symmetrisch is. Daarna kunnen we de metric plotten en pieken zien: + +.. code-block:: python + + magnitude_metric = magnitude_metric[:len(magnitude_metric)//2] # only need half because input is real + magnitude_metric[0] = 0 # null out the DC component + f = np.linspace(-0.5, 0.5, len(samples)) + plt.plot(f, magnitude_metric) + +Daarna kun je een piekzoekalgoritme gebruiken, zoals SciPy's :code:`signal.find_peaks()`. Hieronder plotten we :code:`magnitude_metric` voor elk van de drie signalen uit de sectie met overlappende signalen, eerst apart en daarna gecombineerd: + +.. image:: ../_images/non_csp_metric.svg + :align: center + :target: ../_images/non_csp_metric.svg + :alt: Metric voor detectie van cyclostationariteit zonder CAF of SCF + +De harmonischen van rechthoekig BPSK overlappen helaas met cyclische frequenties van de andere signalen. Dit toont meteen een nadeel van deze alternatieve aanpak: je kunt cyclische frequentie niet over RF-frequentie bekijken zoals bij SCF. + +Hoewel deze methode cyclostationariteit benut, wordt ze meestal niet als volwaardige "CSP-techniek" gezien, mogelijk door haar eenvoud. + +Voor het vinden van de RF-frequentie (carrier frequency offset) bestaat een vergelijkbare truc. Voor BPSK neem je de FFT van het signaal in het kwadraat (complexe FFT-invoer); je krijgt dan een piek op tweemaal de carrier-offset. Voor QPSK neem je de FFT van het signaal tot de vierde macht; dan krijg je een piek op viermaal de carrier-offset. + +.. code-block:: python + + samples_squared = samples**2 + squared_metric = np.abs(np.fft.fftshift(np.fft.fft(samples_squared)))/len(samples) + squared_metric[len(squared_metric)//2] = 0 # null out the DC component + + samples_quartic = samples**4 + quartic_metric = np.abs(np.fft.fftshift(np.fft.fft(samples_quartic)))/len(samples) + quartic_metric[len(quartic_metric)//2] = 0 # null out the DC component + +Probeer deze methode gerust op eigen gesimuleerde of opgenomen signalen; ook buiten CSP is dit erg bruikbaar. + +********************************* +Spectral Coherence Function (COH) +********************************* + +*TLDR: de spectral coherence function is een genormaliseerde versie van de SCF die in sommige situaties beter werkt dan de gewone SCF.* + +Een andere maat voor cyclostationariteit, die vaak informatiever is dan ruwe SCF, is de Spectral Coherence Function (COH). COH normaliseert de SCF zodat de uitkomst tussen -1 en 1 ligt (voor magnitude bekijken we 0 tot 1). Dit is nuttig omdat informatie over cyclostationariteit wordt gescheiden van informatie over het vermogensspectrum, die in ruwe SCF door elkaar zitten. Door normalisatie blijft vooral het effect van cyclische correlatie over. + +Om COH beter te begrijpen helpt het om het statistische concept van de `correlatiecoefficient `_ te herhalen. De correlatiecoefficient :math:`\rho_{X,Y}` kwantificeert hoe sterk twee toevalsvariabelen :math:`X` en :math:`Y` samenhangen op schaal -1 tot 1. Definitie: + +.. math:: + \rho_{X,Y} = \frac{E[(X-\mu_X)(Y-\mu_Y)]}{\sigma_X \sigma_Y} + +COH breidt dit concept uit naar spectrale correlatie: het meet hoe sterk de PSD van een signaal op de ene frequentie samenhangt met de PSD van datzelfde signaal op een andere frequentie. Deze twee frequenties zijn de verschuivingen die we bij SCF-berekening toepassen. Voor COH berekenen we eerst de SCF zoals eerder, :math:`S_X(f,\alpha)`, en normaliseren vervolgens met het product van twee verschoven PSD-termen, analoog aan normaliseren met standaarddeviaties: + +.. math:: + \rho = C_x(f, \alpha) = \frac{S_X(f,\alpha)}{\sqrt{C_x^0(f + \alpha/2) C_x^0(f - \alpha/2)}} + +De noemer is het belangrijkste nieuwe onderdeel: de termen :math:`C_x^0(f + \alpha/2)` en :math:`C_x^0(f - \alpha/2)` zijn simpelweg de PSD verschoven met :math:`\alpha/2` en :math:`-\alpha/2`. Anders gezegd: de SCF is een cross-spectral density (vermogensspectrum met twee ingangen), terwijl de normalisatietermen autospectrale dichtheden zijn (een ingang). + +We passen dit nu toe in Python, specifiek op SCF met FSM. Omdat FSM in het frequentiedomein middelt, hebben we :math:`C_x^0(f + \alpha/2)` en :math:`C_x^0(f - \alpha/2)` al beschikbaar; in code zijn dat :code:`np.roll(X, -shift)` en :code:`np.roll(X, shift)`, omdat :code:`X` het signaal na FFT is. We vermenigvuldigen die, nemen de wortel en delen de SCF-slice door dat resultaat (binnen de for-loop over alpha): + +.. code-block:: python + + COH_slice = SCF_slice / np.sqrt(np.roll(X, -shift) * np.roll(X, shift)) + +Tot slot herhalen we dezelfde convolve- en decimatiestap als bij de uiteindelijke SCF-slice. + +.. code-block:: python + + COH[i, :] = np.convolve(COH_slice, window, mode='same')[::Nw] + +.. raw:: html + +
+ Klap open voor volledige code om zowel SCF als COH te genereren en te plotten + +.. code-block:: python + + alphas = np.arange(0, 0.3, 0.001) + Nw = 256 # window length + N = len(samples) # signal length + window = np.hanning(Nw) + + X = np.fft.fftshift(np.fft.fft(samples)) # FFT of entire signal + + num_freqs = int(np.ceil(N/Nw)) # freq resolution after decimation + SCF = np.zeros((len(alphas), num_freqs), dtype=complex) + COH = np.zeros((len(alphas), num_freqs), dtype=complex) + for i in range(len(alphas)): + shift = int(alphas[i] * N/2) + SCF_slice = np.roll(X, -shift) * np.conj(np.roll(X, shift)) + SCF[i, :] = np.convolve(SCF_slice, window, mode='same')[::Nw] # apply window and decimate by Nw + COH_slice = SCF_slice / np.sqrt(np.roll(X, -shift) * np.roll(X, shift)) + COH[i, :] = np.convolve(COH_slice, window, mode='same')[::Nw] # apply the same windowing + decimation + SCF = np.abs(SCF) + COH = np.abs(COH) + + # null out alpha=0 for both so that it doesnt hurt our dynamic range and ability to see the non-zero alphas + SCF[np.argmin(np.abs(alphas)), :] = 0 + COH[np.argmin(np.abs(alphas)), :] = 0 + + extent = (-0.5, 0.5, float(np.max(alphas)), float(np.min(alphas))) + fig, [ax0, ax1] = plt.subplots(1, 2, figsize=(10, 5)) + ax0.imshow(SCF, aspect='auto', extent=extent, vmax=np.max(SCF)/2) + ax0.set_xlabel('Frequency [Normalized Hz]') + ax0.set_ylabel('Cyclic Frequency [Normalized Hz]') + ax0.set_title('Regular SCF') + ax1.imshow(COH, aspect='auto', extent=extent, vmax=np.max(COH)/2) + ax1.set_xlabel('Frequency [Normalized Hz]') + ax1.set_title('Spectral Coherence Function (COH)') + plt.show() + +.. raw:: html + +
+ +Laten we nu COH (en gewone SCF) berekenen voor een rechthoekig BPSK-signaal met 20 samples per symbool en 0.2 Hz frequentie-offset: + +.. image:: ../_images/scf_coherence.svg + :align: center + :target: ../_images/scf_coherence.svg + :alt: SCF en COH van een rechthoekig BPSK-signaal met 20 samples per symbool en 0.2 Hz frequentie-offset + +Zoals je ziet zijn hogere alpha's in COH veel duidelijker dan in SCF. Draaien we dezelfde code op pulse-shaped BPSK, dan is het verschil kleiner: + +.. image:: ../_images/scf_coherence_pulse_shaped.svg + :align: center + :target: ../_images/scf_coherence_pulse_shaped.svg + :alt: SCF en COH van een pulse-shaped BPSK-signaal met 20 samples per symbool en 0.2 Hz frequentie-offset + +Probeer voor je eigen toepassing zowel SCF als COH te genereren om te zien welke het beste werkt. + +********** +Conjugates +********** + +Tot nu toe gebruikten we voor CAF en SCF formules waarin in de tweede term het complex geconjugeerde (:math:`*`) van het signaal staat: + +.. math:: + R_x(\tau,\alpha) = \lim_{T\rightarrow\infty} \frac{1}{T} \int_{-T/2}^{T/2} x(t + \tau/2)x^*(t - \tau/2)e^{-j2\pi \alpha t}dt \\ + S_X(f,\alpha) = \lim_{T\rightarrow\infty} \frac{1}{T} \lim_{U\rightarrow\infty} \frac{1}{U} \int_{-U/2}^{U/2} X(t,f + \alpha/2) X^*(t,f - \alpha/2) dt + +Er bestaat echter ook een alternatieve vorm van CAF en SCF zonder geconjugeerde term. Deze heten respectievelijk *conjugate CAF* en *conjugate SCF*. De naamgeving is wat verwarrend; onthoud vooral dat er een "normale" en een geconjugeerde variant is. De geconjugeerde versie kan extra informatie opleveren, maar is niet altijd nodig. + +.. math:: + R_{x^*}(\tau,\alpha) = \lim_{T\rightarrow\infty} \frac{1}{T} \int_{-T/2}^{T/2} x(t + \tau/2)x(t - \tau/2)e^{-j2\pi \alpha t}dt \\ + S_{x^*}(f,\alpha) = \lim_{T\rightarrow\infty} \frac{1}{T} \lim_{U\rightarrow\infty} \frac{1}{U} \int_{-U/2}^{U/2} X(t,f + \alpha/2) X(t,f - \alpha/2) dt + +Dit is dezelfde structuur als de oorspronkelijke CAF/SCF, maar zonder geconjugeerde term. Ook in discrete tijd geldt hetzelfde verschil. + +Om de betekenis van de geconjugeerde vormen goed te begrijpen, bekijken we de kwadratuurrepresentatie van een reeel bandpass-signaal: + +.. math:: + y(t) = x_I(t) \cos(2\pi f_c t + \phi) + x_Q(t) \sin(2\pi f_c t + \phi) + +:math:`x_I(t)` en :math:`x_Q(t)` zijn respectievelijk de in-phase (I) en quadratuur (Q)-component van het signaal, en het zijn deze IQ-samples die we uiteindelijk met CSP in baseband verwerken. + +Met de formule van Euler, :math:`e^{jx} = \cos(x) + j \sin(x)`, kunnen we de vergelijking hierboven herschrijven met complexe exponenten: + +.. math:: + y(t) = \frac{x_I(t) - j x_Q(t)}{2} e^{j 2\pi f_c t + j \phi} + \frac{x_I(t) + j x_Q(t)}{2} e^{-j 2\pi f_c t - j \phi} + +We kunnen de complexe envelop, die we :math:`z(t)` noemen, gebruiken om het reele signaal :math:`y(t)` te representeren, onder de aanname dat de signaalbandbreedte veel kleiner is dan de draaggolffrequentie :math:`f_c`, wat typisch zo is in RF-toepassingen: + +.. math:: + y(t) = z(t) e^{j 2 \pi f_c t + j \phi} + z^*(t) e^{-j 2 \pi f_c t - j \phi} + +Dit staat bekend als de complex-basebandrepresentatie. + +Terug naar de CAF: laten we het deel berekenen dat bekend staat als het "lag product", oftewel :math:`x(t + \tau/2) x(t - \tau/2)`. + +.. math:: + \left(z(t + \tau/2) e^{j 2 \pi f_c (t + \tau/2) + j \phi} + z^*(t + \tau/2) e^{-j 2 \pi f_c (t + \tau/2) - j \phi}\right) \times \\ \left(z(t - \tau/2) e^{j 2 \pi f_c (t - \tau/2) + j \phi} + z^*(t - \tau/2) e^{-j 2 \pi f_c (t - \tau/2) - j \phi}\right) + +Hoewel het niet meteen zichtbaar is, bevat dit resultaat vier termen die overeenkomen met de vier combinaties van geconjugeerde en niet-geconjugeerde :math:`z(t)`: + +.. math:: + z(t + \tau/2) z(t - \tau/2) e^{(\ldots)} \\ + z(t + \tau/2) z^*(t - \tau/2) e^{(\ldots)} \\ + z^*(t + \tau/2) z(t - \tau/2) e^{(\ldots)} \\ + z^*(t + \tau/2) z^*(t - \tau/2) e^{(\ldots)} + +Het blijkt dat de 1e en 4e term qua informatie-inhoud effectief hetzelfde zijn, net als de 2e en 3e. In de praktijk blijven dus twee relevante gevallen over: het geconjugeerde en het niet-geconjugeerde geval. Samengevat: om alle statistische informatie uit :math:`y(t)` te halen, moeten beide combinaties worden meegenomen. + +Om de geconjugeerde SCF met de frequentie-smoothingmethode te implementeren, is er naast het verwijderen van :code:`conj()` nog een extra stap nodig, omdat we een grote FFT doen en daarna in het frequentiedomein middelen. Een eigenschap van de Fouriertransformatie is dat complex geconjugeerd in het tijddomein overeenkomt met omklappen en conjugeren in het frequentiedomein: + +.. math:: + x^*(t) \leftrightarrow X^*(-f) + +Omdat we in de normale SCF de tweede term al complex conjugeren (met :code:`SCF_slice = np.roll(X, -shift) * np.conj(np.roll(X, shift))`), valt die extra conjugatie weg. Dan blijft het volgende over: + +.. code-block:: python + + SCF_slice = np.roll(X, -shift) * np.flip(np.roll(X, -shift - 1)) + +Let op de toegevoegde :code:`np.flip()`, en dat :code:`roll()` in omgekeerde richting moet gebeuren. De volledige FSM-implementatie van de geconjugeerde SCF is: + +.. code-block:: python + + alphas = np.arange(-1, 1, 0.01) # Conj SCF should be calculated from -1 to +1 + Nw = 256 # window length + N = len(samples) # signal length + window = np.hanning(Nw) + + X = np.fft.fftshift(np.fft.fft(samples)) # FFT of entire signal + + num_freqs = int(np.ceil(N/Nw)) # freq resolution after decimation + SCF = np.zeros((len(alphas), num_freqs), dtype=complex) + for i in range(len(alphas)): + shift = int(np.round(alphas[i] * N/2)) + SCF_slice = np.roll(X, -shift) * np.flip(np.roll(X, -shift - 1)) # THIS LINE IS THE ONLY DIFFERENCE + SCF[i, :] = np.convolve(SCF_slice, window, mode='same')[::Nw] + SCF = np.abs(SCF) + + extent = (-0.5, 0.5, float(np.min(alphas)), float(np.max(alphas))) + plt.imshow(SCF, aspect='auto', extent=extent, vmax=np.max(SCF)/2, origin='lower') + plt.xlabel('Frequency [Normalized Hz]') + plt.ylabel('Cyclic Frequency [Normalized Hz]') + plt.show() + +Een andere grote wijziging is dat we voor geconjugeerde SCF alpha's tussen -1 en +1 willen berekenen, terwijl we bij normale SCF door symmetrie vaak 0.0 tot 0.5 gebruikten. Zodra we voorbeeldsignalen bekijken, wordt duidelijk waarom. + +Waarom is de geconjugeerde SCF belangrijk? Om dat te laten zien bekijken we de geconjugeerde SCF van ons basisvoorbeeld: rechthoekig BPSK met 20 samples per symbool (cyclische frequentie 0.05 Hz) en 0.2 Hz frequentie-offset: + +.. image:: ../_images/scf_conj_rect_bpsk.svg + :align: center + :target: ../_images/scf_conj_rect_bpsk.svg + :alt: Geconjugeerde SCF van rechthoekig BPSK met de Frequency Smoothing Method (FSM) + +De kern uit deze sectie: in de geconjugeerde SCF krijg je pieken op de cyclische frequentie +/- **tweemaal** de carrier-frequency-offset, die we :math:`f_c` noemen. Op de frequentie-as liggen ze rond 0 Hz in plaats van rond :math:`f_c`. Met onze offset van 0.2 Hz krijg je dus pieken op 0.4 Hz +/- de cyclische frequentie van 0.05 Hz. Onthoud vooral dat je in de geconjugeerde SCF pieken verwacht op: + +.. math:: + 2f_c \pm \alpha + +Laten we nu naar pulse-shaped BPSK kijken met dezelfde 0.2 Hz offset, 20 samples per symbool en 0.3 roll-off: + +.. image:: ../_images/scf_conj_pulseshaped_bpsk.svg + :align: center + :target: ../_images/scf_conj_pulseshaped_bpsk.svg + :alt: Geconjugeerde SCF van raised-cosine pulse-shaped BPSK met de Frequency Smoothing Method (FSM) + +Dit is logisch gezien het normale SCF-patroon dat we voor BPSK zagen. + +Nu het interessante deel: de geconjugeerde SCF van rechthoekig QPSK met dezelfde 0.2 Hz en 20 samples per symbool: + +.. image:: ../_images/scf_conj_rect_qpsk.svg + :align: center + :target: ../_images/scf_conj_rect_qpsk.svg + :alt: Geconjugeerde SCF van rechthoekig QPSK met de Frequency Smoothing Method (FSM) + +Op het eerste gezicht lijkt dit misschien op een bug in de code, maar kijk naar de colorbar: die geeft aan welke waarden bij welke kleuren horen. Bij :code:`plt.imshow()` met automatische schaal worden kleuren (hier paars tot geel) altijd geschaald van minimum tot maximum van de 2D-array. Bij de geconjugeerde SCF van QPSK is de volledige output relatief laag, omdat *er bij QPSK geen duidelijke pieken in de geconjugeerde SCF zitten*. Hieronder dezelfde QPSK-output met schaalinstelling zoals in de eerdere BPSK-voorbeelden: + +.. image:: ../_images/scf_conj_rect_qpsk_scaled.svg + :align: center + :target: ../_images/scf_conj_rect_qpsk_scaled.svg + :alt: Geconjugeerde SCF van rechthoekig QPSK met de Frequency Smoothing Method (FSM), met schaalinstelling + +Let op het bereik van de colorbar. + +De geconjugeerde SCF voor QPSK, en ook voor hogere-orde PSK en QAM, is in essentie nul/ruis. Dat betekent dat we de geconjugeerde SCF kunnen gebruiken om de aanwezigheid van BPSK te detecteren (bijvoorbeeld de chipping-sequentie in DSSS), zelfs als er veel overlappende QPSK/QAM-signalen aanwezig zijn. Dit is een zeer krachtig hulpmiddel in de CSP-gereedschapskist. + +Laten we de geconjugeerde SCF draaien op het drie-signalen-scenario dat we eerder meerdere keren gebruikten, met de volgende signalen: + +* Signaal 1: rechthoekig BPSK met 20 samples per symbool en 0.2 Hz frequentie-offset +* Signaal 2: pulse-shaped BPSK met 20 samples per symbool, -0.1 Hz frequentie-offset en 0.35 roll-off +* Signaal 3: pulse-shaped QPSK met 4 samples per symbool, 0.2 Hz frequentie-offset en 0.21 roll-off + +.. image:: ../_images/scf_conj_multiple_signals.svg + :align: center + :target: ../_images/scf_conj_multiple_signals.svg + :alt: Geconjugeerde SCF van drie verschillende signalen met de Frequency Smoothing Method (FSM) + +Merk op dat we de twee BPSK-signalen zien, terwijl het QPSK-signaal niet zichtbaar is; anders zouden we een piek op alpha = 0.65 en 0.15 Hz zien. Zonder inzoomen is dat soms lastig, maar er zijn pieken op 0.4 +/- 0.05 Hz en -0.2 +/- 0.05 Hz. + +******************************** +FFT-accumulatiemethode (FAM) +******************************** + +De eerder besproken FSM- en TSM-technieken werken uitstekend, vooral als je een specifieke set cyclische frequenties wilt berekenen (beide implementaties hebben een buitenste lus over cyclische frequentie). Er is echter een nog efficientere SCF-implementatie: de FFT Accumulation Method (FAM), die direct de volledige set cyclische frequenties berekent (dus de frequenties die horen bij alle gehele verschuivingen van het signaal; het aantal hangt af van de signaallengte). Een vergelijkbare techniek is de `Strip Spectral Correlation Analyzer (SSCA) `_, die ook alle cyclische frequenties tegelijk berekent, maar om herhaling te vermijden hier niet wordt behandeld. Deze klasse technieken wordt soms "blind estimators" genoemd, omdat ze vaak worden gebruikt wanneer vooraf geen kennis van cyclische frequenties beschikbaar is. De FAM is een tijd-smoothingmethode (zie het als geavanceerde TSM), terwijl SSCA te vergelijken is met geavanceerde FSM. + +De minimale Python-code voor FAM is eigenlijk vrij compact, al is de koppeling met de wiskunde minder direct omdat we niet meer over alpha itereren. Net als bij TSM splitsen we het signaal op in tijdvensters met overlap. Op elk sampleblok passen we een Hanning-venster toe. In het FAM-algoritme zitten twee FFT-stappen; de eerste gebeurt op een 2D-array, dus veel FFT's worden in een regel uitgevoerd. Na een frequentieverschuiving voeren we een tweede FFT uit om de SCF op te bouwen (daarna nemen we de magnitude in het kwadraat). Voor meer detail zie de externe bronnen onderaan deze sectie. + +.. mermaid:: + + flowchart TD + A[Input samples] --> B[Opsplitsen in overlappende vensters] + B --> C[Hanning venster toepassen] + C --> D[Eerste FFT over elk venster] + D --> E[Frequentieverschuiving] + E --> F[Tweede FFT] + F --> G[Magnitude in het kwadraat nemen] + G --> H[SCF benadering] + + +.. code-block:: python + + N = 2**14 + x = samples[0:N] + Np = 512 # Number of input channels, should be power of 2 + L = Np//4 # Offset between points in the same column at consecutive rows in the same channelization matrix. It should be chosen to be less than or equal to Np/4 + num_windows = (len(x) - Np) // L + 1 + Pe = int(np.floor(int(np.log(num_windows)/np.log(2)))) + P = 2**Pe + N = L*P + + # channelization + xs = np.zeros((num_windows, Np), dtype=complex) + for i in range(num_windows): + xs[i,:] = x[i*L:i*L+Np] + xs2 = xs[0:P,:] + + # windowing + xw = xs2 * np.tile(np.hanning(Np), (P,1)) + + # first FFT + XF1 = np.fft.fftshift(np.fft.fft(xw)) + + # freq shift down + f = np.arange(Np)/float(Np) - 0.5 + f = np.tile(f, (P, 1)) + t = np.arange(P)*L + t = t.reshape(-1,1) # make it a column vector + t = np.tile(t, (1, Np)) + XD = XF1 * np.exp(-2j*np.pi*f*t) + + # main calcs + SCF = np.zeros((2*N, Np)) + Mp = N//Np//2 + for k in range(Np): + for l in range(Np): + XF2 = np.fft.fftshift(np.fft.fft(XD[:,k]*np.conj(XD[:,l]))) # second FFT + i = (k + l) // 2 + a = int(((k - l) / Np + 1) * N) + SCF[a-Mp:a+Mp, i] = np.abs(XF2[(P//2-Mp):(P//2+Mp)])**2 + +.. image:: ../_images/scf_fam.svg + :align: center + :target: ../_images/scf_fam.svg + :alt: SCF met de FFT-accumulatiemethode (FAM), cyclostationaire signaalverwerking + +Laten we inzoomen op het interessante gebied rond 0.2 Hz en lage cyclische frequenties voor meer detail: + +.. image:: ../_images/scf_fam_zoomedin.svg + :align: center + :target: ../_images/scf_fam_zoomedin.svg + :alt: Ingezoomde SCF met de FFT-accumulatiemethode (FAM), cyclostationaire signaalverwerking + +Er is een duidelijke hotspot op 0.05 Hz en een zwakkere op 0.1 Hz die met deze kleurenschaal lastig te zien kan zijn. + +We kunnen de RF-frequentie-as ook samendrukken en de SCF in 1D plotten om makkelijker te zien welke cyclische frequenties aanwezig zijn: + +.. image:: ../_images/scf_fam_1d.svg + :align: center + :target: ../_images/scf_fam_1d.svg + :alt: Plot van cyclische frequentie met de FFT-accumulatiemethode (FAM), cyclostationaire signaalverwerking + +Een belangrijke valkuil van FAM is dat het, afhankelijk van je signaallengte, een enorm aantal pixels kan opleveren. Als slechts een of twee rijen in :code:`imshow()` de energie bevatten, kunnen die door de schaalinstelling op je scherm deels gemaskeerd worden. Let daarom op de afmeting van de 2D-SCF-matrix. Wil je minder pixels op de cyclische-frequentie-as, gebruik dan max pooling of mean pooling. Zet onderstaande code na de SCF-berekening en voor het plotten (mogelijk moet je :code:`pip install scikit-image` uitvoeren): + +.. code-block:: python + + # Max pooling in cyclic domain + import skimage.measure + print("Old shape of SCF:", SCF.shape) + SCF = skimage.measure.block_reduce(SCF, block_size=(16, 1), func=np.max) # type: ignore + print("New shape of SCF:", SCF.shape) + +Externe bronnen over FAM: + +* R.S. Roberts, W. A. Brown, and H. H. Loomis, Jr., "Computationally Efficient Algorithms for Cyclic Spectral Analysis," IEEE Signal Processing Magazine, April 1991, pp. 38-49. `Hier beschikbaar `_ +* Da Costa, Evandro Luiz. Detection and identification of cyclostationary signals. Diss. Naval Postgraduate School, 1996. `Hier beschikbaar `_ +* Chad's blog post on FAM: https://cyclostationary.blog/2018/06/01/csp-estimators-the-fft-accumulation-method/ + +******************************** +OFDM +******************************** + +Cyclostationariteit is extra sterk in OFDM-signalen door het gebruik van een cyclic prefix (CP), waarbij de laatste samples van elk OFDM-symbool worden gekopieerd en vooraan toegevoegd. Dat levert een sterke cyclische frequentie op die hoort bij de OFDM-symboollengte (de inverse van de subcarrier spacing plus de CP-duur). + +Laten we met een OFDM-signaal experimenteren. Hieronder staat een simulatie van OFDM met CP, 64 subcarriers, 25% CP en QPSK-modulatie op elke subcarrier. We interpoleren 2x om een realistische sample rate te simuleren, waardoor de OFDM-symboollengte in samples (64 + (64*0.25)) * 2 = 160 wordt. Dan verwachten we pieken op alpha's die gehele veelvouden van 1/160 zijn: 0.00625, 0.0125, 0.01875, enzovoort. We simuleren 200k samples, wat overeenkomt met 1250 OFDM-symbolen (elk OFDM-symbool is relatief lang). + +.. code-block:: python + + from scipy.signal import resample + N = 200000 # number of samples to simulate + num_subcarriers = 64 + cp_len = num_subcarriers // 4 # length of the cyclic prefix in symbols, in this case 25% of the starting OFDM symbol + print("CP length in samples", cp_len*2) # remember there is 2x interpolation at the end + print("OFDM symbol length in samples", (num_subcarriers+cp_len)*2) # remember there is 2x interpolation at the end + num_symbols = int(np.floor(N/(num_subcarriers+cp_len))) // 2 # remember the interpolate by 2 + print("Number of OFDM symbols:", num_symbols) + + qpsk_mapping = { + (0,0) : 1+1j, + (0,1) : 1-1j, + (1,0) : -1+1j, + (1,1) : -1-1j, + } + bits_per_symbol = 2 + + samples = np.empty(0, dtype=np.complex64) + for _ in range(num_symbols): + data = np.random.binomial(1, 0.5, num_subcarriers*bits_per_symbol) # 1's and 0's + data = data.reshape((num_subcarriers, bits_per_symbol)) # group into subcarriers + symbol_freq = np.array([qpsk_mapping[tuple(b)] for b in data]) # remember we start in the freq domain with OFDM + symbol_time = np.fft.ifft(symbol_freq) + symbol_time = np.hstack([symbol_time[-cp_len:], symbol_time]) # take the last CP samples and stick them at the start of the symbol + samples = np.concatenate((samples, symbol_time)) # add symbol to samples buffer + + samples = resample(samples, len(samples)*2) # interpolate by 2x + samples = samples[:N] # clip off the few extra samples + + # Add noise + SNR_dB = 5 + n = np.sqrt(np.var(samples) * 10**(-SNR_dB/10) / 2) * (np.random.randn(N) + 1j*np.random.randn(N)) + samples = samples + n + +Omdat we pieken verwachten op 0.00625, 0.0125 en 0.01875, gebruiken we een cyclische-frequentieresolutie van 1e-5 zodat dit op nette veelvouden uitkomt. Als zo'n fijne resolutie onpraktisch is of cyclische frequenties onbekend zijn, kun je oversampling gebruiken (bijvoorbeeld meer samples per symbool; hier factor 2). Binnen de FSM-aanpak verwerken we dan minstens :code:`2 / alpha_resolution` samples, dus 200k samples. Hieronder staan resultaten met :code:`alphas = np.arange(0, 0.02, 1e-5)` en max pooling actief: + +.. image:: ../_images/scf_freq_smoothing_ofdm_zoomed_in.svg + :align: center + :target: ../_images/scf_freq_smoothing_ofdm_zoomed_in.svg + :alt: SCF van OFDM met de Frequency Smoothing Method (FSM) + +Let op de drie pieken; die worden nog duidelijker als je de RF-frequentie-as comprimeert en cyclische frequentie in 1D plot. + +Externe bronnen over OFDM binnen CSP: + +#. Sutton, Paul D., Keith E. Nolan, and Linda E. Doyle. "Cyclostationary signatures in practical cognitive radio applications." IEEE Journal on selected areas in Communications 26.1 (2008): 13-24. `Hier beschikbaar `_ + +******************************************** +Signaaldetectie met Bekende Cyclische Frequentie +******************************************** + +In sommige toepassingen wil je CSP gebruiken om een al bekend signaal of golfvorm te detecteren, zoals varianten van 802.11, LTE of 5G. Als je de cyclische frequentie van het signaal kent en je sample rate bekend is, hoef je in principe maar een enkele alpha en tau te berekenen. Een voorbeeld van dit type probleem met een RF-opname van WiFi volgt binnenkort. diff --git a/content-nl/detection.rst b/content-nl/detection.rst new file mode 100644 index 00000000..b05744ff --- /dev/null +++ b/content-nl/detection.rst @@ -0,0 +1,1107 @@ +.. _detection-chapter: + +##################################################### +Detectie met Correlatie +##################################################### + +.. raw:: html + + Mede-auteur: Sam Brown + +In dit hoofdstuk leren we hoe we de aanwezigheid van signalen kunnen detecteren en hun timing terug kunnen vinden door ontvangen samples kruis te correleren met een voor ons bekend deel van het signaal, zoals de preamble van een pakket. Deze methode leidt van nature tot een eenvoudige vorm van classificatie met een rij correlators. We introduceren de basisconcepten van signaaldetectie, met focus op de beslissing of een specifiek signaal wel of niet aanwezig is in een ruisachtige omgeving. Daarbij behandelen we zowel de theoretische basis als praktische technieken om onder onzekerheid zo goed mogelijk te beslissen. + +**************************************************** +Basis van Signaaldetectie en Correlators +**************************************************** + +Signaaldetectie is de taak waarbij wordt besloten of een waargenomen energiepiek een betekenisvol signaal is of alleen achtergrondruis. + +De Uitdaging: In systemen zoals radar of sonar is ruis overal aanwezig. Als de detector te gevoelig is, krijg je "valse alarmen". Is hij niet gevoelig genoeg, dan "mist" hij het echte doel. + +De oplossing begint met de Neyman-Pearson-detector, die een wiskundige "sweet spot" geeft: maximale kans op detectie bij een strikt begrensde kans op vals alarm. CFAR-detectors (CFAR: Constant False Alarm Rate) bouwen hierop voort door adaptief te reageren op veranderingen in het ruisniveau. Meer specifiek worden CFAR-detectors gebruikt wanneer de ruisstatistiek niet stationair is, dus wanneer ruisvloer en ruisverdeling veranderen door interferentie en veranderende kanaalomstandigheden. Het doel is om de detectiedrempel automatisch mee te laten bewegen met de achtergrondruis, zodat een gewenste vals-alarmkans behouden blijft. Dat vereist een continue schatting van de ruisvloer. + +Zodra een systeem weet dat er iets aanwezig is, moet het precies bepalen waar de data start. Digitale pakketten in LTE, 5G en wifi beginnen met een "preamble": een bekend en herhaald patroon. Een preamble-correlator werkt als een "slot-en-sleutel"-mechanisme, waarbij de sleutel een symboolreeks is die de ontvanger kent en die uniek is voor het te herstellen signaal. Door een kopie van die preamble over het inkomende signaal te schuiven en op elke vertraging een inwendig product te nemen, meet de ontvanger de overeenkomst tussen sjabloon en ontvangen reeks. Als beide bijna perfect uitlijnen, ontstaat een scherpe piek die exact aangeeft waar de data begint. Geavanceerde varianten houden ook rekening met frequentie-offset door afstemverschillen of Doppler-verschuiving. + +Wanneer een bekend signaal (de preamble) over een kanaal met alleen Additive White Gaussian Noise (AWGN) wordt verzonden, is de taak simpel: beslissen of het signaal aanwezig is. Dit is het eenvoudigste, maar ook meest fundamentele detectieprobleem. + +De Kruiscorrelatiefunctie +############################### + +Een correlator in de eenvoudigste vorm is gewoon een kruiscorrelatie tussen een ontvangen signaal en een sjabloon van wat je zoekt. Kruiscorrelatie is een inwendig product tussen twee vectoren terwijl één vector over de andere schuift. Als je convolutie kent: het is bijna hetzelfde, behalve dat je de tweede vector niet omkeert, dus net iets eenvoudiger. Voor complexe signalen, waar we hier mee werken, neem je ook de complex geconjugeerde van één ingang. In Python kan dat zo: + +.. code-block:: python + + def correlate(a, v): + n = len(a) + m = len(v) + result = [] + for i in range(n - m + 1): + s = 0 + for j in range(m): + s += a[i + j] * v[j].conjugate() + result.append(s) + return result + + # Voorbeeldgebruik: + a = [1+2j, 2+1j, 3+0j, 4-1j, 5-2j] + v = [0+1j, 1+0j, 0.5-0.5j] + correlate(a, v) + +Let op hoe :code:`a` schuift en :code:`v` complex geconjugeerd wordt, en hoe de loop met :code:`j` en :code:`s` in feite gewoon een inwendig vector-product is. Gelukkig hoeven we kruiscorrelatie niet zelf van nul te implementeren: in Python kunnen we NumPy's :code:`correlate` gebruiken (er is ook een SciPy-variant). + +Python-voorbeeld van een Kruiscorrelatie +######################################################## + +Om een basisvoorbeeld van een correlator in Python te maken, bouwen we eerst een voorbeeldsignaal met een bekende preamble in ruis. We gebruiken een Zadoff-Chu-sequentie als bekende preamble vanwege de uitstekende autocorrelatie-eigenschappen en het veelvuldige gebruik in communicatiesystemen. We negeren hier de rest van de payload-data, al volgt in echte systemen na de preamble meestal onbekende data. Een Zadoff-Chu-sequentie genereren we zo: + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + N = 839 # Length of Zadoff-Chu sequence + u = 25 # Root of ZC sequence + t = np.arange(N) + zadoff_chu = np.exp(-1j * np.pi * u * t * (t + 1) / N) + +De resulterende sequentie *is* een signaal: de IQ-samples van :code:`zadoff_chu` vormen een complex basisbandsignaal zoals we vaak in dit boek zien, alleen encodeert het hier geen bits. We kunnen een realistischer scenario nabootsen door dit Zadoff-Chu-signaal op een willekeurige offset in een langere AWGN-stroom te plaatsen: + +.. code-block:: python + + signal_length = 10 * N # overall simulated signal length + offset = np.random.randint(N, signal_length - N) + print(f"True offset: {offset}") + snr_db = -15 + noise_power = 1 / (2 * (10**(snr_db / 10))) + signal = np.sqrt(noise_power/2) * (np.random.randn(signal_length) + 1j * np.random.randn(signal_length)) + signal[offset:offset+N] += zadoff_chu # place our ZC signal at the random offset + +Let op dat we hier een *zeer* lage SNR gebruiken. Die is zo laag dat je de Zadoff-Chu-sequentie in het tijdsdomein helemaal niet terugziet. De sequentie is 839 samples lang op ongeveer 8000 gesimuleerde samples, en zit zo diep in de ruis dat je zelfs geen kleine toename in signaalamplitude ziet. + +.. image:: ../_images/detection_basic_1.svg + :align: center + :target: ../_images/detection_basic_1.svg + :alt: Time Domain Signal with Zadoff-Chu Sequence + +Nu kunnen we de correlator implementeren door een kruiscorrelatie uit te voeren tussen het ontvangen signaal en onze bekende Zadoff-Chu-sequentie met :code:`np.correlate()`. Dit veronderstelt dat de ontvanger de exacte preamble kent. :code:`zadoff_chu` werd eerst gebruikt om het scenario te simuleren, maar fungeert nu ook als sjabloon dat de ontvanger in de correlator gebruikt. In Python kan dit in één regel: + +.. code-block:: python + + correlation = np.correlate(signal, zadoff_chu, mode='valid') + +De :code:`valid`-modus lichten we zo toe. We normaliseren de uitgang ook met de sequentielengte en nemen de magnitude in het kwadraat om vermogen te krijgen. Je kunt ook alleen de magnitude nemen; dat werkt meestal ook. Het belangrijkste blijft de :code:`np.correlate()`-operatie. + +.. code-block:: python + + correlation = np.abs(correlation / N)**2 # normalize by N, and take magnitude squared + +Hieronder plotten we de magnitude in het kwadraat en markeren we de echte startpositie van de sequentie om te zien of de correlator die vindt: + +.. image:: ../_images/detection_basic_2.svg + :align: center + :target: ../_images/detection_basic_2.svg + :alt: Correlator Output + +Ondanks de zeer lage SNR zien we een duidelijke piek in de correlator-uitgang precies waar de Zadoff-Chu-sequentie is geplaatst. Dat is de *start* van de sequentie: de 839 samples vanaf die piek bevatten het patroon. Dit laat de kracht van correlatiegebaseerde detectie zien, zeker in combinatie met een lange preamble. We hebben nog geen expliciete drempel ingesteld om te beslissen of de piek een echt signaal of ruis is; we inspecteren nu visueel. Voor automatische detectie is een drempel nodig. De rest van dit hoofdstuk gaat grotendeels over hoe je die drempel goed kiest, vooral wanneer ruisvloer en interferentie continu veranderen. + +Modi: Valid, Same en Full +####################################### + +Je hebt misschien gezien dat :code:`np.correlate()` en :code:`np.convolve()` drie modi hebben: :code:`valid`, :code:`same` en :code:`full`. Die bepalen de lengte van de output-array ten opzichte van de inputs. In ons geval gebruikten we :code:`valid`, wat betekent dat alleen punten worden teruggegeven waar beide arrays volledig overlappen. De outputlengte wordt dan :code:`len(signal) - len(zadoff_chu) + 1`. Met :code:`same` krijg je een output met dezelfde lengte als het (langste) ingangssignaal. Met :code:`full` krijg je de volledige lineaire discrete convolutie, met een iets langere output van lengte :code:`max(M, N) - min(M, N) + 1`, waarbij :code:`M` en :code:`N` de lengtes van beide arrays zijn. In veel RF-signaalbewerking gebruiken we convolutie om een FIR-filter toe te passen, en dan is :code:`same` handig omdat input en output even lang blijven. Voor correlatiegebaseerde detectie willen we meestal :code:`valid`, omdat vooral de posities interessant zijn waar de preamble volledig overlapt met het ontvangen signaal. + +De Neyman-Pearson-detector +############################ + +De gouden standaard voor het kiezen van een goede drempel voor correlatoruitgang is de Neyman-Pearson-detector. Deze theorie helpt een optimale beslissing nemen onder een specifieke randvoorwaarde: maximaliseer de detectiekans, :math:`P_{D}`, bij een vaste en acceptabele vals-alarmkans, :math:`P_{FA}`. Simpel gezegd: jij kiest hoeveel valse detecties je maximaal accepteert (bijvoorbeeld één per uur), en Neyman-Pearson geeft de beste drempel om zoveel mogelijk echte signalen te vinden. Voor detectie van een bekende preamble in AWGN is de aanpak eenvoudig: bereken een correlatiewaarde tussen het ontvangen signaal en een bekend preamble-patroon. Overschrijdt die waarde de drempel :math:`\tau`, dan verklaar je het signaal aanwezig; anders ga je ervan uit dat er alleen ruis is. + +De prestatie van deze detector, gemeten met :math:`P_{D}` en :math:`P_{FA}`, hangt af van de drempel :math:`\tau`, de SNR en de preamblelengte :math:`L`. De vals-alarmkans is een functie van de drempel en ruisvariantie :math:`\sigma_n^2`: + +:math:`P_{FA} = Q\left(\frac{\tau}{\sigma_n}\right)` + +De detectiekans is een functie van drempel, ruisvariantie en preamble-energie (:math:`E_s = L \cdot S`, met :math:`S` als gemiddeld symboolvermogen): + +:math:`P_{D} = Q\left(\frac{\tau - \sqrt{E_s}}{\sigma_n}\right) = Q\left(\frac{\tau - \sqrt{L \cdot S}}{\sigma_n}\right)` + +Hier is :math:`Q(x)` de Q-functie (staartkans van de standaardnormale verdeling), oftewel de kans dat een standaardnormale variabele groter is dan :math:`x`. + +Prestatie-analyse: ROC-curves en Pd-vs-SNR-curves +################################################################# + +Om te kwantificeren hoe goed een correlatie-detector presteert in ruis, gebruiken engineers twee hoofdvisualisaties: de Receiver Operating Characteristic (ROC)-curve en de Probability of Detection (:math:`P_{d}`)-tegen-SNR-curve. + +De ROC-curve zet :math:`P_{d}` uit tegen :math:`P_{fa}` bij vaste SNR. Door de detectiedrempel op de correlatoruitgang te variëren kies je een punt op deze curve; het blijft een afweging. Een lagere drempel verhoogt :math:`P_{d}` (meer signalen gevonden), maar ook :math:`P_{fa}` (meer ruis-triggers). Hoe sterker de curve naar linksboven buigt, hoe beter de detector. Een perfecte detector zit linksboven (100% :math:`P_{d}`, 0% :math:`P_{fa}`), terwijl een diagonaal overeenkomt met gokken. + +.. image:: ../_images/detection_pd_vs_snr.svg + :align: center + :target: ../_images/detection_pd_vs_snr.svg + :alt: Pd vs SNR Curve and ROC curve + +Uit de vergelijkingen (en intuïtie) volgt dat preamblelengte :math:`L` een cruciale ontwerpparameter is, omdat die direct de processing gain en daarmee de detectieprestatie bepaalt. Bij vaste drempel en SNR groeit :math:`P_{D}` met :math:`L`. Een langere preamble verzamelt meer signaalenergie, waardoor scheiding tussen signaal en achtergrondruis eenvoudiger wordt. Deze prestatieverbetering heet "processing gain" en wordt vaak in dB uitgedrukt als :math:`10\log_{10}(L)`. Dit is essentieel voor zwakke signalen die anders gemist worden. Door energie over meer samples te integreren kun je signalen detecteren die onder de ruisvloer liggen. + + +**************************************************** +Voorbeeld: GPS-signalen detecteren onder de ruisvloer +**************************************************** + +Korte introductie tot GPS-signalen +################################## + +In maart 2026 waren er 31 operationele satellieten in de Amerikaanse GPS-constellatie. Ze vliegen in medium Earth orbit (MEO) rond de aarde en doen ongeveer twee omwentelingen per dag. Alle satellieten zenden een signaal uit rond 1575.42 MHz (L1); dit signaal staat continu aan en gebruikt voor alle satellieten dezelfde draagfrequentie. Tegen de tijd dat het signaal het aardoppervlak bereikt is het extreem zwak, ruim onder de ruisvloer. Orthogonaliteit tussen satellieten wordt bereikt doordat elke satelliet een unieke PRN-code (pseudo-random noise) van 1023 chips krijgt, de C/A-code. Daarom zie je dit signaal ook vaak als "L1 C/A". Deze C/A-codes zijn Gold-codes en zo ontworpen dat twee verschillende codes vrijwel orthogonaal zijn; correleer je twee verschillende satellietcodes met elkaar, dan krijg je bijna nul. De C/A-code loopt op 1.023 miljoen chips per seconde en is 1023 chips lang, dus herhaalt exact elke 1 ms. Boven op die herhalende code moduleert elke satelliet langzaam navigatiedata (baaninformatie, klokcorrecties, enz.) met slechts 50 bits/s, waardoor een databit 20 volledige coderepetities beslaat. Dit principe van een andere code per zender heet CDMA (Code Division Multiple Access), hetzelfde idee als achter 3G. + +Aan de ontvangerkant gebruikt de ontvanger, om een van de 31 satellieten te vinden, de code van die satelliet en genereert lokaal een kopie van de PRN-sequentie. Vervolgens gebruikt hij een correlator om het begin van de sequentie te vinden, vergelijkbaar met het begin van een pakket/frame, al zendt GPS in de praktijk continu uit. De exacte correlatiepiek wordt ook gebruikt om te bepalen hoe ver het signaal heeft afgelegd voor het de ontvanger bereikt. Als je dit voor 4 of meer satellieten doet, kan de ontvanger via trilateratie zijn positie op aarde bepalen. Omdat satellieten zo snel bewegen (ongeveer 4 km/s relatief), is er bovendien aanzienlijke Dopplerverschuiving. Daarom moet de ontvanger ook over een raster van mogelijke frequentie-offsets zoeken naar de beste correlatiepiek, feitelijk een 2D-zoekprobleem. De maximale Dopplerverschuiving is ongeveer +/-20 kHz (:code:`4e3 / 3e8 * 1.575e9`). Dit proces herhaalt elke 1 ms, al houdt de ontvanger de tijdsverschillen en Dopplerverschuiving daarna bij zodat niet telkens een volledige zoekactie nodig is. Het eerste vinden van een satelliet heet "acquisition", en het daarna volgen van het signaal heet "tracking". Acquisition is rekenintensiever en kan minuten duren bij een "cold start", wanneer de ontvanger nog geen informatie heeft over zichtbare satellieten, hun Doppler of de eigen locatie. + +Correlatie-aanpak +################# + +We kruiscorreleren het binnenkomende signaal (hier: een L1-opname) met een lokaal gegenereerde replica van de code van elke satelliet. Een grote correlatiepiek betekent dat die satelliet zichtbaar is en geeft de start van de 1 ms codeperiode. Om ook over frequentie te zoeken en Doppler mee te nemen, gebruiken we een FFT en voeren we de correlatie uit in het frequentiedomein. Daardoor kunnen we efficient meerdere frequentie-offsets testen door de FFT-bins van de lokale codereplica te verschuiven. Tot slot accumuleren we vermogen (correlatiemagnitude in het kwadraat) over meerdere 1 ms-blokken om de SNR te verbeteren. Dat heet non-coherente integratie en helpt om GPS-signalen onder de ruisvloer te detecteren. In het tijddomein zoeken we de pieken in de correlatie-uitgang gedeeld door het gemiddelde correlatievermogen over alle delays, als normalisatie. + +Voorbeeldopname +############### + +We gebruiken een voorbeeldopname van GPS van Daniel Estevez, die je `hier kunt downloaden `_. Het bestand is complex float32 met 4 MHz samplerate en gecentreerd op 1575.42 MHz. + +Hieronder zie je het spectrogram van de opname; er is niet veel zichtbaar. De verticale lijn is niet het GPS-signaal zelf, maar waarschijnlijk smalbandige interferentie. De werkelijke GPS L1-signalen gebruiken een chiprate van 1.023 MHz met daarbovenop een signaal met zeer lag datarate, waardoor de totale signaalbandbreedte ongeveer 2 MHz is. Dat zie je hier nauwelijks terug in het spectrogram. Dit is een goed voorbeeld van hoe GPS-signalen ruim onder de ruisvloer binnenkomen en waarom correlatiegebaseerde detectie noodzakelijk is. + +.. image:: ../_images/detection_gps_spectrogram.svg + :align: center + :target: ../_images/detection_gps_spectrogram.svg + :alt: Spectrogram van GPS L1-opname + +Voor wie verder wil kijken: deze opname is een klein deel van een veel groter bestand op `IQEngine `_, onder :code:`estevez/GPS and other GNSS`, met opname :code:`GPS-L1-2022-03-27`. Op IQEngine staat die als int16 in SigMF-formaat. + +Python-voorbeeld +################ + +Pas :code:`filename` aan naar de locatie waar je het IQ-bestand hebt opgeslagen. Let op dat :code:`num_integrations` bepaalt hoeveel van de IQ-opname wordt ingelezen en verwerkt: die waarde maal 1 ms (bij deze korte opname is 10 de maximale waarde). + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + + filename = "GPS_L1_recording_10ms_4MHz_cf32.iq" + sample_rate = 4e6 + chip_rate = 1023000 # chips / sec (part of the GPS spec) + num_chips = 1023 # chips per C/A code period + samples_per_code = int(round(sample_rate / chip_rate * num_chips)) # Exact number of samples in one 1 ms code period at 4 MHz + doppler_min_hz = -5e3 # GPS Doppler ≈ ±4 kHz for stationary receiver + doppler_max_hz = 5e3 + doppler_step_hz = 500 # good enough for a coarse search + num_integrations = 10 # non-coherent power integrations (so 10 ms total), determines how much of the IQ recording we read in and process! + detection_thresh_dB = 14.0 # Peak-to-mean ratio (PMR) threshold in dB to declare a detection, GPS C/A signals are typically 14–20 dB PMR above threshold with 10ms of integration + gps_svs = list(range(1, 33)) # 1–32 + + ##### C/A Code Generation ##### + # The GPS C/A code is a Gold code formed by XOR-ing two 10-stage maximal-length + # shift registers (G1 and G2). G2 is effectively delayed by a satellite- + # specific number of chips before the XOR + # Reference: IS-GPS-200, Table 3-Ia + G2_DELAY = [ # G2 phase delay (chips) for gps_svs 1–32 + 5, 6, 7, 8, 17, 18, 139, 140, # 1– 8 + 141, 251, 252, 254, 255, 256, 257, 258, # 9–16 + 469, 470, 471, 472, 473, 474, 509, 512, # 17–24 + 513, 514, 515, 516, 859, 860, 861, 862, # 25–32 + ] + + """G1 LFSR: polynomial x^10 + x^3 + 1, all-ones init, output at stage 10.""" + reg = np.ones(10, dtype=np.int8) + G1 = np.empty(num_chips, dtype=np.int8) + for i in range(num_chips): + G1[i] = reg[9] + fb = reg[2] ^ reg[9] # stages 3 and 10 (0-indexed: 2 and 9) + reg = np.roll(reg, 1) + reg[0] = fb + + """G2 LFSR: polynomial x^10+x^9+x^8+x^6+x^3+x^2+1, all-ones init.""" + reg = np.ones(10, dtype=np.int8) + G2 = np.empty(num_chips, dtype=np.int8) + for i in range(num_chips): + G2[i] = reg[9] + fb = reg[1]^reg[2]^reg[5]^reg[7]^reg[8]^reg[9] # taps 2,3,6,8,9,10 + reg = np.roll(reg, 1) + reg[0] = fb + + # 1023-chip C/A PRN code for SV sv (1-32) as float32, 1's and -1's, so BPSK + def make_prn(sv: int) -> np.ndarray: + g2_delayed = np.roll(G2, G2_DELAY[sv - 1]) + bits = G1 ^ g2_delayed # {0, 1} + return (1 - 2 * bits).astype(np.float32) # BPSK: {+1, −1} + + def upsample_prn(sv: int) -> np.ndarray: + """Nearest-neighbour upsample 1023-chip C/A code → samples_per_code samples.""" + code = make_prn(sv) + idx = (np.arange(samples_per_code) * num_chips / samples_per_code).astype(int) + return code[idx] + + # Pre-compute template signals - conjugate FFTs of all upsampled PRN codes + template_signals = {sv: np.conj(np.fft.fft(upsample_prn(sv))) for sv in gps_svs} + + # Read in IQ file + n_needed = samples_per_code * num_integrations + iq = np.fromfile(filename, dtype=np.complex64, count=n_needed) + # For the full version from IQEngine use the following instead + #iq = np.fromfile(filename, dtype=np.int16, count=n_needed * 2) + #iq = (iq[0::2] + 1j * iq[1::2]).astype(np.complex64) + + # Loop through satellites performing acquisition + results = [] + detected = [] + print(f" {'SV':>3} {'Doppler (Hz)':>13} {'Phase (chips)':>14}" + f" {'Phase (samp)':>13} {'Delay (µs)':>11} {'PMR (dB)':>9}") + doppler_bins = np.arange(doppler_min_hz, doppler_max_hz + doppler_step_hz, doppler_step_hz) + for sv in gps_svs: + corr_map = np.zeros((len(doppler_bins), samples_per_code)) + n_total = samples_per_code * num_integrations + for di, f_d in enumerate(doppler_bins): + t = np.arange(n_total) / sample_rate # time vector + mixed = iq[:n_total] * np.exp(-2j*np.pi*float(f_d)*t) # freq shift + + # Non-coherent integration: accumulate squared correlation magnitude + for k in range(num_integrations): + blk = mixed[k * samples_per_code:(k + 1) * samples_per_code] + sig_fft = np.fft.fft(blk) + corr = np.fft.ifft(sig_fft * template_signals[sv]) # cross-correlation in freq domain + corr_map[di] += np.abs(corr)**2 + + # Normalize by mean and convert to dB + peak_val = float(np.max(corr_map)) + mean_val = float(np.mean(corr_map)) + pmr_db = 10.0 * np.log10(peak_val / mean_val) + + peak_idx = np.unravel_index(np.argmax(corr_map), corr_map.shape) + best_doppler_hz = float(doppler_bins[peak_idx[0]]) + best_phase_samp = int(peak_idx[1]) + best_phase_chips = best_phase_samp * num_chips / samples_per_code + + r = { + "sv": sv, + "detected": pmr_db >= detection_thresh_dB, + "doppler_hz": best_doppler_hz, + "code_phase_samp": best_phase_samp, # sample offset = "start of packet" + "code_phase_chip": best_phase_chips, + "pmr_db": pmr_db, + "corr_map": corr_map, + "doppler_bins": doppler_bins, + } + results.append(r) + + # Print row + delay_us = r['code_phase_samp'] / sample_rate * 1e6 + flag = " ← DETECTED" if r['detected'] else "" + print(f" {sv:>3} {r['doppler_hz']:>+13.0f} {r['code_phase_chip']:>14.2f}" + f" {r['code_phase_samp']:>13d} {delay_us:>11.3f} {r['pmr_db']:>9.1f}{flag}") + +Dit zou de volgende uitvoer moeten geven: + +.. code-block:: + + SV Doppler (Hz) Phase (chips) Phase (samp) Delay (µs) PMR (dB) + 1 -3000 757.79 2963 740.750 5.6 + 2 +1500 264.19 1033 258.250 9.1 + 3 -2000 316.62 1238 309.500 5.8 + 4 +5000 577.48 2258 564.500 5.0 + 5 +1000 64.96 254 63.500 5.3 + 6 +1500 511.76 2001 500.250 5.0 + 7 -4000 763.41 2985 746.250 5.0 + 8 +3500 961.62 3760 940.000 5.4 + 9 +3500 118.67 464 116.000 4.9 + 10 +0 890.52 3482 870.500 5.4 + 11 +2500 837.33 3274 818.500 14.6 ← GEDETECTEERD + 12 -500 871.60 3408 852.000 16.4 ← GEDETECTEERD + 13 +1000 137.85 539 134.750 5.9 + 14 +2500 287.72 1125 281.250 5.0 + 15 -5000 908.68 3553 888.250 5.3 + 16 +1500 292.58 1144 286.000 5.9 + 17 +500 994.61 3889 972.250 5.3 + 18 +4500 1005.61 3932 983.000 5.4 + 19 +5000 588.48 2301 575.250 5.0 + 20 +0 768.53 3005 751.250 5.4 + 21 -3000 749.60 2931 732.750 5.0 + 22 +2500 558.05 2182 545.500 14.4 ← GEDETECTEERD + 23 -5000 390.02 1525 381.250 5.3 + 24 +2500 955.48 3736 934.000 5.9 + 25 +1500 597.94 2338 584.500 15.5 ← GEDETECTEERD + 26 -1500 239.89 938 234.500 6.2 + 27 -2500 488.74 1911 477.750 4.7 + 28 +3000 858.81 3358 839.500 5.2 + 29 -4000 998.70 3905 976.250 5.2 + 30 -2000 937.58 3666 916.500 5.2 + 31 +5000 463.42 1812 453.000 15.9 ← GEDETECTEERD + 32 +1000 342.45 1339 334.750 16.2 ← GEDETECTEERD + +Zoals je ziet detecteren we 6 satellieten. Hoewel onze drempel op 14.0 staat, kun je uit de lijst vrij duidelijk afleiden dat de meeste andere satellieten niet zichtbaar waren, met mogelijk uitzondering van SV-2, die waarschijnlijk net onder de drempel bleef. Voor wie dit wil verifiëren: de opname is gemaakt op 2022-03-27T11:32:04 ergens in Spanje. + +Plotten +####### + +Laten we de resultaten van satelliet 11, de eerste die we detecteerden, plotten. De eerste plot is de 2D-correlatiekaart over Doppler en tijd/delay. De tweede plot is een doorsnede van die correlatiekaart bij de beste Doppler-bin, en toont correlatievermogen over de tijd zoals in de vorige sectie. + +.. code-block:: python + + # Plotting + sv = 11 # we detected 11, 12, 22, 25, 31, 32 although try looking at one we didnt find as well! + r = results[sv - 1] # print the dict of results for this SV to see what we got + cmap = r['corr_map'] # 2-D array of correlation power vs Doppler and code phase + d_bins = r['doppler_bins'] # Doppler bins corresponding + chips_axis = np.arange(samples_per_code) * num_chips / samples_per_code + + # 2-D Doppler × code-phase map + plt.figure(0, figsize=(10, 6)) + im = plt.pcolormesh(chips_axis, d_bins, cmap, shading='auto', cmap='viridis') + plt.xlabel("Code Phase (chips)") + plt.ylabel("Doppler (Hz)") + plt.title(f"SV {sv} — 2-D Acquisition Map (PMR = {r['pmr_db']:.1f} dB)") + plt.legend(fontsize=8, loc='upper right') + plt.colorbar(im, label="Correlation Power") + + # code-phase slice at best Doppler + best_di = int(np.argmin(np.abs(d_bins - r['doppler_hz']))) + plt.figure(1, figsize=(10, 6)) + plt.plot(chips_axis, cmap[best_di], lw=1, color='steelblue') + plt.xlabel("Code Phase (chips)") + plt.ylabel("Correlation Power") + plt.title(f"SV {sv} — Code-Phase Slice (Doppler = {r['doppler_hz']:+.0f} Hz)") + plt.legend(fontsize=8) + plt.grid(True, alpha=0.3) + + plt.show() + +.. image:: ../_images/detection_gps_2d_map.png + :align: center + :width: 700px + :alt: 2D-acquisitiekaart + +.. image:: ../_images/detection_gps_code_phase_slice.svg + :align: center + :target: ../_images/detection_gps_code_phase_slice.svg + :alt: Doorsnede van codefase + +We gaan hier niet dieper in op het trilateratieproces, maar juist de exacte positie van die piek maakt het mogelijk om de afstand tot de satelliet te bepalen. Combineer je die informatie van 4 of meer satellieten, dan kan de ontvanger zijn positie op aarde berekenen. + + +**************************************************** +CFAR-detectors: Robuust in Veranderende Omgevingen +**************************************************** + +Hoewel de Neyman-Pearson-detector optimaal is bij een vaste ruisvloer, zijn praktijkomstandigheden zelden zo stabiel. In een dynamische omgeving, zoals radar door regen of een draadloze ontvanger in een drukke stad, schommelen achtergrondruis en interferentie voortdurend. Hier wordt een Constant False Alarm Rate (CFAR)-detector essentieel. + +CFAR-detectors zijn de werkpaarden van systemen waar een onvoorspelbare achtergrond een vaste drempel onbruikbaar maakt: + +- Radar en sonar detecteren doelen (vliegtuigen, onderzeeers) tegen "clutter": reflecties van golven, regen of land die veranderen terwijl de sensor beweegt. +- Draadloze communicatie, zoals cognitieve radio en LTE/5G-systemen, gebruikt CFAR om beschikbaar spectrum te vinden of inkomende pakketten te detecteren bij grillige interferentie van andere apparaten. +- Medische beeldvorming gebruikt CFAR in automatische analyse van echo- of MRI-data om echte weefselstructuren te onderscheiden van variërende elektronische ruis. + +De "C" in CFAR staat voor Constant, omdat het doel is om de vals-alarmkans (:math:`P_{FA}`) op een stabiel, voorspelbaar niveau te houden. + +Om een drempelwaarde te kiezen, moet je een statistisch ruismodel aannemen (de ruisverdeling). In eenvoudige AWGN is dat een Gauss-verdeling. In radarclutter kan het bijvoorbeeld een Rayleigh- of Weibull-verdeling zijn. Als je model niet klopt, gaat :math:`P_{FA}` "driften", waardoor het systeem óf blind wordt óf overspoeld raakt door valse triggers. + +In plaats van een vaste waarde schat een CFAR-detector het ruisvermogen in de lokale "omgeving" van het signaal en vermenigvuldigt die schatting met een schaalfactor (:math:`T`) afgeleid van de gewenste :math:`P_{FA}`. Daardoor stijgt de drempel automatisch mee als de ruisvloer stijgt. + +Per-lag versus Systeemniveau Vals-alarmkans +#################################################### + +Dit is een cruciaal onderscheid dat beginners vaak missen. Bij preamble-zoekacties voer je meestal een schuivende correlatie uit, waarbij je de drempel op duizenden tijdsverschuivingen ("lags") per seconde controleert. + +Per-lag :math:`P_{FA}`: de kans dat één specifieke correlatietoets een vals alarm oplevert. Stel je :math:`P_{FA}` op 0,001, dan heeft elke losse lag 1 op 1000 kans op een "spooksignaal". + +Systeemniveau (globaal) :math:`P_{FA}`: de kans dat het systeem minstens één vals alarm geeft in een volledig zoekvenster (bijv. over 2048 lags). + +Wiskundig geldt: als je per-lag :math:`P_{FA}` gelijk is aan :math:`p`, dan is de kans op minstens één vals alarm over :math:`N` lags ongeveer :math:`1-(1-p)^{N}`. + +Gevolg: bij 1000 lags en per-lag :math:`P_{FA}` van 0,001 rapporteert het systeem in bijna 63% van de zoekacties minstens één vals alarm. Om de systeemniveau-kans laag te houden moet per-lag :math:`P_{FA}` dus extreem klein zijn. + +Python-voorbeeld +################# + +Om zelf met een CFAR-detector te experimenteren, simuleren we eerst een scenario met herhaalde QPSK-pakketten met bekende preamble over een kanaal met tijdsvariërende ruisvloer. Daarna implementeren we een eenvoudige Cell-Averaging CFAR (CA-CFAR)-detector om preambles in het ontvangen signaal te vinden. De volgende Python-code genereert het ontvangen signaal: + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + from scipy.signal import correlate + + def generate_qpsk_packets(num_packets, sps, preamble): + """Generates repeating QPSK packets with gaps and varying noise.""" + qpsk_map = np.array([1+1j, -1+1j, -1-1j, 1-1j]) / np.sqrt(2) + data_len = 200 + gap_len = 100 + full_signal = [] + + # Pre-calculate preamble upsampled for correlation + upsampled_preamble = np.repeat(preamble, sps) + + for _ in range(num_packets): + data = qpsk_map[np.random.randint(0, 4, data_len)] + packet = np.concatenate([preamble, data]) + full_signal.extend(np.repeat(packet, sps)) + full_signal.extend(np.zeros(gap_len * sps)) + + return np.array(full_signal), upsampled_preamble + + # Setup Parameters + sps = 4 + preamble_syms = np.array([1+1j, 1+1j, -1-1j, -1-1j, 1-1j, -1+1j]) / np.sqrt(2) + tx_signal, ref_preamble = generate_qpsk_packets(5, sps, preamble_syms) + + # Channel: Time-Varying Noise Floor + t = np.arange(len(tx_signal)) + noise_env = 0.05 + 0.3 * np.sin(2 * np.pi * 0.0003 * t)**2 + noise = (np.random.randn(len(tx_signal)) + 1j*np.random.randn(len(tx_signal))) * noise_env + rx_signal = tx_signal + noise + +De eerste stap is één correlatie van het ontvangen signaal met de bekende preamble. In de praktijk gebeurt dit vaak in batches, maar hier doen we het in één batch: + +.. code-block:: python + + # Preamble-correlatie, een correlatiepiek ontstaat wanneer referentie en ontvangen segment matchen + corr_out = correlate(rx_signal, ref_preamble, mode='same') + corr_power = np.abs(corr_out)**2 + +TODO: kijk naar de ruwe output van alleen deze stap + +Nu implementeren we de CFAR-detector, passen die toe op de correlatoruitgang en visualiseren de resultaten: + +.. code-block:: python + + # CFAR Detection on Correlator Output + def ca_cfar_adaptive(data, num_train, num_guard, pfa): + num_cells = len(data) + thresholds = np.zeros(num_cells) + alpha = num_train * (pfa**(-1/num_train) - 1) # Scaling factor + half_window = (num_train + num_guard) // 2 + guard_half = num_guard // 2 + for i in range(half_window, num_cells - half_window): + # Extract training cells (excluding guard cells and CUT) + lagging_win = data[i - half_window : i - guard_half] + leading_win = data[i + guard_half + 1 : i + half_window + 1] + noise_floor_est = np.mean(np.concatenate([lagging_win, leading_win])) + thresholds[i] = alpha * noise_floor_est + return thresholds + + # Detect on correlator power + cfar_thresholds = ca_cfar_adaptive(corr_power, num_train=60, num_guard=20, pfa=1e-5) + detections = np.where(corr_power > cfar_thresholds)[0] + # Filter detections to only include those where threshold is non-zero (avoid edges) + detections = detections[cfar_thresholds[detections] > 0] + + # Subplot 1: Received Signal and Raw Power + plt.figure(figsize=(14, 8)) + plt.subplot(2, 1, 1) + plt.plot(np.abs(rx_signal)**2, color='gray', alpha=0.4, label='Rx Signal Power ($|r(t)|^2$)') + plt.title("Time-Domain Received Signal") + plt.ylabel("Power") + plt.legend() + plt.grid(True, alpha=0.3) + + # Subplot 2: Correlator Output vs Adaptive Threshold + plt.subplot(2, 1, 2) + plt.plot(corr_power, label='Correlator Output $|r(t) * p^*(-t)|^2$', color='blue') + plt.plot(cfar_thresholds, label='CFAR Adaptive Threshold', color='red', linestyle='--', linewidth=1.5) + if len(detections) > 0: # Overlay detections + plt.scatter(detections, corr_power[detections], color='lime', edgecolors='black', label='Detections (Preamble Found)', zorder=5) + plt.title("Preamble Correlator Output with Adaptive CFAR Threshold") + plt.xlabel("Sample Index") + plt.ylabel("Correlation Power") + plt.legend() + plt.grid(True, alpha=0.3) + plt.show() + +.. image:: ../_images/detection_cfar.svg + :align: center + :target: ../_images/detection_cfar.svg + :alt: CFAR Detector Output Example + + + +Frequentie-offset-robuuste Preamble-correlators +#################################################### + +Het detecteren van een preamble wordt een meerdimensionaal zoekprobleem wanneer de middenfrequentie onbekend is. In een perfect gesynchroniseerd systeem werkt een coherente correlator als matched filter en maximaliseert die de SNR. Frequentie-offset introduceert echter een tijdsafhankelijke faserotatie die het signaal loskoppelt van het lokale sjabloon, met potentieel dramatisch verlies van detectiegevoeligheid als gevolg. + +De impact van frequentie-offset :math:`\Delta f` hangt af van de grootte ervan ten opzichte van de preambleduur (:math:`T_{p}`): + +Licht verschoven (Doppler/clock drift): meestal veroorzaakt door ppm-onnauwkeurigheid van de lokale oscillator (LO) of beweging met lage snelheid. Hier geldt :math:`\Delta f \cdot T_{p} \ll 1`. De correlatiepiek verzwakt iets, maar de timing is nog steeds terug te winnen. + +In gevallen waar de frequentie-offset volledig onbekend is, zoals bij "cold start"-satellietacquisitie of sterk dynamische UAV-links, kan de coherente som zelfs naar nul uitdoven als de fase over de preamble meer dan :math:`180^{\circ}` roteert (:math:`\Delta f > 1/(2T_{p})`). Detectie wordt dan praktisch onmogelijk, ongeacht de SNR. + +Het verlies in correlatiemagnitude door frequentie-offset wordt beschreven door de Dirichlet-kern (de periodieke sinc-functie). Naarmate de frequentie-offset toeneemt, volgt de coherente som van geroteerde vectoren deze sinc-achtige afrol. + +Het verlies in dB door frequentie-offset kan benaderd worden met: + +:math:`L_{dB}(\Delta f) = 20 \log_{10} \left| \frac{\sin(\pi \Delta f N T_{s})}{N \sin(\pi \Delta f T_{s})} \right|` + +Waarbij: + + - :math:`N`: aantal symbolen in de preamble. + - :math:`T_{s}`: symboolperiode. + - :math:`\Delta f`: frequentie-offset in Hz. + +Als :math:`\Delta f` toeneemt, oscilleert de teller terwijl de noemer groeit, waardoor "nullen" in de gevoeligheid ontstaan. Voor een standaard correlator ligt de eerste nul bij :math:`\Delta f = 1/(N T_{s})`. Zit je offset op een halve binbreedte, dan verlies je ongeveer 3,9 dB, wat je effectieve SNR en :math:`P_{d}` merkbaar verslechtert. + +Methoden voor Robuustheid tegen Frequentie-offset +################################################# + +A. Coherente Gesegmenteerde Correlator + +De preamble met lengte :math:`N` wordt opgesplitst in :math:`M` segmenten van lengte :math:`L = N/M`. Elk segment wordt coherent gecorreleerd, waarna de resultaten worden gecombineerd met compensatie voor de fasedrift tussen segmenten. + +:math:`Y_{coh} = \sum_{m=0}^{M-1} \left( \sum_{k=0}^{L-1} r[k+mL] \cdot p^{*}[k] \right) e^{-j \hat{\phi}_m}` + +Hierbij is :math:`\hat{\phi}_m` een schatting van de faserotatie voor dat segment. Dit behoudt de SNR-gain van een preamble over volledige lengte, maar vraagt een nauwkeurige frequentieschatting om fasen goed uit te lijnen. + +B. Niet-coherente Gesegmenteerde Correlator + +Segmenten worden coherent gecorreleerd, maar de magnitudes worden opgeteld, waarbij fase-informatie wordt weggegooid. + +:math:`Y_{non-coh} = \sum_{m=0}^{M-1} \left| \sum_{k=0}^{L-1} r[k+mL] \cdot p^{*}[k] \right|^{2}` + +Deze aanpak is zeer robuust tegen frequentie-offset (tot ongeveer :math:`1/(L T_{s})`). Nadeel is Non-Coherent Integration Loss. Door magnitudes op te tellen in plaats van complexe waarden stapelt ruis sneller op dan signaal, wat de "post-detection" SNR effectief verlaagt. + +C. Brute-force Frequentiezoektocht + +De ontvanger draait meerdere parallelle correlators, elk verschoven met een discrete frequentie :math:`\Delta f_{i}`. + +Deze methode biedt de beste SNR-prestatie (volledige coherente gain), maar is ook het meest rekentechnisch kostbaar. De "bin spacing" moet klein genoeg zijn (volgens de Dirichlet-formule) zodat het worst-case verlies tussen bins acceptabel blijft (bijv. < 1 dB). + +Bij time-domain tapping worden samples geconvolueerd met een vaste set gewichten. Voor een frequentiezoekactie heb je dan een aparte FIR-bank per frequentiebin nodig. Dat is efficiënt voor korte preambles op FPGA's met Xilinx DSP48-slices. +Frequentiedomeinverwerking (FFT): voor een zoekactie neem je de FFT van het inkomende signaal en de preamble. Vermenigvuldiging in het frequentiedomein is equivalent aan correlatie. +De "frequency shift trick": om verschillende frequentie-offsets te testen heb je geen meerdere FFT's nodig. Je kunt de FFT-bins van de preamble circulair verschuiven ten opzichte van het signaal vóór puntsgewijze vermenigvuldiging en IFFT. +Voor continue stromen gebruik je chunkmethoden zoals Overlap-Save of Overlap-Add, zodat correlatiepieken aan de randen van FFT-vensters niet verloren gaan. + +Robuustheid tegen frequentie-offset is een afruil tussen processing gain en rekentechnische complexiteit. Niet-coherente gesegmenteerde correlatie is het meest robuust in omgevingen met veel onzekerheid, maar vraagt een hogere linkmarge. Coherente segmentmethoden en brute-force FFT-zoekacties bieden betere gevoeligheid, maar vereisen aanzienlijk meer hardwarebronnen. Begrijpen hoe het Dirichlet-verlies werkt is cruciaal om de benodigde "bin density" van een frequentiezoekende ontvanger te bepalen. + +TODO: Licht deze figuur toe en voeg een relevant stuk Python toe aan deze sectie + +.. image:: ../_images/detection_freq_offset.svg + :align: center + :target: ../_images/detection_freq_offset.svg + :alt: Invloed van frequentie-offset op correlatie + +***************************************************************** +DSSS-signalen (Direct Sequence Spread Spectrum) Detecteren +***************************************************************** + +In een DSSS-systeem is de correlator-detector de vitale schakel die een bruikbaar signaal uit schijnbaar willekeurige ruis haalt. Met een chipsequentie op hoge snelheid ("chipping code") spreidt het systeem de signaalenergie over een veel bredere band dan de oorspronkelijke data nodig heeft. Omdat het totale vermogen gelijk blijft, daalt de vermogensspectrale dichtheid (PSD) sterk. Dit "spectraal verdunnen" kan het signaal onder de thermische ruisvloer brengen, waardoor het voor klassieke smalbandontvangers bijna onzichtbaar wordt. Voor buitenstaanders lijkt het op achtergrondruis, maar de bedoelde ontvanger gebruikt dezelfde chipsequentie om te "ontspreiden", waardoor de energie terug samenkomt in de oorspronkelijke smalle band en smalbandinterferentie juist uitgesmeerd wordt. Dat maakt betrouwbare detectie mogelijk, zelfs in zeer ruisrijke omstandigheden. + +De Rol van Autocorrelatie-eigenschappen +######################################## + +De juiste sequentie kiezen is cruciaal voor synchronisatie en multipad-onderdrukking. Idealiter heeft een sequentie perfecte autocorrelatie: een hoge piek bij perfecte uitlijning en bijna nul op alle andere tijdsverschuivingen. Scherpe autocorrelatiepieken laten de ontvanger locken met sub-chip timingnauwkeurigheid. Als een signaal via een reflectie later binnenkomt, zorgt goede autocorrelatie dat de ontvanger die vertraagde kopie als ongecorreleerde ruis behandelt in plaats van destructieve interferentie. + + +Veelgebruikte Spreidingssequenties +#################################### + +Verschillende toepassingen vragen verschillende wiskundige eigenschappen van hun sequenties. Voorbeelden zijn: + +- Barker-codes, bekend om de best mogelijke autocorrelatie bij korte lengtes (tot 13), en klassiek gebruikt in 802.11b-wifi. +- M-sequenties (maximale lengte), opgewekt met linear-feedback shift registers (LFSR's), bieden uitstekende pseudo-willekeurigheid en autocorrelatie over lange periodes. +- Gold-codes, afgeleid van paren m-sequenties, leveren een grote set sequenties met gecontroleerde kruiscorrelatie, en zijn daarom standaard in GPS en CDMA met meerdere gelijktijdige signalen. +- Zadoff-Chu (ZC)-sequenties, complexwaardig met constante amplitude en nul autocorrelatie voor alle niet-nul shifts, zijn nu een hoeksteen van LTE en 5G-synchronisatie. +- Kasami-codes, vergelijkbaar met Gold-codes maar vaak met nog lagere kruiscorrelatie bij gegeven lengte, nuttig in hoge-dichtheidsomgevingen. + +Chip-timing-synchronisatie in DSSS +#################################################### + +In een DSSS-systeem hangt het kunnen terughalen van data volledig af van synchronisatie met de inkomende chipsequentie. Omdat chips veel korter zijn dan databits kan zelfs een kleine fractionele timingfout, waarbij de ontvanger "tussen" chips samplet, de correlatiepiek sterk verlagen. We verkennen dit effect met een simpele DSSS-simulatie en plotten de correlatie-output terwijl we de timing-offset variëren van 0 tot 1 chip. Let op: we doen hier geen volledige correlatie, maar een dotproduct bij lag 0, omdat we weten dat daar de piek hoort te zitten. + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + + # Barker 11 sequence: +1, -1, +1, +1, -1, +1, +1, +1, -1, -1, -1 + barker11 = np.array([1, -1, 1, 1, -1, 1, 1, 1, -1, -1, -1]) + samples_per_chip = 100 + + # Upsample the sequence to simulate continuous-ish time + sig = np.repeat(barker11, samples_per_chip) + + offsets = np.linspace(-1.5, 1.5, 500) # Fractional chip offsets + peaks = [] + + for offset in offsets: + # Shift the signal by a fractional number of chips (converted to samples) + shift_samples = int(offset * samples_per_chip) + if shift_samples > 0: + shifted_sig = np.pad(sig, (shift_samples, 0))[:len(sig)] + elif shift_samples < 0: + shifted_sig = np.pad(sig, (0, abs(shift_samples)))[abs(shift_samples):] + else: + shifted_sig = sig + + # Compute normalized correlation at zero lag for this specific offset + correlation = np.vdot(sig, shifted_sig) / np.vdot(sig, sig) + peaks.append(np.abs(correlation)) + + plt.figure(figsize=(10, 5)) + plt.plot(offsets, peaks, label='Normalized Correlation', color='blue', linewidth=2) + plt.axvline(0, color='red', linestyle='--', alpha=0.5, label='Perfect Alignment') + plt.title('DSSS Correlation Peak vs. Fractional Chip Timing Offset') + plt.xlabel('Offset (Fraction of a Chip)') + plt.ylabel('Normalized Correlation Peak Magnitude') + plt.grid(True, which='both', linestyle='--', alpha=0.6) + plt.legend() + plt.savefig('../_images/detection_dsss.svg', bbox_inches='tight') + plt.show() + +.. image:: ../_images/detection_dsss.svg + :align: center + :target: ../_images/detection_dsss.svg + :alt: DSSS + +De piek ligt zoals verwacht bij offset nul en daalt ongeveer lineair; bij een halve chip-offset zit je rond de helft van de piekwaarde. Na meer dan één chip-offset kan het lijken alsof de correlatie weer stijgt, maar de echte piek blijft laag omdat de uitlijning met de sequentie dan weg is. + +**************************************************** +Realtime Pakketdetectie in Continue IQ-stromen +**************************************************** + +Tot nu toe hebben we de theoretische basis van signaaldetectie verkend, van correlators via CFAR-detectors tot spread-spectrumsystemen. Nu brengen we alles samen voor een veelvoorkomend praktijkprobleem: **pakketten detecteren in een continue stroom IQ-samples van een SDR**. Stel je dit scenario voor: een modem of IoT-apparaat verstuurt eens per seconde (of onregelmatig) een datapakket. Je SDR ontvangt continu samples, bijvoorbeeld op 1 MHz. Pakketten komen op onvoorspelbare momenten binnen, verborgen in ruis en interferentie. Je moet: + +1. Detecteren wanneer een pakket aankomt +2. De exacte sample-index bepalen waar het start +3. Het pakket uitknippen voor verdere verwerking (demodulatie, decodering, enz.) +4. Dit realtime doen zonder pakketten te missen + +Dit is fundamenteel anders dan een vooraf opgenomen IQ-bestand verwerken, waarbij je het hele signaal in één keer kunt analyseren. Hier komen samples continu binnen en moet je met beperkte rekenmiddelen realtime beslissingen nemen. We combineren hiervoor meerdere technieken uit dit hoofdstuk: + +1. **Kruiscorrelatie**: om het bekende preamblepatroon te vinden +2. **CFAR-detectie**: om drempels adaptief te zetten bij variërende ruis +3. **Bufferbeheer**: om continue streamdata af te handelen +4. **Piekdetectie**: om precieze pakkettiming te bepalen + +Om realtime te kunnen werken verzamelen we samples in **buffers** (bijvoorbeeld chunks van 100.000 samples), draaien de detector op elke buffer en houden toestand bij over buffergrenzen heen, zodat pakketten die twee buffers overspannen niet gemist worden. + +Implementatie +############## + +Onze detector volgt deze workflow: + +.. mermaid:: + + flowchart TD + A("Continue IQ-stroom van SDR
(1 MHz sample rate)") + B("Buffer-opbouw
(bijv. 100k samples = 0,1 s)") + C("Kruiscorrelatie met bekende preamble") + D("CFAR-drempelberekening") + E("Piekdetectie
(correlatie > drempel)") + F("Pakketextractie & validatie") + A --> B --> C --> D --> E --> F + +Om pakketten die over buffergrenzen gaan niet te missen gebruiken we een **overlap-save**-aanpak, waarbij elke buffer de laatste ``N_preamble`` samples van de vorige buffer bevat. Zo zit elk pakket dat aan het einde van buffer ``i`` start volledig in buffer ``i+1``. Dat kost iets extra rekentijd, maar voorkomt gemiste pakketten op buffergrenzen. + +Laten we stap voor stap een complete pakketdetector in Python bouwen. We gebruiken een Zadoff-Chu-preamble zoals eerder, maar korter, en implementeren een adaptieve CFAR-detector. + +Stap 1: Definieer de Preamble en Parameters +******************************************* + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + from scipy.signal import correlate + + # Preamble: Zadoff-Chu sequence (excellent correlation properties) + N_zc = 63 # ZC sequence length (typically prime or power of 2 - 1) + u = 5 # ZC root + t = np.arange(N_zc) + preamble = np.exp(-1j * np.pi * u * t * (t + 1) / N_zc) + + # System parameters + sample_rate = 1e6 + buffer_size = 100000 + overlap_size = len(preamble) # Overlap to catch boundary packets + + # CFAR parameters + cfar_guard = 10 + cfar_train = 50 + pfa_target = 1e-6 + + # Packet parameters (for simulation) + packet_length = 500 # Total packet length in samples (preamble + data) + snr_db = -5 + +Stap 2: CFAR-detectorfunctie +******************************* + +We gebruiken de Cell-Averaging CFAR (CA-CFAR) van eerder, licht geoptimaliseerd: + +.. code-block:: python + + def ca_cfar_1d(signal, num_train, num_guard, pfa): + """ + 1D Cell-Averaging CFAR detector. + + Args: + signal: Input signal (typically correlation magnitude) + num_train: Number of training cells (on each side) + num_guard: Number of guard cells (on each side) + pfa: Target probability of false alarm + + Returns: + threshold: Adaptive threshold array + """ + n = len(signal) + threshold = np.zeros(n) + alpha = num_train * (pfa**(-1/num_train) - 1) + + for i in range(n): + # Define training window indices + train_start_left = max(0, i - num_guard - num_train) + train_end_left = max(0, i - num_guard) + train_start_right = min(n, i + num_guard + 1) + train_end_right = min(n, i + num_guard + num_train + 1) + + # Collect training cells (avoid guard cells and CUT) + train_cells = np.concatenate([ + signal[train_start_left:train_end_left], + signal[train_start_right:train_end_right] + ]) + + if len(train_cells) > 0: + noise_est = np.mean(train_cells) + threshold[i] = alpha * noise_est + + return threshold + +Stap 3: Pakketdetectiefunctie +********************************** + +.. code-block:: python + + def detect_packets(buffer, preamble, cfar_guard, cfar_train, pfa, + min_spacing=None): + """ + Detect packets in a buffer of IQ samples. + + Args: + buffer: Complex IQ samples + preamble: Known preamble sequence + cfar_guard: CFAR guard cells + cfar_train: CFAR training cells + pfa: Target false alarm probability + min_spacing: Minimum samples between detections (prevents duplicates) + + Returns: + detections: List of sample indices where packets start + """ + # Correlate buffer with preamble + corr = correlate(buffer, preamble, mode='same') + corr_power = np.abs(corr)**2 + + # Compute adaptive threshold + threshold = ca_cfar_1d(corr_power, cfar_train, cfar_guard, pfa) + + # Find peaks above threshold + detections_raw = np.where(corr_power > threshold)[0] + + # Compensate for correlation offset (peak occurs at len(preamble)//2 after true start) + half_preamble = len(preamble) // 2 + detections_raw = detections_raw - half_preamble + + # Remove edge detections (unreliable) + half_preamble = len(preamble) // 2 + detections_raw = detections_raw[ + (detections_raw > half_preamble) & + (detections_raw < len(buffer) - half_preamble) + ] + + # Remove duplicate detections (peaks close together) + if min_spacing is None: + min_spacing = len(preamble) + + detections = [] + if len(detections_raw) > 0: + detections.append(detections_raw[0]) + for det in detections_raw[1:]: + if det - detections[-1] > min_spacing: + detections.append(det) + + return detections, corr_power, threshold + +Stap 4: Simulatie - Genereer Testsignaal +****************************************** + +.. code-block:: python + + def generate_packet_stream(preamble, packet_length, num_packets, + sample_rate, snr_db): + """ + Generate a simulated IQ stream with intermittent packets. + + Returns: + signal: Complex IQ samples + true_starts: Ground truth packet start indices + """ + # Calculate noise power from SNR + signal_power = 1.0 # Normalized preamble power + noise_power = signal_power / (10**(snr_db/10)) + noise_std = np.sqrt(noise_power / 2) # Complex noise + + # Generate QPSK data (random payload after preamble) + qpsk_map = np.array([1+1j, -1+1j, -1-1j, 1-1j]) / np.sqrt(2) + + # Time between packets (1 second +/- 20% jitter) + packets_per_sec = 1 + avg_gap = int(sample_rate / packets_per_sec) + + signal = [] + true_starts = [] + + for i in range(num_packets): + # Add gap (noise only) + if i == 0: + gap_length = np.random.randint(avg_gap//2, avg_gap) + else: + gap_length = np.random.randint(int(avg_gap*0.8), int(avg_gap*1.2)) + + noise = noise_std * (np.random.randn(gap_length) + + 1j*np.random.randn(gap_length)) + signal.extend(noise) + + # Record true packet start + true_starts.append(len(signal)) + + # Add packet (preamble + data) + data_length = packet_length - len(preamble) + data = qpsk_map[np.random.randint(0, 4, data_length)] + packet = np.concatenate([preamble, data]) + + # Add noise to packet + packet_noisy = packet + noise_std * (np.random.randn(len(packet)) + + 1j*np.random.randn(len(packet))) + signal.extend(packet_noisy) + + # Add final gap + gap_length = np.random.randint(avg_gap//2, avg_gap) + noise = noise_std * (np.random.randn(gap_length) + + 1j*np.random.randn(gap_length)) + signal.extend(noise) + + return np.array(signal), true_starts + + # Generate 5 seconds of signal with ~5 packets + signal, true_starts = generate_packet_stream( + preamble, packet_length, num_packets=5, + sample_rate=sample_rate, snr_db=snr_db + ) + + print(f"Generated {len(signal)} samples ({len(signal)/sample_rate:.1f} sec)") + print(f"True packet starts: {true_starts}") + +Stap 5: Detectie in Streaming-modus +**************************************** + +Nu verwerken we het signaal in stukken en simuleren daarmee realtime streaming: + +.. code-block:: python + + def process_stream(signal, preamble, buffer_size, overlap_size, + cfar_guard, cfar_train, pfa): + """ + Process continuous IQ stream in buffers (simulates real-time). + + Returns: + all_detections: List of detected packet starts (global indices) + """ + all_detections = [] + n_samples = len(signal) + current_pos = 0 + + while current_pos < n_samples: + # Define buffer with overlap + buffer_start = max(0, current_pos - overlap_size) + buffer_end = min(n_samples, current_pos + buffer_size) + buffer = signal[buffer_start:buffer_end] + + # Detect packets in this buffer + detections, corr_power, threshold = detect_packets( + buffer, preamble, cfar_guard, cfar_train, pfa + ) + + # Convert buffer-relative indices to global indices + for det in detections: + global_idx = buffer_start + det + + # Avoid duplicate detections from overlap region + if len(all_detections) == 0 or \ + global_idx - all_detections[-1] > len(preamble): + all_detections.append(global_idx) + + current_pos += buffer_size + + return all_detections + + + detected_starts = process_stream( + signal, preamble, buffer_size, overlap_size, + cfar_guard, cfar_train, pfa_target + ) + + print(f"\nDetection Results:") + print(f"True packets: {len(true_starts)}") + print(f"Detected packets: {len(detected_starts)}") + print(f"Detected starts: {detected_starts}") + +Stap 6: Evalueer Prestaties +***************************** + +.. code-block:: python + + # Calculate detection statistics + tolerance = len(preamble) + + matched_detections = [] + false_alarms = [] + + for det in detected_starts: + # Check if detection matches any true packet + matched = False + for true_start in true_starts: + if abs(det - true_start) <= tolerance: + matched_detections.append(det) + matched = True + break + if not matched: + false_alarms.append(det) + + missed_packets = len(true_starts) - len(matched_detections) + + print(f"\nPerformance Metrics:") + print(f" Correct detections: {len(matched_detections)}/{len(true_starts)}") + print(f" Missed packets: {missed_packets}") + print(f" False alarms: {len(false_alarms)}") + + # Calculate timing errors + timing_errors = [] + for det in matched_detections: + errors = [abs(det - ts) for ts in true_starts] + timing_errors.append(min(errors)) + + if len(timing_errors) > 0: + print(f" Timing error (avg): {np.mean(timing_errors):.1f} samples") + print(f" Timing error (max): {np.max(timing_errors):.1f} samples") + +Stap 7: Visualiseer Resultaten +******************************* + +.. code-block:: python + + # Process one buffer for detailed visualization + buffer_start = max(0, true_starts[0] - 5000) + buffer_end = min(len(signal), true_starts[0] + 20000) + viz_buffer = signal[buffer_start:buffer_end] + + detections_viz, corr_viz, thresh_viz = detect_packets( + viz_buffer, preamble, cfar_guard, cfar_train, pfa_target + ) + + # Convert to global indices for plotting + detections_viz_global = [d + buffer_start for d in detections_viz] + + # Create visualization + fig, axes = plt.subplots(3, 1, figsize=(14, 10)) + time_axis = (np.arange(len(viz_buffer)) + buffer_start) / sample_rate * 1000 # ms + + # Subplot 1: Received signal power + axes[0].plot(time_axis, np.abs(viz_buffer)**2, 'gray', alpha=0.6, linewidth=0.5) + axes[0].set_ylabel('Power') + axes[0].set_title('Received IQ Signal Power') + axes[0].grid(True, alpha=0.3) + + # Mark true packet locations + for ts in true_starts: + if buffer_start <= ts <= buffer_end: + t_ms = ts / sample_rate * 1000 + axes[0].axvline(t_ms, color='green', linestyle='--', alpha=0.7, + label='True Packet' if ts == true_starts[0] else '') + axes[0].legend() + + # Subplot 2: Correlation output + axes[1].plot(time_axis, corr_viz, 'blue', linewidth=1, label='Correlation') + axes[1].plot(time_axis, thresh_viz, 'red', linestyle='--', linewidth=1.5, + label='CFAR Threshold') + axes[1].set_ylabel('Correlation Power') + axes[1].set_title('Preamble Correlation with Adaptive CFAR Threshold') + axes[1].grid(True, alpha=0.3) + axes[1].legend() + + # Subplot 3: Detections + detection_mask = np.zeros(len(viz_buffer)) + for det in detections_viz: + detection_mask[det] = corr_viz[det] + + axes[2].plot(time_axis, corr_viz, 'blue', alpha=0.4, linewidth=0.8) + axes[2].scatter(time_axis[detection_mask > 0], detection_mask[detection_mask > 0], + color='lime', edgecolors='black', s=100, zorder=5, + label='Detected Packets') + axes[2].set_xlabel('Time (ms)') + axes[2].set_ylabel('Correlation Power') + axes[2].set_title('Detected Packet Locations') + axes[2].grid(True, alpha=0.3) + axes[2].legend() + + plt.tight_layout() + + plt.show() + +De visualisatie zou het volgende moeten laten zien: + +1. **Bovenste plot**: ruwe signaalpower met gemarkeerde echte pakketlocaties +2. **Middelste plot**: correlatie-output met adaptieve CFAR-drempel die de ruisvloer volgt +3. **Onderste plot**: gedetecteerde pakketten gemarkeerd als pieken boven de drempel + +.. image:: ../_images/detection_realtime.png + :align: center + :scale: 50 % + :alt: Real-time packet detection results + +Praktische Overwegingen en Tuning +#################################### + +Afweging op basis van buffergrootte +************************************ + +**Grotere buffers (bijv. 1M samples):** + +- ✅ Betere CFAR-ruisschatting (meer trainingscellen) +- ✅ Lagere rekentechnische overhead (minder functie-aanroepen) +- ❌ Hogere latency (buffer moet eerst gevuld worden) +- ❌ Meer geheugen nodig + +**Kleinere buffers (bijv. 10k samples):** + +- ✅ Lagere latency (snellere respons) +- ✅ Minder geheugengebruik +- ❌ CFAR-prestatie verslechtert (minder trainingscellen) +- ❌ Hoger CPU-gebruik (vaker verwerken) + +**Aanbeveling**: begin met buffergrootte = 10× tot 100× je preamblelengte. Voor een preamble van 63 samples bij 1 Msps kun je 10k-100k samples proberen. + +CFAR-parametertuning +********************** + +De drie CFAR-parameters bepalen het detectorgedrag: + +**num_guard** (guard-cellen): + +- Doel: voorkomt dat signaalenergie in de ruisschatting lekt +- Te klein: signaal lekt in trainingsregio → hogere drempel → gemiste detecties +- Te groot: minder trainingscellen → slechtere ruisschatting +- **Vuistregel**: zet op ongeveer 0,5 tot 1,0× de preamblelengte + +**num_train** (training-cellen): + +- Doel: schat de lokale ruisvloer +- Te klein: ruisachtige drempel → valse alarmen of gemiste detecties +- Te groot: drempel past zich te traag aan ruisveranderingen aan +- **Vuistregel**: zet op ongeveer 3 tot 5× de preamblelengte + +**pfa** (kans op vals alarm): + +- Doel: regelt de detectiegevoeligheid +- Te hoog (bijv. 1e-2): veel valse alarmen +- Te laag (bijv. 1e-10): zwakke pakketten worden gemist +- **Vuistregel**: start met 1e-5 voor per-lag PFA en stuur bij op basis van vals-alarmkans op systeemniveau + +Onthoud de relatie tussen per-lag en systeemniveau vals-alarmkansen uit het eerdere deel van dit hoofdstuk. diff --git a/content-nl/digital_modulation.rst b/content-nl/digital_modulation.rst index 9316fe00..a438b40f 100644 --- a/content-nl/digital_modulation.rst +++ b/content-nl/digital_modulation.rst @@ -84,7 +84,7 @@ Amplitude Shift Keying (ASK) (Nederlands: amplitudeverschuivingsmodulatie) is he Let op hoe de gemiddelde waarde nul is; dit heeft altijd onze voorkeur. -We kunnen meer dan twee niveaus gebruiken om meer bits per symbool te versturen. Hieronder een voorbeeld van 4-ASK. In dit geval bevat elk symbool 2 bits aan informatie. +We kunnen meer dan twee niveaus gebruiken om meer bits per symbool te versturen. Hieronder een voorbeeld van 4-ASK (waarvan 0 ook een van de vier niveaus is). In dit geval bevat elk symbool 2 bits aan informatie. .. image:: ../_images/ask2.svg :align: center @@ -114,7 +114,7 @@ Dit moduleert ons signaal op de draaggolf (de sinusoïde is die draaggolf). Het :alt: Samples per symbol depiction using 2-ASK in the time domain, with 10 samples per symbol (sps) Het bovenste figuur laat de discrete samples zien als rode punten, dus ons digitale signaal. Het onderste figuur laat zien hoe het resulterende gemoduleerde signaal eruitziet, dit zou door de lucht verzonden kunnen worden. -In echte systemen is de frequentie van de draaggolf veel hoger dan de snelheid waarmee de symbolen afwisselen. In ons voorbeeld zijn er maar 3 perioden van de draaggolf per symbool, maar in de praktijk zouden er duizenden kunnen zijn, afhankelijk van hoe hoog in het spectrum het verzonden wordt. +In echte systemen is de frequentie van de draaggolf veel hoger dan de snelheid waarmee de symbolen afwisselen. In ons voorbeeld zijn er maar 2.5 perioden van de draaggolf per symbool, maar in de praktijk zouden er duizenden kunnen zijn, afhankelijk van hoe hoog in het spectrum het verzonden wordt. We raden deze links aan voor meer info over ASK `` ************************ Phase Shift Keying (PSK) @@ -208,7 +208,7 @@ We willen niet een 0 ontvangen als een 1. -Even terug naar ASK. Net als PSK kun je ASK ook laten zien in het IQ-diagram. Hier is het IQ-diagram van 2-ASK, 4-ASK, en 8-ASK, in bipolaire vorm, en ook 2-ASK en 4-ASK in de unipolaire vorm. +Even terug naar ASK. Net als PSK kun je ASK ook laten zien in het IQ-diagram. Hier is het IQ-diagram van 2-ASK, 4-ASK, en 8-ASK, in bipolaire vorm, en ook 2-ASK en 4-ASK in de unipolaire vorm. Bipolair betekent in deze context dat het signaal zowel positieve als negatieve waarden kan aannemen. Unipolair gebruikt daarentegen alleen positieve waarden. .. image:: ../_images/ask_set.png :scale: 50 % @@ -269,7 +269,8 @@ FSK is niet moeilijk te vatten -- we schuiven tussen N frequenties waarbij elke 4. 1.1990 GHz Dit zou dan om 4-FSK met twee bits per symbool gaan. -In het frequentiedomein zou 4-FSK er zo uit kunnen zien: +De afstand tussen de frequenties is 200 kHz, dus het totale signaal zou dan net iets meer dan 600 kHz in beslag nemen. +Wanneer we de FFT nemen van veel symbolen in een 4-FSK signaal, zou het spectrum in de basisband er zou uit kunnen zien: .. image:: ../_images/fsk.svg :align: center @@ -278,7 +279,7 @@ In het frequentiedomein zou 4-FSK er zo uit kunnen zien: Een belangrijke vraag die je jezelf moet stellen is: Welke afstand moet ik tussen de frequenties aanhouden? Deze afstand wordt vaak aangegeven als :math:`\Delta f` in Hz. Om er voor te zorgen dat de ontvanger symbolen aan frequenties kan koppelen, willen we vermijden dat signalen in het frequentiedomein overlappen, dus :math:`\Delta f` moet groot genoeg zijn. -De bandbreedte van elke draaggolf is een functie van de symboolsnelheid. +De bandbreedte van elke draaggolf is een functie van de symboolsnelheid en het toegepaste pulsvormingsfilter. Meer symbolen per seconde geeft kortere symbolen en dus een grotere bandbreedte (denk aan de inverse relatie tussen tijd en frequentie). Hoe sneller we symbolen gaan oversturen, hoe breder elke draaggolf wordt en dus hoe groter we :math:`\Delta f` moeten maken om te voorkomen dat de draaggolven elkaar overlappen. @@ -293,9 +294,9 @@ Dit is een analoge versie van FSK. In plaats van het springen tussen discrete frequenties, gebruikt de FM-zender een continu audiosignaal waarmee het de frequentie van de draaggolf moduleert. Hieronder is een voorbeeld te zien van FM- en AM-modulatie, waarbij het "signaal" waarmee gemoduleerd wordt, in het bovenste figuur te zien is. -.. image:: ../_images/Carrier_Mod_AM_FM.webp +.. image:: ../_images/am_fm_animation.gif :align: center - :target: ../_images/Carrier_Mod_AM_FM.webp + :target: ../_images/am_fm_animation.gif :alt: Animation of a carrier, amplitude modulation (AM), and frequency modulation (FM) in the time domain In dit boek maken we ons vooral druk over de digitale vormen van modulatie. diff --git a/content-nl/doa.rst b/content-nl/doa.rst index 4a0cc16d..7c42c131 100644 --- a/content-nl/doa.rst +++ b/content-nl/doa.rst @@ -1,10 +1,10 @@ .. _doa-chapter: #################################### -DOA & Beamforming +DOA en Bundelvorming #################################### -We zullen in dit hoofdstuk het gaan hebben over de concepten van bundelvorming (eng: beamforming), direction-of-arrival (DOA) (Nederlands: aankomstrichting) en phased arrays. Met behulp van Python simulatievoorbeelden worden Technieken zoals Capon en MUSIC besproken. We behandelen beamforming vs. DOA en twee verschillende soorten phased arrays (passief en actief). +In dit hoofdstuk behandelen we bundelvorming, direction-of-arrival (DOA, aankomstrichting) en phased arrays. Met Python-simulatievoorbeelden bespreken we technieken zoals Capon en MUSIC. Ook vergelijken we bundelvorming met DOA en behandelen we twee soorten phased arrays (passief en actief). **N.B. Dit hoofdstuk wordt momenteel vertaald en kan nog fouten bevatten.** ************************ @@ -140,7 +140,7 @@ We hebben een 1 dimensionale array van antennes die uniform zijn uitgespreid: .. image:: ../_images/doa.svg :align: center :target: ../_images/doa.svg - :alt: Diagram showing direction of arrival (DOA) of a signal impinging on a uniformly spaced antenna array, showing boresight angle and distance between elements or apertures + :alt: Diagram showing direction of arrival (DOA) of a signal impinging on a uniformly spaced antenna array, showing kijkrichting angle and distance between elements or apertures In dit voorbeeld komt het signaal van rechts dus het raakt het meest rechtste element als eerste. Laten we de vertraging berekenen tussen wanneer het signaal het eerste element raakt en wanneer het het volgende element bereikt. We kunnen dit doen door het volgende trigonometrische probleem te vormen, probeer te begrijpen hoe deze driehoek is gevormd vanuit het bovenstaande figuur. Het rode segment vertegenwoordigt de afstand die het signaal moet afleggen *nadat* het het eerste element heeft bereikt en voordat het het volgende element raakt. @@ -156,62 +156,93 @@ Als je SOS CAS TOA nog kent, zijn we in dit geval geinteresseerd in de "aanligge De aanliggende vertelt ons hoe ver het signaal moet reizen tussen het raken van het eerste en het raken van het volgende element, dus het wordt aanliggende :math:`= d \cos(90 - \theta)`. Nu is er een goniometrische identiteit die ons in staat stelt dit om te zetten in aanliggende :math:`= d \sin(\theta)`. Dit is slechts een afstand, we moeten dit omzetten in een tijd met behulp van de lichtsnelheid: verstreken tijd :math:`= d \sin(\theta) / c` [seconden]. Deze vergelijking geldt tussen elk aangrenzend element van onze array, hoewel we het hele ding met een geheel getal kunnen vermenigvuldigen om de niet-aangrenzende elementen te berekenen, omdat ze gelijkmatig verdeeld zijn (dit zullen we later doen). -Nu zullen we deze formules koppelen aan de DSP-wereld. Laten we ons signaal op de basisband :math:`s(t)` noemen en het verzenden op een bepaalde frequentie, :math:`f_c`, dus het verzonden signaal is :math:`s(t) e^{2j \pi f_c t}`. Laten we zeggen dat dit signaal het eerste element op tijd :math:`t = 0` raakt, wat betekent dat het volgende element na :math:`d \sin(\theta) / c` [seconden] wordt geraakt, zoals we hierboven hebben berekend. Het tweede element ontvangt dan: +Nu zullen we deze gonio en lichtsnelheid formules koppelen aan de DSP-wereld. Laten we ons signaal op de basisband :math:`x(t)` noemen en het verzenden op een bepaalde frequentie, :math:`f_c`, dus het verzonden signaal is :math:`x(t) e^{2j \pi f_c t}`. We gebruiken :math:`d_m` om de afstand in meters tussen de elementen aan te geven. Laten we zeggen dat dit signaal het eerste element op tijd :math:`t = 0` raakt, wat betekent dat het volgende element na :math:`d_m \sin(\theta) / c` [seconden] wordt geraakt, zoals we hierboven hebben berekend. Het tweede element ontvangt dan: .. math:: - s(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} + x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} .. math:: - \mathrm{waar} \quad \Delta t = d \sin(\theta) / c + \mathrm{waar} \quad \Delta t = d_m \sin(\theta) / c tijdverschuivingen worden afgetrokken van het tijdsargument. -De ontvanger of SDR vermenigvuldigt effectief het signaal met de draaggolf, maar in omgekeerde richting. Na de verschuiving naar de basisband ziet de ontvanger: +De ontvanger of SDR vermenigvuldigt het signaal met de draaggolf, maar in omgekeerde richting. Na de verschuiving naar de basisband ziet de ontvanger: .. math:: - s(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} e^{-2j \pi f_c t} + x(t - \Delta t) e^{2j \pi f_c (t - \Delta t)} e^{-2j \pi f_c t} .. math:: - = s(t - \Delta t) e^{-2j \pi f_c \Delta t} + = x(t - \Delta t) e^{-2j \pi f_c \Delta t} -Met een kleine truc is dit nog verder te vereenvoudigen. Bedenk dat wanneer we een signaal samplen, we dit kunnen modelleren door :math:`t` te vervangen door :math:`nT` waar :math:`T` de sampleperiodetijd is en :math:`n` gewoon 0, 1, 2, 3... . Door dit in te vullen krijgen we :math:`s(nT - \Delta t) e^{-2j \pi f_c \Delta t}`. Welnu, :math:`nT` is zoveel groter dan :math:`\Delta t` dat we het eerste :math:`\Delta t`-termijn kunnen weglaten en we :math:`s(nT) e^{-2j \pi f_c \Delta t}` overhouden. Als de samplefrequentie ooit snel genoeg wordt om de snelheid van het licht over een kleine afstand te benaderen, kunnen we dit opnieuw bekijken, maar onthoud dat onze samplefrequentie slechts een beetje hoger moet zijn dan de bandbreedte van het signaal van belang. +Met een kleine truc is dit nog verder te vereenvoudigen. Bedenk dat wanneer we een signaal samplen, we dit kunnen modelleren door :math:`t` te vervangen door :math:`nT` waar :math:`T` de sampleperiodetijd is en :math:`n` gewoon 0, 1, 2, 3... . Door dit in te vullen krijgen we :math:`x(nT - \Delta t) e^{-2j \pi f_c \Delta t}`. Nu is :math:`nT` zoveel groter dan :math:`\Delta t` dat we de eerste :math:`\Delta t`-term weg kunnen laten en we :math:`x(nT) e^{-2j \pi f_c \Delta t}` overhouden. Als de samplefrequentie ooit snel genoeg wordt om de snelheid van het licht over een kleine afstand te benaderen, kunnen we dit opnieuw bekijken, maar onthoud dat onze samplefrequentie slechts een beetje hoger moet zijn dan de bandbreedte van het signaal van belang. Laten we doorgaan met deze wiskunde maar dingen in discrete termen gaan vertegenwoordigen zodat het meer op onze Python-code lijkt. De laatste vergelijking kan als volgt worden voorgesteld, laten we :math:`\Delta t` weer invullen: .. math:: - s[n] e^{-2j \pi f_c \Delta t} + x[n] e^{-2j \pi f_c \Delta t} .. math:: - = s[n] e^{-2j \pi f_c d \sin(\theta) / c} + = x[n] e^{-2j \pi f_c d_m \sin(\theta) / c} We zijn bijna klaar. Gelukkig is er nog een vereenvoudiging die we kunnen maken. Herinner je de relatie tussen middenfrequentie en golflengte: :math:`\lambda = \frac{c}{f_c}` of de vorm die we zullen gebruiken: :math:`f_c = \frac{c}{\lambda}`. Als we dit invullen krijgen we: .. math:: - s[n] e^{-2j \pi \frac{c}{\lambda} d \sin(\theta) / c} + = x[n] e^{-2j \pi d \sin(\theta) / \lambda} + +Wat we normaal willen doen met DOA is de afstand tussen twee elementen uit te drukken als een fractie van de golflengte in plaats van meters. De meest gekozen waarde tijdens het ontwerpen van een array is om voor :math:`d` een halve golflengte te gebruiken. Ongeacht wat :math:`d` is, vanaf dit punt gaan we :math:`d` uitdrukken als een fractie van de golflengte in plaats van meters, waardoor de vergelijking en al onze code eenvoudiger wordt. Dus, :math:`d` (zonder subscript :math:`m`) is de genormaliseerde afstand, gelijk aan :math:`d = d_m / \lambda`. Dan kunnen we de vergelijking nog verder vereenvoudigen tot: .. math:: - = s[n] e^{-2j \pi d \sin(\theta) / \lambda} + x[n] e^{-2j \pi d \sin(\theta)} -Wat we normaal willen doen met DOA si de afstand tussen twee elementen uit te drukken als een fractie van de golflengte. De meest gekozen waarde tijdens het ontwerpen van een array is om voor :math:`d` een halve golflengte te gebruiken. Ongeacht wat :math:`d` is, vanaf dit punt gaan we :math:`d` uitdrukken als een fractie van de golflengte in plaats van meters, waardoor de vergelijking en al onze code eenvoudiger wordt: +Dit is voor aangrenzende elementen, voor het :math:`k`'de element moeten we gewoon :math:`d` keer :math:`k` vermenigvuldigen: .. math:: - s[n] e^{-2j \pi d \sin(\theta)} + x[n] e^{-2j \pi d k \sin(\theta)} -Dit is voor aangrenzende elementen, voor het :math:`k`'de element moeten we gewoon :math:`d` keer :math:`k` vermenigvuldigen: +Nu moeten we afspreken welke conventies we willen gebruiken voor het coordinatenstelsel. In dit boek gaan we ervan uit dat 0 graden de raaklijn is van de plaatsing van de array (d.w.z. de lijn waarop de elementen zich bevinden), zoals te zien is in het bovenstaande diagram, en dat theta met de klok mee toeneemt. We zullen ook het meest linker element als het referentie-element beschouwen, en elk extra element ligt dan :math:`d_m` verder naar rechts. Dit is het tegenovergestelde van ons diagram hierboven, dus we moeten de richting van de faseverschuiving omkeren, wat betekent dat we het negatieve teken moeten verwijderen: + +.. math:: + x[n] e^{2j \pi d k \sin(\theta)} + +Dit kunnen we in matrixformaat gieten door k op te laten lopen voor alle :code:`Nr`elementen in de array, van :math:`k = 0, 1, ... , N-1`: .. math:: - s[n] e^{-2j \pi d k \sin(\theta)} -Nu zijn we klaar! De bovenstaande vergelijking zul je in alle DOA artikelen en implementaties tegenkomen! We noemen die exponentiële term de "array factor" (vaak aangeduid als :math:`a`) en stellen het voor als een array, een 1D array voor een 1D antenne array, enz. In Python is :math:`a`: + x + \begin{bmatrix} + e^{2j \pi d (0) \sin(\theta)} \\ + e^{2j \pi d (1) \sin(\theta)} \\ + e^{2j \pi d (2) \sin(\theta)} \\ + \vdots \\ + e^{2j \pi d (N_r - 1) \sin(\theta)} \\ + \end{bmatrix} + +Hierbij is :math:`x` de 1D rij-vector van het te verzenden signaal, en noemen we de getoonde kolom-vector de "stuurvector" (vaak aangeduid als :math:`s` en in code :code:`s`) en stellen we deze voor als een array, een 1D array voor een 1D antenne array, enz. Omdat :math:`e^{0} = 1`, is het eerste element van de stuurvector altijd 1, en de rest zijn faseverschuivingen ten opzichte van het eerste element: + +.. math:: + + s = + \begin{bmatrix} + 1 \\ + e^{2j \pi d (1) \sin(\theta)} \\ + e^{2j \pi d (2) \sin(\theta)} \\ + \vdots \\ + e^{2j \pi d (N_r - 1) \sin(\theta)} \\ + \end{bmatrix} + + +Nu zijn we klaar! De bovenstaande vergelijking zul je in alle DOA artikelen en ULA implementaties tegenkomen! Je kunt ook tegenkomen dat :math:`2\pi\sin(\theta)` als :math:`\psi` wordt uitgedrukt, waardoor de stuurvector gelijk wordt aan :math:`e^{jd\psi}`, de meer algemene vorm (die we dus niet gebruiken). In python is :code:`s`: .. code-block:: python - a = [np.exp(-2j*np.pi*d*0*np.sin(theta)), np.exp(-2j*np.pi*d*1*np.sin(theta)), np.exp(-2j*np.pi*d*2*np.sin(theta)), ...] # let op de oplopende k + s = [np.exp(2j*np.pi*d*0*np.sin(theta)), np.exp(2j*np.pi*d*1*np.sin(theta)), np.exp(2j*np.pi*d*2*np.sin(theta)), ...] # k wordt hier dus opgehoogd # of - a = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # Nr is het aantal elementen in de array + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # met Nr het aaantal ontvangstantennes + +Merk op dat het eerste element in een 1+0j resulteert (omdat :math:`e^{0}=1`); dit is logisch omdat alles hierboven relatief is aan dat eerste element, dus het ontvangt het signaal zoals het is zonder enige relatieve faseverschuivingen. Dit is puur hoe dat resulteert uit de wiskunde. In werkelijkheid kan elk element als referentie worden gebruikt, maar zoals je later in onze wiskunde/code zult zien, is het verschil in fase/amplitude dat tussen elementen wordt ontvangen wat telt. Het is allemaal relatief. -Merk op dat het eerste element in een 1+0j resulteert (omdat :math:`e^{0}=1`); dit is logisch omdat alles hierboven relatief is aan dat eerste element, dus het ontvangt het signaal zoals het is zonder enige relatieve faseverschuivingen. Dit is puur hoe dat resulteert uit de wiskunde. In werkelijkheid kan elk element als referentie worden beschouwd, maar zoals je later in onze wiskunde/code zult zien, is het verschil in fase/amplitude dat tussen elementen wordt ontvangen wat telt. Het is allemaal relatief. +Vergeet niet dat :code:`d` is uitgedrukt in golflengte als eenheid en niet in meters! ********************** Een signaal ontvangen @@ -241,23 +272,23 @@ Nu gaan we een antenne simuleren, met drie omnidirectionele antennes op een rij, Nr = 3 theta_degrees = 20 # aankomstrichting in graden theta = theta_degrees / 180 * np.pi # naar radialen - a = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # array factor van hierboven - print(a) # 3 complexe elementen, de eerste is 1+0j + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # de stuurvector + print(s) # 3 complexe elementen, de eerste is 1+0j -Nu gaan we het signaal ontvangen. Om de array factor toe te passen moeten we een matrixvermenigvuldiging van :code:`a` en :code:`tx` uitvoeren, dus laten we beide omzetten naar 2D met de metode die we eerder hebben besproken toen we de matrixwiskunde in Python doornamen. Eerst zetten we het om naar rijvectoren met :code:`x.reshape(-1,1)`. Vervolgens voeren we de matrixvermenigvuldiging uit, aangegeven door het :code:`@`-symbool. Ook moeten we met een transpositie-operatie :code:`tx` omzetten van een rijvector naar een kolomvector (zie het als een rotatie van 90 graden), zodat de matrixvermenigvuldiging gelijke binnenste dimensies heeft. +Om de array factor toe te passen moeten we een matrixvermenigvuldiging doen van :code:`s` en :code:`tx`, dus laten we beide omzetten naar 2D met de methode die we eerder hebben besproken toen we de matrixwiskunde in Python doornamen. Eerst zetten we het om naar rijvectoren met :code:`onzearray.reshape(-1,1)`. Vervolgens voeren we de matrixvermenigvuldiging uit, aangegeven door het :code:`@`-symbool. Ook moeten we met een transpositie-operatie :code:`tx` omzetten van een rijvector naar een kolomvector (zie het als een rotatie van 90 graden), zodat de matrixvermenigvuldiging gelijke binnenste dimensies heeft. .. code-block:: python - a = a.reshape(-1,1) - print(a.shape) # 3x1 - tx = tx.reshape(-1,1) - print(tx.shape) # 10000x1 + s = s.reshape(-1,1) # omzetten naar een kolomvector + print(s.shape) # 3x1 + tx = tx.reshape(1,-1) # meteen transponeren naar een rijvector + print(tx.shape) # 1x10000x # matrixvermenigvuldiging - r = a @ tx.T # laat je niet afleiden door het transponeren, het belangrijkste is dat we de het tx signaal vermenigvuldigen met de a-factor - print(r.shape) # 3x10000. r is nu tweedimensionaal: tijd en afstand + X = s @ tx # We simuleren het ontvangen signaal X met een matrixvermenigvuldiging + print(X.shape) # 3x10000. X is nu tweedimensionaal: tijd en afstand -Op dit moment is :code:`r` een 2D array van 3 x 10000 elementen. Dit is omdat we drie array-elementen en 10000 gesimuleerde samples hebben. We kunnen elk individueel signaal eruit halen en de eerste 200 samples laten zien. Hieronder zullen we alleen de reële delen weergeven, maar net als bij elk basisbandsignaal is er ook een imaginair deel. Een vervelend onderdeel van matrixwiskunde in Python is dat we :code:`.squeeze()` moeten toevoegen oom de extra dimensies met lengte 1 te verwijderen, zodat we naar een normale 1D NumPy-array gaan die we verder kunnen gebruiken. +Op dit moment is :code:`X` een 2D array van 3 x 10000 elementen. Dit is omdat we drie array-elementen en 10000 gesimuleerde samples hebben. We gebruiken de hoofdletter :code:`X` om duidelijk aan tegeven dat het om meerdere ontvangen, opgestapelde signalen gaat. We kunnen elk individueel signaal eruit halen en de eerste 200 samples laten zien. Hieronder zullen we alleen de reële delen weergeven, maar net als bij elk basisbandsignaal is er ook een imaginair deel. Een vervelend onderdeel van matrixwiskunde in Python is dat we :code:`.squeeze()` moeten toevoegen oom de extra dimensies met lengte 1 te verwijderen, zodat we naar een normale 1D NumPy-array gaan die we verder kunnen gebruiken. .. code-block:: python @@ -269,56 +300,58 @@ Op dit moment is :code:`r` een 2D array van 3 x 10000 elementen. Dit is omdat we .. image:: ../_images/doa_time_domain.svg :align: center :target: ../_images/doa_time_domain.svg + +Het faseverschil tussen de element is zoals we hadden verwacht (tenzij het signaal haaks aankomt, en dan alle element op het zelfde moment bereikt, en er dus geen verschuiving is, zet theta op 0 om dit te zien). Probeer de hoek aan te passen en kijk wat er gebeurt. -Note the phase shifts between elements like we expect to happen (unless the signal arrives at boresight in which case it will reach all elements at the same time and there wont be a shift, set theta to 0 to see). Element 0 appears to arrive first, with the others slightly delayed. Try adjusting the angle and see what happens. +Laten we als laatste nog wat ruis toevoegen aan dit ontvangen signaal, want elk signaal dat we zullen behandelen heeft een bepaalde hoeveelheid ruis. We willen de ruis toepassen nadat de stuurvector is toegepast, omdat elk element een onafhankelijk ruisignaal ervaart (we kunnen dit doen omdat AWG-ruis na een faseverschuiving nog steeds AWG-ruis is): -As one final step, let's add noise to this received signal, as every signal we will deal with has some amount of noise. We want to apply the noise after the array factor is applied, because each element experiences an independent noise signal (we can do this because AWGN with a phase shift applied is still AWGN): .. code-block:: python n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) - r = r + 0.5*n # r and n are both 3x10000 + X = X + 0.1*n # X en n zijn allebij 3x10000 .. image:: ../_images/doa_time_domain_with_noise.svg :align: center :target: ../_images/doa_time_domain_with_noise.svg -******************* -Conventional DOA -******************* +*********************************** +Conventionele Bundelvorming en DOA +*********************************** -We will now process these samples :code:`r`, pretending we don't know the angle of arrival, and perform DOA, which involves estimating the angle of arrival(s) with DSP and some Python code! As discussed earlier in this chapter, the act of beamforming and performing DOA are very similar and are often built off the same techniques. Throughout the rest of this chapter we will investigate different "beamformers", and for each one we will start with the beamformer math/code that calculates the weights, :math:`w`. These weights can be "applied" to the incoming signal :code:`r` through the simple equation :math:`w^H r`, or in Python :code:`w.conj().T @ r`. In the example above, :code:`r` is a :code:`3x10000` matrix, but after we apply the weights we are left with :code:`1x10000`, as if our receiver only had one antenna, and we can use normal RF DSP to process the signal. After developing the beamformer, we will apply that beamformer to the DOA problem. +We gaan deze samples :code:`X` nu verwerken alsof we de aankomstrichting niet kennen, en vervolgens DOA uitvoeren. Daarbij schatten we de aankomstrichting(en) met DSP en Python-code. Zoals eerder in dit hoofdstuk besproken zijn bundelvorming en DOA sterk aan elkaar verwant en vaak gebaseerd op dezelfde technieken. In de rest van dit hoofdstuk bekijken we verschillende "beamformers". Voor elke techniek starten we met de wiskunde/code om de gewichten, :math:`w`, te berekenen. Deze gewichten kunnen we vervolgens op het inkomende signaal :code:`X` "toepassen" met de eenvoudige vergelijking :math:`w^H X`, of in Python :code:`w.conj().T @ X`. In het voorbeeld hierboven is :code:`X` een :code:`3x10000`-matrix, maar na het toepassen van de gewichten houden we :code:`1x10000` over, alsof onze ontvanger maar één antenne heeft. Daarna kunnen we normale RF signaalbewerking toepassen op het signaal. Zodra we de beamformer hebben opgebouwd, passen we die toe op het DOA-probleem. -We'll start with the "conventional" beamforming approach, a.k.a. delay-and-sum beamforming. Our weights vector :code:`w` needs to be a 1D array for a uniform linear array, in our example of three elements, :code:`w` is a :code:`3x1` array of complex weights. With conventional beamforming we leave the magnitude of the weights at 1, and adjust the phases so that the signal constructively adds up in the direction of our desired signal, which we will refer to as :math:`\theta`. It turns out that this is the exact same math we did above! +We beginnen met de "conventionele" bundelvormingsaanpak, ook wel delay-and-sum genoemd. Onze gewichtenvector :code:`w` moet voor een uniforme lineaire array een 1D-array zijn. In ons voorbeeld met drie elementen is :code:`w` een :code:`3x1`-array met complexe gewichten. Bij conventionele bundelvorming laten we de amplitudes van de gewichten op 1 staan en passen we alleen de fases aan, zodat het signaal constructief in de richting van het gewenste signaal optelt, aangeduid met :math:`\theta`. Dit blijkt exact dezelfde wiskunde te zijn als hierboven; onze gewichten zijn dus gewoon onze stuurvector. .. math:: - w_{conventional} = e^{-2j \pi d k \sin(\theta)} + w_{conv} = e^{2j \pi d k \sin(\theta)} -or in Python: +of in Python: .. code-block:: python - w = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # Conventional, aka delay-and-sum, beamformer - r = w.conj().T @ r # example of applying the weights to the received signal (i.e., perform the beamforming) + w = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # conventionele, oftewel delay-and-sum-beamformer + X_weighted = w.conj().T @ X # voorbeeld van gewichten toepassen op het ontvangen signaal (dus bundelvorming uitvoeren) + print(X_weighted.shape) # 1x10000 -where :code:`Nr` is the number of elements in our uniform linear array with spacing of :code:`d` fractions of wavelength (most often ~0.5). As you can see, the weights don't depend on anything other than the array geometry and the angle of interest. If our array involved calibrating the phase, we would include those calibration values too. +waar :code:`Nr` het aantal elementen is in onze uniforme lineaire array met een onderlinge afstand van :code:`d` golflengtefracties (meestal ~0,5). Zoals je ziet hangen de gewichten alleen af van de arraygeometrie en de gewenste hoek. Als onze array fasekalibratie nodig heeft, nemen we die kalibratiewaarden ook mee. Je ziet in de vergelijking voor :code:`w` ook dat de gewichten complex zijn en allemaal een amplitude van één (unity) hebben. -But how do we know the angle of interest :code:`theta`? We must start by performing DOA, which involves scanning through (sampling) all directions of arrival from -π to +π (-180 to +180 degrees), e.g., in 1 degree increments. At each direction we calculate the weights using a beamformer; we will start by using the conventional beamformer. Applying the weights to our signal :code:`r` will give us a 1D array of samples, as if we received it with 1 directional antenna. We can then calculate the power in the signal by taking the variance with :code:`np.var()`, and repeat for every angle in our scan. We will plot the results and look at it with our human eyes/brain, but what most RF DSP does is find the angle of maximum power (with a peak-finding algorithm) and call it the DOA estimate. +Maar hoe kennen we de gewenste hoek :code:`theta`? We moeten eerst DOA uitvoeren, waarbij we alle aankomstrichtingen van -π tot +π (-180 tot +180 graden) scannen (samplen), bijvoorbeeld in stappen van 1 graad. Voor elke richting berekenen we de gewichten met een beamformer; we beginnen met de conventionele beamformer. Als we de gewichten op :code:`X` toepassen, krijgen we een 1D-array met samples, alsof we met één richtantenne ontvangen. Daarna kunnen we het signaalvermogen bepalen via de variantie met :code:`np.var()`, en dit herhalen voor elke hoek in de scan. We plotten de resultaten en beoordelen ze visueel, maar in de praktijk zoekt RF-DSP meestal de hoek met het maximale vermogen (via een piekzoekalgoritme) en noemt die de DOA-schatting. .. code-block:: python - theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 different thetas between -180 and +180 degrees + theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 verschillende theta-waarden tussen -180 en +180 graden results = [] for theta_i in theta_scan: - w = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta_i)) # Conventional, aka delay-and-sum, beamformer - r_weighted = w.conj().T @ r # apply our weights. remember r is 3x10000 - results.append(10*np.log10(np.var(r_weighted))) # power in signal, in dB so its easier to see small and large lobes at the same time - results -= np.max(results) # normalize + w = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_i)) # conventionele, oftewel delay-and-sum-beamformer + X_weighted = w.conj().T @ X # pas de gewichten toe; onthoud dat X 3x10000 is + results.append(10*np.log10(np.var(X_weighted))) # signaalvermogen in dB, zodat kleine en grote lobben tegelijk zichtbaar zijn + results -= np.max(results) # normalize (optional) - # print angle that gave us the max value + # print de hoek die de maximale waarde geeft print(theta_scan[np.argmax(results)] * 180 / np.pi) # 19.99999999999998 - plt.plot(theta_scan*180/np.pi, results) # lets plot angle in degrees + plt.plot(theta_scan*180/np.pi, results) # plot de hoek in graden plt.xlabel("Theta [Degrees]") plt.ylabel("DOA Metric") plt.grid() @@ -328,249 +361,762 @@ But how do we know the angle of interest :code:`theta`? We must start by perfor :align: center :target: ../_images/doa_conventional_beamformer.svg -We found our signal! You're probably starting to realize where the term electrically steered array comes in. Try increasing the amount of noise to push it to its limit, you might need to simulate more samples being received for low SNRs. Also try changing the direction of arrival. +We hebben ons signaal gevonden. Je ziet nu waarschijnlijk ook waar de term "elektronisch gestuurde array" vandaan komt. Probeer de hoeveelheid ruis te verhogen om de limiet op te zoeken; bij lage SNR heb je mogelijk meer gesimuleerde samples nodig. Probeer ook de aankomstrichting te veranderen. -If you prefer viewing angle on a polar plot, use the following code: +Als je de DOA-resultaten liever in een poolplot ziet, gebruik dan de volgende code: .. code-block:: python fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) - ax.plot(theta_scan, results) # MAKE SURE TO USE RADIAN FOR POLAR - ax.set_theta_zero_location('N') # make 0 degrees point up - ax.set_theta_direction(-1) # increase clockwise - ax.set_rlabel_position(55) # Move grid labels away from other labels + ax.plot(theta_scan, results) # GEBRUIK RADIALEN VOOR EEN POOLPLOT + ax.set_theta_zero_location('N') # maak dat 0 graden omhoog wijst + ax.set_theta_direction(-1) # laat de hoek met de klok mee toenemen + ax.set_rlabel_position(55) # verplaats rasterlabels weg van andere labels plt.show() .. image:: ../_images/doa_conventional_beamformer_polar.svg :align: center :target: ../_images/doa_conventional_beamformer_polar.svg - :alt: Example polar plot of performing direction of arrival (DOA) showing the beam pattern and 180 degree ambiguity + :alt: Example polar plot of performing direction of arrival (DOA) showing the beam pattern and 180-degree ambiguity -We will keep seeing this pattern of looping over angles, and having some method of calculating the beamforming weights, then applying them to the recieved signal. In the next beamforming method (MVDR) we will use our received signal :code:`r` as part of the weight calculations, making it an adaptive technique. But first we will investigate some interesting things that happen with phased arrays, including why we have that second peak at 160 degrees. +We blijven dit patroon terugzien: over alle hoeken, op een bepaalde manier de gewichten berekenen en die vervolgens op het ontvangen signaal toepassen. In de volgende methode (MVDR) gebruiken we het ontvangen signaal :code:`X` ook in de gewichtenberekening, waardoor het een adaptieve techniek wordt. Maar eerst bekijken we een paar interessante effecten van phased arrays, waaronder waarom er een tweede piek bij 160 graden staat. -******************** -180 Degree Ambiguity -******************** +********************* +180-gradenambiguiteit +********************* -Let's talk about why is there a second peak at 160 degrees; the DOA we simulated was 20 degrees, but it is not a coincidence that 180 - 20 = 160. Picture three omnidirectional antennas in a line placed on a table. The array's boresight is 90 degrees to the axis of the array, as labeled in the first diagram in this chapter. Now imagine the transmitter in front of the antennas, also on the (very large) table, such that its signal arrives at a +20 degree angle from boresight. Well the array sees the same effect whether the signal is arriving with respect to its front or back, the phase delay is the same, as depicted below with the array elements in red and the two possible transmitter DOA's in green. Therefore, when we perform the DOA algorithm, there will always be a 180 degree ambiguity like this, the only way around it is to have a 2D array, or a second 1D array positioned at any other angle w.r.t the first array. You may be wondering if this means we might as well only calculate -90 to +90 degrees to save compute cycles, and you would be correct! +Laten we bespreken waarom er een tweede piek op 160 graden staat. De gesimuleerde DOA was 20 graden, en het is geen toeval dat 180 - 20 = 160. Stel je drie omnidirectionele antennes in een lijn op een tafel voor. De kijkrichting van de array staat 90 graden op de as van de array, zoals in het eerste diagram van dit hoofdstuk. Denk nu aan een zender vóór de antennes, ook op die (erg grote) tafel, zodat het signaal binnenkomt onder +20 graden ten opzichte van de kijkrichting. Voor de array is het faseverschil echter hetzelfde of het signaal van voren of van achteren komt. Dat zie je hieronder, met de array-elementen in rood en de twee mogelijke DOA-posities van de zender in groen. Daarom krijg je bij het uitvoeren van een DOA-algoritme altijd dit soort 180-gradenambiguiteit. De enige oplossing is een 2D-array, of een tweede 1D-array onder een andere hoek ten opzichte van de eerste. Je vraagt je misschien af of je dan net zo goed alleen van -90 tot +90 graden kunt rekenen om rekentijd te besparen. Dat klopt. .. image:: ../_images/doa_from_behind.svg :align: center :target: ../_images/doa_from_behind.svg -*********************** -Broadside of the Array -*********************** - -To demonstrate this next concept, let's try sweeping the angle of arrival (AoA) from -90 to +90 degrees instead of keeping it constant at 20: +Laten we de aankomstrichting (Engels: Angle of Arrival AoA) eens sweepen van -90 tot +90 graden, in plaats van hem constant op 20 te houden: .. image:: ../_images/doa_sweeping_angle_animation.gif :scale: 100 % :align: center - :alt: Animation of direction of arrival (DOA) showing the broadside of the array + :alt: Animation of direction of arrival (DOA) showing the endfire of the array -As we approach the broadside of the array (a.k.a. endfire), which is when the signal arrives at or near the axis of the array, performance drops. We see two main degradations: 1) the main lobe gets wider and 2) we get ambiguity and don't know whether the signal is coming from the left or the right. This ambiguity adds to the 180 degree ambiguity discussed earlier, where we get an extra lobe at 180 - theta, causing certain AoA to lead to three lobes of roughly equal size. This broadside ambiguity makes sense though, the phase shifts that occur between elements are identical whether the signal arrives from the left or right side w.r.t. the array axis. Just like with the 180 degree ambiguity, the solution is to use a 2D array or two 1D arrays at different angles. In general, beamforming works best when the angle is closer to the boresight. +Wanneer we de endfire-regio van de array naderen (dus wanneer het signaal op of dicht bij de array-as aankomt), daalt de prestatie. We zien twee belangrijke verslechteringen: 1) de hoofdlob wordt breder en 2) er ontstaat ambiguiteit, waardoor je niet weet of het signaal van links of rechts komt. Deze ambiguiteit komt boven op de eerder besproken 180-gradenambiguiteit, waarbij je een extra lob op 180 - theta krijgt. Daardoor kunnen bepaalde AoA's tot drie lobben van ongeveer gelijke grootte leiden. Deze endfire-ambiguiteit is logisch: de faseverschuivingen tussen elementen zijn identiek of het signaal nu van links of rechts van de array-as komt. Net als bij de 180-gradenambiguiteit is de oplossing een 2D-array of twee 1D-arrays onder verschillende hoeken. In het algemeen werkt beamforming het beste wanneer de hoek dichter bij de kijkrichting ligt. -******************* -When d is not λ/2 -******************* +Vanaf nu tonen we in poolplots alleen nog -90 tot +90 graden, omdat het patroon voor 1D-lineaire arrays (waar dit hoofdstuk over gaat) toch gespiegeld is rond de array-as. + +******************** +Beam Pattern +******************** -So far we have been using a distance between elements, d, equal to one half wavelength. So for example, an array designed for 2.4 GHz WiFi with λ/2 spacing would have a spacing of 3e8/2.4e9/2 = 12.5cm or about 5 inches, meaning a 4x4 element array would be about 15" x 15" x the height of the antennas. There are times when an array may not be able to achieve exactly λ/2 spacing, such as when space is restricted, or when the same array has to work on a variety of carrier frequencies. +De grafieken die we tot nu toe hebben getoond zijn DOA-resultaten; ze geven het ontvangen vermogen per hoek na het toepassen van de beamformer. Ze horen bij een specifiek scenario met zenders op bepaalde hoeken. We kunnen echter ook het bundelpatroon zelf bekijken, dus vóórdat we een signaal ontvangen. Dit heet soms het "quiescent antenna pattern" of "array response". -Let's examine when the spacing is greater than λ/2, i.e., too much spacing, by varying d between λ/2 and 4λ. We will remove the bottom half of the polar plot since it's a mirror of the top anyway. +Onthoud dat onze stuurvector, die we steeds terugzien, + +.. code-block:: python + + np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) + +de ULA-geometrie vastlegt, en als extra parameter alleen de richting heeft waar je naartoe wilt sturen. We kunnen het quiescent antenna pattern (array response) berekenen en plotten voor een gekozen stuurhoek. Dat laat de natuurlijke respons van de array zien als we geen extra bundelvorming toepassen. Dit kan door de FFT van de complex geconjugeerde gewichten te nemen, dus zonder for-loop. Het lastige deel is zero-padding voor extra resolutie en het mappen van FFT-bins naar hoeken in radialen of graden, waarbij een arcsinus nodig is, zoals je in het voorbeeld hieronder ziet. + +.. code-block:: python + + Nr = 3 + d = 0.5 + N_fft = 512 + theta_degrees = 20 # er is geen SOI; we verwerken geen samples, dit is alleen de richting waar we op richten + theta = theta_degrees / 180 * np.pi + w = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # conventionele beamformer + w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero-pad naar N_fft elementen voor meer FFT-resolutie + w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # FFT-magnitude in dB + w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak + + # map FFT-bins naar hoeken in radialen + theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # in radians + + # vind het maximum zodat we het in de plot kunnen tonen + theta_max = theta_bins[np.argmax(w_fft_dB)] + + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.plot(theta_bins, w_fft_dB) # GEBRUIK RADIALEN VOOR EEN POOLPLOT + ax.plot([theta_max], [np.max(w_fft_dB)],'ro') + ax.text(theta_max - 0.1, np.max(w_fft_dB) - 4, np.round(theta_max * 180 / np.pi)) + ax.set_theta_zero_location('N') # laat 0 graden omhoog wijzen + ax.set_theta_direction(-1) # laat de hoek met de klok mee toenemen + ax.set_rlabel_position(55) # verplaats rasterlabels weg van andere labels + ax.set_thetamin(-90) # toon alleen de bovenste helft + ax.set_thetamax(90) + ax.set_ylim([-30, 1]) # zonder ruis hoeft de schaal maar tot -30 dB te gaan + plt.show() + +.. image:: ../_images/doa_quiescent.svg + :align: center + :target: ../_images/doa_quiescent.svg + +Dit patroon blijkt bijna exact overeen te komen met het patroon dat je krijgt bij DOA met de conventionele beamformer (delay-and-sum), wanneer er één toon op `theta_degrees` aanwezig is en weinig tot geen ruis. De plot kan er anders uitzien door hoe ver de y-as in dB naar beneden loopt, of door de FFT-grootte waarmee dit quiescent-patroon is gemaakt. Probeer :code:`theta_degrees` of het aantal elementen :code:`Nr` te variëren om te zien hoe de respons verandert. + +Voor het leuke, laat de volgende animatie het bundelpatroon van de conventionele beamformer zien, voor een 8-element-array die tussen -90 en +90 graden wordt gestuurd. Ook zie je de acht gewichten in het complexe vlak (reële en imaginaire as). + +.. image:: ../_images/delay_and_sum.gif + :scale: 90 % + :align: center + :alt: Beam pattern of delay and sum while viewing each weight on the complex plane + +Let erop dat alle gewichten eenheidsamplitude hebben (ze blijven op de eenheidscirkel), en dat elementen met een hoger indexnummer sneller "draaien". Als je goed kijkt, zie je dat ze bij 0 graden allemaal samenvallen; ze hebben dan allemaal 0 faseverschuiving (1+0j). + +******************** +Array Pulsbreedte +******************** + +Voor wie nieuwsgierig is: er bestaan vergelijkingen die de breedte van de hoofdlob benaderen op basis van het aantal elementen. Ze werken vooral goed bij grotere arrays (bijvoorbeeld 8 elementen of meer). De half-power beamwidth (HPBW) is de breedte op 3 dB onder de piek van de hoofdlob, en is ongeveer :math:`\frac{0.9 \lambda}{N_rd\cos(\theta)}` [1]. Voor halve-golflengteafstand vereenvoudigt dit tot: + +.. math:: + + \text{HPBW} \approx \frac{1.8}{N_r\cos(\theta)} \text{ [radians]} \qquad \text{when } d = \lambda/2 + +De first-null beamwidth (FNBW), dus de hoofdlobbreedte van nul tot nul, is ongeveer :math:`\frac{2\lambda}{N_rd}` [1]. Voor halve-golflengteafstand vereenvoudigt dit tot: + +.. math:: + + \text{FNBW} \approx \frac{4}{N_r} \text{ [radians]} \qquad \text{when } d = \lambda/2 + +Laten we de vorige code gebruiken maar :code:`Nr` verhogen naar 16 elementen. Met de vergelijkingen hierboven zou de HPBW, gericht op 20 graden (0,35 radialen), ongeveer 0,12 radialen of **6,8 graden** moeten zijn. De FNBW zou ongeveer 0,25 radialen of **14,3 graden** moeten zijn. Laten we simuleren hoe dicht we daarbij in de buurt komen. Voor het bekijken van bundelbreedtes gebruiken we meestal rechthoekige plots in plaats van poolplots. Hieronder staan de resultaten, met HPBW in groen en FNBW in rood. + +.. image:: ../_images/doa_quiescent_beamwidth.svg + :align: center + :target: ../_images/doa_quiescent_beamwidth.svg + +In de plot is het misschien lastig te zien, maar als je ver inzoomt blijkt de HPBW ongeveer 6,8 graden en de FNBW ongeveer 15,4 graden te zijn. Dat ligt dus behoorlijk dicht bij de berekening, zeker voor HPBW. + +********************* +Wanneer d niet λ/2 is +********************* + +Tot nu toe hebben we de elementafstand :math:`d` gelijk genomen aan een halve golflengte. Een array voor 2,4 GHz wifi met λ/2-afstand heeft bijvoorbeeld een elementafstand van 3e8/2.4e9/2 = 12,5 cm (ongeveer 5 inch). Een 4x4-array komt dan uit op ongeveer 15" x 15" x de hoogte van de antennes. Soms kun je echter geen exacte λ/2-afstand halen, bijvoorbeeld door ruimtegebrek, of omdat dezelfde array op meerdere draaggolffrequenties moet werken. + +Laten we bekijken wat er gebeurt als de afstand groter is dan λ/2, dus te groot, door :math:`d` te variëren tussen λ/2 en 4λ. We laten de onderste helft van de poolplot weg, omdat die toch een spiegeling van de bovenkant is. .. image:: ../_images/doa_d_is_large_animation.gif :scale: 100 % :align: center :alt: Animation of direction of arrival (DOA) showing what happens when distance d is much more than half-wavelength -As you can see, in addition to the 180 degree ambiguity we discussed earlier, we now have additional ambiguity, and it gets worse as d gets higher (extra/incorrect lobes form). These extra lobes are known as grating lobes, and they are a result of "spatial aliasing". As we learned in the :ref:`sampling-chapter` chapter, when we don't sample fast enough we get aliasing. The same thing happens in the spatial domain; if our elements are not spaced close enough together w.r.t. the carrier frequency of the signal being observed, we get garbage results in our analysis. You can think of spacing out antennas as sampling space! In this example we can see that the grating lobes don't get too problematic until d > λ, but they will occur as soon as you go above λ/2 spacing. +Zoals je ziet krijgen we, naast de eerder besproken 180-gradenambiguiteit, extra ambiguiteit. Die wordt erger naarmate :math:`d` groter wordt (extra/foute lobben ontstaan). Deze extra lobben heten grating lobes en zijn het gevolg van "spatial aliasing". Zoals we in het :ref:`sampling-chapter`-hoofdstuk hebben gezien: als je niet snel genoeg samplet, krijg je aliasing. Hetzelfde gebeurt in het ruimtelijke domein. Als elementen niet dicht genoeg op elkaar staan ten opzichte van de draaggolffrequentie van het waargenomen signaal, krijg je slechte analyseresultaten. Je kunt antenneafstand zien als het samplen van ruimte. In dit voorbeeld worden grating lobes pas echt problematisch bij :math:`d > \lambda`, maar ze ontstaan al zodra je boven λ/2 gaat. Dat komt doordat Nyquist zegt dat we minstens twee keer zo snel moeten samplen als het waargenomen signaal, dus twee samples per cyclus. Onze ruimtelijke samplefrequentie meten we in samples per meter. Omdat de equivalente radiaalfrequentie in de ruimte :math:`2\pi/\lambda` radialen per meter is, en één cyclus :math:`2\pi` radialen (360 graden) bevat, moeten we de ruimte minstens samplen met: + +.. math:: + + \text{spatial sampling rate} \geq 2 \text{ [samples/cycle]} \cdot \frac{2\pi/\lambda \text{ [radians/meter]}}{2\pi \text{ [radians/cycle]}} + + \text{spatial sampling rate} \geq 2/\lambda \text{ [samples/meter]} + +of, uitgedrukt in elementafstand :math:`d` (in feite meter per ruimtelijke sample): + +.. math:: + + d \leq \lambda/2 -Now what happens when d is less than λ/2, such as when we need to fit the array in a small space? Let's repeat the same simulation: +Zolang :math:`d \leq \lambda/2` krijgen we geen grating lobes. + +Wat gebeurt er dan als :math:`d` kleiner is dan λ/2, bijvoorbeeld wanneer de array in een kleine ruimte moet passen? We weten dat we dan geen grating lobes krijgen, maar er gebeurt wel iets anders. Laten we dezelfde simulatie herhalen, startend bij 0,5λ en dan :math:`d` verlagen: .. image:: ../_images/doa_d_is_small_animation.gif :scale: 100 % :align: center :alt: Animation of direction of arrival (DOA) showing what happens when distance d is much less than half-wavelength -While the main lobe gets wider as d gets lower, it still has a maximum at 20 degrees, and there are no grating lobes, so in theory this would still work (at least at high SNR). To better understand what breaks as d gets too small, let's repeat the experiment but with an additional signal arriving from -40 degrees: +Terwijl de hoofdlob breder wordt als :math:`d` kleiner wordt, blijft het maximum wel op 20 graden liggen en ontstaan er geen grating lobes. In theorie werkt dit dus nog steeds (tenminste bij hoge SNR en zolang onderlinge koppeling geen groot probleem wordt). Om beter te begrijpen wat er misgaat bij te kleine :math:`d`, herhalen we het experiment met een extra signaal dat binnenkomt op -40 graden: .. image:: ../_images/doa_d_is_small_animation2.gif :scale: 100 % :align: center :alt: Animation of direction of arrival (DOA) showing what happens when distance d is much less than half-wavelength and there are two signals present -Once we get lower than λ/4 there is no distinguishing between the two different paths, and the array performs poorly. As we will see later in this chapter, there are beamforming techniques that provide more precise beams than conventional beamforming, but keeping d as close to λ/2 as possible will continue to be a theme. +Zodra we onder λ/4 komen, is er nauwelijks nog onderscheid te maken tussen de twee verschillende paden en presteert de array slecht. Zoals we later in dit hoofdstuk zullen zien, zijn er beamformingtechnieken met scherpere bundels dan conventionele beamforming. Toch blijft het een belangrijk uitgangspunt om :math:`d` zo dicht mogelijk bij λ/2 te houden. + +.. + UITGECOMMENTARIEERD OMDAT NIET DUIDELIJK IS WAT DEZE SECTIE TOEVOEGT VOOR DE LEZER, BEHALVE EEN ALTERNATIEVE VERGELIJKING EN TERM DIE VEEL COMPACTER GEPRESENTEERD KAN WORDEN + ********************** + Bartlett Beamformer + ********************** + + Nu we de basis hebben behandeld, maken we een korte zijstap naar notatie en algebraische details van wat we net deden, zodat we bundelsweeps door de ruimte compact en elegant wiskundig kunnen beschrijven. De volgende algebraische notatie leent zich goed voor vectorisatie, en is daardoor geschikt voor realtime verwerking. + + Het proces van bundels door de ruimte sweepen om DOA te schatten heeft een technische naam: "Bartlett Beamforming" (soms ook Fourier beamforming genoemd, al kan die term ook naar een andere techniek verwijzen). Hieronder een korte samenvatting van wat we eerder hebben gedaan om DOA te berekenen, nu in Bartlett-termen: + + #. We kozen een reeks richtingen om op te richten (bijv. -90 tot +90 graden met een bepaalde stap) + #. We berekenden voor elke richting bundelvormingsgewichten om de bundel daarheen te sturen + #. De uitgangen van de array-elementen werden met hun bijbehorende gewichten vermenigvuldigd en opgeteld + #. We berekenden het signaalvermogen per richting en plotten de resultaten + #. Piekdetectie gaf aan uit welke richtingen waarschijnlijk signalen werden ontvangen + + We schrijven die stappen nu wiskundig op. Laat het door de array ontvangen signaal worden weergegeven met stuurvector :math:`\mathbf{s}`. Dit ontvangen signaal hangt af van de aankomstrichting (DOA), genoteerd als :math:`\theta`. De gewichten noteren we als :math:`\mathbf{w}`. De array-uitgang is dan het inwendig product :math:`\mathbf{w}^{H} \mathbf{s}`. Het signaalvermogen volgt uit het kwadraat van de magnitude van die uitgang: :math:`\left| \mathbf{w}^{H} \mathbf{s} \right|^{2} = \mathbf{w}^{H} \mathbf{s} \mathbf{s}^{H} \mathbf{w} = \mathbf{w} \mathbf{R_{ss}} \mathbf{w}`, waarbij :math:`\mathbf{R}` de geschatte ruimtelijke covariantiematrix is. Die covariantiematrix meet de overeenkomst tussen samples van verschillende array-elementen. Dit herhalen we voor elke te scannen richting; het enige dat per richting verandert is :math:`\mathbf{w}`. We zijn vrij in de gekozen richtingen, dus dat hoeft niet per se een sweep van -90 tot +90 graden te zijn. Alles kan desgewenst parallel met dezelfde :math:`\mathbf{R}` worden verwerkt. Dit is de essentie van Bartlett beamforming: de bundelsweep zoals eerder in Python beschreven. + + .. math:: + P = \left\| \mathbf{w} \mathbf{s}\right\|^2 + + = (\mathbf{w}^H\mathbf{s})(\mathbf{w}^H\mathbf{s})^* + + = \mathbf{s}^H\mathbf{w}\mathbf{w}^H\mathbf{s} + + = \mathbf{s}^H\mathbf{R}\mathbf{s} + + Deze wiskundige representatie is ook toepasbaar op andere DOA-technieken. + +********************** +Ruimtelijke Tapering +********************** + +Spatial tapering is een techniek die je naast de conventionele beamformer gebruikt, waarbij je de amplitude van de gewichten aanpast om bepaalde eigenschappen te krijgen. Ook als je geen conventionele beamformer gebruikt, is het tapering-concept belangrijk om te begrijpen. Toen we de gewichten van de conventionele beamformer berekenden, waren dat complexe getallen met allemaal amplitude één (unity). Met spatial tapering vermenigvuldigen we de gewichten met scalars om die amplitude te schalen. Laten we beginnen met wat er gebeurt als we de gewichten met willekeurige waarden tussen 0 en 1 vermenigvuldigen: + +.. code-block:: python + + tapering = np.random.uniform(0, 1, Nr) # willekeurige tapering + w *= tapering + +We simuleren een signaal dat op kijkrichting (0 graden) wordt ontvangen bij hoge SNR om te zien wat er gebeurt. Merk op dat dit proces equivalent is aan het simuleren van het quiescent antenna pattern voor deze gewichten, en dus dezelfde resultaten geeft, zoals we aan het eind van dit hoofdstuk bespreken. + +.. image:: ../_images/spatial_tapering_animation.gif + :scale: 80 % + :align: center + :alt: Spatial tapering using random values to adjust the magnitude of the weights + +Probeer de breedte van de hoofdlob en de positie van de nullen te observeren. + +Het blijkt dat tapering de zijlobben kan verlagen, wat vaak gewenst is, door de amplitude van de gewichten aan de **randen** van de array te verlagen. Een Hamming-venster kan bijvoorbeeld als taperingwaarden worden gebruikt: + +.. code-block:: python + + tapering = np.hamming(Nr) # Hamming-vensterfunctie + w *= tapering + +Voor de leuk laten we de taperingfunctie geleidelijk overgaan van een rechthoekvenster (geen venster) naar een Hamming-venster: + +.. image:: ../_images/spatial_tapering_animation2.gif + :scale: 80 % + :align: center + :alt: Spatial tapering using a hamming window to adjust the magnitude of the weights + +We zien hier een paar veranderingen. Ten eerste kan de hoofdlob breder of smaller worden afhankelijk van de taperingfunctie (minder zijlobben betekent meestal een bredere hoofdlob). Een rechthoekige taper (dus geen tapering) geeft de smalste hoofdlob, maar ook de hoogste zijlobben. Ten tweede zien we dat de gain van de hoofdlob afneemt wanneer we tapering toepassen. Dat komt doordat we uiteindelijk minder signaalenergie ontvangen doordat we niet de volledige gain van alle elementen gebruiken. Bij zeer lage SNR kan dat een belangrijk nadeel zijn. + +Als je je afvraagt waarom er zoveel zijlobben zijn bij een rechthoekvenster (geen tapering): dat is dezelfde reden waarom een rechthoekvenster in het tijdsdomein tot spectrale lekkage in het frequentiedomein leidt. De Fourier-transformatie van een rechthoekvenster is een sinc-functie, :math:`sin(x)/x`, met zijlobben die oneindig doorlopen. Bij arrays samplen we in het ruimtelijke domein, en het bundelpatroon is de Fourier-transformatie van dat ruimtelijke sampleproces in combinatie met de gewichten. Daarom konden we eerder in dit hoofdstuk het bundelpatroon met een FFT plotten. In de sectie over vensterfuncties in het frequentiedomein hebben we de frequentierespons van venstertypen al vergeleken: + +.. image:: ../_images/windows.svg + :align: center + :target: ../_images/windows.svg + +****************************** +Gewichten Handmatig Aanpassen +****************************** + +De conventionele beamformer geeft ons een vergelijking om gewichten te berekenen voor een specifieke richting. Maar laten we nu even doen alsof we geen methode hebben en handmatig met de gewichten (zowel amplitude als fase) spelen om te zien wat er gebeurt. Hieronder staat een kleine JavaScript-app die het bundelpatroon van een 8-element-array simuleert, met sliders voor gain en fase per element. Je kunt tapering toevoegen, of minder dan 8 elementen simuleren door de amplitude van één of meer elementen op nul te zetten. + +.. raw:: html + +
+
+ Element     Magnitude (Gain)                  Phase +
+ + + +************************ +Adaptieve Bundelvorming +************************ + +De conventionele beamformer die we eerder hebben besproken is een eenvoudige en effectieve manier om bundelvorming uit te voeren, maar hij heeft beperkingen. Hij werkt bijvoorbeeld minder goed wanneer meerdere signalen uit verschillende richtingen binnenkomen, of wanneer het ruisniveau hoog is. In zulke gevallen gebruiken we geavanceerdere technieken, vaak "adaptieve" beamforming genoemd. Het idee hierachter is dat we het ontvangen signaal gebruiken om de gewichten te berekenen, in plaats van een vaste set gewichten zoals bij conventionele beamforming. Daardoor kan de beamformer zich aanpassen aan de omgeving en beter presteren, omdat de gewichten nu op statistieken van de ontvangen data zijn gebaseerd. + +Adaptieve bundelvormingstechnieken kun je verder opdelen in reguliere en subspace-gebaseerde methoden. Subspace-methoden zoals MUSIC en ESPRIT zijn erg krachtig, maar vereisen dat je schat hoeveel signalen aanwezig zijn. Daarnaast hebben ze minimaal drie elementen nodig om te werken (al is minimaal vier aanbevolen). + +De eerste adaptieve bundelvormingstechniek die we bekijken is MVDR, vaak het de-facto-algoritme wanneer mensen over adaptieve bundelvorming praten. ********************** -MVDR/Capon Beamformer +MVDR/Capon-beamformer ********************** -We will now look at a beamformer that is slightly more complicated than the conventional/delay-and-sum technique, but tends to perform much better, called the Minimum Variance Distortionless Response (MVDR) or Capon Beamformer. Recall that variance of a signal corresponds to how much power is in the signal. The idea behind MVDR is to keep the signal at the angle of interest at a fixed gain of 1 (0 dB), while minimizing the total variance/power of the resulting beamformed signal. If our signal of interest is kept fixed then minimizing the total power means minimizing interferers and noise as much as possible. It is often refered to as a "statistically optimal" beamformer. +We bekijken nu een beamformer die iets complexer is dan de conventionele/delay-and-sum-techniek, maar meestal veel beter presteert: de Minimum Variance Distortionless Response (MVDR), ook wel Capon-beamformer genoemd. Onthoud dat de variantie van een signaal overeenkomt met het vermogen in dat signaal. Het idee achter MVDR is om de versterking van het signaal in de gewenste richting 1 (0 dB) te houden, terwijl de totale variantie/het totale vermogen van het gebundelde signaal wordt geminimaliseerd. Als het gewenste signaal vast staat, betekent het minimaliseren van het totale vermogen dat interferentie en ruis zo veel mogelijk worden onderdrukt. Daarom wordt MVDR vaak een "statistisch optimale" beamformer genoemd. -The MVDR/Capon beamformer can be summarized in the following equation: +De MVDR/Capon-beamformer kan worden samengevat met de volgende vergelijking: .. math:: - w_{mvdr} = \frac{R^{-1} a}{a^H R^{-1} a} + w_{mvdr} = \frac{R^{-1} s}{s^H R^{-1} s} -where :math:`R` is the covariance matrix estimate based on our recieved samples, calculated by multiplying :code:`r` with the complex conjugate transpose of itself, i.e., :math:`R = r r^H`, and the result will be a :code:`Nr` x :code:`Nr` size matrix (3x3 in the examples we have seen so far). This covariance matrix tells us how similar the samples received from the three elements are. The vector :math:`a` is the steering vector corresponding to the desired direction and was discussed at the beginning of this chapter. +De vector :math:`s` is de stuurvector voor de gewenste richting en is aan het begin van dit hoofdstuk besproken. :math:`R` is de geschatte ruimtelijke covariantiematrix op basis van onze ontvangen samples, te bepalen via :code:`R = np.cov(X)` of handmatig met :math:`R = X X^H`, dus :code:`X` vermenigvuldigd met zijn complex geconjugeerde getransponeerde. De ruimtelijke covariantiematrix heeft grootte :code:`Nr` x :code:`Nr` (3x3 in de voorbeelden tot nu toe) en geeft aan hoe sterk de samples van de elementen op elkaar lijken. De vergelijking kan in eerste instantie verwarrend zijn, maar de noemer dient vooral voor schaling. De teller is het belangrijkst: de inverse van de covariantiematrix vermenigvuldigd met de stuurvector. Toch moeten we de noemer wel meenemen, omdat die als normalisatieconstante werkt zodat de amplitude van de gewichten niet wegdrijft wanneer :math:`R` in de tijd verandert. -If we already know the direction of the signal of interest, and that direction does not change, we only have to calculate the weights once and simply use them to receive our signal of interest. Although even if the direction doesn't change, we benefit from recalculating these weights periodically, to account for changes in the interference/noise, which is why we refer to these non-conventional digital beamformers as "adaptive" beamforming; they use information in the signal we receive to calculate the best weights. Just as a reminder, we can *perform* beamforming using MVDR by calculating these weights and applying them to the signal with :code:`w.conj().T @ r`, just like we did in the conventional method, the only difference is how the weights are calculated. +.. raw:: html -To perform DOA using the MVDR beamformer, we simply repeat the MVDR calculation while scanning through all angles of interest. I.e., we act like our signal is coming from angle :math:`\theta`, even if it isn't. At each angle we calculate the MVDR weights, then apply them to the received signal, then calculate the power in the signal. The angle that gives us the highest power is our DOA estimate, or even better we can plot power as a function of angle to see the beam pattern, as we did above with the conventional beamformer, that way we don't need to assume how many signals are present. +
+ Voor wie interesse heeft in de MVDR-afleiding: klap dit open -In Python we can implement the MVDR/Capon beamformer as follows, which will be done as a function so that it's easy to use later on: + +**Uitgang van de beamformer** - De uitgang van de beamformer met gewichtenvector :math:`\mathbf{w}` is: + +.. math:: + + y(t) = \mathbf{w}^H \mathbf{x}(t) + + +**Optimalisatieprobleem** - Het doel is om beamforminggewichten te bepalen die het uitgangsvermogen minimaliseren, onder de voorwaarde van een distortionless respons in de gewenste richting :math:`\theta_0`. Formeel schrijven we dat als: + +.. math:: + + \min_{\mathbf{w}} \, \mathbf{w}^H \mathbf{R} \mathbf{w} \quad \text{subject to} \quad \mathbf{w}^H \mathbf{s} = 1 + +waarbij: + +* :math:`\mathbf{R} = E[\mathbf{X}\mathbf{X}^H]` de covariantiematrix van de ontvangen signalen is +* :math:`\mathbf{s}` de stuurvector in de gewenste signaalrichting :math:`\theta_0` is + +**Lagrangemethode** - Introduceer een Lagrange-multiplier :math:`\lambda` en vorm de Lagrangiaan: + +.. math:: + + L(\mathbf{w}, \lambda) = \mathbf{w}^H \mathbf{R} \mathbf{w} - \lambda (\mathbf{w}^H \mathbf{s} - 1) + +**Oplossen van de optimalisatie** - Door de Lagrangiaan af te leiden naar :math:`\mathbf{w^H}` en gelijk te stellen aan nul krijgen we: + +.. math:: + + \frac{\partial L}{\partial \mathbf{w}^*} = 2\mathbf{R}\mathbf{w} - \lambda \mathbf{s} = 0 + + \mathbf{w} = \lambda \mathbf{s} \mathbf{{R^{-1}}} + + +Om :math:`\lambda` op te lossen, passen we de randvoorwaarde :math:`\mathbf{w}^H \mathbf{s} = 1` toe: + +.. math:: + + \implies (\lambda \mathbf{s^{H}}\mathbf{{R^{-1}}})s = 1 + + \implies \lambda = \frac{1}{\mathbf{s}^{H}\mathbf{R}^{-1}\mathbf{s}} + + \mathbf{R}\mathbf{w} = \lambda \mathbf{s} + + \mathbf{w_{mvdr}} = \frac{\mathbf{R}^{-1} \mathbf{s}}{\mathbf{s}^H \mathbf{R}^{-1} \mathbf{s}} + +.. raw:: html + +
+ +Als we de richting van het gewenste signaal al kennen en die richting niet verandert, hoeven we de gewichten maar één keer te berekenen en kunnen we die gebruiken om het signaal te ontvangen. Toch is periodiek herberekenen vaak nuttig, zelfs bij constante richting, om veranderingen in interferentie/ruis op te vangen. Daarom noemen we dit soort niet-conventionele digitale beamformers "adaptief"; ze gebruiken informatie uit het ontvangen signaal om betere gewichten te berekenen. Ter herinnering: we *voeren* bundelvorming met MVDR uit door deze gewichten te berekenen en toe te passen met :code:`w.conj().T @ X`, net als bij de conventionele methode. Alleen de manier waarop de gewichten worden berekend verschilt. + +Om DOA met de MVDR-beamformer uit te voeren, herhalen we eenvoudig de MVDR-berekening terwijl we alle relevante hoeken scannen. Met andere woorden: we doen alsof het signaal uit hoek :math:`\theta` komt, ook als dat niet zo is. Per hoek berekenen we de MVDR-gewichten, passen die toe op het ontvangen signaal en berekenen vervolgens het signaalvermogen. De hoek met het hoogste vermogen is onze DOA-schatting. Nog beter is om vermogen als functie van hoek te plotten, zoals we eerder deden met de conventionele beamformer, zodat we niet vooraf hoeven aan te nemen hoeveel signalen aanwezig zijn. + +In Python kunnen we de MVDR/Capon-beamformer als volgt implementeren, hier als functie zodat hij later makkelijk te hergebruiken is: .. code-block:: python - # theta is the direction of interest, in radians, and r is our received signal - def w_mvdr(theta, r): - a = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # steering vector in the desired direction theta - a = a.reshape(-1,1) # make into a column vector (size 3x1) - R = r @ r.conj().T # Calc covariance matrix. gives a Nr x Nr covariance matrix of the samples - Rinv = np.linalg.pinv(R) # 3x3. pseudo-inverse tends to work better/faster than a true inverse - w = (Rinv @ a)/(a.conj().T @ Rinv @ a) # MVDR/Capon equation! numerator is 3x3 * 3x1, denominator is 1x3 * 3x3 * 3x1, resulting in a 3x1 weights vector - return w + # theta is de gewenste richting in radialen, en X is het ontvangen signaal + def w_mvdr(theta, X): + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # stuurvector in de gewenste richting theta + s = s.reshape(-1,1) # maak er een kolomvector van (grootte 3x1) + R = (X @ X.conj().T)/X.shape[1] # bereken covariantiematrix; dit geeft een Nr x Nr-matrix van de samples + Rinv = np.linalg.pinv(R) # 3x3. pseudo-inverse werkt meestal beter/sneller dan een echte inverse + w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # MVDR/Capon-vergelijking; teller is 3x3 * 3x1, noemer is 1x3 * 3x3 * 3x1, resultaat is 3x1 + return w -Using this MVDR beamformer in the context of DOA, we get the following Python example: +Als we deze MVDR-beamformer in DOA-context gebruiken, krijgen we het volgende Python-voorbeeld: .. code-block:: python - theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 different thetas between -180 and +180 degrees + theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 verschillende theta-waarden tussen -180 en +180 graden results = [] for theta_i in theta_scan: - w = w_mvdr(theta_i, r) # 3x1 - r_weighted = w.conj().T @ r # apply weights - power_dB = 10*np.log10(np.var(r_weighted)) # power in signal, in dB so its easier to see small and large lobes at the same time + w = w_mvdr(theta_i, X) # 3x1 + X_weighted = w.conj().T @ X # pas gewichten toe + power_dB = 10*np.log10(np.var(X_weighted)) # vermogen in dB, zodat kleine en grote lobben tegelijk zichtbaar zijn results.append(power_dB) results -= np.max(results) # normalize -When applied to the previous DOA example simulation, we get the following: +Toegepast op de vorige DOA-simulatie krijgen we: .. image:: ../_images/doa_capons.svg :align: center :target: ../_images/doa_capons.svg -It appears to work fine, but to really compare this to other techniques we'll have to create a more interesting problem. Let's set up a simulation with an 8-element array receiving three signals from different angles: 20, 25, and 40 degrees, with the 40 degree one received at a much lower power than the other two, as a way to spice things up. Our goal will be to detect all three signals, meaning we want to be able to see noticeable peaks (high enough for a peak-finder algorithm to extract). The code to generate this new scenario is as follows: +Dit lijkt goed te werken, maar om echt met andere technieken te vergelijken maken we een interessanter scenario. We zetten een simulatie op met een 8-element-array die drie signalen ontvangt vanuit verschillende hoeken: 20, 25 en 40 graden, waarbij het signaal op 40 graden met veel lager vermogen binnenkomt dan de andere twee. Ons doel is alle drie signalen te detecteren, dus we willen duidelijk zichtbare pieken hebben (hoog genoeg voor een piekzoekalgoritme). De code om dit scenario te genereren is: .. code-block:: python - Nr = 8 # 8 elements - theta1 = 20 / 180 * np.pi # convert to radians + Nr = 8 # 8 elementen + theta1 = 20 / 180 * np.pi # omzetten naar radialen theta2 = 25 / 180 * np.pi theta3 = -40 / 180 * np.pi - a1 = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta1)).reshape(-1,1) # 8x1 - a2 = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta2)).reshape(-1,1) - a3 = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta3)).reshape(-1,1) - # we'll use 3 different frequencies. 1xN + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta1)).reshape(-1,1) # 8x1 + s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta2)).reshape(-1,1) + s3 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta3)).reshape(-1,1) + # we gebruiken 3 verschillende frequenties. 1xN tone1 = np.exp(2j*np.pi*0.01e6*t).reshape(1,-1) tone2 = np.exp(2j*np.pi*0.02e6*t).reshape(1,-1) tone3 = np.exp(2j*np.pi*0.03e6*t).reshape(1,-1) - r = a1 @ tone1 + a2 @ tone2 + 0.1 * a3 @ tone3 + X = s1 @ tone1 + s2 @ tone2 + 0.1 * s3 @ tone3 # let op: de laatste heeft 1/10 van het vermogen n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) - r = r + 0.05*n # 8xN + X = X + 0.05*n # 8xN -You can put this code at the top of your script, since we are generating a different signal than the original example. If we run our MVDR beamformer on this new scenario we get the following results: +Je kunt deze code bovenaan je script plaatsen, omdat we hier een ander signaal genereren dan in het oorspronkelijke voorbeeld. Als we in dit scenario de MVDR-beamformer draaien, krijgen we: .. image:: ../_images/doa_capons2.svg :align: center :target: ../_images/doa_capons2.svg -It works pretty well, we can see the two signals received only 5 degrees apart, and we can also see the 3rd signal (at -40 or 320 degrees) that was received at one tenth the power of the others. Now let's run the conventional beamformer on this new scenario: +Dit werkt vrij goed: we zien twee signalen die slechts 5 graden uit elkaar liggen, en ook het derde signaal (op -40 of 320 graden) dat met een tiende van het vermogen van de andere binnenkomt. Laten we nu in hetzelfde scenario de conventionele beamformer draaien: .. image:: ../_images/doa_complex_scenario.svg :align: center :target: ../_images/doa_complex_scenario.svg -While it might be a pretty shape, it's not finding all three signals at all... By comparing these two results we can see the benefit from using a more complex and "adptive" beamformer. +Hoewel het er visueel mooi uitziet, vindt deze methode duidelijk niet alle drie de signalen. Door deze twee resultaten te vergelijken zie je het voordeel van een complexere en "adaptieve" beamformer. -As a quick aside for the interested reader, there is actually an optimization that can be made when performing DOA with MVDR, using a trick. Recall that we calculate the power in a signal by taking the variance, which is the mean of the magnitude squared (assuming our signals average value is zero which is almost always the case for baseband RF). We can represent taking the power in our signal after applying our weights as: +Als korte zijstap voor geïnteresseerden: er is een optimalisatie mogelijk bij DOA met MVDR. Onthoud dat we signaalvermogen berekenen via de variantie, oftewel het gemiddelde van de magnitude in het kwadraat (aangenomen dat het gemiddelde van het signaal ongeveer nul is, wat bij basisband-RF vrijwel altijd zo is). Het vermogen na toepassen van de gewichten kunnen we schrijven als: .. math:: P_{mvdr} = \frac{1}{N} \sum_{n=0}^{N-1} \left| w^H_{mvdr} r_n \right|^2 -If we plug in the equation for the MVDR weights we get: +Als we overstappen van een sommatie naar de verwachtingsoperator, en de vergelijking voor MVDR-gewichten invullen, krijgen we: .. math:: - P_{mvdr} = \frac{1}{N} \sum_{n=0}^{N-1} \left| \left( \frac{R^{-1} a}{a^H R^{-1} a} \right)^H r_n \right|^2 + P_{mvdr} = E \left( \left| w^H_{mvdr} X_n \right| ^2 \right) - = \frac{1}{N} \sum_{n=0}^{N-1} \left| \frac{a^H R^{-1}}{a^H R^{-1} a} r_n \right|^2 - - ... \mathrm{math} - - = \frac{1}{a^H R^{-1} a} + = w^H_{mvdr} E \left( X X^H \right) w_{mvdr} + + = w^H_{mvdr} R w_{mvdr} + + = \frac{s^H R^{-1} s}{s^H R^{-1} s} \cdot R \cdot \frac{R^{-1} s}{s^H R^{-1} s} + + = \frac{s^H R^{-1} s}{(s^H R^{-1} s)(s^H R^{-1} s)} + + = \frac{1}{s^H R^{-1} s} + +Dit betekent dat we de gewichten niet expliciet hoeven toe te passen; de laatste vermogensvergelijking hierboven kan direct in de DOA-scan worden gebruikt en bespaart rekenwerk: + +.. code-block:: python + + def power_mvdr(theta, X): + s = np.exp(2j * np.pi * d * np.arange(r.shape[0]) * np.sin(theta)) # stuurvector in de gewenste richting theta_i + s = s.reshape(-1,1) # maak er een kolomvector van (grootte 3x1) + R = (X @ X.conj().T)/X.shape[1] # bereken covariantiematrix; dit geeft een Nr x Nr-matrix van de samples + Rinv = np.linalg.pinv(R) # 3x3. pseudo-inverse werkt meestal beter dan een echte inverse + return 1/(s.conj().T @ Rinv @ s).squeeze() + +Om dit in de vorige simulatie te gebruiken hoef je in de for-loop alleen nog :code:`10*np.log10()` toe te passen; er zijn geen gewichten meer om toe te passen, want die berekening hebben we overgeslagen. -Meaning we don't have to apply the weights at all, this final equation above for power can be used directly in our DOA scan, saving us some computations: +Er bestaan nog veel meer beamformers, maar hierna staan we eerst kort stil bij hoe het aantal elementen invloed heeft op bundelvorming en DOA. + +********************** +Covariantiematrix +********************** + +Laten we kort de ruimtelijke covariantiematrix bespreken, een kernbegrip in *adaptieve* bundelvorming. Een covariantiematrix is een wiskundige representatie van de overeenkomst tussen paren elementen in een willekeurige vector (in ons geval de array-elementen, daarom noemen we dit de *ruimtelijke* covariantiematrix). Een covariantiematrix is altijd vierkant, en de waarden op de diagonaal zijn de covariantie van elk element met zichzelf. We berekenen in de praktijk een *schatting* van de ruimtelijke covariantiematrix, omdat we maar een beperkt aantal samples hebben. + +In het algemeen is de covariantiematrix gedefinieerd als: + +:math:`\mathrm{cov}(X) = E \left[ (X - E[X])(X - E[X])^H \right]` + +voor draadloze basisbandsignalen is :math:`E[X]` meestal nul of bijna nul, dus dit vereenvoudigt tot: + +:math:`\mathrm{cov}(X) = E[X X^H]` + +Met een beperkt aantal IQ-samples, :math:`\boldsymbol{X}`, kunnen we deze covariantie schatten. We noteren die als :math:`\hat{R}`: + +.. math:: + + \hat{R} = \frac{\boldsymbol{X} \boldsymbol{X}^H}{N} + + = \frac{1}{N} \sum^N_{n=1} X_n X_n^H + +waar :math:`N` het aantal samples is (niet het aantal elementen). In Python ziet dat er zo uit: + +:code:`R = (X @ X.conj().T)/X.shape[1]` + +Als alternatief kunnen we de ingebouwde NumPy-functie gebruiken: + +:code:`R = np.cov(X)` + +Als voorbeeld bekijken we de ruimtelijke covariantiematrix voor het scenario met één zender en drie elementen: + +.. code-block:: python + + [[ 1.494+0.j 0.486+0.881j -0.543+0.839j] + [ 0.486-0.881j 1.517 +0.j 0.483+0.886j] + [-0.543-0.839j 0.483-0.886j 1.499+0.j ]] + +Let op dat de diagonale elementen reëel zijn en ongeveer gelijk. Dat komt doordat ze vooral het ontvangen signaalvermogen per element weergeven, en dat is vergelijkbaar omdat alle elementen dezelfde gain hebben. De off-diagonale elementen bevatten de meest relevante informatie, al zie je uit de ruwe waarden vooral dat er duidelijke correlatie tussen elementen aanwezig is. + +Als onderdeel van adaptieve bundelvorming zie je vaak dat we de inverse van de ruimtelijke correlatiematrix nemen. Die inverse vertelt hoe twee elementen zich tot elkaar verhouden nadat de invloed van de andere elementen is verwijderd. In statistiek heet dit de "precision matrix" en in radar de "whitening matrix". + +********************** +LCMV-beamformer +********************** + +Hoewel MVDR krachtig is, wat als we meer dan één SOI hebben? Met een kleine aanpassing op MVDR kunnen we gelukkig een schema bouwen dat meerdere SOI's aankan: de Linearly Constrained Minimum Variance (LCMV)-beamformer. Dit is een generalisatie van MVDR waarbij we de gewenste respons voor meerdere richtingen specificeren, een beetje als een ruimtelijke variant van SciPy's :code:`firwin2()` voor wie dat kent. De optimale gewichtenvector voor de LCMV-beamformer is samen te vatten als: + +.. math:: + + w_{lcmv} = R^{-1} C [C^H R^{-1} C]^{-1} f + +waar :math:`C` een matrix is met stuurvectoren van de bijbehorende SOI's en stoorzenders, en :math:`f` de gewenste responsvector is. Voor een bepaalde rij krijgt :math:`f` de waarde 0 als de bijbehorende stuurvector onderdrukt moet worden (null), en 1 als we er een bundel op willen richten. Hebben we bijvoorbeeld twee gewenste bronnen en twee interferentiebronnen, dan kunnen we :code:`f = [1,1,0,0]` kiezen. De LCMV-beamformer is een krachtig hulpmiddel om interferentie en ruis uit meerdere richtingen te onderdrukken en tegelijk gewenste signalen uit meerdere richtingen te versterken. De keerzijde is dat het totale aantal nullen en bundels dat je tegelijk kunt vormen beperkt is door de arraygrootte (het aantal elementen). Daarnaast moet je voor elke SOI en interferer een stuurvector opstellen, wat in de praktijk niet altijd eenvoudig beschikbaar is. Als je schattingen gebruikt, kan de prestatie van de LCMV-beamformer dalen. Daarom sturen we nullen liever met de ruimtelijke covariantiematrix :math:`R` (gebaseerd op statistiek van het ontvangen signaal), in plaats van nullen te "hardcoden" door de AoA van een interferer te schatten en daar een stuurvector voor te bouwen met een 0 in :math:`f`. + +LCMV uitvoeren in Python lijkt sterk op MVDR, maar we moeten :code:`C` opgeven (mogelijk samengesteld uit meerdere stuurvectoren) en :code:`f` als 1D-array met 1'en en 0'en zoals hierboven beschreven. De volgende code laat zien hoe je de LCMV-beamformer implementeert voor twee SOI's (15 en 60 graden). Onthoud dat MVDR maar één SOI tegelijk ondersteunt. Daarom is hier :code:`f = [1; 1]` zonder nullen, omdat we geen "hardcoded" nullen opnemen. We simuleren een scenario met vier stoorzenders op -60, -30, 0 en 30 graden. .. code-block:: python - def power_mvdr(theta, r): - a = np.exp(-2j * np.pi * d * np.arange(r.shape[0]) * np.sin(theta)) # steering vector in the desired direction theta_i - a = a.reshape(-1,1) # make into a column vector (size 3x1) - R = r @ r.conj().T # Calc covariance matrix. gives a Nr x Nr covariance matrix of the samples - Rinv = np.linalg.pinv(R) # 3x3. pseudo-inverse tends to work better than a true inverse - return 1/(a.conj().T @ Rinv @ a).squeeze() + # Richt op de SOI bij 15 graden en nog een potentiële SOI op 60 graden die we niet hebben gesimuleerd + soi1_theta = 15 / 180 * np.pi # omzetten naar radialen + soi2_theta = 60 / 180 * np.pi -To use this in the previous simulation, within the for loop, the only thing left to do is take the :code:`10*np.log10()` and you're done, there are no weights to apply; we skipped calculating the weights! + # LCMV-gewichten + R_inv = np.linalg.pinv(np.cov(X)) # 8x8 + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi1_theta)).reshape(-1,1) # 8x1 + s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi2_theta)).reshape(-1,1) # 8x1 + C = np.concatenate((s1, s2), axis=1) # 8x2 + f = np.ones(2).reshape(-1,1) # 2x1 -There are many more beamformers out there, but next we are going to take a moment to discuss how the number of elements impacts our ability to perform beamforming and DOA. + # LCMV-vergelijking + # 8x8 8x2 2x8 8x8 8x2 2x1 + w = R_inv @ C @ np.linalg.pinv(C.conj().T @ R_inv @ C) @ f # output is 8x1 + +We kunnen het bundelpatroon van :code:`w` plotten met de FFT-methode van eerder: + +.. image:: ../_images/lcmv_beam_pattern.svg + :align: center + :target: ../_images/lcmv_beam_pattern.svg + :alt: Example beam pattern when using the LCMV beamformer + +Zoals je ziet hebben we bundels naar de twee gewenste richtingen en nullen op de locaties van de stoorzenders (net als bij MVDR hoeven we niet expliciet te zeggen waar de zenders zitten; dat volgt uit het ontvangen signaal). Groene en rode punten in de plot geven respectievelijk de AoA's van SOI's en stoorzenders aan. + +.. raw:: html + +
+ Klap dit open voor de volledige code + +.. code-block:: python + + # Simuleer ontvangen signaal + Nr = 8 # 8 elementen + theta1 = -60 / 180 * np.pi # omzetten naar radialen + theta2 = -30 / 180 * np.pi + theta3 = 0 / 180 * np.pi + theta4 = 30 / 180 * np.pi + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta1)).reshape(-1,1) # 8x1 + s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta2)).reshape(-1,1) + s3 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta3)).reshape(-1,1) + s4 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta4)).reshape(-1,1) + # we gebruiken 3 verschillende frequenties. 1xN + tone1 = np.exp(2j*np.pi*0.01e6*t).reshape(1,-1) + tone2 = np.exp(2j*np.pi*0.02e6*t).reshape(1,-1) + tone3 = np.exp(2j*np.pi*0.03e6*t).reshape(1,-1) + tone4 = np.exp(2j*np.pi*0.04e6*t).reshape(1,-1) + X = s1 @ tone1 + s2 @ tone2 + s3 @ tone3 + s4 @ tone4 + n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) + X = X + 0.5*n # 8xN + + # Richt op de SOI bij 15 graden en nog een potentiële SOI op 60 graden die we niet hebben gesimuleerd + soi1_theta = 15 / 180 * np.pi # omzetten naar radialen + soi2_theta = 60 / 180 * np.pi + + # LCMV-gewichten + R_inv = np.linalg.pinv(np.cov(X)) # 8x8 + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi1_theta)).reshape(-1,1) # 8x1 + s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi2_theta)).reshape(-1,1) # 8x1 + C = np.concatenate((s1, s2), axis=1) # 8x2 + f = np.ones(2).reshape(-1,1) # 2x1 + + # LCMV-vergelijking + # 8x8 8x2 2x8 8x8 8x2 2x1 + w = R_inv @ C @ np.linalg.pinv(C.conj().T @ R_inv @ C) @ f # output is 8x1 + + # Plot bundelpatroon + w = w.squeeze() # reduceer naar een 1D-array + N_fft = 1024 + w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero-pad naar N_fft elementen voor meer FFT-resolutie + w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # FFT-magnitude in dB + w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak + theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # map FFT-bins naar hoeken in radialen + + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.plot(theta_bins, w_fft_dB) # GEBRUIK RADIALEN VOOR EEN POOLPLOT + # Voeg punten toe op de locaties van stoorzenders en SOI's + ax.plot([theta1], [0], 'or') + ax.plot([theta2], [0], 'or') + ax.plot([theta3], [0], 'or') + ax.plot([theta4], [0], 'or') + ax.plot([soi1_theta], [0], 'og') + ax.plot([soi2_theta], [0], 'og') + ax.set_theta_zero_location('N') # laat 0 graden omhoog wijzen + ax.set_theta_direction(-1) # laat de hoek met de klok mee toenemen + ax.set_thetagrids(np.arange(-90, 105, 15)) # dit is in graden + ax.set_rlabel_position(55) # verplaats rasterlabels weg van andere labels + ax.set_thetamin(-90) # toon alleen de bovenste helft + ax.set_thetamax(90) + ax.set_ylim([-30, 1]) # zonder ruis hoeven we maar tot -30 dB te gaan + plt.show() + +.. raw:: html + +
+ +Er is een interessante toepassing van LCMV waar je misschien al aan dacht: stel dat je de hoofdbundel niet exact op 20 graden wilt richten, maar juist breder wilt maken dan conventionele beamforming normaal oplevert. Dat kan door de gewenste responsvector :code:`f` op 1 te zetten voor een hoekbereik (bijvoorbeeld meerdere waarden tussen 10 en 30 graden) en daarbuiten op 0. Daarmee kun je een bundelpatroon maken dat breder is dan de hoofdlob van de conventionele beamformer, wat handig is in praktijksituaties waar de exacte aankomstrichting niet bekend is. Je kunt dezelfde aanpak ook gebruiken om een null over een breder hoekbereik te maken. Houd er wel rekening mee dat dit meerdere vrijheidsgraden kost. Als voorbeeld simuleren we een 18-element-array, met een interessehoek van 15 tot 30 graden via 4 verschillende theta's, en een null van 45 tot 60 graden ook met 4 theta's. We simuleren hier geen echte stoorzenders. + +.. code-block:: python + + Nr = 18 + X = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) # simuleer ontvangen signaal met alleen ruis + + # Richt op de SOI van 15 tot 30 graden met 4 verschillende theta's + soi_thetas = np.linspace(15, 30, 4) / 180 * np.pi # omzetten naar radialen + + # Maak een null van 45 tot 60 graden met 4 verschillende theta's + null_thetas = np.linspace(45, 60, 4) / 180 * np.pi # omzetten naar radialen + + # LCMV-gewichten + R_inv = np.linalg.pinv(np.cov(X)) + s = [] + for soi_theta in soi_thetas: + s.append(np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(soi_theta)).reshape(-1,1)) + for null_theta in null_thetas: + s.append(np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(null_theta)).reshape(-1,1)) + C = np.concatenate(s, axis=1) + f = np.asarray([1]*len(soi_thetas) + [0]*len(null_thetas)).reshape(-1,1) + w = R_inv @ C @ np.linalg.pinv(C.conj().T @ R_inv @ C) @ f # LCMV-vergelijking + + # Plot bundelpatroon zoals eerder... + +.. image:: ../_images/lcmv_beam_pattern_spread.svg + :align: center + :target: ../_images/lcmv_beam_pattern_spread.svg + :alt: Example beam pattern when using the LCMV beamformer with a spread beam and a spread null + +De bundel en null zijn nu uitgespreid over het gevraagde bereik. Probeer het aantal theta's voor de hoofdbundel en/of null te wijzigen, en ook het aantal elementen, om te zien of de resulterende gewichten de gewenste respons nog kunnen realiseren. ******************* -Number of Elements +Nullsturing ******************* -Coming soon! +Nu we LCMV hebben gezien, is het de moeite waard om een eenvoudigere techniek te bekijken die zowel in analoge als digitale arrays kan worden gebruikt: null steering. Zie het als een uitbreiding op de conventionele beamformer: naast een bundel naar de gewenste richting kun je ook nullen op specifieke hoeken plaatsen. Deze techniek past gewichten niet aan op basis van het ontvangen signaal (we berekenen bijvoorbeeld geen :code:`R`) en wordt dus niet als adaptief beschouwd. In de simulatie hieronder hoeven we zelfs geen signaal te simuleren; we construeren alleen de gewichten met null steering en visualiseren vervolgens het bundelpatroon. + +De gewichten voor null steering bereken je door te starten met de conventionele beamformer op de interessehoek, en daarna met de sidelobe-canceler-vergelijking de gewichten bij te werken zodat nullen worden toegevoegd, één voor één. De sidelobe-canceler-vergelijking is: + +.. math:: + + w_{\text{new}} = w_{\text{orig}} - \frac{w_{\text{null}}^H w_{\text{orig}}}{w_{\text{null}}^H w_{\text{null}}} w_{\text{null}} + +waar :math:`w_{\text{null}}` de stuurvector is in de richting van de null die we aan :math:`w_{\text{orig}}` willen toevoegen. De gewichten worden bijgewerkt door de geschaalde null-stuurvector van de huidige gewichten af te trekken. De schaalfactor volgt uit projectie van de huidige gewichten op de null-stuurvector, gedeeld door de projectie van die null-stuurvector op zichzelf. Dit herhaal je voor elke null-richting (:math:`w_{\text{orig}}` begint als conventionele beamforminggewichten en wordt na elke null bijgewerkt). Het volledige proces: + +.. math:: + + \text{1:} \qquad w_{\text{orig}} = e^{2j \pi d k \sin(\theta_{SOI})} \qquad + + \text{2:} \qquad w_{\text{null}} = e^{2j \pi d k \sin(\theta_{null})} \qquad + + \text{3:} \qquad w_{\text{new}} = w_{\text{orig}} - \frac{w_{\text{null}}^H w_{\text{orig}}}{w_{\text{null}}^H w_{\text{null}}} w_{\text{null}} + + \text{4:} \qquad w_{\text{orig}} = w_{\text{new}} \qquad \qquad \qquad + + \text{5:} \qquad \text{GOTO 2 to add next null} + +Laten we een 8-element-array simuleren en vier nullen plaatsen: + +.. code-block:: python + + d = 0.5 + Nr = 8 + + theta_soi = 30 / 180 * np.pi # omzetten naar radialen + nulls_deg = [-60, -30, 0, 60] # graden + nulls_rad = np.asarray(nulls_deg) / 180 * np.pi + + # Start met een conventionele beamformer gericht op theta_soi + w = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_soi)).reshape(-1,1) + + # Loop over de nullen + for null_rad in nulls_rad: + # gewichten gelijk aan stuurvector in de gewenste null-richting + w_null = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(null_rad)).reshape(-1,1) + + # scaling_factor (complex scalar) voor w in de genulde richting + scaling_factor = w_null.conj().T @ w / (w_null.conj().T @ w_null) + print("scaling_factor:", scaling_factor, scaling_factor.shape) + + # Werk gewichten bij om de null toe te voegen + w = w - w_null @ scaling_factor # sidelobe-canceler equation + + # Plot bundelpatroon + N_fft = 1024 + w_padded = np.concatenate((w.squeeze(), np.zeros(N_fft - Nr))) # zero-pad naar N_fft elementen voor meer FFT-resolutie + w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # FFT-magnitude in dB + w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak + theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # map FFT-bins naar hoeken in radialen + + fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) + ax.plot(theta_bins, w_fft_dB) + # Voeg punten toe op de locaties van nullen en SOI + for null_rad in nulls_rad: + ax.plot([null_rad], [0], 'or') + ax.plot([theta_soi], [0], 'og') + ax.set_theta_zero_location('N') # laat 0 graden omhoog wijzen + ax.set_theta_direction(-1) # laat de hoek met de klok mee toenemen + ax.set_thetagrids(np.arange(-90, 105, 15)) # dit is in graden + ax.set_rlabel_position(55) # verplaats rasterlabels weg van andere labels + ax.set_thetamin(-90) # toon alleen de bovenste helft + ax.set_thetamax(90) + ax.set_ylim([-40, 1]) # zonder ruis hoeven we maar tot -40 dB te gaan + plt.show() + +We krijgen het volgende bundelpatroon. Je ziet mogelijk nullen op posities die je niet expliciet hebt gevraagd; dat is verwacht gedrag en komt door het beperkte aantal elementen. Bij te weinig elementen kan het ook zijn dat nullen/bundel niet exact op de bedoelde plek liggen, of dat de criteria helemaal niet haalbaar zijn door een gebrek aan vrijheidsgraden (aantal elementen min 1). + +.. image:: ../_images/null_steering.svg + :align: center + :target: ../_images/null_steering.svg + :alt: Example of null steering beamforming ******************* MUSIC ******************* -We will now change gears and talk about a different kind of beamformer. All of the previous ones have fallen in the "delay-and-sum" category, but now we will dive into "sub-space" methods. These involve dividing the signal subspace and noise subspace, which means we must estimate how many signals are being received by the array, to get a good result. MUltiple SIgnal Classification (MUSIC) is a very popular sub-space method that involves calculating the eigenvectors of the covariance matrix (which is a computationally intensive operation by the way). We split the eigenvectors into two groups: signal sub-space and noise-subspace, then project steering vectors into the noise sub-space and steer for nulls. That might seem confusing at first, which is part of why MUSIC seems like black magic! +We schakelen nu over naar een ander type beamformer. Alle eerdere methoden vielen in de "delay-and-sum"-categorie, maar nu duiken we in "sub-space"-methoden. Daarbij splitsen we in een signaal-subruimte en een ruis-subruimte, wat betekent dat we eerst moeten schatten hoeveel signalen de array ontvangt. MUltiple SIgnal Classification (MUSIC) is een populaire subspace-methode die eigenvectoren van de covariantiematrix gebruikt (een rekenintensieve operatie). We splitsen de eigenvectoren in twee groepen: signaal-subruimte en ruis-subruimte, en projecteren daarna stuurvectoren in de ruis-subruimte om nullen te sturen. Dat klinkt in het begin verwarrend, wat mede verklaart waarom MUSIC soms als zwarte magie voelt. -The core MUSIC equation is the following: +De kernvergelijking van MUSIC is: .. math:: - \hat{\theta} = \mathrm{argmax}\left(\frac{1}{a^H V_n V^H_n a}\right) + \hat{\theta} = \mathrm{argmax}\left(\frac{1}{s^H V_n V^H_n s}\right) -where :math:`V_n` is that list of noise sub-space eigenvectors we mentioned (a 2D matrix). It is found by first calculating the eigenvectors of :math:`R`, which is done simply by :code:`w, v = np.linalg.eig(R)` in Python, and then splitting up the vectors (:code:`w`) based on how many signals we think the array is receiving. There is a trick for estimating the number of signals that we'll talk about later, but it must be between 1 and :code:`Nr - 1`. I.e., if you are designing an array, when you are choosing the number of elements you must have one more than the number of anticipated signals. One thing to note about the equation above is :math:`V_n` does not depend on the array factor :math:`a`, so we can precalculate it before we start looping through theta. The full MUSIC code is as follows: +waar :math:`V_n` de lijst is met eigenvectoren van de ruis-subruimte (een 2D-matrix). Die krijg je door eerst de eigenvectoren van :math:`R` te berekenen, in Python simpel met :code:`w, v = np.linalg.eig(R)`, en daarna de vectoren te splitsen op basis van hoeveel signalen we denken dat de array ontvangt. Er is een truc om het aantal signalen te schatten, die komt later, maar het moet tussen 1 en :code:`Nr - 1` liggen. Ontwerp je een array, dan moet het aantal elementen dus minstens één hoger zijn dan het verwachte aantal signalen. Belangrijk detail: in de vergelijking hierboven hangt :math:`V_n` niet af van stuurvector :math:`s`, dus :math:`V_n` kunnen we vooraf berekenen voordat we over theta loopen. De volledige MUSIC-code: .. code-block:: python - num_expected_signals = 3 # Try changing this! + num_expected_signals = 3 # Probeer dit te veranderen! - # part that doesn't change with theta_i - R = r @ r.conj().T # Calc covariance matrix, it's Nr x Nr - w, v = np.linalg.eig(R) # eigenvalue decomposition, v[:,i] is the eigenvector corresponding to the eigenvalue w[i] - eig_val_order = np.argsort(np.abs(w)) # find order of magnitude of eigenvalues - v = v[:, eig_val_order] # sort eigenvectors using this order - # We make a new eigenvector matrix representing the "noise subspace", it's just the rest of the eigenvalues + # deel dat niet verandert met theta_i + R = np.cov(X) # bereken covariantiematrix; dit geeft een Nr x Nr-matrix + w, v = np.linalg.eig(R) # eigenwaarde-ontbinding, v[:,i] is de eigenvector bij eigenwaarde w[i] + eig_val_order = np.argsort(np.abs(w)) # bepaal volgorde op grootte van eigenwaarden + v = v[:, eig_val_order] # sorteer eigenvectoren volgens die volgorde + # maak een nieuwe eigenvectormatrix voor de "ruis-subruimte"; dit zijn de overblijvende eigenwaarden V = np.zeros((Nr, Nr - num_expected_signals), dtype=np.complex64) for i in range(Nr - num_expected_signals): V[:, i] = v[:, i] - theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # -180 to +180 degrees + theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # -180 tot +180 graden results = [] for theta_i in theta_scan: - a = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta_i)) # array factor - a = a.reshape(-1,1) - metric = 1 / (a.conj().T @ V @ V.conj().T @ a) # The main MUSIC equation - metric = np.abs(metric.squeeze()) # take magnitude - metric = 10*np.log10(metric) # convert to dB - results.append(metric) + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_i)) # stuurvector + s = s.reshape(-1,1) + metric = 1 / (s.conj().T @ V @ V.conj().T @ s) # de hoofdvergelijking van MUSIC + metric = np.abs(metric.squeeze()) # neem de magnitude + metric = 10*np.log10(metric) # converteer naar dB + results.append(metric) results /= np.max(results) # normalize -Running this algorithm on the complex scenario we have been using, we get the following very precise results, showing the power of MUSIC: +Als we dit algoritme op het complexe scenario van hierboven toepassen, krijgen we zeer precieze resultaten, wat de kracht van MUSIC laat zien: .. image:: ../_images/doa_music.svg :align: center :target: ../_images/doa_music.svg :alt: Example of direction of arrival (DOA) using MUSIC algorithm beamforming -Now what if we had no idea how many signals were present? Well there is a trick; you sort the eigenvalue magnitudes from highest to lowest, and plot them (it may help to plot them in dB): +Wat als we geen idee hebben hoeveel signalen aanwezig zijn? Daar is een truc voor: sorteer de magnitudes van de eigenwaarden van hoog naar laag en plot ze (in dB plotten helpt vaak): .. code-block:: python @@ -580,147 +1126,320 @@ Now what if we had no idea how many signals were present? Well there is a trick :align: center :target: ../_images/doa_eigenvalues.svg -The eigenvalues associated with the noise-subspace are going to be the smallest, and they will all tend around the same value, so we can treat these low values like a "noise floor", and any eigenvalue above the noise floor represents a signal. Here we can clearly see there are three signals being received, and adjust our MUSIC algorithm accordingly. If you don't have a lot of IQ samples to process or the signals are at low SNR, the number of signals might not be as obvious. Feel free to play around by adjusting :code:`num_expected_signals` between 1 and 7, you'll find that underestimating the number will lead to missing signal(s) while overestimating will only slightly hurt performance. +De eigenwaarden die bij de ruis-subruimte horen zijn het kleinst en clusteren rond ongeveer dezelfde waarde. Je kunt deze lage waarden dus als "ruisvloer" zien, en elke eigenwaarde erboven komt overeen met een signaal. Hier zien we duidelijk dat er drie signalen worden ontvangen, en kunnen we het MUSIC-algoritme daarop afstemmen. Heb je weinig IQ-samples of lage SNR, dan is het aantal signalen minder duidelijk. Speel gerust met :code:`num_expected_signals` tussen 1 en 7; onderschatting zorgt voor gemiste signalen, overschatting schaadt de prestatie meestal maar beperkt. -Another experiment worth trying with MUSIC is to see how close two signals can arrive at (in angle) while still distinguishing between them; sub-space techniques are especially good at that. The animation below shows an example, with one signal at 18 degrees and another slowly sweeping angle of arrival. +Nog een interessant experiment met MUSIC is kijken hoe dicht twee signalen qua hoek bij elkaar kunnen liggen terwijl je ze nog kunt onderscheiden; subspace-technieken zijn hier juist erg goed in. De animatie hieronder laat een voorbeeld zien, met één signaal op 18 graden en een tweede waarvan de aankomstrichting langzaam sweept. .. image:: ../_images/doa_music_animation.gif :scale: 100 % :align: center +*** +LMS +*** + +De Least Mean Squares (LMS)-beamformer is een beamformer met lage complexiteit, geïntroduceerd door Bernard Widrow. Deze verschilt op twee punten van de beamformers die we eerder zagen: 1) je moet de SOI kennen, of ten minste een deel ervan (bijv. synchronisatiereeks, pilots, enz.), en 2) hij is iteratief, dus de gewichten worden in meerdere iteraties aangescherpt. LMS werkt door de gemiddelde kwadratische fout te minimaliseren tussen het gewenste signaal (SOI) en de uitgang van de beamformer (dus gewichten toegepast op ontvangen samples). In de klassieke implementatie is elk ontvangen sample de volgende iteratiestap: pas huidige gewichten toe op één sample, bereken fout, en gebruik die fout om gewichten bij te sturen. Daarna herhaal je dit. De LMS-beamformer is toepasbaar in zowel analoge als digitale bundelvorming. Het LMS-algoritme: + +.. math:: + + w_{n+1} = w_n + \mu \underbrace{\left(y_n - w_{n}^H x_n\right)^*}_{error} x_n + +waar :math:`w_n` de gewichtenvector is bij iteratie/sample :math:`n`, :math:`\mu` de stapgrootte is, :math:`x_n` het ontvangen sample op :math:`n`, :math:`y_n` de verwachte waarde in die iteratie (de bekende SOI), en :math:`*` de complex geconjugeerde is. Laat :math:`w_{n}^H x_n` de vergelijking niet ingewikkelder laten lijken dan nodig: dat is simpelweg het toepassen van de huidige gewichten op het ingangssignaal, oftewel standaard bundelvorming. De stapgrootte :math:`\mu` bepaalt hoe snel de gewichten convergeren naar optimale waarden. Een kleine :math:`\mu` geeft trage convergentie (je haalt mogelijk de beste gewichten niet voordat het bekende signaal weg is), terwijl een grote :math:`\mu` instabiliteit kan veroorzaken. LMS is krachtig voor adaptieve bundelvorming, maar heeft beperkingen: je hebt een bekende SOI nodig, en tijd- en frequentiesynchronisatie maken onderdeel uit van het LMS-proces zodat je SOI-referentie is uitgelijnd met de ontvangen samples. + +In het Python-voorbeeld hieronder simuleren we een 8-element-array met een SOI die bestaat uit een herhaalde Gold-code, gemoduleerd als BPSK. Gold-codes worden gebruikt in 5G en GPS en hebben uitstekende kruiscorrelatie-eigenschappen, waardoor ze goed zijn als synchronisatiesignaal. In de simulatie nemen we ook twee toon-stoorzenders op, op 60 en -50 graden. Let op: deze simulatie bevat geen tijd- of frequentieverschuiving; anders zouden we SOI-synchronisatie in het LMS-proces moeten opnemen (dus gecombineerde bundelvorming en synchronisatie). In de animatie hieronder sweepen we de AoA van de SOI en plotten we het bundelpatroon dat LMS na 10k samples oplevert. Je ziet dat LMS de gain richting de SOI op exact 0 dB houdt (tenzij er een interferer precies bovenop zit), terwijl nullen naar de stoorzenders worden gezet. + +.. image:: ../_images/doa_lms_animation.gif + :scale: 100 % + :align: center + +.. code-block:: python + + # Scenario + sample_rate = 1e6 + d = 0.5 # halve-golflengteafstand + N = 100000 # aantal te simuleren samples + Nr = 8 # elementen + theta_soi = 20 / 180 * np.pi # omzetten naar radialen + theta2 = 60 / 180 * np.pi + theta3 = -50 / 180 * np.pi + t = np.arange(N)/sample_rate # tijdsvector + s1 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta_soi)).reshape(-1,1) # 8x1 + s2 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta2)).reshape(-1,1) + s3 = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta3)).reshape(-1,1) + + # SOI is een Gold-code, herhaald, lengte 127 + gold_code = np.array([-1, 1, 1, -1, 1, 1, 1, 1, -1, -1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, 1, 1, 1, -1, -1, 1, 1, -1, -1, 1, -1, 1, -1, -1, 1, -1, -1, -1, -1, -1, -1, 1, 1, -1, 1, -1, 1, -1, 1, 1, -1, -1, -1, -1, 1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1, -1, -1, -1, -1, 1, 1, 1, -1, 1, -1, -1, -1, 1, 1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1, -1, -1, -1, 1, 1]) + soi_samples_per_symbol = 8 + soi = np.repeat(gold_code, soi_samples_per_symbol) + num_sequence_repeats = int(N / soi.shape[0]) + 1 # aantal herhalingen om N samples te vullen + soi = np.tile(soi, num_sequence_repeats)[:N] # herhaal reeks over simulatieduur en knip af + soi = soi.reshape(1, -1) # 1xN + + # Interferentie, bv. toonjammers, uit verschillende richtingen + tone2 = np.exp(2j*np.pi*0.02e6*t).reshape(1,-1) + tone3 = np.exp(2j*np.pi*0.03e6*t).reshape(1,-1) + + # Simuleer ontvangen signaal + r = s1 @ soi + s2 @ tone2 + s3 @ tone3 + n = np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N) + r = r + 0.5*n # 8xN + + # LMS: richting van SOI is onbekend, SOI-signaal zelf is wel bekend + mu = 0.5e-5 # LMS-stapgrootte + w_lms = np.zeros((Nr, 1), dtype=np.complex128) # start met nullen + + # Loop over ontvangen samples + error_log = [] + for i in range(N): + r_sample = r[:, i].reshape(-1, 1) # 8x1 + soi_sample = soi[0, i] # scalar + y = w_lms.conj().T @ r_sample # pas de gewichten toe + y = y.squeeze() # maak er een scalar van + error = soi_sample - y + error_log.append(np.abs(error)**2) + w_lms += mu * np.conj(error) * r_sample # gewichten zijn nog steeds 8x1 + + w_lms /= np.linalg.norm(w_lms) # normaliseer gewichten + + plt.plot(error_log) + plt.xlabel('Iteration') + plt.ylabel('Mean Square Error') + plt.show() + + # Plot het bundelpatroon zoals eerder getoond + +Probeer :code:`theta_soi`, de hoeveelheid ruis (dus :code:`0.5*n`) en de stapgrootte :code:`mu` te variëren om te zien hoe het LMS-algoritme presteert. + ******************* -ESPRIT +Training Data ******************* -Coming soon! +Binnen array processing bestaat het concept "training", waarbij je covariantiematrix :code:`R` vastlegt voordat een mogelijke SOI aanwezig is. Dit wordt vooral in radar gebruikt, waar meestal geen SOI aanwezig is en het detectieproces bestaat uit het testen van hoeken om te zien of er ergens een SOI zit. Als we :code:`R` vóór aanwezigheid van de SOI berekenen, kunnen we met methoden zoals MVDR gewichten bepalen waarin alleen stoorzenders en ruisomgeving zijn opgenomen. Zo voorkom je dat MVDR een null op of vlak bij de SOI-richting zet. Daarna passen we de gewichten toe op het ontvangen signaal om te testen of de SOI nu op die hoek aanwezig is. -********************* -Radar-Style Scenario -********************* +Om de waarde van trainingsdata te laten zien voeren we MVDR uit op een opname van een echte 16-element-array (met het QUAD-MxFE-platform van Analog Devices). Eerst doen we MVDR op de gebruikelijke manier, dus met het volledige ontvangen signaal voor :code:`R` en de gewichten. Daarna gebruiken we een aparte opname, gemaakt voordat de SOI werd ingeschakeld, om :code:`R` en de gewichten te berekenen. -In all of the previous DOA examples, we had one or more signals and we were interested in finding the directions of all of them. Now we will shift gears to a more radar-oriented scenario, where you have an environment with noise and interferers, and then a signal of interest (SOI) that is only present during certain times. A training phase, occurring when you know the SOI is not present, is performed, to capture the characteristics of the interference. We will be using the MVDR beamformer. +Deze opnames zijn gemaakt op 3,3 GHz RF, met een array-elementafstand van 0,045 meter, dus :math:`d = 0.495`. Er is een samplefrequentie van 30 MHz gebruikt. We noemen de drie signalen A, B en C. Signaal C is de aangewezen SOI, A en B zijn stoorzenders. Daarom hebben we een opname nodig met alleen A en B om trainingsdata te maken, zonder dat A en B verplaatsen tussen de trainingsopname en de opname waarin C ook aanwezig is. Hieronder staan de links naar de twee opnames: -A new scenario is used in the Python simulation below, involving one jammer and one SOI. In addition to simulating the samples of both signals combined (with noise), we also simulate just the jammer (with noise), which represents samples taken before the SOI was present. The received samples :code:`r` that only contain the jammer, are used as part of a training step, where we calculate the :code:`R_inv` in the MVDR equation. We then "turn on" the SOI by using :code:`r` that contains both the jammer and SOI, and the rest of the code is the same as normal MVDR DOA, except for one little but important detail- the :code:`R_inv`'s we use in the MVDR equation have to be: +https://github.com/777arc/777arc.github.io/raw/master/3p3G_A_B.npy -.. math:: +https://github.com/777arc/777arc.github.io/raw/master/3p3G_A_B_C.npy + +Laten we beginnen met normale MVDR op de A_B_C-opname. Die opname staat in :code:`np.save()`-formaat met een 2D-array: eerste dimensie is het aantal elementen in de array, tweede dimensie het aantal samples. + +.. code-block:: python + + import matplotlib.pyplot as plt + import numpy as np - w_{mvdr} = \frac{R_{jammer}^{-1} a}{a^H R_{both}^{-1} a} + # Arrayparameters + center_freq = 3.3e9 + sample_rate = 30e6 + d = 0.045 * center_freq / 3e8 + print("d:", d) -The full Python code example is as follows, try tweaking :code:`Nr` and :code:`theta1`: + # Bevat alle drie signalen; C noemen we onze SOI + filename = '3p3G_A_B_C.npy' + X = np.load(filename) + Nr = X.shape[0] + +Daarna voeren we basis-DOA met MVDR uit om de aankomstrichtingen van de drie signalen te bepalen: .. code-block:: python - # 1 jammer 1 SOI, generating two different received signals so we can isolate jammer for the training step - Nr = 4 # number of elements - theta1 = 20 / 180 * np.pi # Jammer - theta2 = 30 / 180 * np.pi # SOI - a1 = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta1)).reshape(-1,1) # Nr x 1 - a2 = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta2)).reshape(-1,1) - tone1 = np.exp(2j*np.pi*0.01*np.arange(N)).reshape(1,-1) # assume sample rate = 1 Hz, its arbitrary - tone2 = np.exp(2j*np.pi*0.02*np.arange(N)).reshape(1,-1) - r_jammer = a1 @ tone1 + 0.1*(np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N)) - r_both = a1 @ tone1 + a2 @ tone2 + 0.1*(np.random.randn(Nr, N) + 1j*np.random.randn(Nr, N)) + # Voer DOA uit om de aankomstrichting van C te vinden + theta_scan = np.linspace(-1*np.pi/2, np.pi/2, 10000) # tussen -90 en +90 graden + results = [] + R = X @ X.conj().T # bereken covariantiematrix; dit geeft een Nr x Nr-matrix van de samples + Rinv = np.linalg.pinv(R) # pseudo-inverse werkt meestal beter dan een echte inverse + for theta_i in theta_scan: + a = np.exp(2j * np.pi * d * np.arange(X.shape[0]) * np.sin(theta_i)) # stuurvector in de gewenste richting theta_i + a = a.reshape(-1,1) # maak er een kolomvector van + power = 1/(a.conj().T @ Rinv @ a).squeeze() # MVDR power equation + power_dB = 10*np.log10(np.abs(power)) # vermogen in dB, zodat kleine en grote lobben tegelijk zichtbaar zijn + results.append(power_dB) + results -= np.max(results) # normalize to 0 dB at peak + +Dit is zo'n situatie waarin een rechthoekige plot handiger is dan een poolplot. We hebben de signalen A, B en C gelabeld. + +.. image:: ../_images/DOA_without_training.svg + :align: center + :target: ../_images/DOA_without_training.svg + :alt: DOA without training data - # "Training" step, with just jammer present - Rinv_jammer = np.linalg.pinv(r_jammer @ r_jammer.conj().T) # Nr x Nr, inverse covariance matrix estimate using the received samples +Als we C als SOI willen gebruiken en MVDR-gewichten willen maken die A en B nullen maar C behouden, moeten we de exacte aankomstrichting van C kennen. Dat doen we met een argmax op de DOA-resultaten van hierboven, maar pas nadat we de hoeken van A en B hebben onderdrukt (door de bovenste 60% van de DOA-resultaten op een zeer lage waarde te zetten). - # Now add in the SOI and perform DOA - theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # sweep theta between -180 and +180 degrees - results = [] - for theta_i in theta_scan: - s = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta_i)) # steering vector in the desired direction theta - s = s.reshape(-1,1) # make into a column vector (size Nr x 1) - Rinv_both = np.linalg.pinv(r_both @ r_both.conj().T) # could be outside for loop but more clear having it here - w = (Rinv_jammer @ s)/(s.conj().T @ Rinv_both @ s) # MVDR/Capon equation! Note which R's are being used where - r_weighted = w.conj().T @ r_both # apply weights to the signal that contains both jammer and SOI - power_dB = 10*np.log10(np.var(r_weighted)) # power in signal, in dB so its easier to see small and large lobes at the same time - results.append(power_dB) +.. code-block:: python - results -= np.max(results) # normalize + # Haal de hoek van C eruit na het onderdrukken van hoeken met stoorzenders + results_temp = np.array(results) + results_temp[int(len(results)*0.4):] = -9999*np.ones(int(len(results)*0.6)) + max_angle = theta_scan[np.argmax(results_temp)] # radians + print("max_angle:", max_angle) - fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) - ax.plot(theta_scan, results) - ax.set_theta_zero_location('N') # make 0 degrees point up - ax.set_theta_direction(-1) # increase clockwise - ax.set_rlabel_position(55) # Move grid labels away from other labels - ax.set_ylim([-40, 0]) # only plot down to -40 dB +Het blijkt dat C binnenkomt op -0,3407 radialen, en die waarde gebruiken we dus bij het berekenen van de MVDR-gewichten. Dat hebben we al vaker gedaan; het is gewoon de MVDR-vergelijking: - plt.show() +.. code-block:: python + + # Bereken MVDR-gewichten + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(max_angle)) # stuurvector in de gewenste richting theta + s = s.reshape(-1,1) # maak er een kolomvector van + w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # MVDR/Capon-vergelijking + +Als laatste plotten we het bundelpatroon van de zojuist berekende MVDR-gewichten, samen met de eerdere DOA-resultaten en een groene stippellijn op :code:`max_angle`: -.. image:: ../_images/doa_radar_scenario.svg +.. raw:: html + +
+ Klap dit open voor de plotcode (niets nieuws) + +.. code-block:: python + + # Bereken bundelpatroon + w = w.squeeze() + N_fft = 2048 + w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero-pad naar N_fft elementen voor meer FFT-resolutie + w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # FFT-magnitude in dB + w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak + theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # map FFT-bins naar hoeken in radialen + + # Plot bundelpatroon en DOA-resultaten + plt.plot(theta_bins * 180 / np.pi, w_fft_dB) # GEBRUIK RADIALEN VOOR EEN POOLPLOT + plt.plot(theta_scan * 180 / np.pi, results, 'r') + plt.vlines(ymax=np.max(results), ymin=np.min(results) , x=max_angle*180/np.pi, color='g', linestyle='--') + plt.xlabel("Angle [deg]") + plt.ylabel("Magnitude [dB]") + plt.title("Bundelpatroon en DOA-resultaten, zonder training") + plt.grid() + plt.show() + +.. raw:: html + +
+ +.. image:: ../_images/DOA_without_training_pattern.svg :align: center - :target: ../_images/doa_radar_scenario.svg + :target: ../_images/DOA_without_training_pattern.svg + :alt: DOA without training data DOA and MVDR beam pattern -As you can see, there is a peak at the SOI (30 degrees) and null in the direction of the jammer (20 degrees). The jammers null is not as low as the -90 to 0 degree region (which are so low they are not even displayed on the plot), but that's only because there are no signals coming from that direction, and even though we are nulling the jammer, it's not perfectly nulled out because it's so close to the angle of arrival of the SOI and we only simulated 4 elements. +Het is gelukt om nullen op A en B te maken. Op de positie van C (groene stippellijn) hebben we geen null, maar ook niet echt een uitgesproken hoofdlob; eerder een verlaagde lob. Dat komt deels doordat er buiten de richtingen van A, B en C weinig tot geen energie binnenkomt, dus extra lobben (bijv. rond -70, 25 en 40 graden) maken in de praktijk weinig uit. Een andere reden dat de lob bij C niet sterker is, is dat de hoofdlob als het ware concurreert met nullen die MVDR zou plaatsen als we niet exact op die richting gericht waren. Een sterke hoofdlob op :code:`max_angle` zou mooier zijn, en daarvoor gebruiken we **training data**. -Note that you don't have to perform full DOA, your goal may be simply to receive the SOI (at an angle you already know) with the interferers nulled out as well as possible, e.g., if you were receiving a radar pulse from a certain direction and wanted to check if it contained energy above a threshold. +We laden nu de opname met alleen A en B om trainingsdata op te bouwen. In een radarsituatie is dit vergelijkbaar met :code:`R` berekenen voordat je een radar-puls uitzendt (idealiter kort daarvoor). -************************** -Quiescent Antenna Pattern -************************** +.. code-block:: python + + # Laad "training data" met alleen A en B, en bereken daarna Rinv + filename = '3p3G_A_B.npy' + X_A_B = np.load(filename) + R_training = X_A_B @ X_A_B.conj().T # bereken covariantiematrix + Rinv_training = np.linalg.pinv(R_training) -Recall that our steering vector we keep seeing, +Het grote verschil is nu dat we :code:`Rinv_training` gebruiken bij het berekenen van de MVDR-gewichten. We hergebruiken :code:`max_angle` van eerder. Zo richten we op C, maar nemen we C niet op in het ontvangen signaal dat voor :code:`R` en :code:`R_inv` wordt gebruikt. .. code-block:: python - np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta)) + # Bereken MVDR-gewichten met training-Rinv + s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(max_angle)) # stuurvector in de gewenste richting theta + s = s.reshape(-1,1) # maak er een kolomvector van (grootte 3x1) + w = (Rinv_training @ s)/(s.conj().T @ Rinv_training @ s) # MVDR/Capon-vergelijking -encapsulates the array geometry, and its only other parameter is the direction you want to steer towards. We can calculate and plot the "quiescent" antenna pattern (array response) when steered towards a certain direction, which will tell us the arrays natural response if we don't do any additional beamforming. This can be done by taking the FFT of the complex conjugated weights, no for loop needed. The tricky part is mapping the bins of the FFT output to angle in radians or degrees, which involves an arcsine as you can see in the full example below: +Met dezelfde plotmethode krijgen we: + +.. image:: ../_images/DOA_with_training.svg + :align: center + :target: ../_images/DOA_with_training.svg + :alt: DOA with training data DOA and MVDR beam pattern + +Let op dat we nog steeds nullen bij A en B krijgen (de null van B is minder diep, maar B is ook een zwakker signaal), maar nu zien we een sterke hoofdlob richting onze interessehoek C. Dit is precies de kracht van trainingsdata, en waarom het zo belangrijk is in radar-toepassingen. + +**************************************** +Simulatie van breedband-stoorzenders +**************************************** + +De methode die we dit hoofdstuk gebruikten om signalen op een bepaalde aankomstrichting op de array te simuleren (stuurvector maal verzonden signaal) gaat uit van een smalbandige-aanname: het signaal wordt als enkelvoudige frequentie beschouwd en de stuurvector wordt op die frequentie berekend. Dat is voor veel signalen een goede benadering, maar werkt minder goed voor breedband-signalen, bijvoorbeeld met bandbreedte groter dan circa 5% van de middenfrequentie. We behandelen kort een truc om breedband-**ruis** uit een bepaalde richting te simuleren (bijv. barrage jamming uit één hoekrichting). + +Deze methode werkt door een covariantiematrix :code:`R` op te bouwen als som van bijdragen van elke breedband-ruisbron. Daarna berekenen we de wortelmatrix :code:`A`, en genereren we de sampleset :code:`X` door standaard complexe Gaussische ruis met :code:`A` te "kleuren". Een belangrijke parameter is :code:`fractional_bw`: de bandbreedte van het ruissignaal gedeeld door de middenfrequentie. Als :code:`fractional_bw=0` moet de code hieronder hetzelfde scenario geven als de traditionele methode voor ontvangen-signaalsimulatie. De onderstaande Python-code kun je in eerdere voorbeelden gebruiken om :code:`X` te simuleren. .. code-block:: python - N_fft = 512 - theta = theta_degrees / 180 * np.pi # doesnt need to match SOI, we arent processing samples, this is just the direction we want to point at - w = np.exp(-2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # steering vector - w = np.conj(w) # or else our answer will be negative/inverted - w_padded = np.concatenate((w, np.zeros(N_fft - Nr))) # zero pad to N_fft elements to get more resolution in the FFT - w_fft_dB = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(w_padded)))**2) # magnitude of fft in dB - w_fft_dB -= np.max(w_fft_dB) # normalize to 0 dB at peak + N = 10 # aantal elementen in ULA + num_samples = 10000 + d = 0.5 - # Map the FFT bins to angles in radians - theta_bins = np.arcsin(np.linspace(-1, 1, N_fft)) # in radians + num_jammers = 3 + jammer_pow_dB = np.array([30, 30, 30]) # jammervermogens in dB + jammer_aoa_deg = np.array([-70, -20, 40]) # jammerhoeken in graden + jammer_aoa = np.sin(np.deg2rad(jammer_aoa_deg)) * np.pi + element_gain_dB = np.zeros(N) # gains in dB voor array-elementen (hier overal 0 dB) + element_gain_linear = 10.0 ** (element_gain_dB / 10) # converteer arraygains naar lineaire waarden + fractional_bw = 0.1 # als dit 0 is, komt deze methode overeen met traditionele arrayfactor-simulatie - # find max so we can add it to plot - theta_max = theta_bins[np.argmax(w_fft_dB)] + # Bouw NxN-jammer-covariantiematrix R + R = np.zeros((N, N), dtype=complex) + for m in range(N): + for n in range(N): + for j in range(num_jammers): + total_element_gain = np.sqrt(element_gain_linear[m] * element_gain_linear[n]) + sinc_term = np.sinc(0.5 * fractional_bw * (m - n) * jammer_aoa[j] / np.pi) + exp_term = np.exp(1j * (m - n) * jammer_aoa[j]) + R[m, n] += 10.0 ** (jammer_pow_dB[j] / 10) * total_element_gain * sinc_term * exp_term + R = np.eye(N, dtype=complex) + R - fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}) - ax.plot(theta_bins, w_fft_dB) # MAKE SURE TO USE RADIAN FOR POLAR - ax.plot([theta_max], [np.max(w_fft_dB)],'ro') - ax.text(theta_max - 0.1, np.max(w_fft_dB) - 4, np.round(theta_max * 180 / np.pi)) - ax.set_theta_zero_location('N') # make 0 degrees point up - ax.set_theta_direction(-1) # increase clockwise - ax.set_rlabel_position(55) # Move grid labels away from other labels - ax.set_thetamin(-90) # only show top half - ax.set_thetamax(90) - ax.set_ylim([-30, 1]) # because there's no noise, only go down 30 dB - plt.show() + # Genereer ontvangen samples + A = fractional_matrix_power(R, 0.5) # bereken matrixwortel (effectieve Cholesky-factorisatie) + A = A / np.sqrt(2) + X = np.zeros((N, num_samples), dtype=complex) + for k in range(num_samples): + noise_vec = np.random.randn(N) + 1j * np.random.randn(N) # complexe ruis + X[:, k] = A.conj().T @ noise_vec -.. image:: ../_images/doa_quiescent.svg +In de onderstaande plots zijn de MVDR-gewichten berekend voor 20 graden en in zwart weergegeven, terwijl de conventionele beamformer op 20 graden als blauwe stippellijn staat. De drie ruisbronnen zijn rood aangegeven. In de eerste plot is de fractionele bandbreedte 0, wat betekent dat de MVDR-gewichten overeen moeten komen met eerdere narrowband-scenario's. Volgens de plot werkt dit prima, maar als de werkelijke ruis breedband is (en je SOI ook breedband is, waardoor je ruis niet simpel kunt wegfilteren), dan komt de simulatie niet overeen met de praktijk. + +.. image:: ../_images/doa_covariance_method_1.svg :align: center - :target: ../_images/doa_quiescent.svg + :target: ../_images/doa_covariance_method_1.svg + :alt: DOA Covariance method with a fractional bandwidth of 0 -It turns out that this pattern is going to almost exactly match the pattern you get when performing DOA with the conventional beamformer (delay-and-sum), when there is a single tone present at `theta_degrees` and little-to-no noise. The plot may look different because of how low the y-axis gets in dB, or due to the size of the FFT used to create this quiescent response pattern. Try tweaking :code:`theta_degrees` or the number of elements :code:`Nr` to see how the response changes. +Nu passen we een fractionele bandbreedte van 0,1 toe, waardoor de ruisbronnen effectief over een brede band worden uitgesmeerd en MVDR veel bredere nullen vormt. Voor veel praktijkscenario's is dit realistischer. + +.. image:: ../_images/doa_covariance_method_2.svg + :align: center + :target: ../_images/doa_covariance_method_2.svg + :alt: DOA Covariance method with a fractional bandwidth of 0.1 ******************* -2D DOA +Cirkelarrays ******************* -Coming soon! +We bespreken kort de Uniform Circular Array (UCA), een populaire arraygeometrie voor DOA omdat deze de 180-gradenambiguiteit van ULA's omzeilt. De KrakenSDR is bijvoorbeeld een 5-element-array, en vaak worden die vijf elementen in een cirkel met gelijke tussenafstand geplaatst. In theorie zijn maar drie elementen nodig om een UCA te vormen, net zoals je met twee elementen al een ULA kunt maken. -******************* -Steering Nulls -******************* +Alle code die we tot nu toe hebben bekeken geldt ook voor UCA's; we hoeven alleen de stuurvectorvergelijking te vervangen door de UCA-variant: + +.. code-block:: python + + radius = 0.05 # genormaliseerd op golflengte! + d = np.sqrt(2 * radius**2 * (1 - np.cos(2*np.pi/Nr))) + sf = 1.0 / (np.sqrt(2.0) * np.sqrt(1.0 - np.cos(2*np.pi/Nr))) # schaalfactor op basis van geometrie; bij een hexagoon is dit bv. 1.0 + x = d * sf * np.cos(2 * np.pi / Nr * np.arange(Nr)) + y = -1 * d * sf * np.sin(2 * np.pi / Nr * np.arange(Nr)) + s = np.exp(1j * 2 * np.pi * (x * np.cos(theta) + y * np.sin(theta))) + s = s.reshape(-1, 1) # Nrx1 + +Tot slot wil je hier van 0 tot 360 graden scannen, in plaats van -90 tot +90 zoals bij een ULA. + +Voor 2D-arrays (bijv. rechthoekig), zie :ref:`2d-beamforming-chapter`. + +************************** +Conclusie en Referenties +************************** -Coming soon! +Alle Python-code, inclusief de code waarmee de figuren/animaties zijn gemaakt, staat `op de GitHub-pagina van het boek `_. -************************* -Conclusion and References -************************* +* DOA-implementatie in GNU Radio - https://github.com/EttusResearch/gr-doa +* DOA-implementatie gebruikt door KrakenSDR - https://github.com/krakenrf/krakensdr_doa/blob/main/_signal_processing/krakenSDR_signal_processor.py -All Python code, including code used to generate the figures/animations, can be found `on the textbook's GitHub page `_. +[1] Mailloux, Robert J. Phased Array Antenna Handbook. Second edition, Artech House, 2005 -* DOA implementation in GNU Radio - https://github.com/EttusResearch/gr-doa -* DOA implementation used by KrakenSDR - https://github.com/krakenrf/krakensdr_doa/blob/main/_signal_processing/krakenSDR_signal_processor.py +[2] Van Trees, Harry L. Optimum Array Processing: Part IV of Detection, Estimation, and Modulation Theory. Wiley, 2002. .. |br| raw:: html diff --git a/content-nl/frequency_domain.rst b/content-nl/frequency_domain.rst index c8d10db7..5ee9344e 100644 --- a/content-nl/frequency_domain.rst +++ b/content-nl/frequency_domain.rst @@ -142,17 +142,17 @@ Het is als volgt gedefinieerd: .. math:: X(f) = \int x(t) e^{-j2\pi ft} dt -Voor een tijdsignaal x(t) kunnen we de frequentiedomein-versie, X(f), vinden met deze formule. -We willen de tijddomein-versie van een functie met x(t) of y(t) aangeven, en de corresponderende frequentiedomein-versie met X(f) en Y(F). -Hierbij staat de "t" voor tijd en "f" voor frequentie. -De "j" is simpelweg de imaginaire eenheid. -Misschien herken je dit als "i" van de wiskundelessen. +Voor een tijdsignaal :math:`x(t)` kunnen we de frequentiedomein-versie, :math:`X(f)`, vinden met deze formule. +We willen de tijddomein-versie van een functie met :math:`x(t)` of :math:`y(t)` aangeven, en de corresponderende frequentiedomein-versie met :math:`X(f)` en :math:`Y(f)`. +Hierbij staat de :math:`t` voor tijd en :math:`f` voor frequentie. +De :math:`j` is simpelweg de imaginaire eenheid. +Misschien herken je dit als :math:`i` van de wiskundelessen. We gebruiken "j" in de elektrotechniek en computerkunde omdat "i" vaak gebruikt wordt voor stroom en bij programmeren voor een iterator. -Teruggaan naar het tijddomein vanuit het frequentiedomein is bijna hetzelfde, afgezien van een vermenigvuldigingsfactor en het minteken: +Teruggaan naar het tijddomein vanuit het frequentiedomein is bijna hetzelfde, afgezien van het minteken: .. math:: - x(t) = \frac{1}{2 \pi} \int X(f) e^{j2\pi ft} df + x(t) = \int X(f) e^{j2\pi ft} df Veel boeken gebruiken :math:`w` in plaats van :math:`2\pi f`. :math:`w` is de hoekfrequentie in radialen per seconde terwijl :math:`f` in Hz is. Het enige wat je moet weten is @@ -349,6 +349,30 @@ Dus het signaal dat op ongeveer 97.5 MHz zat, is wanneer we het digitaal bekijke Reëel gezien is dit gewoon een frequentie die lager is dan de middenfrequentie. Dit wordt logischer wanneer we meer over samplen leren en ervaring opdoen met onze SDR's. +Wiskundig kunnen we negatieve frequenties ook zien door naar de complexe exponentiële functie te kijken: :math:`e^{2j \pi f t}`. Negatieve frequenties kun je dan zien als een complex sinusoide die in de tegenovergestelde richting draait. + +.. math:: + e^{2j \pi f t} = \cos(2 \pi f t) + j \sin(2 \pi f t) \quad \mathrm{\textcolor{blue}{blue}} + +.. math:: + e^{2j \pi (-f) t} = \cos(2 \pi f t) - j \sin(2 \pi f t) \quad \mathrm{\textcolor{red}{red}} + +.. image:: ../_images/negative_freq_animation.gif + :align: center + :scale: 75 % + :target: ../_images/negative_freq_animation.gif + :alt: Animation of a positive and negative frequency sinusoid on the complex plane + +De reden om complexe exxponenten te gebruiken is omdat een enkele :math:`cos()` of :math:`sin()` zowel positieve als negatieve frequenties bevat, zoals te zien is door de Euler's formule op een sinus met frequentie :math:`f` over tijd :math:`t` toe te passen: + +.. math:: + \cos(2 \pi f t) = \underbrace{\frac{1}{2} e^{2j \pi f t}}_\text{positief} + \underbrace{\frac{1}{2} e^{-2j \pi f t}}_\text{negatief} + +.. math:: + \sin(2 \pi f t) = \underbrace{\frac{1}{2j} e^{2j \pi f t}}_\text{positief} - \underbrace{\frac{1}{2j} e^{-2j \pi f t}}_\text{negatief} + +We gebruiken over het algemeen dus complexe exponenten in de RF-signaalbewerking, in plaats van sinussen en cosinnusen. + ********************************** Volgorde in de tijd maakt niet uit ********************************** @@ -402,7 +426,7 @@ Als we de inhoud van :code:`S` bekijken, dan zien we dat het een array van compl S = array([-0.01865008 +0.00000000e+00j, -0.01171553 -2.79073782e-01j,0.02526446 -8.82681208e-01j, 3.50536075 -4.71354150e+01j, -0.15045671 +1.31884375e+00j, -0.10769903 +7.10452463e-01j, -0.09435855 +5.01303240e-01j, -0.08808671 +3.92187956e-01j, -0.08454414 +3.23828386e-01j, -0.08231753 +2.76337148e-01j, -0.08081535 +2.41078885e-01j, -0.07974909 +2.13663710e-01j,... -Hint: Wat je ook aan het doen bent, als je ooit complexe getallen tegenkomt, bereken dan de modulus en fase en bekijk of dat er logischer uitziet. Laten we dat doen en de modulus en fase weergeven. In de meeste talen geeft de abs()-functie de modulus van een complex getal. De functie om de fase te bepalen varieert, maar in Python kan dit met :code:`np.angle()`. +Hint: Wat je ook aan het doen bent, als je ooit complexe getallen tegenkomt, bereken dan de modulus en fase en bekijk of dat er logischer uitziet. Laten we dat doen en de modulus en fase weergeven. In de meeste talen geeft de abs()-functie de modulus van een complex getal. De functie om de fase te bepalen varieert tussen programmeertalen, maar in Python kan dit met de NumPy functie :code:`np.angle()`. Dit geeft de fase terug in radialen. .. code-block:: python diff --git a/content-nl/intro.rst b/content-nl/intro.rst index d72ed591..2e92a88e 100644 --- a/content-nl/intro.rst +++ b/content-nl/intro.rst @@ -73,10 +73,14 @@ Bedankt aan iedereen die dit boek heeft gelezen en van feedback heeft voorzien, - James Hayek - Deidre Stuffer - Tarik Benaddi voor het `vertalen van PySDR naar het Frans `_ -- Daniel Versluis voor het `vertalen van PySDR naar het Nederlands `_ +- `Daniel Versluis `_ voor het `vertalen van PySDR naar het Nederlands `_ - `mrbloom `_ voor het `vertalen van PySDR naar het Ukraiens `_ - `Yimin Zhao `_ voor het `vertalen van PySDR naar het Chinees `_ - `Eduardo Chancay `_ voor het `vertalen van PySDR naar het Spaans `_ +- John Marcovici +- `Vishwaksen Reddy Dhareddy `_ for contributing the Detection Chapter section on real-time packet detection + +En alle `PySDR Patreon `_ supporters! ********************** Nederlandse vertaling diff --git a/content-nl/noise.rst b/content-nl/noise.rst index d3128310..204dcdae 100644 --- a/content-nl/noise.rst +++ b/content-nl/noise.rst @@ -324,6 +324,352 @@ Signal-to-Interference-plus-noise verhouding (SINR) of signaal-tot-verstoring-pl Wat die verstoring inhoudt, verschilt per toepassing/situatie, maar meestal gaat het om een ander ongewenst signaal wat het signaal van interesse verstoort op zo'n manier dat het niet weg te filteren is. +********************************* +Diepere duik in stochastische variabelen +********************************* + +Tot nu toe hebben we de wiskunde wat licht gehouden, maar nu doen we een stap terug en introduceren we het concept stochastische variabelen en hoe die in draadloze communicatie en SDR worden gebruikt. Een **stochastische variabele** is een wiskundig object dat uitkomsten van een willekeurig experiment op numerieke waarden afbeeldt. Stochastische variabelen beschrijven grootheden waarvan de waarde pas bekend is nadat je die observeert of meet, zoals onze ruissamples. Denk aan het gooien van een dobbelsteen. Voor de worp weet je niet welk getal valt. We kunnen een stochastische variabele :math:`X` definieren als de uitkomst van die worp. De waarde van :math:`X` ligt in {1, 2, 3, 4, 5, 6}, maar welke het wordt weten we pas na de worp. + +In draadloze communicatie en SDR zijn stochastische variabelen overal: + +* Thermische ruis in een ontvanger wordt op elk tijdstip als stochastische variabele gemodelleerd +* De amplitude van een ontvangen signaal met multipadfading is willekeurig +* De fase-offset door een veranderend kanaal kan als stochastische variabele tussen :math:`0` en :math:`2\pi` worden gezien +* Zelfs de databits die we verzenden kun je als stochastische variabelen beschouwen + +**Een sample versus veel samples** + +Dit onderscheid is cruciaal en zorgt vaak voor verwarring: + +* Een **enkele uitkomst** of **enkel sample** van een stochastische variabele is slechts een getal: een uitkomst van het willekeurige experiment +* Om een stochastische variabele te karakteriseren (gemiddelde, spreiding, enz.) heb je **veel uitkomsten** nodig + +Roep je in Python ``np.random.randn()`` zonder argumenten aan, dan krijg je een enkel willekeurig getal uit een Gauss-verdeling. Met dat ene getal weet je vrijwel niets over de verdeling. Roep je ``np.random.randn(10000)`` aan en genereer je 10.000 samples, dan kun je eigenschappen zoals gemiddelde en variantie schatten. + +.. code-block:: python + + import numpy as np + + # Single sample - just one number + x_single = np.random.randn() + print(x_single) # might be 0.534, -1.23, or any other value + + # Many samples - now we can characterize the distribution + x_many = np.random.randn(10000) + print(np.mean(x_many)) # will be close to 0 + print(np.var(x_many)) # will be close to 1 + +Gezamenlijke verdelingen +######################## + +Tot nu toe keken we naar losse stochastische variabelen. Werk je met twee of meer stochastische variabelen tegelijk, dan gebruik je een **gezamenlijke verdeling**. + +Voor continue variabelen :math:`X` en :math:`Y` wordt dit beschreven door de **gezamenlijke PDF**: + +.. math:: + f_{X,Y}(x,y) + +De gezamenlijke PDF vertelt hoe waarschijnlijk het is dat :math:`X` waarde :math:`x` aanneemt *en* :math:`Y` tegelijk waarde :math:`y`. + +Uit de gezamenlijke PDF kunnen we berekenen: + +* Marginale PDF's (bijv. :math:`f_X(x)` of :math:`f_Y(y)`) +* Verwachtingswaarden zoals :math:`E[XY]` +* Covariantie en correlatie +* Kansen waarin beide variabelen voorkomen + +De marginale PDF van :math:`X` krijg je bijvoorbeeld door over :math:`Y` te integreren: + +.. math:: + f_X(x) = \int_{-\infty}^{\infty} f_{X,Y}(x,y)\,dy + +Gezamenlijke verdelingen vormen de wiskundige basis om afhankelijkheid, correlatie en onafhankelijkheid tussen stochastische variabelen te begrijpen. + + +Kansverdelingen +############### + +Een **kansverdeling** beschrijft hoe waarschijnlijk verschillende waarden van een stochastische variabele zijn. Voor een continue stochastische variabele gebruiken we een **probability density function (PDF)**, genoteerd als :math:`f_X(x)`. De PDF geeft de relatieve waarschijnlijkheid van verschillende waarden. + +De belangrijkste verdeling in SDR en communicatie is de **Gauss- (normale) verdeling**. Een Gaussische stochastische variabele :math:`X` met gemiddelde :math:`\mu` en variantie :math:`\sigma^2` heeft de PDF: + +.. math:: + f_X(x) = \frac{1}{\sqrt{2\pi\sigma^2}} e^{-\frac{(x-\mu)^2}{2\sigma^2}} + +Dit is de bekende "klokvorm". De verdeling wordt volledig bepaald door twee parameters: + +* **Gemiddelde** :math:`\mu`: het centrum van de verdeling +* **Variantie** :math:`\sigma^2`: de spreiding van de verdeling (standaardafwijking :math:`\sigma` is de wortel van de variantie) + +In Python genereert ``np.random.randn()`` samples uit een **standaard-Gaussverdeling** met :math:`\mu = 0` en :math:`\sigma^2 = 1`. Dat kunnen we visualiseren: + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + + # Generate 10,000 samples from standard Gaussian + x = np.random.randn(10000) + + # Create histogram to visualize the distribution + plt.hist(x, bins=50, density=True, alpha=0.7, edgecolor='black') + plt.xlabel('Value') + plt.ylabel('Probability Density') + plt.title('Gaussian Distribution (μ=0, σ²=1)') + plt.grid(True) + plt.show() + +.. image:: ../_images/gaussian_histogram.png + :scale: 80% + :align: center + :alt: Histogram of Gaussian distributed samples + :target: ../_images/gaussian_histogram.png + +Verwachtingswaarde (oftewel gemiddelde) +####################################### + +De **verwachtingswaarde** van een stochastische variabele, genoteerd als :math:`E[X]` of :math:`\mu`, is de gemiddelde waarde over veel realisaties. Voor een continue stochastische variabele met PDF :math:`f_X(x)` is de verwachting: + +.. math:: + E[X] = \int_{-\infty}^{\infty} x \cdot f_X(x) \, dx + +In de praktijk, met :math:`N` samples :math:`x_1, x_2, \ldots, x_N` uit de verdeling, schatten we de verwachting met het **steekproefgemiddelde**: + +.. math:: + \hat{\mu} = \frac{1}{N} \sum_{n=1}^{N} x_n + +De verwachtingswaarde is een **lineaire operator**, dus: + +* :math:`E[aX + b] = aE[X] + b` voor constanten :math:`a` en :math:`b` +* :math:`E[X + Y] = E[X] + E[Y]` voor willekeurige stochastische variabelen + +Die lineariteit is erg nuttig in signaalverwerking. + +Variantie en standaardafwijking +############################### + +De **variantie** van een stochastische variabele, genoteerd als :math:`\text{Var}(X)` of :math:`\sigma^2`, meet hoe ver waarden rond het gemiddelde zijn uitgespreid. Definitie: de verwachtingswaarde van de gekwadrateerde afwijking van het gemiddelde. + +.. math:: + \text{Var}(X) = E[(X - \mu)^2] = E[X^2] - (E[X])^2 + +Met :math:`N` samples schatten we de variantie met: + +.. math:: + \hat{\sigma}^2 = \frac{1}{N} \sum_{n=1}^{N} (x_n - \hat{\mu})^2 + +De **standaardafwijking** :math:`\sigma` is de wortel van de variantie: :math:`\sigma = \sqrt{\sigma^2}`. + +Let op het :math:`\enspace \hat{} \enspace`-symbool ("hoedje") bij :math:`\sigma` en bij het steekproefgemiddelde. Dat geeft aan dat het om een schatting gaat. Die is niet exact gelijk aan de werkelijke waarde, maar benadert die steeds beter naarmate je meer samples gebruikt. + +**Belangrijke eigenschap:** Als :math:`X` variantie :math:`\sigma^2` heeft, dan: + +* Schalen: :math:`\text{Var}(aX) = a^2 \text{Var}(X)` +* Verschuiven: :math:`\text{Var}(X + b) = \text{Var}(X)` (een constante optellen verandert de spreiding niet) + +En dus voor standaardafwijking :math:`\sigma`: + +* Schalen: :math:`\sigma(aX) = a\sigma(X)` +* Verschuiven: :math:`\sigma(X+b) = \sigma(X)` + +.. image:: ../_images/gaussian_transformed.png + :scale: 80% + :align: center + :alt: Scaling and shifting the Gaussian Distribution. (notice the scales on x and y axes) + :target: ../_images/gaussian_transformed.png + +Schalen en verschuiven van de Gaussverdeling (let op de asschalen van x en y). + +**Variantie en vermogen** + +In signaalverwerking geldt voor een **nulgemiddeld** signaal (gemiddelde ~ 0) dat de variantie gelijk is aan het **gemiddelde vermogen**. Daarom gebruiken we die termen vaak door elkaar: + +.. math:: + P = \text{Var}(X) = E[X^2] \quad \text{(when } E[X] = 0\text{)} + +Deze relatie is fundamenteel bij analyse van ruisvermogen, signaal-ruisverhouding (SNR) en linkbudgets. + +.. code-block:: python + + noise_power = 2.0 + n = np.random.randn(N) * np.sqrt(noise_power) + print(np.var(n)) # will be approximately 2.0 + +Covariantie +########### + +De **covariantie** tussen twee stochastische variabelen :math:`X` en :math:`Y` is gedefinieerd als: + +.. math:: + \text{Cov}(X,Y) = E[(X - E[X])(Y - E[Y])] + +Een equivalente en vaak handigere vorm is: + +.. math:: + \text{Cov}(X,Y) = E[XY] - E[X]E[Y] + +Covariantie meet hoe twee variabelen samen variëren: + +* Positieve covariantie: ze nemen meestal samen toe of af +* Negatieve covariantie: als de ene toeneemt, neemt de andere vaak af +* Nul covariantie: ze zijn ongecorreleerd + +Als beide variabelen nulgemiddeld zijn, vereenvoudigt dit tot: + +.. math:: + \text{Cov}(X,Y) = E[XY] + +Covariantie heeft een eenheid (is niet genormaliseerd), daarom gebruiken we in de praktijk vaak de **correlatiecoefficient** (of gewoon correlatie): + +.. math:: + \rho_{XY} = \frac{\text{Cov}(X,Y)}{\sigma_X \sigma_Y} + +Dit levert een dimensieloze waarde tussen -1 en +1. + +Variantie van een som van variabelen +#################################### + +In signaalverwerking werken we vaak met sommen van stochastische variabelen, zoals signaal plus ruis: + +.. math:: + Z = X + Y + +De variantie van die som hangt af van of :math:`X` en :math:`Y` onafhankelijk zijn (of algemener: gecorreleerd). + +In de algemene vorm: + +.. math:: + \text{Var}(X + Y) = \text{Var}(X) + \text{Var}(Y) + 2\,\text{Cov}(X,Y) + +waar :math:`\text{Cov}(X,Y)` de **covariantie** tussen :math:`X` en :math:`Y` is. + +**Onafhankelijk geval** + +Als :math:`X` en :math:`Y` onafhankelijk zijn (of eenvoudiger: ongecorreleerd), dan vereenvoudigt dit tot: + +.. math:: + \text{Var}(X + Y) = \text{Var}(X) + \text{Var}(Y) + +Dit resultaat is erg belangrijk in communicatiesystemen. Bijvoorbeeld, als een ontvangen signaal is: + +.. math:: + R = S + N + +waar :math:`S` het signaal is en :math:`N` onafhankelijke ruis, dan is het totale vermogen simpelweg de som van signaal- en ruisvermogen. + +Daarom zijn SNR-berekeningen zo rechttoe rechtaan. + +**************************** +Complexe stochastische variabelen +**************************** + +In SDR werken we veel met **complexe signalen**, dus ook met complexe stochastische variabelen. Zo'n variabele heeft de vorm: + +.. math:: + Z = X + jY + +waar :math:`X` en :math:`Y` reele stochastische variabelen zijn voor de in-phase (I) en quadratuur (Q)-component. + +**Complexe Gaussische ruis** + +De meest voorkomende complexe stochastische variabele in draadloze communicatie is **complexe Gaussische ruis**, waarbij :math:`X` en :math:`Y` onafhankelijke Gaussische variabelen met dezelfde variantie zijn. + +Als bijvoorbeeld :math:`X \sim \mathcal{N}(\alpha_1, \sigma_1^2)` en :math:`Y \sim \mathcal{N}(\alpha_2, \sigma_2^2)` onafhankelijk zijn, dan heeft :math:`Z = X + jY`: + +* Gemiddelde: :math:`E[Z] = E[X] + jE[Y] = \alpha_1 + j\alpha_2` +* Variantie (vermogen): :math:`\text{Var}(Z) = \text{Var}(X) + \text{Var}(Y) = \sigma_1^2 + \sigma_2^2` + +.. image:: ../_images/gaussian_IQ.png + :scale: 80% + :align: center + :alt: Complex Gaussian noise visualized as two independent Gaussian random variables on the I and Q axes + :target: ../_images/gaussian_IQ.png + +Daarom gebruiken we bij complexe Gaussische ruis met eenheidsvermogen (variantie = 1): + +.. code-block:: python + + N = 10000 + n = (np.random.randn(N) + 1j*np.random.randn(N)) / np.sqrt(2) + print(np.var(n)) # ~ 1 + +De deling door :math:`\sqrt{2}` zorgt dat het totale vermogen (som van I- en Q-variantie) gelijk is aan 1. + +.. code-block:: python + + # Without normalization: + n_raw = np.random.randn(N) + 1j*np.random.randn(N) + print(np.var(np.real(n_raw))) # ~ 1 + print(np.var(np.imag(n_raw))) # ~ 1 + print(np.var(n_raw)) # ~ 2 (total power) + + # With normalization: + n_norm = n_raw / np.sqrt(2) + print(np.var(n_norm)) # ~ 1 (unit power) + +**************** +Toevalsprocessen +**************** + +Tot nu toe bespraken we stochastische variabelen: willekeurige waarden op een enkel punt. Een **toevalsproces** (ook wel **stochastisch proces**) is een verzameling stochastische variabelen geindexeerd door de tijd: + +.. math:: + X(t) \quad \text{or} \quad X[n] \text{ for discrete time} + +Op elk tijdstip :math:`t` is :math:`X(t)` een stochastische variabele. Zie een toevalsproces als een signaal dat in de tijd willekeurig evolueert. + +Voorbeelden in draadloze communicatie: + +* Ruis in de ontvanger: :math:`N(t)` of :math:`N[n]` +* Een signaal met tijdsafhankelijke fading: :math:`H(t)S(t)` +* Samples uit een SDR: elke batch is een realisatie van een toevalsproces + +**Stationaire processen** + +Een toevalsproces is **stationair** als de statistische eigenschappen niet in de tijd veranderen. In het bijzonder heeft een **wide-sense stationary (WSS)** proces: + +* Constant gemiddelde: :math:`E[X(t)] = \mu` voor alle :math:`t` +* Autocorrelatie die alleen van tijdsverschil afhangt: :math:`E[X(t)X(t+\tau)]` hangt alleen van :math:`\tau` af, niet van :math:`t` + +Veel ruisbronnen in draadloze systemen zijn ongeveer stationair, wat de analyse sterk vereenvoudigt. + +**Witte ruis** + +**Witte ruis** is een toevalsproces waarbij samples op verschillende tijdstippen ongecorreleerd zijn en de vermogensspectrale dichtheid over alle frequenties constant is. Additive White Gaussian Noise (AWGN) is tegelijk: + +* **White**: ongecorreleerd in de tijd, vlak spectrum +* **Gaussian**: elke sample is Gaussisch verdeeld + +Als we in Python ruis maken met ``np.random.randn(N)``, is elke van de :math:`N` samples een onafhankelijke Gaussische stochastische variabele, samen een wit-ruisproces. + + +Onafhankelijkheid en correlatie +############################### + +Twee stochastische variabelen :math:`X` en :math:`Y` zijn **onafhankelijk** als kennis van de ene niets zegt over de andere. Wiskundig factoriseert dan de gezamenlijke PDF: + +.. math:: + f_{X,Y}(x,y) = f_X(x) \cdot f_Y(y) + +Onafhankelijkheid is een sterke voorwaarde. Een zwakkere voorwaarde is **ongecorreleerd**, wat betekent: + +.. math:: + E[XY] = E[X]E[Y] + +Voor Gaussische stochastische variabelen impliceert ongecorreleerd ook onafhankelijk (een speciale eigenschap van Gaussische variabelen). + +Bij complexe Gaussische ruis zijn de I- en Q-component onafhankelijk: + +.. code-block:: python + + N = 10000 + I = np.random.randn(N) + Q = np.random.randn(N) + + # Check independence via correlation + correlation = np.corrcoef(I, Q)[0, 1] + print(f"Correlation between I and Q: {correlation:.4f}") # ~ 0 + ************************* Extra leesmateriaal ************************* diff --git a/content-nl/phaser.rst b/content-nl/phaser.rst index 87471777..23af7197 100644 --- a/content-nl/phaser.rst +++ b/content-nl/phaser.rst @@ -1,75 +1,69 @@ .. _phaser-chapter: #################################### -Phased Arrays with Phaser +Phased Arrays met Phaser #################################### -In this chapter we use the `Analog Devices Phaser `_, (a.k.a. CN0566 or ADALM-PHASER) which is an 8-channel low-cost phased array SDR that combines a PlutoSDR, Raspberry Pi, and ADAR1000 beamformers, designed to operate around 10.25 GHz. We will cover the setup and calibration steps, and then go through some beamforming examples in Python. For those that do not have a Phaser, we have included screenshots and animations of what the user would see. +In dit hoofdstuk gebruiken we de `Analog Devices Phaser `_ (ook bekend als CN0566 of ADALM-PHASER), een voordelige 8-kanaals phased-array-SDR die een PlutoSDR, Raspberry Pi en ADAR1000-bundelvormers combineert en ontworpen is voor gebruik rond 10,25 GHz. We behandelen de installatie- en calibratiestappen en lopen daarna door enkele voorbeelden van bundelvorming in Python. Voor wie geen Phaser heeft, zijn screenshots en animaties toegevoegd van wat je normaal zou zien. .. image:: ../_images/phaser_on_tripod.png :scale: 60 % :align: center - :alt: The Phaser (CN0566) by Analog Devices + :alt: De Phaser (CN0566) van Analog Devices ************************ -Intro to Phased Arrays -************************ - -Coming soon! - -************************ -Hardware Overview +Hardware-overzicht ************************ .. image:: ../_images/phaser_front_and_back.png :scale: 40 % :align: center - :alt: The front and back of the Phaser unit + :alt: Voor- en achterkant van de Phaser-unit -The Phaser is a single board containing the phased array and a bunch of other components, with a Raspberry Pi plugged in on one side and a Pluto mounted to the other side. The high-level block diagram is shown below. Some items to note: +De Phaser is een enkel bord met daarop de phased array en diverse andere componenten, met aan de ene zijde een Raspberry Pi en aan de andere zijde een Pluto. Het blokschema op hoofdlijnen staat hieronder. Enkele belangrijke punten: -1. Even though it looks like a 32-element 2d array, it's really an 8-element 1d array -2. Both receive channels on the Pluto are used (the second channel uses a u.FL connector) -3. The LO onboard is used to downconvert the received signal from around 10.25 GHz to around 2 GHz, so that the Pluto can receive it -4. Each ADAR1000 has four phase shifters with adjustable gain, and all four channels are summed together before being sent to the Pluto -5. The Phaser essentially contains two "subarrays" which each subarray containing four channels -6. Not shown below are GPIO and serial signals from the Raspberry Pi used to control various components on the Phaser +1. Hoewel het op een 32-element 2D-array lijkt, is het in werkelijkheid een 8-element 1D-array +2. Beide ontvangstkanalen van de Pluto worden gebruikt (het tweede kanaal gebruikt een u.FL-connector) +3. De onboard LO wordt gebruikt om het ontvangen signaal van rond 10,25 GHz naar rond 2 GHz te downconverten, zodat de Pluto het kan ontvangen +4. Elke ADAR1000 heeft vier faseschuivers met instelbare gain, en alle vier kanalen worden opgeteld voordat ze naar de Pluto gaan +5. De Phaser bevat in essentie twee "subarrays", elk met vier kanalen +6. Niet getoond: GPIO- en seriele signalen van de Raspberry Pi die verschillende onderdelen op de Phaser aansturen .. image:: ../_images/phaser_components.png :scale: 40 % :align: center - :alt: The components of the Phaser (CN0566) including ADF4159, LTC5548, ADAR1000 + :alt: Componenten van de Phaser (CN0566), inclusief ADF4159, LTC5548 en ADAR1000 -For now let's ignore the transmit side of the Phaser, as in this chapter we will only be using the HB100 device as a test transmitter. The ADF4159 is a frequency synthesizer that produces a tone up to 13 GHz in frequency, what we call the local oscillator or LO. This LO is fed into a mixer, the LTC5548, which is able to do upconversion or downconversion, although we'll be using it for downconversion. For downconversion it takes in the LO as well as a signal anywhere from 2 - 14 GHz, and multiplies the two together which performs a frequency shift. The resulting downconverted signal can be anywhere from DC to 6 GHz, although we are going to target around 2 GHz. The ADAR1000 is a 4-channel analog beamformer, so the Phaser utilizes two of them. An analog beamformer has independently adjustable phase shifters and gain for each channel, allowing each channel to be time-delayed and attenuated before being summed together in the analog domain (resulting in a single channel). On the Phaser, each ADAR1000 outputs a signal which gets downconverted and then received by the Pluto. Using the Raspberry Pi we can control the phase and gain of all eight channels in real-time, to perform beamforming. We also have the option to do two-channel digital beamforming/array processing, discussed in the next chapter. +Voor nu negeren we de zendkant van de Phaser, omdat we in dit hoofdstuk alleen de HB100 als testzender gebruiken. De ADF4159 is een frequentiesynthesizer die een toon tot 13 GHz kan maken; dit is onze lokale oscillator (LO). Deze LO gaat naar de mixer LTC5548, die zowel upconversion als downconversion kan doen, maar wij gebruiken downconversion. Daarbij worden LO en een signaal tussen 2 en 14 GHz met elkaar vermenigvuldigd, wat een frequentieverschuiving geeft. Het resulterende downconverted signaal kan tussen DC en 6 GHz liggen, al mikken wij op ongeveer 2 GHz. De ADAR1000 is een 4-kanaals analoge bundelvormer; daarom gebruikt de Phaser er twee. Een analoge bundelvormer heeft per kanaal onafhankelijk instelbare fase en gain, zodat elk kanaal tijdsvertraging en attenuatie kan krijgen voordat alle kanalen in het analoge domein worden opgeteld (tot een enkel kanaal). Op de Phaser levert elke ADAR1000 een signaal dat wordt downconverted en daarna door de Pluto wordt ontvangen. Met de Raspberry Pi kunnen we fase en gain van alle acht kanalen realtime regelen voor bundelvorming. We hebben ook de optie voor tweekanaals digitale bundelvorming/arrayverwerking, besproken in het volgende hoofdstuk. -For those interested, a slightly more detailed block diagram is provided below. +Voor geinteresseerden staat hieronder een iets gedetailleerder blokschema. .. image:: ../_images/phaser_detailed_block_diagram.png :scale: 80 % :align: center - :alt: Detailed block diagram of the Phaser (CN0566) + :alt: Gedetailleerd blokschema van de Phaser (CN0566) ************************ -SD Card Preparation +SD-kaartvoorbereiding ************************ -We will assume you are using the Raspberry Pi onboard the Phaser (directly, with a monitor/keyboard/mouse). This simplifies setup, as Analog Devices publishes a pre-built SD card image with all the necessary drivers and software. You can download the SD card image and find SD imaging instructions `here `_. The image is based on Raspberry Pi OS and includes all the software you'll need already installed. +We gaan ervan uit dat je de Raspberry Pi op de Phaser gebruikt (direct, met monitor/toetsenbord/muis). Dat vereenvoudigt de setup, omdat Analog Devices een kant-en-klaar SD-kaartimage aanbiedt met alle benodigde drivers en software. Je kunt het SD-image downloaden en instructies voor het flashen vinden `hier `_. Het image is gebaseerd op Raspberry Pi OS en bevat de benodigde software al vooraf geinstalleerd. ************************ -Hardware Preparation +Hardwarevoorbereiding ************************ -1. Connect Pluto's CENTER micro-USB port to Raspberry Pi -2. Optionally, carefully thread the tripod into the tripod mount -3. We will assume you're using an HDMI display, USB keyboard, and USB mouse connected to the Raspberry pi -4. Power the Pi and Phaser board through the type-C port of the Phaser (CN0566), i.e. do NOT connect a supply to the Raspberry Pi's USB C +1. Verbind de MIDDELSTE micro-USB-poort van de Pluto met de Raspberry Pi +2. Optioneel: schroef voorzichtig het statief in de statiefaansluiting +3. We gaan ervan uit dat je een HDMI-scherm, USB-toetsenbord en USB-muis op de Raspberry Pi gebruikt +4. Voed de Pi en het Phaser-bord via de USB-C-poort van de Phaser (CN0566), dus sluit GEEN aparte voeding op de USB-C van de Raspberry Pi aan ************************ -Software Install +Software-installatie ************************ -Once you have booted into the Raspberry Pi using the pre-build image, using the default user/pass analog/analog, it is recommended to run the following steps: +Nadat je met het voorgebouwde image bent opgestart op de Raspberry Pi (standaard gebruiker/wachtwoord: analog/analog), is het aanbevolen om de volgende stappen uit te voeren: .. code-block:: bash @@ -80,69 +74,69 @@ Once you have booted into the Raspberry Pi using the pre-build image, using the sudo raspi-config -For more assistance setting up the Phaser, reference the `Phaser wiki quickstart page `_. +Voor extra hulp bij het opzetten van de Phaser, zie de `Phaser wiki quickstart-pagina `_. ************************ -HB100 Setup +HB100-setup ************************ .. image:: ../_images/phaser_hb100.png :scale: 50 % :align: center - :alt: HB100 that comes with Phaser + :alt: HB100 die met de Phaser wordt meegeleverd -The HB100 that comes with the Phaser is a low-cost Doppler radar module that we will be using as a test transmitter, as it transmits a continuous tone around 10 GHz. It runs off 2 AA batteries or a 3V benchtop supply, and when it's on it will have a solid red LED. +De HB100 die bij de Phaser wordt geleverd is een voordelige Doppler-radarmodule die we als testzender gebruiken, omdat deze een continue toon rond 10 GHz uitzendt. Hij werkt op 2 AA-batterijen of een 3V-labvoeding, en bij inschakelen brandt er een constante rode LED. -Because the HB100 is low-cost and uses cheap RF components, its transmit frequency varies from unit to unit, over hundreds of MHz, which is a range that is greater than the highest bandwidth we can receive using the Pluto (56 MHz). So to make sure we are tuning our Pluto and downconverter in a manner that will always receive the HB100 signal, we must determine the HB100's transmit frequency. This is done using an example app from Analog Devices, which performs a frequency sweep and calculates FFTs while looking for a spike. Make sure your HB100 is on and in the general vicinity of the Phaser, and then run the utility with: +Omdat de HB100 goedkoop is en eenvoudige RF-componenten gebruikt, varieert de zendfrequentie per exemplaar met honderden MHz, een bereik groter dan de maximale bandbreedte die de Pluto kan ontvangen (56 MHz). Om de Pluto en downconverter zo af te stemmen dat we het HB100-signaal zeker ontvangen, moeten we dus eerst de zendfrequentie van de HB100 bepalen. Dat doen we met een voorbeeldapp van Analog Devices die een frequentiesweep uitvoert en FFT's berekent om een piek te vinden. Zorg dat de HB100 aan staat en in de buurt van de Phaser is, en voer daarna het hulpprogramma uit met: .. code-block:: bash cd ~/pyadi-iio/examples/phaser python phaser_find_hb100.py -It should create a file called hb100_freq_val.pkl in the same directory. This file contains the HB100 transmit frequency in Hz (pickled, so not viewable in plaintext) which we will use in the next step. +Dit zou in dezelfde map een bestand genaamd hb100_freq_val.pkl moeten maken. Dat bestand bevat de HB100-zendfrequentie in Hz (gepickled, dus niet als platte tekst leesbaar), die we in de volgende stap gebruiken. ************************ Calibration ************************ -Lastly, we need to calibrate the phased array. This requires holding the HB100 at the array's boresight (0 degrees). The side of the HB100 with the barcode is the side that transmits the signal, so that face should be held a few feet away from the Phaser, right in-front and centered to it, and then pointed straight at the Phaser. In the next step you can experiment with different angles and orientations, but for now let's run the calibration utility: +Tot slot moeten we de phased array calibreren. Daarvoor houd je de HB100 op boresight van de array (0 graden). De zijde van de HB100 met de barcode is de zendzijde; houd die op enige afstand recht voor en gecentreerd op de Phaser en richt hem direct op de Phaser. In de volgende stap kun je met verschillende hoeken en orientaties experimenteren, maar voer nu eerst de calibratietool uit: .. code-block:: bash python phaser_examples.py cal -This will create two more pickle files: phase_cal_val.pkl and gain_cal_val.pkl, in the same directory. Each one contains an array of 8 numbers corresponding to the phase and gain tweaks needed to calibrate each channel. These values are unique to each Phaser, as they can very during manufacturing. Subsequent runs of this utility will lead to slightly different values which is normal. +Dit maakt in dezelfde map nog twee picklebestanden aan: phase_cal_val.pkl en gain_cal_val.pkl. Elk bestand bevat een array met 8 waarden die de fase- en gain-correcties per kanaal aangeven. Deze waarden zijn uniek per Phaser, omdat productievariaties een rol spelen. Herhaalde runs van deze tool geven normaal gesproken licht verschillende waarden. ************************ -Pre-built Example App +Voorgebouwde Voorbeeldapp ************************ -Now that we have calibrated our Phaser and found the HB100 frequency, we can run the example app that Analog Devices provides. +Nu we de Phaser hebben gecalibreerd en de HB100-frequentie kennen, kunnen we de voorbeeldapp van Analog Devices starten. .. code-block:: bash python phaser_gui.py -If you check the "Auto Refresh Data" checkbox in the bottom-left it should begin running. You should see something similar to the following when holding the HB100 in the Phaser's boresight. +Als je linksonder het vakje "Auto Refresh Data" aanvinkt, zou de app moeten starten. Wanneer je de HB100 op boresight van de Phaser houdt, zou je iets als het volgende moeten zien. .. image:: ../_images/phaser_gui.png :scale: 50 % :align: center - :alt: Phaser example GUI tool by Analog Devices + :alt: Phaser-voorbeeldtool met GUI van Analog Devices ************************ Phaser in Python ************************ -We will now dive into the hands-on Python portion. For those who don't have a Phaser, screenshots and animations are provided. +We gaan nu naar het praktische Python-gedeelte. Voor wie geen Phaser heeft, zijn screenshots en animaties toegevoegd. -Initializing Phaser and Pluto +Phaser en Pluto initialiseren ############################## -The following Python code sets up our Phaser and Pluto. By this point you should have already run the calibration steps, which produce three pickle files. Make sure you are running the Python script below from within the same directory as these pickle files. +De volgende Python-code zet onze Phaser en Pluto op. Op dit punt heb je de calibratiestappen al uitgevoerd, die drie picklebestanden opleveren. Zorg dat je het onderstaande script uitvoert vanuit dezelfde map als die picklebestanden. -There are a lot of settings to deal with, so it's OK if you don't absorb the entire code snippet below, just note that we are using a sample rate of 30 MHz, manual gain which we set very low, we set all of the element gains to the same value, and point the array towards boresight (0 degrees). +Er zijn veel instellingen, dus het is prima als je niet direct de hele code begrijpt. Let vooral op dat we een sample rate van 30 MHz gebruiken, handmatige gain op een lage waarde zetten, alle elementgains gelijk maken en de array op boresight (0 graden) richten. .. code-block:: python @@ -204,10 +198,10 @@ There are a lot of settings to deal with, so it's OK if you don't absorb the ent phaser.lo = int(signal_freq + sdr.rx_lo - offset) -Receiving Samples from the Pluto +Samples Ontvangen van de Pluto ################################ -At this point the Phaser and Pluto are configured and ready to go. We can now start receiving data from the Pluto. Let's grab a single batch of 1024 samples, then take the FFT of each of the two channels. +Op dit punt zijn de Phaser en Pluto geconfigureerd en klaar. We kunnen nu data van de Pluto ontvangen. Laten we een enkele batch van 1024 samples ophalen en daarna van beide kanalen de FFT nemen. .. code-block:: python @@ -235,31 +229,31 @@ At this point the Phaser and Pluto are configured and ready to go. We can now s plt.tight_layout() plt.show() -What you see at this point will depend if your HB100 is on and where it's pointing. If you hold it a few feet from the Phaser and point it towards the center, you should see something like this: +Wat je hier ziet hangt af van of de HB100 aan staat en waar hij op gericht is. Als je hem op enige afstand van de Phaser houdt en naar het midden richt, zou je ongeveer dit moeten zien: .. image:: ../_images/phaser_rx_psd.png :scale: 100 % :align: center - :alt: Phaser initial example + :alt: Eerste Phaser-voorbeeld -Note the strong spike near 0 Hz, the 2nd shorter spike is simply an artifact that can be ignored, since it's around 40 dB down. The top plot, showing the time domain, displays the real part of the two channels, so the relative amplitude between the two will vary slightly depending on where you hold the HB100. +Let op de sterke piek rond 0 Hz; de tweede, kleinere piek is een artefact dat je kunt negeren, omdat die ongeveer 40 dB lager ligt. De bovenste plot in het tijddomein toont het reele deel van de twee kanalen, waardoor de relatieve amplitude iets varieert afhankelijk van de positie van de HB100. -Performing Beamforming +Bundelvorming Uitvoeren ############################## -Next, let's actually sweep the phase! In the following code we sweep the phase from negative 180 to positive 180 degrees, at a 2 degree step. Note that this is not the angle the beamformer points; it's the phase difference between adjacent channels. We must calculate the angle of arrival corresponding to each phase step, using knowledge of the speed of light, the RF frequency of the received signal, and the Phaser's element spacing. The phase difference between adjacent elements is given by: +Nu gaan we echt de fase sweepen. In de volgende code sweepen we de fase van -180 tot +180 graden, met stappen van 2 graden. Let op: dit is niet direct de hoek waar de bundelvormer naartoe wijst; het is het faseverschil tussen aangrenzende kanalen. We moeten de bijbehorende aankomsthoek per fasestap berekenen met de lichtsnelheid, de RF-frequentie van het ontvangen signaal en de elementafstand van de Phaser. Het faseverschil tussen aangrenzende elementen is: .. math:: \phi = \frac{2 \pi d}{\lambda} \sin(\theta_{AOA}) -where :math:`\theta_{AOA}` is the angle of arrival of the signal with respect to boresight, :math:`d` is the antenna spacing in meters, and :math:`\lambda` is the wavelength of the signal. Using the formula for wavelength and solving for :math:`\theta_{AOA}` we get: +waar :math:`\theta_{AOA}` de aankomsthoek van het signaal is ten opzichte van boresight, :math:`d` de antenneafstand in meter, en :math:`\lambda` de golflengte van het signaal. Met de formule voor golflengte en opgelost naar :math:`\theta_{AOA}` krijgen we: .. math:: \theta_{AOA} = \sin^{-1}\left(\frac{c \phi}{2 \pi f d}\right) -You'll see this when we calculate :code:`steer_angle` below: +Dat zie je terug bij de berekening van :code:`steer_angle` hieronder: .. code-block:: python @@ -290,16 +284,16 @@ You'll see this when we calculate :code:`steer_angle` below: plt.ylabel("Magnitude [dB]") plt.show() -For each :code:`phase` value (remember, this is the phase between adjacent elements) we set the phase shifters, after adding in the phase calibration values and forcing the degrees to be between 0 and 360. We then grab one batch of samples with :code:`rx()`, sum the two channels, then calculate the power in the signal. We then plot power over angle of arrival. The result should look something like this: +Voor elke :code:`phase`-waarde (dit is dus het faseverschil tussen aangrenzende elementen) zetten we de faseschuivers, na optellen van de fasecalibratiewaarden en normalisatie van graden naar 0-360. Daarna halen we met :code:`rx()` een batch samples op, sommeren we de twee kanalen en berekenen we het signaalvermogen. Vervolgens plotten we vermogen tegen aankomsthoek. Het resultaat ziet er ongeveer zo uit: .. image:: ../_images/phaser_sweep.png :scale: 100 % :align: center - :alt: Phaser single sweep + :alt: Phaser enkele sweep -In this example the HB100 was held slightly to the side of boresight. +In dit voorbeeld werd de HB100 iets naast boresight gehouden. -If you want a polar plot you can instead using the following: +Als je een polaire plot wilt, kun je in plaats daarvan het volgende gebruiken: .. code-block:: python @@ -317,14 +311,14 @@ If you want a polar plot you can instead using the following: .. image:: ../_images/phaser_sweep_polar.png :scale: 100 % :align: center - :alt: Phaser single sweep using a polar plot + :alt: Phaser enkele sweep met polaire plot -By taking the max we can estimate the direction of arrival of the signal! +Door het maximum te nemen kunnen we de aankomstrichting van het signaal schatten. -Real-time and with Spatial Tapering +Realtime en met Ruimtelijke Tapering ###################################### -Now let's take a moment to talk about spatial tapering. So far we have left the gain adjustments of each channel to equal values, so that all eight channels get summed equally. Just like we applied a window before taking an FFT, we can apply a window in the spatial domain by applying weights to these eight channels. We'll use the exact same windowing functions like Hanning, Hamming, etc. Let's also tweak the code to run in real-time so that it's a little more fun: +Laten we nu kort stilstaan bij ruimtelijke tapering. Tot nu toe hielden we de gaininstellingen van elk kanaal gelijk, zodat alle acht kanalen gelijk worden opgeteld. Net zoals we een venster toepassen voor een FFT, kunnen we in het ruimtelijke domein een venster toepassen door gewichten op deze acht kanalen te zetten. We gebruiken dezelfde vensterfuncties zoals Hanning, Hamming, enzovoort. We passen de code ook aan voor realtime uitvoering: .. code-block:: python @@ -372,36 +366,36 @@ Now let's take a moment to talk about spatial tapering. So far we have left the except KeyboardInterrupt: sys.exit() # quit python -You should see a real-time version of the previous exercise. Try switching which :code:`gain_list` is used, to play around with the different windows. Here is an example of the Rectangular window (i.e., no windowing function): +Je zou nu een realtimeversie van de vorige oefening moeten zien. Wissel eens van :code:`gain_list` om met verschillende vensters te experimenteren. Hier is een voorbeeld met het rechthoekige venster (dus zonder vensterfunctie): .. image:: ../_images/phaser_animation_rect.gif :scale: 100 % :align: center - :alt: Beamforming animation using the Phaser and a rectangular window + :alt: Bundelvormingsanimatie met de Phaser en rechthoekig venster -and here is an example of the Hamming window: +en hier een voorbeeld met het Hamming-venster: .. image:: ../_images/phaser_animation_hamming.gif :scale: 100 % :align: center - :alt: Beamforming animation using the Phaser and a Hamming window + :alt: Bundelvormingsanimatie met de Phaser en Hamming-venster -Note the lack of sidelobes for Hamming. In fact, every window aside from Rectangular will greatly reduce the sidelobes, but in return the main lobe will be a little wider. +Let op het ontbreken van sidelobes bij Hamming. In feite zal elk venster behalve Rectangular de sidelobes sterk verminderen, maar in ruil daarvoor wordt de hoofdlob iets breder. ************************ Monopulse Tracking ************************ -Up until this point we have been performing individual sweeps in order to find the angle of arrival of a test transmitter (the HB100). But lets say we wish to continuously receive a communications or radar signal, that may be moving an causing the angle of arrival to change over time. We refer to this process as tracking, and it assumes we already have a rough estimate of the angle of arrival (i.e., the initial sweep has identified a signal of interest). We will use monopulse tracking to adaptively update the weights in order to keep the main lobe pointed at the signal over time, although note that there are other methods of tracking besides monopulse. +Tot nu toe voerden we losse sweeps uit om de aankomsthoek van een testzender (de HB100) te vinden. Stel nu dat we continu een communicatie- of radarsignaal willen ontvangen dat beweegt en daardoor een veranderende aankomsthoek heeft. Dit noemen we tracking, en het veronderstelt dat we al een ruwe schatting van de aankomsthoek hebben (de eerste sweep heeft dus een interessant signaal gevonden). We gebruiken monopulse-tracking om de gewichten adaptief bij te werken en de hoofdlob in de tijd op het signaal gericht te houden, al zijn er ook andere trackingmethoden. -Invented in 1943 by Robert Page at the Naval Research Laboratory (NRL), the basic concept of monopulse tracking is to use two beams, both slightly offset from the current angle of arrival (or at least our estimate of it), but on different sides as shown in the diagram below. +Monopulse-tracking werd in 1943 bedacht door Robert Page bij het Naval Research Laboratory (NRL). Het basisidee is om twee bundels te gebruiken die beide iets afwijken van de huidige aankomsthoek (of onze schatting daarvan), maar aan tegengestelde kanten zoals in het diagram hieronder. .. image:: ../_images/monopulse.svg :align: center :target: ../_images/monopulse.svg - :alt: Monopulse beam diagram showing two beams and the sum beam + :alt: Monopulse-diagram met twee bundels en de sombundel -We then take both the sum and difference (a.k.a. delta) of these two beams digitally, which means we must use two digital channels of the Phaser, making this a hybrid array approach (although you could certainly do the sum and difference in analog with custom hardware). The sum beam will equate to a beam centered at the current angle of arrival estimate, as shown above, which means this beam can be used for demod/decoding the signal of interest. The delta beam, as we will call it, is harder to visualize, but it will have a null at the angle of arrival estimate. We can use the ratio between the sum beam and delta beam (refered to as the error) to perform our tracking. This process is best explained with a short Python snippet; recall that the :code:`rx()` function returns a batch of samples from both channels, so in the code below :code:`data[0]` is the first channel of the Pluto (first set of four Phaser elements) and :code:`data[1]` is the second channel (second set of four elements). In order to create two beams, we will steer each of the two sets separately. We can calculate the sum, delta, and error as follows: +Vervolgens nemen we digitaal zowel de som als het verschil (delta) van deze twee bundels. Dat betekent dat we twee digitale kanalen van de Phaser gebruiken, dus dit is een hybride array-aanpak (al kun je som en verschil ook analoog realiseren met aangepaste hardware). De sombundel is gecentreerd rond de huidige aankomsthoekschatting, zoals hierboven, en kan worden gebruikt voor demodulatie/decodering van het doelsignaal. De delta-bundel is lastiger te visualiseren, maar heeft een null op de geschatte aankomsthoek. We kunnen de verhouding tussen sombundel en delta-bundel (de error) gebruiken voor tracking. Dit wordt het duidelijkst met een korte Python-snippet; de :code:`rx()`-functie geeft een batch samples van beide kanalen terug. In de code hieronder is :code:`data[0]` het eerste Pluto-kanaal (eerste set van vier Phaser-elementen) en :code:`data[1]` het tweede kanaal (tweede set van vier elementen). Om twee bundels te maken sturen we deze twee sets apart aan. Som, delta en error berekenen we als volgt: .. code-block:: python @@ -410,9 +404,9 @@ We then take both the sum and difference (a.k.a. delta) of these two beams digit delta_beam = data[0] - data[1] error = np.mean(np.real(delta_beam / sum_beam)) -The sign of the error tells us which direction the signal is actually coming from, and the magnitude tells us how far off we are from the signal. We can then use this information to update the angle of arrival estimate and weights. By repeating this process in real-time we can track the signal. +Het teken van de error vertelt ons aan welke kant het signaal werkelijk zit, en de grootte van de error geeft aan hoe ver we van het signaal af zitten. Met die informatie werken we de aankomsthoekschatting en de gewichten bij. Door dit realtime te herhalen kunnen we het signaal volgen. -Now jumping into the full Python example, we will start by copying the code we used earlier to perform a 180 degree sweep. The only code we will add is to pull out the phase at which the received power was maximum: +In het volledige Python-voorbeeld beginnen we met de code van de eerdere 180-gradensweep. De enige toevoeging is dat we de fase nemen waarbij het ontvangen vermogen maximaal was: .. code-block:: python @@ -421,7 +415,7 @@ Now jumping into the full Python example, we will start by copying the code we u current_phase = phase_angles[np.argmax(powers)] print("max_phase:", current_phase) -Next we will create two beams, we will start by trying 5 degrees lower and 5 degrees higher than the current estimate, although note that this is in units of phase, we haven't converted to steering angle, although they are similar. The following code is essentially two copies of the code we used earlier to set the phase shifters of each channel, except we use the first 4 elements for the lower beam and last 4 elements for upper beam: +Vervolgens maken we twee bundels: eerst 5 graden lager en 5 graden hoger dan de huidige schatting. Let op dat dit in fase-eenheden is; we hebben nog niet naar stuurhoek omgerekend, al zijn die vergelijkbaar. De volgende code is in essentie twee kopieen van de eerdere code voor faseschuivers per kanaal, met dit verschil: de eerste 4 elementen voor de lage bundel en de laatste 4 voor de hoge bundel: .. code-block:: python @@ -439,7 +433,7 @@ Next we will create two beams, we will start by trying 5 degrees lower and 5 deg phaser.elements.get(i + 1).rx_phase = channel_phase phaser.latch_rx_settings() # apply settings -Before doing the actual tracking, lets test the above by keeping the beam weights constant and moving the HB100 left and right (after it finishes initializing to find the starting angle): +Voordat we echte tracking doen, testen we dit eerst door de bundelgewichten constant te houden en de HB100 links en rechts te bewegen (nadat de initialisatie de starthoek heeft bepaald): .. code-block:: python @@ -463,11 +457,11 @@ Before doing the actual tracking, lets test the above by keeping the beam weight .. image:: ../_images/monopulse_waving.svg :align: center :target: ../_images/monopulse_waving.svg - :alt: Showing error function for monopulse tracking without actually updating the weights + :alt: Errorfunctie voor monopulse-tracking zonder de gewichten bij te werken -What's happening in this example is I'm moving the HB100 around. I start by holding it in a steady position while the 180 degree sweep happens, then after it's done I move it a little to the right, and wiggle it around, then I move it to the left of where I started and wiggle it around. Then around time = 400 in the plot I move it back to the other side and hold it there for a moment, before waving it around one more time. The take-away is that the further the HB100 gets from the starting angle, the higher the error, and the sign of the error tells us which side the HB100 is on relative to the starting angle. +Wat hier gebeurt: ik beweeg de HB100 rond. Ik begin met een vaste positie terwijl de 180-gradensweep loopt, daarna beweeg ik hem iets naar rechts en wiebel ik ermee, vervolgens naar links van de startpositie en weer wat beweging. Rond tijd = 400 in de plot ga ik weer naar de andere kant en houd ik hem kort stil, daarna nogmaals wat beweging. De kern: hoe verder de HB100 van de starthoek zit, hoe groter de error, en het teken van de error geeft aan aan welke kant de HB100 zich bevindt ten opzichte van de starthoek. -Now lets use the error value to update the weights. We will get rid of the previous for loop, and make a new for loop around the entire process. For the sake of clarity we have the entire code example below, except for the initial part where we did the 180 degree sweep: +Laten we nu de error gebruiken om de gewichten bij te werken. We vervangen de vorige for-loop door een nieuwe for-loop rond het volledige proces. Voor de duidelijkheid staat hieronder het complete codevoorbeeld, behalve het initiele deel met de 180-gradensweep: .. code-block:: python @@ -526,21 +520,21 @@ Now lets use the error value to update the weights. We will get rid of the prev .. image:: ../_images/monopulse_tracking.svg :align: center :target: ../_images/monopulse_tracking.svg - :alt: Monopulse tracking demo using a Phaser and HB100 being waved around infront of it + :alt: Monopulse-trackingdemo met een Phaser en een bewegende HB100 ervoor -You can see the error is essentially the derivative of the phase estimate; because we're performing successful tracking, the phase estimate is more or less the actual angle of arrival. It's not clear looking only at these plots, but when there is a sudden movement, it takes the system a small fraction of a second to adjust and catch up. The goal is for the change in angle of arrival to never be so quick that the signal arrives beyond the main lobes of the two beams. +Je ziet dat de error in essentie de afgeleide van de faseschatting is; omdat tracking hier werkt, benadert de faseschatting de werkelijke aankomsthoek. Alleen op basis van deze plots is dat niet altijd direct zichtbaar, maar bij een plotselinge beweging heeft het systeem een kleine fractie van een seconde nodig om bij te sturen. Het doel is dat de verandering in aankomsthoek nooit zo snel gaat dat het signaal buiten de hoofdlobben van de twee bundels terechtkomt. -It is a lot easier to visualize the process when the array is only 1D, but practical use-cases of monopulse tracking are almost always 2D (using a 2D/planar array instead of a linear array like the Phaser). For the 2D case, there are four beams created instead of two, and after process there is a single sum beam and four delta beams used to steer in both dimensions. +Het proces is veel makkelijker te visualiseren met een 1D-array, maar praktische toepassingen van monopulse-tracking zijn vrijwel altijd 2D (met een 2D/planaire array in plaats van een lineaire array zoals de Phaser). In het 2D-geval maak je vier bundels in plaats van twee, en na verwerking houd je een enkele sombundel en vier delta-bundels over voor sturing in beide dimensies. ************************ Radar with Phaser ************************ -Coming soon! +Komt binnenkort! ************************ -Conclusion +Conclusie ************************ -The entire code used to generate the figures in this chapter is available on the textbook's GitHub page. +Alle code die is gebruikt om de figuren in dit hoofdstuk te genereren is beschikbaar op de GitHub-pagina van het leerboek. diff --git a/content-nl/pyqt.rst b/content-nl/pyqt.rst new file mode 100644 index 00000000..86fbe6b3 --- /dev/null +++ b/content-nl/pyqt.rst @@ -0,0 +1,881 @@ +.. _pyqt-chapter: + +########################## +Realtime GUI's met PyQt +########################## + +In dit hoofdstuk leren we hoe je realtime grafische gebruikersinterfaces (GUI's) in Python maakt met PyQt, de Python-bindings voor Qt. Als onderdeel van dit hoofdstuk bouwen we een spectrum analyzer met tijd-, frequentie- en spectrogram/waterfall-weergave, plus invoerwidgets om verschillende SDR-parameters aan te passen. Het voorbeeld ondersteunt PlutoSDR, USRP en een simulatiemodus. + +**************** +Introductie +**************** + +Qt (uitgesproken als het engelse woord "Cute") is een framework om GUI-applicaties te maken die op Linux, Windows, macOS en zelfs Android kunnen draaien. Het is een krachtig framework dat in veel commerciële applicaties wordt gebruikt en in C++ is geschreven voor hoge prestaties. PyQt is de Python-verbinding met Qt en biedt daarmee een manier om GUI-applicaties in Python te bouwen, terwijl je profiteert van de prestaties van het onderliggende C++-framework. In dit hoofdstuk gebruiken we PyQt om een realtime spectrum analyzer te bouwen die met een SDR (of met een gesimuleerd signaal) werkt. De analyzer krijgt tijd-, frequentie- en spectrogram/waterfall-weergaven, plus invoerwidgets om SDR-parameters bij te sturen. Voor het plotten gebruiken we `PyQtGraph `_, een aparte library bovenop PyQt. Voor invoer gebruiken we sliders, combo-boxes en push-buttons. Het voorbeeld ondersteunt PlutoSDR, USRP en simulatiemodus. Hoewel de voorbeeldcode PyQt6 gebruikt, is vrijwel elke regel identiek aan PyQt5 (op de :code:`import` na); qua API is er weinig veranderd tussen die versies. Dit hoofdstuk bevat daarom veel Python-code met uitleg via voorbeelden. Aan het einde heb je de belangrijkste bouwstenen in handen om je eigen interactieve SDR-app te maken. + +**************** +Qt-overzicht +**************** + +Qt is een groot framework en we behandelen slechts een klein deel van de mogelijkheden. Er zijn wel enkele kernconcepten die belangrijk zijn bij werken met Qt/PyQt: + +- **Widgets**: Widgets zijn de bouwstenen van een Qt-applicatie en vormen de GUI. Er zijn veel soorten widgets, zoals knoppen, sliders, labels en plots. Widgets worden in layouts geplaatst, die bepalen hoe ze op het scherm staan. + +- **Layouts**: Layouts worden gebruikt om widgets in een venster te ordenen. Er zijn meerdere types, waaronder horizontale, verticale, grid- en form-layouts. Layouts maken complexe GUI's mogelijk die goed reageren op veranderingen in venstergrootte. + +- **Signals en Slots**: Signals en slots zijn een manier om tussen onderdelen van een Qt-applicatie te communiceren. Een signal wordt uitgezonden wanneer een gebeurtenis plaatsvindt en is gekoppeld aan een slot (een callbackfunctie) die dan wordt uitgevoerd. Dit maakt een event-driven structuur mogelijk en houdt de GUI responsief. + +- **Style Sheets**: Style sheets worden gebruikt om het uiterlijk van widgets aan te passen. Ze zijn geschreven in een CSS-achtige taal en kunnen kleur, lettertype en grootte wijzigen. + +- **Graphics**: Qt heeft een krachtig graphics-framework om custom grafische elementen te maken. Het bevat classes voor lijnen, rechthoeken, ellipsen en tekst, plus klassen voor muis- en toetsenbordevents. + +- **Multithreading**: Qt ondersteunt multithreading ingebouwd en biedt classes om worker-threads op de achtergrond te draaien. Daarmee kun je langdurige taken uitvoeren zonder de hoofd-GUI-thread te blokkeren. + +- **OpenGL**: Qt heeft ingebouwde OpenGL-ondersteuning en classes voor 3D-graphics. Dat is nuttig voor toepassingen die hoge 3D-prestaties vragen. In dit hoofdstuk richten we ons alleen op 2D-toepassingen. + +******************************* +Basislayout van een Applicatie +******************************* + +Voordat we de verschillende Qt-widgets behandelen, kijken we naar de layout van een typische Qt-applicatie. Een Qt-app bestaat uit een hoofdvenster met daarin een centrale widget, die op zijn beurt de hoofdinhoud bevat. Met PyQt kunnen we een minimale app maken met slechts een enkele QPushButton: + +.. code-block:: python + + from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton + + # Subclass QMainWindow to customize your application's main window + class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + # Example GUI component + example_button = QPushButton('Push Me') + def on_button_click(): + print("beep") + example_button.clicked.connect(on_button_click) + + self.setCentralWidget(example_button) + + app = QApplication([]) + window = MainWindow() + window.show() # Windows are hidden by default + app.exec() # Start the event loop + +Probeer de code zelf uit; waarschijnlijk moet je :code:`pip install PyQt6` uitvoeren. Merk op dat de allerlaatste regel blokkerend is: alles wat je daaronder zet, draait pas nadat je het venster sluit. De gemaakte QPushButton heeft zijn :code:`clicked`-signal gekoppeld aan een callback die "beep" naar de console print. + +******************************* +Applicatie met Worker-thread +******************************* + +Er is een probleem met het minimale voorbeeld: er is geen goede plek voor SDR/DSP-code. De :code:`__init__` van :code:`MainWindow` is bedoeld voor GUI-configuratie en callbacks, maar daar wil je geen andere logica (zoals SDR of DSP) in stoppen. De reden: de GUI is single-threaded. Als je de GUI-thread blokkeert met langdurige code, bevriest of stottert de interface. Daarom gebruiken we een worker-thread om SDR/DSP op de achtergrond uit te voeren. + +Het onderstaande voorbeeld breidt het minimale voorbeeld uit met een worker-thread die code in de :code:`run`-functie continu laat draaien. We gebruiken bewust geen :code:`while True:`, omdat we door de interne werking van PyQt willen dat :code:`run` periodiek afrondt en opnieuw start. Daarom koppelen we het :code:`end_of_run`-signal van de worker-thread (volgende sectie) aan een callback die :code:`run` opnieuw triggert. We initialiseren de worker-thread in :code:`MainWindow`, door een :code:`QThread` te maken en onze custom worker eraan toe te wijzen. Dit lijkt misschien complex, maar het is een veelgebruikt patroon in PyQt-apps. Belangrijkste punt: GUI-code hoort in :code:`MainWindow`, SDR/DSP-code in de worker-thread (:code:`run`). + +.. code-block:: python + + from PyQt6.QtCore import QThread, pyqtSignal, QObject, QTimer + from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton + import time + + # Non-GUI operations (including SDR) need to run in a separate thread + class SDRWorker(QObject): + end_of_run = pyqtSignal() + + # Main loop + def run(self): + print("Starting run()") + time.sleep(1) + self.end_of_run.emit() # let MainWindow know we're done + + # Subclass QMainWindow to customize your application's main window + class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + # Initialize worker and thread + self.sdr_thread = QThread() + worker = SDRWorker() + worker.moveToThread(self.sdr_thread) + + # Example GUI component + example_button = QPushButton('Push Me') + def on_button_click(): + print("beep") + example_button.clicked.connect(on_button_click) + self.setCentralWidget(example_button) + + # This is what keeps the run() function repeating nonstop + def end_of_run_callback(): + QTimer.singleShot(0, worker.run) # Run worker again immediately + worker.end_of_run.connect(end_of_run_callback) + + self.sdr_thread.started.connect(worker.run) # kicks off the first run() when the thread starts + self.sdr_thread.start() # start thread + + app = QApplication([]) + window = MainWindow() + window.show() # Windows are hidden by default + app.exec() # Start the event loop + +Probeer de code hierboven uit. Je zou elke seconde "Starting run()" in de console moeten zien en de knop moet zonder merkbare vertraging blijven werken. In de worker-thread doen we nu alleen print en sleep, maar zo voegen we straks eenvoudig SDR- en DSP-code toe. + +************************* +Signals en Slots +************************* + +In het vorige voorbeeld gebruikten we :code:`end_of_run` om tussen worker-thread en GUI-thread te communiceren. Dit is een veelvoorkomend patroon in PyQt, het "signals en slots"-mechanisme. Een signal wordt uitgezonden door een object (hier: de worker-thread) en gekoppeld aan een slot (hier: callback :code:`end_of_run_callback` in de GUI-thread). Een signal kan aan meerdere slots gekoppeld worden, en een slot aan meerdere signals. Een signal kan ook argumenten meedragen die aan het slot worden doorgegeven. Dit werkt ook andersom: de GUI-thread kan een signal naar een slot in de worker-thread sturen. Het signal/slot-mechanisme is een krachtige manier om onderdelen van een PyQt-app event-driven te laten samenwerken en komt veel terug in de code hieronder. Denk simpel: een slot is een callbackfunctie; een signal triggert die callback. + +************************* +PyQtGraph +************************* + +PyQtGraph is een library bovenop PyQt en NumPy die snelle, efficiente plotting biedt, omdat PyQt zelf te algemeen is om uitgebreide plotfunctionaliteit standaard te bevatten. De library is ontworpen voor realtime toepassingen en geoptimaliseerd voor snelheid. In veel opzichten lijkt het op Matplotlib, maar PyQtGraph is meer gericht op continue updates dan op losse statische plots. Met het eenvoudige voorbeeld hieronder kun je de prestaties van PyQtGraph vergelijken met Matplotlib door :code:`if True:` te wijzigen naar :code:`False:`. Op een Intel Core i9-10900K @ 3.70 GHz haalde PyQtGraph meer dan 1000 FPS, terwijl Matplotlib rond 40 FPS zat. Als Matplotlib jou toch voordeel geeft (bijvoorbeeld ontwikkeltijd of een specifieke feature), kun je Matplotlib-plots ook in een PyQt-app integreren, met de onderstaande code als startpunt. + +.. raw:: html + +
+ Expand for comparison code + +.. code-block:: python + + import numpy as np + import time + import matplotlib + matplotlib.use('Qt5Agg') + from PyQt6 import QtCore, QtWidgets + from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas + from matplotlib.figure import Figure + import pyqtgraph as pg # tested with pyqtgraph==0.13.7 + + n_data = 1024 + + if True: + class MplCanvas(FigureCanvas): + def __init__(self): + fig = Figure(figsize=(13, 8), dpi=100) + self.axes = fig.add_subplot(111) + super(MplCanvas, self).__init__(fig) + + + class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super(MainWindow, self).__init__() + + self.canvas = MplCanvas() + self._plot_ref = self.canvas.axes.plot(np.arange(n_data), '.-r')[0] + self.canvas.axes.set_xlim(0, n_data) + self.canvas.axes.set_ylim(-5, 5) + self.canvas.axes.grid(True) + self.setCentralWidget(self.canvas) + + # Setup a timer to trigger the redraw by calling update_plot. + self.timer = QtCore.QTimer() + self.timer.setInterval(0) # causes the timer to start immediately + self.timer.timeout.connect(self.update_plot) # causes the timer to start itself again automatically + self.timer.start() + self.start_t = time.time() # used for benchmarking + + self.show() + + def update_plot(self): + self._plot_ref.set_ydata(np.random.randn(n_data)) + self.canvas.draw() # Trigger the canvas to update and redraw. + print('FPS:', 1/(time.time()-self.start_t)) # got ~42 FPS on an i9-10900K + self.start_t = time.time() + + else: + class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super(MainWindow, self).__init__() + + self.time_plot = pg.PlotWidget() + self.time_plot.setYRange(-5, 5) + self.time_plot_curve = self.time_plot.plot([]) + self.setCentralWidget(self.time_plot) + + # Setup a timer to trigger the redraw by calling update_plot. + self.timer = QtCore.QTimer() + self.timer.setInterval(0) # causes the timer to start immediately + self.timer.timeout.connect(self.update_plot) # causes the timer to start itself again automatically + self.timer.start() + self.start_t = time.time() # used for benchmarking + + self.show() + + def update_plot(self): + self.time_plot_curve.setData(np.random.randn(n_data)) + print('FPS:', 1/(time.time()-self.start_t)) # got ~42 FPS on an i9-10900K + self.start_t = time.time() + + app = QtWidgets.QApplication([]) + w = MainWindow() + app.exec() + +.. raw:: html + +
+ +Qua gebruik van PyQtGraph importeren we het met :code:`import pyqtgraph as pg` en maken daarna een Qt-widget voor een 1D-plot, zoals hieronder (deze code hoort in :code:`MainWindow.__init__`): + +.. code-block:: python + + # Example PyQtGraph plot + time_plot = pg.PlotWidget(labels={'left': 'Amplitude', 'bottom': 'Time'}) + time_plot_curve = time_plot.plot(np.arange(1000), np.random.randn(1000)) # x and y + time_plot.setYRange(-5, 5) + + self.setCentralWidget(time_plot) + +.. image:: ../_images/pyqtgraph_example.png + :scale: 80 % + :align: center + :alt: PyQtGraph-voorbeeld + +Je ziet dat een plot opzetten relatief eenvoudig is en dat het resultaat gewoon een extra widget in je GUI is. Naast 1D-plots heeft PyQtGraph ook een equivalent van Matplotlib's :code:`imshow()` voor 2D-weergave met colormap, wat we gebruiken voor onze realtime spectrogram/waterfall. Een groot voordeel is dat de plots gewone Qt-widgets zijn, zodat je met pure PyQt extra elementen kunt toevoegen (bijvoorbeeld een rechthoek op een bepaalde locatie). Dat komt doordat PyQtGraph gebruikmaakt van PyQt's :code:`QGraphicsScene`, een oppervlak voor veel 2D-objecten. Je kunt dus zonder probleem lijnen, rechthoeken, tekst, ellipsen, polygonen en bitmaps toevoegen met standaard PyQt. + +******* +Layouts +******* + +In de voorbeelden hierboven gebruikten we :code:`self.setCentralWidget()` om de hoofdwidget van het venster te zetten. Dat is eenvoudig, maar beperkt voor complexere layouts. Daarvoor gebruik je layouts om widgets te rangschikken. Er zijn meerdere types, waaronder :code:`QHBoxLayout`, :code:`QVBoxLayout`, :code:`QGridLayout` en :code:`QFormLayout`. :code:`QHBoxLayout` en :code:`QVBoxLayout` plaatsen widgets respectievelijk horizontaal en verticaal. :code:`QGridLayout` plaatst widgets in een raster, en :code:`QFormLayout` in twee kolommen met labels links en invoerwidgets rechts. + +Om een nieuwe layout te maken en widgets toe te voegen, probeer het volgende in :code:`MainWindow.__init__`: + +.. code-block:: python + + layout = QHBoxLayout() + layout.addWidget(QPushButton("Left-Most")) + layout.addWidget(QPushButton("Center"), 1) + layout.addWidget(QPushButton("Right-Most"), 2) + self.setLayout(layout) + +In dit voorbeeld stapelen we widgets horizontaal. Door :code:`QHBoxLayout` te vervangen door :code:`QVBoxLayout` stapel je ze verticaal. De functie :code:`addWidget` voegt widgets toe aan de layout, en het optionele tweede argument is een stretchfactor die bepaalt hoeveel ruimte de widget relatief inneemt. + +:code:`QGridLayout` heeft extra parameters omdat je rij en kolom expliciet opgeeft. Je kunt optioneel ook aangeven over hoeveel rijen en kolommen een widget moet lopen (standaard 1 en 1). Voorbeeld: + +.. code-block:: python + + layout = QGridLayout() + layout.addWidget(QPushButton("Button at (0, 0)"), 0, 0) + layout.addWidget(QPushButton("Button at (0, 1)"), 0, 1) + layout.addWidget(QPushButton("Button at (0, 2)"), 0, 2) + layout.addWidget(QPushButton("Button at (1, 0)"), 1, 0) + layout.addWidget(QPushButton("Button at (1, 1)"), 1, 1) + layout.addWidget(QPushButton("Button at (1, 2)"), 1, 2) + layout.addWidget(QPushButton("Button at (2, 0) spanning 2 columns"), 2, 0, 1, 2) + self.setLayout(layout) + +.. image:: ../_images/qt_layouts.svg + :align: center + :target: ../_images/qt_layouts.svg + :alt: Qt-layouts met voorbeelden van QHBoxLayout, QVBoxLayout en QGridLayout + +Voor onze spectrum analyzer gebruiken we :code:`QGridLayout` als hoofdlayout, maar voegen we ook :code:`QHBoxLayout` toe om widgets horizontaal te stapelen binnen een cel van het grid. Layouts kun je eenvoudig nesten door een nieuwe layout te maken en die aan de bovenliggende layout toe te voegen, bijvoorbeeld: + +.. code-block:: python + + layout = QGridLayout() + self.setLayout(layout) + inner_layout = QHBoxLayout() + layout.addLayout(inner_layout) + +******************* +:code:`QPushButton` +******************* + +De eerste widget die we behandelen is :code:`QPushButton`, een eenvoudige klikbare knop. We zagen al hoe je een :code:`QPushButton` maakt en het :code:`clicked`-signal aan een callback koppelt. :code:`QPushButton` heeft ook andere signals, zoals :code:`pressed`, :code:`released` en :code:`toggled`. Het :code:`toggled`-signal komt vrij wanneer een knop wordt in- of uitgeschakeld en is handig voor toggle-knoppen. Verder zijn er properties zoals :code:`text`, :code:`icon` en :code:`checkable`. Er is ook een :code:`click()`-methode om een klik te simuleren. In onze SDR spectrum analyzer gebruiken we knoppen om auto-range voor plots te triggeren op basis van actuele data. Omdat we :code:`QPushButton` al gebruikt hebben, gaan we hier niet dieper in op details; zie de `QPushButton-documentatie `_. + +*************** +:code:`QSlider` +*************** + +De :code:`QSlider` is een widget waarmee de gebruiker een waarde uit een bereik kiest. Belangrijke properties zijn :code:`minimum`, :code:`maximum`, :code:`value` en :code:`orientation`. Belangrijke signals zijn :code:`valueChanged`, :code:`sliderPressed` en :code:`sliderReleased`. Met :code:`setValue()` zet je de sliderwaarde, wat we vaak gebruiken. De documentatie staat `hier voor QSlider `_. + +In onze spectrum analyzer gebruiken we :code:`QSlider`-widgets om centerfrequentie en gain van de SDR aan te passen. Hieronder staat de snippet uit de uiteindelijke app voor de gain-slider: + +.. code-block:: python + + # Gain slider with label + gain_slider = QSlider(Qt.Orientation.Horizontal) + gain_slider.setRange(0, 73) # min and max, inclusive. interval is always 1 + gain_slider.setValue(50) # initial value + gain_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + gain_slider.setTickInterval(2) # for visual purposes only + gain_slider.sliderMoved.connect(worker.update_gain) + gain_label = QLabel() + def update_gain_label(val): + gain_label.setText("Gain: " + str(val)) + gain_slider.sliderMoved.connect(update_gain_label) + update_gain_label(gain_slider.value()) # initialize the label + layout.addWidget(gain_slider, 5, 0) + layout.addWidget(gain_label, 5, 1) + +Een belangrijk punt bij :code:`QSlider`: deze werkt met gehele getallen. Met bereik 0 tot 73 kan de slider dus alleen integers tussen die waarden kiezen (inclusief begin en eind). :code:`setTickInterval(2)` is alleen visueel. Daarom gebruiken we kHz als eenheid voor de frequentieslider, zodat we tot 1 kHz resolutie hebben. + +Halverwege de code zie je dat we een :code:`QLabel` maken, een tekstlabel voor weergave. Om daar de actuele sliderwaarde in te tonen, maken we een slot (callbackfunctie) die het label bijwerkt. Die callback koppelen we aan :code:`sliderMoved`, dat automatisch wordt uitgezonden bij het bewegen van de slider. We roepen de callback ook eenmalig aan om het label met de beginwaarde te initialiseren (50 in ons geval). Daarnaast koppelen we :code:`sliderMoved` aan een slot in de worker-thread die de SDR-gain aanpast (SDR-beheer en DSP willen we niet in de hoofd-GUI-thread doen). Die slot-callback bespreken we later. + +***************** +:code:`QComboBox` +***************** + +De :code:`QComboBox` is een dropdown-widget waarmee de gebruiker een item uit een lijst kiest. Belangrijke properties zijn :code:`currentText`, :code:`currentIndex` en :code:`count`. Belangrijke signals zijn :code:`currentTextChanged`, :code:`currentIndexChanged` en :code:`activated`. Daarnaast heeft :code:`QComboBox` methodes zoals :code:`addItem()` om items toe te voegen en :code:`insertItem()` om op een specifieke index in te voegen; die laatste gebruiken we in dit voorbeeld niet. De documentatie staat `hier voor QComboBox `_. + +In onze spectrum analyzer gebruiken we :code:`QComboBox` om de sample rate uit een vooraf gedefinieerde lijst te kiezen. Aan het begin van de code zetten we bijvoorbeeld :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. Binnen :code:`MainWindow.__init__` maken we de :code:`QComboBox` als volgt: + +.. code-block:: python + + # Sample rate dropdown using QComboBox + sample_rate_combobox = QComboBox() + sample_rate_combobox.addItems([str(x) + ' MHz' for x in sample_rates]) + sample_rate_combobox.setCurrentIndex(0) # must give it the index, not string + sample_rate_combobox.currentIndexChanged.connect(worker.update_sample_rate) + sample_rate_label = QLabel() + def update_sample_rate_label(val): + sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz") + sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label) + update_sample_rate_label(sample_rate_combobox.currentIndex()) # initialize the label + layout.addWidget(sample_rate_combobox, 6, 0) + layout.addWidget(sample_rate_label, 6, 1) + +Het belangrijkste verschil met de slider is :code:`addItems()` (je geeft een lijst strings als opties mee) en :code:`setCurrentIndex()` (je zet de startwaarde via index). + +**************** +Lambdafuncties +**************** + +Herinner je de code van hierboven waar we dit deden: + +.. code-block:: python + + def update_sample_rate_label(val): + sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz") + sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label) + +We maken hier een functie met slechts een regel code en geven die functie (functies zijn ook objecten) door aan :code:`connect()`. Om dit patroon te vereenvoudigen, schrijven we het eerst om in basis-Python: + +.. code-block:: python + + def my_function(x): + print(x) + y.call_that_takes_in_function_obj(my_function) + +In deze situatie heeft de functie maar een regel code en gebruiken we die functie maar eenmaal, bij het zetten van de :code:`connect`-callback. In zulke gevallen kun je een lambdafunctie gebruiken, een manier om een functie in een regel te definieren. De code hierboven herschreven met lambda: + +.. code-block:: python + + y.call_that_takes_in_function_obj(lambda x: print(x)) + +Als je nog niet eerder lambdafuncties hebt gebruikt, kan dit vreemd overkomen. Je bent niet verplicht ze te gebruiken, maar ze besparen vaak enkele regels en maken code compacter. Werking: de tijdelijke argumentnaam staat na "lambda", en alles na de dubbele punt is de code die op dat argument werkt. Dit ondersteunt ook meerdere argumenten met komma's, of zelfs geen argumenten met :code:`lambda : `. Als oefening kun je :code:`update_sample_rate_label` hierboven herschrijven met een lambdafunctie. + +*********************** +PlotWidget van PyQtGraph +*********************** + +PyQtGraph's :code:`PlotWidget` is een PyQt-widget voor 1D-plots, vergelijkbaar met Matplotlib's :code:`plt.plot(x,y)`. Wij gebruiken deze voor tijd- en frequentieplots (PSD), al werkt hij ook goed voor IQ-plots (die onze analyzer niet bevat). Voor wie dieper wil: PlotWidget is een subclass van PyQt's `QGraphicsView `_, een widget om de inhoud van een `QGraphicsScene `_ te tonen. Die scene is een oppervlak voor veel 2D-grafische items in Qt. Belangrijk voor gebruik: PlotWidget is in de kern gewoon een widget met een enkel `PlotItem `_. Vanuit documentatieperspectief kun je daarom vaak direct naar de PlotItem-documentatie gaan: ``_. Een PlotItem bevat een ViewBox voor de data plus AxisItems en labels voor assen en titel. + +Het eenvoudigste voorbeeld van PlotWidget-gebruik is als volgt (plaats dit in :code:`MainWindow.__init__`): + +.. code-block:: python + + import pyqtgraph as pg + plotWidget = pg.plot(title="My Title") + plotWidget.plot(x, y) + +waar x en y doorgaans NumPy-arrays zijn, net als bij Matplotlib's :code:`plt.plot()`. Dit is echter een statische plot waarin de data niet verandert. Voor onze spectrum analyzer willen we data in de worker-thread updaten, dus bij initialisatie van de plot hoeven we nog geen data mee te geven; alleen opzetten is genoeg. Zo initialiseren we de tijd-domeinplot: + +.. code-block:: python + + # Time plot + time_plot = pg.PlotWidget(labels={'left': 'Amplitude', 'bottom': 'Time [microseconds]'}) + time_plot.setMouseEnabled(x=False, y=True) + time_plot.setYRange(-1.1, 1.1) + time_plot_curve_i = time_plot.plot([]) + time_plot_curve_q = time_plot.plot([]) + layout.addWidget(time_plot, 1, 0) + +Je ziet dat we twee curves maken: een voor I en een voor Q. De rest spreekt grotendeels voor zich. Om de plot te kunnen updaten, maken we een slot (callbackfunctie) in :code:`MainWindow.__init__`: + +.. code-block:: python + + def time_plot_callback(samples): + time_plot_curve_i.setData(samples.real) + time_plot_curve_q.setData(samples.imag) + +Dit slot koppelen we aan het signal van de worker-thread dat wordt uitgezonden wanneer nieuwe samples beschikbaar zijn, zoals later te zien is. + +Het laatste dat we in :code:`MainWindow.__init__` doen is rechts naast de plot een paar knoppen toevoegen die auto-range triggeren. De ene gebruikt de huidige min/max, de andere zet het bereik op -1.1 tot 1.1 (de ADC-limieten van veel SDR's plus 10% marge). We maken hiervoor een geneste layout, specifiek QVBoxLayout, om de twee knoppen verticaal te stapelen. De code: + +.. code-block:: python + + # Time plot auto range buttons + time_plot_auto_range_layout = QVBoxLayout() + layout.addLayout(time_plot_auto_range_layout, 1, 1) + auto_range_button = QPushButton('Auto Range') + auto_range_button.clicked.connect(lambda : time_plot.autoRange()) # lambda just means its an unnamed function + time_plot_auto_range_layout.addWidget(auto_range_button) + auto_range_button2 = QPushButton('-1 to +1\n(ADC limits)') + auto_range_button2.clicked.connect(lambda : time_plot.setYRange(-1.1, 1.1)) + time_plot_auto_range_layout.addWidget(auto_range_button2) + +En zo ziet het er uiteindelijk uit: + +.. image:: ../_images/pyqt_time_plot.png + :scale: 50 % + :align: center + :alt: PyQtGraph tijdplot + +Voor de frequentiedomeinplot (PSD) gebruiken we een vergelijkbaar patroon. + +********************* +ImageItem van PyQtGraph +********************* + +Een spectrum analyzer is niet compleet zonder waterfall (realtime spectrogram), en daarvoor gebruiken we PyQtGraph's ImageItem, dat beelden met 1, 3 of 4 "kanalen" rendert. Een kanaal betekent dat je een 2D-array met floats of ints aanbiedt; vervolgens wordt via een lookup table (LUT) een colormap toegepast om het beeld te maken. Je kunt ook RGB (3 kanalen) of RGBA (4 kanalen) aanleveren. Wij berekenen ons spectrogram als 2D NumPy-array met floats en geven die direct aan ImageItem. We kiezen een colormap en gebruiken ook de ingebouwde LUT-weergave die de waardeverdeling van de data en de kleurtoewijzing laat zien. + +De initialisatie van de waterfall-plot is vrij eenvoudig: we gebruiken een PlotWidget als container (zodat x- en y-as zichtbaar blijven) en voegen daar een ImageItem aan toe: + +.. code-block:: python + + # Waterfall plot + waterfall = pg.PlotWidget(labels={'left': 'Time [s]', 'bottom': 'Frequency [MHz]'}) + imageitem = pg.ImageItem(axisOrder='col-major') # this arg is purely for performance + waterfall.addItem(imageitem) + waterfall.setMouseEnabled(x=False, y=False) + waterfall_layout.addWidget(waterfall) + +Het slot/de callback voor het updaten van de waterfall-data, eveneens in :code:`MainWindow.__init__`, is: + +.. code-block:: python + + def waterfall_plot_callback(spectrogram): + imageitem.setImage(spectrogram, autoLevels=False) + sigma = np.std(spectrogram) + mean = np.mean(spectrogram) + self.spectrogram_min = mean - 2*sigma # save to window state + self.spectrogram_max = mean + 2*sigma + +Hierbij is spectrogram een 2D NumPy-array met floats. Naast het zetten van de beelddata berekenen we een min en max voor de colormap op basis van gemiddelde en variantie van de data, die we later gebruiken. Het laatste GUI-deel voor het spectrogram is de colorbar, die ook de gebruikte colormap bepaalt: + +.. code-block:: python + + # Colorbar for waterfall + colorbar = pg.HistogramLUTWidget() + colorbar.setImageItem(imageitem) # connects the bar to the waterfall imageitem + colorbar.item.gradient.loadPreset('viridis') # set the color map, also sets the imageitem + imageitem.setLevels((-30, 20)) # needs to come after colorbar is created for some reason + waterfall_layout.addWidget(colorbar) + +De tweede regel is belangrijk: die koppelt de colorbar daadwerkelijk aan het ImageItem. Hier kiezen we ook de colormap en de startniveaus (-30 dB tot +20 dB in ons geval). In de worker-thread-code zie je hoe de 2D spectrogramarray wordt berekend/opgeslagen. Hieronder staat een screenshot van dit GUI-deel; let op de sterke ingebouwde functionaliteit van colorbar en LUT-weergave. De zijwaartse klokvormige curve is de verdeling van spectrogramwaarden, wat erg nuttig is. + +.. image:: ../_images/pyqt_spectrogram.png + :scale: 50 % + :align: center + :alt: PyQtGraph-spectrogram en colorbar + +*********************** +Worker-thread +*********************** + +Aan het begin van dit hoofdstuk zagen we hoe je een aparte thread maakt met een class genaamd SDRWorker en een run()-functie. Daar zetten we alle SDR- en DSP-code in, behalve de SDR-initialisatie die we voorlopig globaal doen. De worker-thread werkt ook de drie plots bij door signals uit te zenden zodra nieuwe samples beschikbaar zijn. Die triggeren callbacks in :code:`MainWindow` die de plots daadwerkelijk verversen. De SDRWorker-class is op te delen in drie onderdelen: + +#. :code:`init()` - initialiseert status, bijvoorbeeld de 2D spectrogramarray +#. PyQt Signals - hier definieren we custom signals die we uitzenden +#. PyQt Slots - callbacks die reageren op GUI-events, zoals een bewegende slider +#. :code:`run()` - de hoofdloop die continu draait + +*********************** +PyQt-signals +*********************** + +In de GUI-code hoefden we geen eigen signals te definieren, omdat die al in widgets ingebouwd zijn, zoals :code:`QSlider.valueChanged`. Onze SDRWorker-class is custom, dus de signals die we willen uitzenden moeten we zelf definieren voordat :code:`run()` wordt gebruikt. Hieronder de vier gebruikte signals met hun datatypen: + +.. code-block:: python + + # PyQt Signals + time_plot_update = pyqtSignal(np.ndarray) + freq_plot_update = pyqtSignal(np.ndarray) + waterfall_plot_update = pyqtSignal(np.ndarray) + end_of_run = pyqtSignal() # happens many times a second + +De eerste drie signals sturen een enkel object mee (een NumPy-array). Het laatste signal stuurt geen object mee. Je kunt ook meerdere objecten tegelijk sturen door datatypen met komma's te scheiden, maar dat is hier niet nodig. Binnen :code:`run()` kun je op elke plek een signal naar de GUI-thread uitsturen met een regel code, bijvoorbeeld: + +.. code-block:: python + + self.time_plot_update.emit(samples) + +Er is nog een laatste stap voor alle signal/slot-koppelingen: in de GUI-code (helemaal aan het eind van :code:`MainWindow.__init__`) moeten we de signals van de worker-thread verbinden met slots in de GUI, bijvoorbeeld: + +.. code-block:: python + + worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback + +Onthoud dat :code:`worker` de instantie is van SDRWorker die we in de GUI-code hebben gemaakt. We koppelen hierboven dus het worker-signal :code:`time_plot_update` aan het GUI-slot :code:`time_plot_callback` dat eerder is gedefinieerd. Dit is een goed moment om de snippets terug te bekijken en te zien hoe alles samenwerkt, zodat duidelijk is hoe GUI-thread en worker-thread communiceren; dat is cruciaal in PyQt-programmering. + +************************* +Slots in de Worker-thread +************************* + +De slots van de worker-thread zijn callbacks die door GUI-events worden getriggerd, zoals het bewegen van de gain-slider. Ze zijn vrij rechttoe rechtaan; dit slot zet bijvoorbeeld de SDR-gain op de nieuwe sliderwaarde: + +.. code-block:: python + + def update_gain(self, val): + print("Updated gain to:", val, 'dB') + sdr.set_rx_gain(val) + +************************** +Run() van de Worker-thread +************************** + +In de :code:`run()`-functie gebeurt het eigenlijke DSP-werk. In onze applicatie start elke run met het ophalen van samples uit de SDR (of met simulatie als je geen SDR hebt). + +.. code-block:: python + + # Main loop + def run(self): + if sdr_type == "pluto": + samples = sdr.rx()/2**11 # Receive samples + elif sdr_type == "usrp": + streamer.recv(recv_buffer, metadata) + samples = recv_buffer[0] # will be np.complex64 + elif sdr_type == "sim": + tone = np.exp(2j*np.pi*self.sample_rate*0.1*np.arange(fft_size)/self.sample_rate) + noise = np.random.randn(fft_size) + 1j*np.random.randn(fft_size) + samples = self.gain*tone*0.02 + 0.1*noise + # Truncate to -1 to +1 to simulate ADC bit limits + np.clip(samples.real, -1, 1, out=samples.real) + np.clip(samples.imag, -1, 1, out=samples.imag) + + ... + +Zoals je ziet genereren we in de simulatie een toon met wat witte ruis en begrenzen we samples daarna op -1 tot +1. + +Nu het DSP-deel: we hebben een FFT nodig voor zowel frequentieplot als spectrogram. De PSD van deze sample-set kan direct dienen als een rij in het spectrogram. We schuiven dus de waterfall een rij op en vullen de nieuwe rij onderaan (of bovenaan) in. Voor elke plotupdate sturen we een signal met de bijgewerkte data. Daarna signaleren we het einde van :code:`run()`, zodat de GUI-thread meteen een nieuwe :code:`run()` start. Al met al is het weinig code: + +.. code-block:: python + + ... + + self.time_plot_update.emit(samples[0:time_plot_samples]) + + PSD = 10.0*np.log10(np.abs(np.fft.fftshift(np.fft.fft(samples)))**2/fft_size) + self.PSD_avg = self.PSD_avg * 0.99 + PSD * 0.01 + self.freq_plot_update.emit(self.PSD_avg) + + self.spectrogram[:] = np.roll(self.spectrogram, 1, axis=1) # shifts waterfall 1 row + self.spectrogram[:,0] = PSD # fill last row with new fft results + self.waterfall_plot_update.emit(self.spectrogram) + + self.end_of_run.emit() # emit the signal to keep the loop going + # end of run() + +Let op dat we niet de hele samplebatch naar de tijdplot sturen; dat zijn te veel punten. In plaats daarvan sturen we alleen de eerste 500 samples (instelbaar bovenin het script, hier niet getoond). Voor de PSD-plot gebruiken we een running average door de vorige PSD op te slaan en daar 1% van de nieuwe PSD aan toe te voegen. Dat is een eenvoudige manier om de PSD-plot te egaliseren. De volgorde van :code:`emit()`-aanroepen maakt daarbij niet uit; ze hadden ook allemaal aan het einde van :code:`run()` kunnen staan. + +**************************** +Volledige Eindvoorbeeldcode +**************************** + +Tot nu toe bekeken we losse snippets van de spectrum analyzer-app, maar nu kijken we naar de volledige code en proberen we die te draaien. De code ondersteunt momenteel PlutoSDR, USRP en simulatiemodus. Heb je geen Pluto of USRP, laat de code dan zoals die is; dan gebruikt hij simulatiemodus. Anders wijzig je :code:`sdr_type`. In simulatiemodus zie je bij maximale gain dat het signaal in het tijddomein wordt afgeknipt, wat spurs in het frequentiedomein veroorzaakt. + +Gebruik deze code gerust als startpunt voor je eigen realtime SDR-app. Hieronder staat ook een animatie van de app in actie: met een Pluto eerst op de 750 MHz cellulaire band en daarna op 2,4 GHz WiFi. Een hogere kwaliteit versie staat op YouTube `hier `_. + +.. image:: ../_images/pyqt_animation.gif + :scale: 100 % + :align: center + :alt: Geanimeerde gif van de PyQt spectrum analyzer-app in actie + +Bekende bugs (om te helpen oplossen kun je `dit bewerken `_): + +#. De x-as van de waterfall wordt niet bijgewerkt bij wijzigen van centerfrequentie (de PSD-plot wel) + +Volledige code: + +.. code-block:: python + + from PyQt6.QtCore import QSize, Qt, QThread, pyqtSignal, QObject, QTimer + from PyQt6.QtWidgets import QApplication, QMainWindow, QGridLayout, QWidget, QSlider, QLabel, QHBoxLayout, QVBoxLayout, QPushButton, QComboBox # tested with PyQt6==6.7.0 + import pyqtgraph as pg # tested with pyqtgraph==0.13.7 + import numpy as np + import time + import signal # lets control-C actually close the app + + # Defaults + fft_size = 4096 # determines buffer size + num_rows = 200 + center_freq = 750e6 + sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5] # MHz + sample_rate = sample_rates[0] * 1e6 + time_plot_samples = 500 + gain = 50 # 0 to 73 dB. int + + sdr_type = "sim" # or "usrp" or "pluto" + + # Init SDR + if sdr_type == "pluto": + import adi + sdr = adi.Pluto("ip:192.168.1.10") + sdr.rx_lo = int(center_freq) + sdr.sample_rate = int(sample_rate) + sdr.rx_rf_bandwidth = int(sample_rate*0.8) # antialiasing filter bandwidth + sdr.rx_buffer_size = int(fft_size) + sdr.gain_control_mode_chan0 = 'manual' + sdr.rx_hardwaregain_chan0 = gain # dB + elif sdr_type == "usrp": + import uhd + #usrp = uhd.usrp.MultiUSRP(args="addr=192.168.1.10") + usrp = uhd.usrp.MultiUSRP(args="addr=192.168.1.201") + usrp.set_rx_rate(sample_rate, 0) + usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(center_freq), 0) + usrp.set_rx_gain(gain, 0) + + # Set up the stream and receive buffer + st_args = uhd.usrp.StreamArgs("fc32", "sc16") + st_args.channels = [0] + metadata = uhd.types.RXMetadata() + streamer = usrp.get_rx_stream(st_args) + recv_buffer = np.zeros((1, fft_size), dtype=np.complex64) + + # Start Stream + stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont) + stream_cmd.stream_now = True + streamer.issue_stream_cmd(stream_cmd) + + def flush_buffer(): + for _ in range(10): + streamer.recv(recv_buffer, metadata) + + class SDRWorker(QObject): + def __init__(self): + super().__init__() + self.gain = gain + self.sample_rate = sample_rate + self.freq = 0 # in kHz, to deal with QSlider being ints and with a max of 2 billion + self.spectrogram = -50*np.ones((fft_size, num_rows)) + self.PSD_avg = -50*np.ones(fft_size) + + # PyQt Signals + time_plot_update = pyqtSignal(np.ndarray) + freq_plot_update = pyqtSignal(np.ndarray) + waterfall_plot_update = pyqtSignal(np.ndarray) + end_of_run = pyqtSignal() # happens many times a second + + # PyQt Slots + def update_freq(self, val): # TODO: WE COULD JUST MODIFY THE SDR IN THE GUI THREAD + print("Updated freq to:", val, 'kHz') + if sdr_type == "pluto": + sdr.rx_lo = int(val*1e3) + elif sdr_type == "usrp": + usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(val*1e3), 0) + flush_buffer() + + def update_gain(self, val): + print("Updated gain to:", val, 'dB') + self.gain = val + if sdr_type == "pluto": + sdr.rx_hardwaregain_chan0 = val + elif sdr_type == "usrp": + usrp.set_rx_gain(val, 0) + flush_buffer() + + def update_sample_rate(self, val): + print("Updated sample rate to:", sample_rates[val], 'MHz') + if sdr_type == "pluto": + sdr.sample_rate = int(sample_rates[val] * 1e6) + sdr.rx_rf_bandwidth = int(sample_rates[val] * 1e6 * 0.8) + elif sdr_type == "usrp": + usrp.set_rx_rate(sample_rates[val] * 1e6, 0) + flush_buffer() + + # Main loop + def run(self): + start_t = time.time() + + if sdr_type == "pluto": + samples = sdr.rx()/2**11 # Receive samples + elif sdr_type == "usrp": + streamer.recv(recv_buffer, metadata) + samples = recv_buffer[0] # will be np.complex64 + elif sdr_type == "sim": + tone = np.exp(2j*np.pi*self.sample_rate*0.1*np.arange(fft_size)/self.sample_rate) + noise = np.random.randn(fft_size) + 1j*np.random.randn(fft_size) + samples = self.gain*tone*0.02 + 0.1*noise + # Truncate to -1 to +1 to simulate ADC bit limits + np.clip(samples.real, -1, 1, out=samples.real) + np.clip(samples.imag, -1, 1, out=samples.imag) + + self.time_plot_update.emit(samples[0:time_plot_samples]) + + PSD = 10.0*np.log10(np.abs(np.fft.fftshift(np.fft.fft(samples)))**2/fft_size) + self.PSD_avg = self.PSD_avg * 0.99 + PSD * 0.01 + self.freq_plot_update.emit(self.PSD_avg) + + self.spectrogram[:] = np.roll(self.spectrogram, 1, axis=1) # shifts waterfall 1 row + self.spectrogram[:,0] = PSD # fill last row with new fft results + self.waterfall_plot_update.emit(self.spectrogram) + + print("Frames per second:", 1/(time.time() - start_t)) + self.end_of_run.emit() # emit the signal to keep the loop going + + + # Subclass QMainWindow to customize your application's main window + class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + self.setWindowTitle("The PySDR Spectrum Analyzer") + self.setFixedSize(QSize(1500, 1000)) # window size, starting size should fit on 1920 x 1080 + + self.spectrogram_min = 0 + self.spectrogram_max = 0 + + layout = QGridLayout() # overall layout + + # Initialize worker and thread + self.sdr_thread = QThread() + self.sdr_thread.setObjectName('SDR_Thread') # so we can see it in htop, note you have to hit F2 -> Display options -> Show custom thread names + worker = SDRWorker() + worker.moveToThread(self.sdr_thread) + + # Time plot + time_plot = pg.PlotWidget(labels={'left': 'Amplitude', 'bottom': 'Time [microseconds]'}) + time_plot.setMouseEnabled(x=False, y=True) + time_plot.setYRange(-1.1, 1.1) + time_plot_curve_i = time_plot.plot([]) + time_plot_curve_q = time_plot.plot([]) + layout.addWidget(time_plot, 1, 0) + + # Time plot auto range buttons + time_plot_auto_range_layout = QVBoxLayout() + layout.addLayout(time_plot_auto_range_layout, 1, 1) + auto_range_button = QPushButton('Auto Range') + auto_range_button.clicked.connect(lambda : time_plot.autoRange()) # lambda just means its an unnamed function + time_plot_auto_range_layout.addWidget(auto_range_button) + auto_range_button2 = QPushButton('-1 to +1\n(ADC limits)') + auto_range_button2.clicked.connect(lambda : time_plot.setYRange(-1.1, 1.1)) + time_plot_auto_range_layout.addWidget(auto_range_button2) + + # Freq plot + freq_plot = pg.PlotWidget(labels={'left': 'PSD', 'bottom': 'Frequency [MHz]'}) + freq_plot.setMouseEnabled(x=False, y=True) + freq_plot_curve = freq_plot.plot([]) + freq_plot.setXRange(center_freq/1e6 - sample_rate/2e6, center_freq/1e6 + sample_rate/2e6) + freq_plot.setYRange(-30, 20) + layout.addWidget(freq_plot, 2, 0) + + # Freq auto range button + auto_range_button = QPushButton('Auto Range') + auto_range_button.clicked.connect(lambda : freq_plot.autoRange()) # lambda just means its an unnamed function + layout.addWidget(auto_range_button, 2, 1) + + # Layout container for waterfall related stuff + waterfall_layout = QHBoxLayout() + layout.addLayout(waterfall_layout, 3, 0) + + # Waterfall plot + waterfall = pg.PlotWidget(labels={'left': 'Time [s]', 'bottom': 'Frequency [MHz]'}) + imageitem = pg.ImageItem(axisOrder='col-major') # this arg is purely for performance + waterfall.addItem(imageitem) + waterfall.setMouseEnabled(x=False, y=False) + waterfall_layout.addWidget(waterfall) + + # Colorbar for waterfall + colorbar = pg.HistogramLUTWidget() + colorbar.setImageItem(imageitem) # connects the bar to the waterfall imageitem + colorbar.item.gradient.loadPreset('viridis') # set the color map, also sets the imageitem + imageitem.setLevels((-30, 20)) # needs to come after colorbar is created for some reason + waterfall_layout.addWidget(colorbar) + + # Waterfall auto range button + auto_range_button = QPushButton('Auto Range\n(-2σ to +2σ)') + def update_colormap(): + imageitem.setLevels((self.spectrogram_min, self.spectrogram_max)) + colorbar.setLevels(self.spectrogram_min, self.spectrogram_max) + auto_range_button.clicked.connect(update_colormap) + layout.addWidget(auto_range_button, 3, 1) + + # Freq slider with label, all units in kHz + freq_slider = QSlider(Qt.Orientation.Horizontal) + freq_slider.setRange(0, int(6e6)) + freq_slider.setValue(int(center_freq/1e3)) + freq_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + freq_slider.setTickInterval(int(1e6)) + freq_slider.sliderMoved.connect(worker.update_freq) # there's also a valueChanged option + freq_label = QLabel() + def update_freq_label(val): + freq_label.setText("Frequency [MHz]: " + str(val/1e3)) + freq_plot.autoRange() + freq_slider.sliderMoved.connect(update_freq_label) + update_freq_label(freq_slider.value()) # initialize the label + layout.addWidget(freq_slider, 4, 0) + layout.addWidget(freq_label, 4, 1) + + # Gain slider with label + gain_slider = QSlider(Qt.Orientation.Horizontal) + gain_slider.setRange(0, 73) + gain_slider.setValue(gain) + gain_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + gain_slider.setTickInterval(2) + gain_slider.sliderMoved.connect(worker.update_gain) + gain_label = QLabel() + def update_gain_label(val): + gain_label.setText("Gain: " + str(val)) + gain_slider.sliderMoved.connect(update_gain_label) + update_gain_label(gain_slider.value()) # initialize the label + layout.addWidget(gain_slider, 5, 0) + layout.addWidget(gain_label, 5, 1) + + # Sample rate dropdown using QComboBox + sample_rate_combobox = QComboBox() + sample_rate_combobox.addItems([str(x) + ' MHz' for x in sample_rates]) + sample_rate_combobox.setCurrentIndex(0) # should match the default at the top + sample_rate_combobox.currentIndexChanged.connect(worker.update_sample_rate) + sample_rate_label = QLabel() + def update_sample_rate_label(val): + sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz") + sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label) + update_sample_rate_label(sample_rate_combobox.currentIndex()) # initialize the label + layout.addWidget(sample_rate_combobox, 6, 0) + layout.addWidget(sample_rate_label, 6, 1) + + central_widget = QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + # Signals and slots stuff + def time_plot_callback(samples): + time_plot_curve_i.setData(samples.real) + time_plot_curve_q.setData(samples.imag) + + def freq_plot_callback(PSD_avg): + # TODO figure out if there's a way to just change the visual ticks instead of the actual x vals + f = np.linspace(freq_slider.value()*1e3 - worker.sample_rate/2.0, freq_slider.value()*1e3 + worker.sample_rate/2.0, fft_size) / 1e6 + freq_plot_curve.setData(f, PSD_avg) + freq_plot.setXRange(freq_slider.value()*1e3/1e6 - worker.sample_rate/2e6, freq_slider.value()*1e3/1e6 + worker.sample_rate/2e6) + + def waterfall_plot_callback(spectrogram): + imageitem.setImage(spectrogram, autoLevels=False) + sigma = np.std(spectrogram) + mean = np.mean(spectrogram) + self.spectrogram_min = mean - 2*sigma # save to window state + self.spectrogram_max = mean + 2*sigma + + def end_of_run_callback(): + QTimer.singleShot(0, worker.run) # Run worker again immediately + + worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback + worker.freq_plot_update.connect(freq_plot_callback) + worker.waterfall_plot_update.connect(waterfall_plot_callback) + worker.end_of_run.connect(end_of_run_callback) + + self.sdr_thread.started.connect(worker.run) # kicks off the worker when the thread starts + self.sdr_thread.start() + + + app = QApplication([]) + window = MainWindow() + window.show() # Windows are hidden by default + signal.signal(signal.SIGINT, signal.SIG_DFL) # this lets control-C actually close the app + app.exec() # Start the event loop + + if sdr_type == "usrp": + stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont) + streamer.issue_stream_cmd(stream_cmd) diff --git a/content-nl/sampling.rst b/content-nl/sampling.rst index 1d767810..ff8ba12c 100644 --- a/content-nl/sampling.rst +++ b/content-nl/sampling.rst @@ -68,7 +68,7 @@ We moeten de hoogste frequentiecomponent vinden, verdubbelen, en op die snelheid :scale: 70% :align: center -Wanneer we te langzaam samplen krijgen we een effect genaamd aliasing (Nederlands: vouwvervorming) waar we later meer over zullen leren, maar dit willen we altijd zien te voorkomen. Wat onze SDR's en bijna alle ontvangers doen, is eerst alles boven Fs/2 wegfilteren voordat er gesampled wordt. Wanneer we dan een signaal proberen te ontvangen en samplen met een te lage snelheid, dan zal het filter dat deel erboven wegkappen. Onze SDR's doen een hoop om ervoor te zorgen dat onze samples vrij zijn van vouwvervorming en andere imperfecties. +Wanneer we te langzaam samplen krijgen we een effect genaamd aliasing (Nederlands: vouwvervorming) waar we later meer over zullen leren, maar dit willen we altijd zien te voorkomen. Wat onze SDR's en bijna alle ontvangers doen, is eerst alles boven Fs/2 wegfilteren voordat er gesampled wordt. Wanneer we dan een signaal proberen te ontvangen en samplen met een te lage snelheid, dan zal het filter dat deel erboven wegkappen. Onze SDR's doen een hoop om ervoor te zorgen dat onze samples vrij zijn van vouwvervorming en andere imperfecties. Het anti-aliasing-filter heeft ook een overgangsgebied, de vuistregel is dan dat alleen de middelste 80% van de samplerate bruikbaar is. ************************* Kwadratuursamplen @@ -95,14 +95,49 @@ Dit kunnen we ook grafisch weergeven door I en Q gelijk te stellen aan 1: De cos() noemen we het "in fase" component, daarom de I, en de sin() is het 90 graden uit fase of "kwadratuur" component, vandaar de Q. Maar als je per ongeluk de Q aan de cos() en de I aan de sin() koppelt, dan maakt dat in de meeste situaties niets uit. -IQ-sampling is gemakkelijker te begrijpen bekeken vanuit de zender, dus vanuit het zenden van een RF signaal door de lucht. -We willen een enkele sinus met bepaalde fase versturen, wat gedaan kan worden door een sin() en cos() zonder faseverschuiving bij elkaar op te tellen. Dit is mogelijk vanwege de volgende eigenschap: :math:`a \cos(x) + b \sin(x) = A \cos(x-\phi)`. -Laten we zeggen dat we het signaal x(t) willen versturen: +Het is makkelijker om IQ sampling te begrijpen als we vanuit de zender gaan kijken. +Stel dat we op een zekere frequentie :math:`f` eem RF-signaal gaan uitzenden. Van deze sinus met frequentie :math:`f` willen we dan de amplitude :math:`A` en fase :math:`\phi` kunnen controleren: .. math:: - x(t) = I \cos(2\pi ft) + Q \sin(2\pi ft) -Wat zou er gebeuren wanneer we een sinus en cosinus optellen? Of eigenlijk, wat zou er gebeuren wanneer we twee sinusoïden optellen die 90 graden uit fase lopen. In de onderstaande video zijn er sliders om I en Q mee aan te passen. Wat geplot wordt zijn de cosinus, sinus en de som van beide. + A \cos(2 \pi f t - \phi) + +Het minteken is hierbij een conventie en niet van belang om het concept te begrijpen. Op elk moment in de tijd zullen we een andere fase en amplitude willen uitzenden, dus beiden zijn een functie van de tijd, formeel geschreven als: + +.. math:: + + A(t) \cos(2 \pi f t - \phi(t)) + +Nu blijkt dat het makkelijk is om de amplitude van een sinus te controleren met RF-schakelingen, maar de fase veel lastiger. Wat we dus kunnen doen is gebruik maken van de goniometrische identiteit: :math:`a \cos(x) + b \sin(x) = A \cos(x - \phi)` die ons vertelt dat een som van een cos() en sin() van dezelfde frequentie, elk met een fase van 0, gelijk is aan een enkele cos() met amplitude :math:`A` en fase :math:`\phi`. Door I en Q te gebruiken in plaats van :math:`a` en :math:`b`, en door onze :math:`2 \pi f t` weer toe te voegen, krijgen we: + +.. math:: + A \cos(2 \pi f t - \phi) + + = I \cos(2 \pi f t) + Q \sin(2 \pi f t) + +waarbij + +.. math:: + A = \sqrt{I^2 + Q^2} + + \phi = \tan^{-1}\left(\frac{Q}{I}\right) + +Door deze aanpak kunnen we met behulp van I en Q elke amplitude of fase genreren dat we zouden willen. Dat zou er ongeveer zo uitzien: + +.. image:: ../_images/IQ_diagram.png + :scale: 80% + :align: center + :alt: Diagram showing how I and Q are modulated onto a carrier + +Stel dat we een IQ sample hebben dat wordt beschreven door het complexe getal :math:`I+JQ`. We kunnen dit IQ sample op de sinus **moduleren**, waarbij de amplitude en fase worden bepaald door het IQ sample: + +.. math:: + + x(t) = I \cos(2\pi ft) + Q \sin(2\pi ft) + + \qquad \qquad \qquad \qquad = \left(\sqrt{I^2+Q^2}\right) \cos\left(2\pi ft - \tan^{-1}\left(\frac{Q}{I}\right)\right) + +Nu we de wiskunde hebben bekeken, laten we even gaan spelen door twee sinusoïden op te tellen die 90 graden uit fase lopen. In de onderstaande video zijn er sliders om I en Q mee aan te passen dus de fase en amplitude van de cosinus en sinus. Wat geplot wordt zijn de cosinus (rood), sinus (blauw) en de som van beide (groen). .. image:: ../_images/IQ3.gif :scale: 100% @@ -112,14 +147,7 @@ Wat zou er gebeuren wanneer we een sinus en cosinus optellen? Of eigenlijk, wat (De code voor deze Python-app kun je hier vinden: `link `_) -Wat je hier uit moet onthouden is dat wanneer de cos() en sin() worden opgeteld, we een andere zuivere sinusoïde krijgen met een andere fase en amplitude. Daarnaast verschuift de fase wanneer we langzaam een van de twee delen groter of kleiner maken. De amplitude verandert ook mee. Dit is allemaal het gevolg van de goniometrische identiteit: :math:`a \cos(x) + b \sin(x) = A \cos(x-\phi)`, waar we dadelijk op terug komen. Het "nut" van dit gedrag is dat we de fase en amplitude van de resulterende sinusoïde kunnen controleren door I en Q aan te passen (we hoeven niets de doen met de fase van cosinus of sinus). We kunnen bijvoorbeeld I en Q op zo'n manier aanpassen dat de amplitude constant blijft en de fase naar wens wordt ingesteld. Omdat we weten dat we een sinusoïde signaal moeten versturen om het door de lucht te laten vliegen als een elektromagnetische golf, is deze mogelijkheid voor een zender extreem handig. Het is daarnaast veel makkelijker om twee amplitudes aan te passen en een optelling uit te voeren, dan amplitude en fase moeten aanpassen. Het resultaat is dat onze zender er ongeveer zo uit zal zien: - -.. image:: ../_images/IQ_diagram.png - :scale: 80% - :align: center - :alt: Diagram showing how I and Q are modulated onto a carrier - -We hoeven alleen een cosinus te genereren en deze 90 graden op te schuiven om het Q gedeelte te krijgen. +Wat je hier uit moet onthouden is dat wanneer de cos() en sin() worden opgeteld, we een andere zuivere sinusoïde krijgen met een andere fase en amplitude maar dezelfde frequentie. Daarnaast verschuift de fase wanneer we langzaam een van de twee delen groter of kleiner maken (en de amplitude verandert ook mee). Dit is allemaal het gevolg van de goniometrische identiteit: :math:`a \cos(x) + b \sin(x) = A \cos(x-\phi)`, waar we dadelijk op terug komen. Het "nut" van dit gedrag is dat we de fase en amplitude van de resulterende sinusoïde kunnen controleren door I en Q aan te passen (we hoeven niets de doen met de fase van cosinus of sinus). We kunnen bijvoorbeeld I en Q op zo'n manier aanpassen dat de amplitude constant blijft en de fase naar wens wordt ingesteld. Omdat we weten dat we een sinusoïde signaal moeten versturen om het door de lucht te laten vliegen als een elektromagnetische golf, is deze mogelijkheid voor een zender extreem handig. Het is daarnaast veel makkelijker om twee amplitudes aan te passen en een optelling uit te voeren, dan amplitude en fase moeten aanpassen. Hiermee kunnen ook makkelijk het basisband signaal weergeven, onafhankelijk van de draaggolf. ************************* Complexe Getallen @@ -197,27 +225,18 @@ Nog een laatste belangrijke opmerking: Het figuur hierboven laat zien wat er **b Draaggolven en frequentieverschuiving ************************************* -Tot nu toe hebben we de frequentie nog niet behandelt, maar er was wel een :math:`f` in de vergelijkingen met de cos() en sin(). Deze frequentie is de middenfrequentie waarop we echt een signaal door de lucht sturen (de frequentie van de elektromagnetische golf). Dit noemen we de "draaggolf" omdat het ons signaal *draagt* op een bepaalde RF-frequentie. Wanneer we onze SDR afstellen op een bepaalde frequentie en samples ontvangen, dan wordt de informatie opgeslagen in I en Q; deze draaggolf verschijnt niet in I en Q. - -.. image:: images/carrier.svg - :scale: 140% - :align: center +Tot nu toe hebben we de frequentie nog niet behandelt, maar er was wel een :math:`f` in de vergelijkingen met de cos() en sin(). Deze frequentie is de middenfrequentie waarop we echt een signaal door de lucht sturen (de frequentie van de elektromagnetische golf). Dit noemen we de "draaggolf" omdat het ons signaal *draagt* op een bepaalde RF-frequentie. Wanneer we onze SDR afstellen op een bepaalde frequentie en samples ontvangen, dan wordt de informatie opgeslagen in I en Q. Ter referentie, radiosignalen zoals FM-radio, WiFi, Bluetooth, LTE, GPS, etc., gebruiken meestal een frequentie (dus een draaggolf) tussen de 100 MHz en 6 GHz. Deze frequenties vliegen erg goed door de lucht, maar hebben niet een superlange antenne nodig of een hoop vermogen om te versturen of te ontvangen. Jouw magnetron maakt het eten warm met elektromagnetische golven op 2.5 GHz. Als de deur signalen zou lekken dan zou de magnetron jouw WiFi verstoren en misschien je huid verbranden. Een andere vorm van elektromagnetische golven is licht. Zichtbaar licht heeft een frequentie rond de 500 THz. Dit is zo hoog dat we geen antennes nodig hebben om licht te versturen. We gebruiken methoden zoals halfgeleider leds. Ze creëren licht wanneer een elektron tussen de atomaire banen van het halfgeleider materiaal springt, en de afstand die wordt gesprongen bepaalt de kleur. Technisch gezien worden frequenties tussen de 20 kHz en 300 GHz beschouwt als radiofrequenties (RF). Dit zijn de frequenties waarbij de energie van een oscillerende stroom door een geleider (antenne) uit kan stralen en door de ruimte bewegen. De meest nuttige frequenties voor moderne toepassingen liggen tussen de 100 MHz en 6 GHz. De frequenties daarboven wordt al decennia gebruikt door radar en satellietcommunicatie en worden nu ook toegepast in 5G "mmWave" (24 - 29 GHz) om de lagere frequenties een helpende hand te bieden en de snelheid te verhogen. -Wanneer we onze IQ-waarden snel veranderen en via onze draaggolf versturen wordt dit het "moduleren" van de draaggolf genoemd (met data of wat we ook willen). Wanneer we de I en Q aanpassen veranderen we dus de fase en amplitude van de draaggolf. Een andere optie is om de frequentie van de draaggolf aan te passen, dus een beetje hoger of lager, dat is wat een FM-zender doet. +Wanneer we onze IQ-waarden snel veranderen en via onze draaggolf versturen wordt dit het "moduleren" van de draaggolf genoemd (met data of wat we ook willen). Wanneer we de I en Q aanpassen veranderen we dus de fase en amplitude van de draaggolf. Een andere optie is om de frequentie van de draaggolf aan te passen, dus een beetje hoger of lager, dat is wat een FM-zender doet. Het is makkelijk om het onderscheid te verliezen tussen het signaal wat we willen versturen (met typisch een hoop frequentiecomponenenten), en de frequentie waarop het verstuurd wordt (de draaggolf). Hopelijk wordt dit duidelijk wanneer we basisband- en banddoorlaatsignalen behandelen. -Als een simpel voorbeeld kunnen we het IQ sample 1+0j en vervolgens 0+1j versturen. Dan versturen we eersst :math:`\cos(2\pi ft)` en dan :math:`\sin(2\pi ft)`. Dit betekent dat onze draaggolf 90 graden van fase verandert wanneer we schakelen van het ene naar het andere sample. - -Het is makkelijk om het onderscheid te verliezen tussen het signaal wat we willen versturen (met typisch een hoop frequentiecomponenenten), en de frequentie waarop het verstuurd wordt (de draaggolf). Hopelijk wordt dit duidelijk wanneer we basisband- en banddoorlaatsignalen behandelen. - -Nu even terug naar samplen. Wat als we, zoals we het hoofdstuk zijn begonnen, in plaats van samples te ontvangen door het antennesignaal te vermenigvuldigen met een cos() en sin(), en I en Q te samplen, we het antennesignaal direct in een ADC zouden stoppen? Stel de draaggolf is 2.4 GHz, zoals bij WiFi of Bluetooth. Zoals we hebben geleerd, zou dat betekenen dat we op 4.8 GHz moeten samplen. Dat is extreem snel! En een ADC die zo snel kan samplen kost duizenden euro's. In plaats hiervan verschuiven we eerst het signaal naar "beneden", zodat het signaal dat we willen samplen gecentreerd is rond DC of 0 Hz. Deze verschuiving vindt plaats voor het samplen. We gaan van: +Nu even terug naar samplen. Wat als we in plaats van samples te ontvangen door het antennesignaal te vermenigvuldigen met een cos() en sin(), en I en Q te samplen, we het antennesignaal direct in een ADC zouden stoppen? Stel de draaggolf is 2.4 GHz, zoals bij WiFi of Bluetooth. Zoals we hebben geleerd, zou dat betekenen dat we op 4.8 GHz moeten samplen. Dat is extreem snel! En een ADC die zo snel kan samplen kost duizenden euro's. In plaats hiervan verschuiven we eerst het signaal naar "beneden", zodat het signaal dat we willen samplen gecentreerd is rond DC of 0 Hz. Deze verschuiving vindt plaats voor het samplen. We gaan van: .. math:: - I \cos(2\pi ft) - - Q \sin(2\pi ft) + + I \underbrace{\cos(2\pi ft)}_{draaggolf} \ + \ \ Q \underbrace{\sin(2\pi ft)}_{draaggolf} Naar alleen I en Q. @@ -258,6 +277,7 @@ Het figuur uit de "ontvangende kant" sectie, laat zien hoe het signaal wordt ver *********************************** Basisband- en Banddoorlaatsignalen *********************************** + We noemen de band waar het signaal rond de 0 Hz zit de "basisband". Andersom, "bandoorlaat" refereert naar wanneer een signaal nergens in de buurt van de 0 Hz zit, maar omhoog is geschoven met draadloze transmissie als doel. Iets als een *basisbandtransmissie* bestaat niet, want je kunt niet iets imaginairs versturen. Een signaal kan in de basisband perfect gecentreerd zijn rond 0 Hz, net als de rechterkant van figuur :numref:`verschuiving`. Het signaal kan ook *in de buurt* van 0 Hz zitten, zoals de twee signalen hieronder. Die signalen worden nog steeds opgevat als basisband. Er is ook een banddoorlaatsignaal weergegeven, gecentreerd op een erg hoge frequentie :math:`f_c`. .. image:: ../_images/baseband_bandpass.png @@ -265,9 +285,11 @@ We noemen de band waar het signaal rond de 0 Hz zit de "basisband". Andersom, "b :align: center :alt: Baseband vs bandpass -Misschien ben je ook de term "intermediate frequency" (IF) of tussenfrequentie tegengekomen; zie IF voor nu als een tussenstap tussen de basisband en RF/bandoorlaatband. +Misschien ben je ook de term "intermediate frequency" (IF) of tussenfrequentie tegengekomen als een tussenstap tussen de basisband en RF/bandoorlaatband. + +We maken, analyseren of slaan signalen op vanuit de basisband zodat we op een lagere sample-frequentie kunnen werken (zoals eerder uitgelegd). Hierbij is het belangrijk op te merken dat basisbandsignalen meestal **complex** zijn, terwijl bandoorlaatsignalen (dus te versturen RF signalen) **reëel** zijn. Als je erover nadenkt: signalen die door een antenne gaan moeten reëel zijn, je kunt geen complex/imaginair signaal uitzenden. Wanneer het negatieve en positieve deel van het frequentiespectrum niet precies hetzelfde zijn, dan weet je zeker dat het signaal complex is. Negatieve frequenties worden immers met complexe getallen weergegeven. In de werkelijkheid bestaan negatieve frequenties niet, alleen frequenties onder de draaggolf. -We maken, analyseren of slaan signalen op vanuit de basisband zodat we op een lagere sample-frequentie kunnen werken (zoals eerder uitgelegd). Hierbij is het belangrijk op te merken dat basisbandsignalen meestal complex zijn, terwijl bandoorlaatsignalen (dus te versturen RF signalen) reëel zijn. Als je erover nadenkt: signalen die door een antenne gaan moeten reëel zijn, je kunt geen complex/imaginair signaal uitzenden. Wanneer het negatieve en positieve deel van het frequentiespectrum niet precies hetzelfde zijn, dan weet je zeker dat het signaal complex is. Negatieve frequenties worden immers met complexe getallen weergegeven. In de werkelijkheid bestaan negatieve frequenties niet, alleen frequenties onder de draaggolf. +Als ons signaal geen imaganair component bevat, dan hebben we geen Q-waarden (of je kunt denken dat alle Q-waarden gelijk zijn aan nul). Dit betekent op zijn beurt dat we alleen cosinus signalen hebben zonder enige faseverschuiving. Een som van cosinus signalen zonder enige faseverschuiving zal symmetrisch zijn rond de y-as om jet frequentiedomein. Dit komt omdat een cosinus dezelfde positieve als negatieve componenten bevat. Eerder speelden we met het complexe punt 0.7 - 0.4j, dat was in feite een sample van een basisbandsignaal. In de meeste gevallen, als je complexe samples (IQ-samples) ziet, ben je in de basisband bezig. Vanwege de hoeveelheid data dat het in beslag zou nemen, worden signalen zelden opgeslagen op RF-frequenties, en om het feit dat we meestal alleen geïnteresseerd zijn in een smal deel van het RF spectrum. @@ -291,7 +313,7 @@ Wanneer alleen een DC-piek te zien is, en de rest van de FFT lijkt op ruis, dan De DC-offset is een gevolg van directe conversie ontvangers, de architectuur die gebruikt wordt door SDR's zoals de PlutoSDR, RTL-SDR, LimeSDR, en veel Ettus USRP's. In directe conversie ontvangers verschuift een oscillator, de LO, het signaal van zijn frequentie naar de basisband. Met als resultaat dat lekkage van de LO in het midden van de waargenomen band verschijnt. LO-lekkage is de extra energie die ontstaat bij het combineren van frequenties. Het is moeilijk deze extra ruis te verwijderen omdat het dicht bij het gewenste uitgangssignaal zit. Veel RF ic's hebben DC offset filters ingebouwd, maar meestal moet er een signaal aanwezig zijn om te kunnen werken. Om deze reden is de DC-piek sterk aanwezig op het moment dat er geen signalen zijn. -Een snelle manier om met DC-offset om te gaan is om het signaal te oversamplen en de LO af te stellen naast de signaalfrequentie. Stel we willen 5 MHz van het spectrum rond 100 MHz bekijken. Wat we dan doen is samplen met bijvoorbeeld 20 MHz en afstellen op 95 MHz. +Een snelle manier om met DC-offset om te gaan is om het signaal te oversamplen en de LO af te stellen naast de signaalfrequentie. Deze techniek wordt *offset tuning* genoemd. Stel we willen 5 MHz van het spectrum rond 100 MHz bekijken. Wat we dan doen is samplen met bijvoorbeeld 20 MHz en afstellen op 95 MHz. .. _afstellen: .. figure:: ../_images/offtuning.png diff --git a/content-nl/sync.rst b/content-nl/sync.rst index c12c6e76..91fe5230 100644 --- a/content-nl/sync.rst +++ b/content-nl/sync.rst @@ -58,7 +58,7 @@ We zullen eerst wat pythoncode gaan bekijken waarmee we een vertraging en freque h = np.sinc(t/Ts) * np.cos(np.pi*beta*t/Ts) / (1 - (2*beta*t/Ts)**2) # signaal x filteren. - samples = np.convolve(pulse_train, h) + samples = np.convolve(pulse_train, h, "same") .. raw:: html @@ -459,6 +459,143 @@ Hieronder zie je een animatie van de tijdsynchronisatie en frequentiecorrectie a :align: center :alt: Costas loop animation +Het volgende (ingeklapte) codeblok geeft het volledige Pythonvoorbeeld van het hoofdstuk tot nu toe, dit is getest met Python 3.12.3 en NumPy 1.26.4. Het bevat ook een bit error check aan het einde, hoewel AWGN is weggelaten om te kunnen zien hoe strak de BPSK door alleen synchronisatie kan komen, je bent welkom om AWGN toe te voegen, bijvoorbeeld direct na het toevoegen van de fractionele vertraging. Let op dat de plot van IQ over tijd is vóór frequentiesynchronisatie, zodat je kunt zien hoe de BPSK-energie langzaam tussen I en Q verschuift. + +.. raw:: html + +
+ Volledig Pythonvoorbeeld + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + from scipy import signal + + # Create BPSK signal + num_symbols = 100 + sps = 8 + bits = np.random.randint(0, 2, num_symbols) # Our data to be transmitted, 1's and 0's + pulse_train = np.array([]) + for bit in bits: + pulse = np.zeros(sps) + pulse[0] = bit*2-1 # set the first value to either a 1 or -1 + pulse_train = np.concatenate((pulse_train, pulse)) # add the 8 samples to the signal + + # Apply pulse shaping to the BPSK + num_taps = 101 + beta = 0.35 + Ts = sps # Assume sample rate is 1 Hz, so sample period is 1, so *symbol* period is 8 + t = np.arange(-51, 52) # remember it's not inclusive of final number + h = np.sinc(t/Ts) * np.cos(np.pi*beta*t/Ts) / (1 - (2*beta*t/Ts)**2) + samples = np.convolve(pulse_train, h, 'same') + + # Create and apply fractional delay filter to emulate a random timing offset + delay = 0.456 # fractional delay, in samples + N = 21 # number of taps, keep this odd + n = np.arange(-(N-1)//2, N//2+1) # -10,-9,...,0,...,9,10 + h = np.sinc(n - delay) # calc filter taps + h *= np.hamming(N) # window the filter to make sure it decays to 0 on both sides + h /= np.sum(h) # normalize to get unity gain, we don't want to change the amplitude/power + samples = np.convolve(samples, h) # apply filter + + # Apply a pretty significant freq offset + fs = 1e6 # assume our sample rate is 1 MHz + fo = 13000 # simulate freq offset THIS REPRESENTS A COARSE OFFSET! + Ts = 1/fs # calc sample period + t = np.arange(0, Ts*len(samples), Ts) # create time vector + samples = samples * np.exp(1j*2*np.pi*fo*t) # perform freq shift + + # Estimate and correct for the coarse freq offset + samples_sq = samples**2 + psd = np.fft.fftshift(np.abs(np.fft.fft(samples_sq, 2048))) + f = np.linspace(-fs/2.0, fs/2.0, len(psd)) + max_freq = f[np.argmax(psd)] / 2.0 + print(f"Estimated freq offset: {max_freq:.2f} Hz") + Ts = 1/fs # calc sample period + t = np.arange(0, Ts*len(samples), Ts) # create time vector + samples = samples * np.exp(-1j*2*np.pi*max_freq*t) + + # At this point there should be less than 1kHz of freq offset in our signal, depending how large an FFT you used above + + # Symbol/Timing Sync + mu = 0 # initial estimate of phase of sample + out = np.zeros(len(samples) // sps + 2, dtype=np.complex64) + out_rail = np.zeros(len(samples) // sps + 2, dtype=np.complex64) # stores values, each iteration we need the previous 2 values plus current value + i_in = 0 # input samples index + i_out = 2 # output index (let first two outputs be 0) + interpolation_factor = 16 + samples_interpolated = signal.resample_poly(samples, interpolation_factor, 1) + while i_out < len(samples) and i_in+16 < len(samples): + out[i_out] = samples_interpolated[i_in*interpolation_factor + int(mu*interpolation_factor)] + out_rail[i_out] = int(np.real(out[i_out]) > 0) + 1j*int(np.imag(out[i_out]) > 0) + x = (out_rail[i_out] - out_rail[i_out-2]) * np.conj(out[i_out-1]) + y = (out[i_out] - out[i_out-2]) * np.conj(out_rail[i_out-1]) + mm_val = np.real(y - x) + mu += sps + 0.3*mm_val + i_in += int(np.floor(mu)) # round down to nearest int since we are using it as an index + mu = mu - np.floor(mu) # remove the integer part of mu + i_out += 1 # increment output index + out = out[3:i_out] # remove the first few due to filter transients, and anything after i_out (that was never filled out) + samples = out + + plt.figure(2) + plt.plot(np.real(samples)) + plt.plot(np.imag(samples)) + plt.xlabel('Sample Index') + plt.ylabel('Sample Value') + plt.legend(['I', 'Q']) + plt.grid() + + N = len(samples) + phase = 0 + freq = 0 + # These next two params is what to adjust, to make the feedback loop faster or slower (which impacts stability) + alpha = 0.132 + beta = 0.00932 + out = np.zeros(N, dtype=np.complex64) + freq_log = [] + for i in range(N): + out[i] = samples[i] * np.exp(-1j*phase) # adjust the input sample by the inverse of the estimated phase offset + error = np.real(out[i]) * np.imag(out[i]) # This is the error formula for 2nd order Costas Loop (e.g. for BPSK) + + # Advance the loop (recalc phase and freq offset) + freq += (beta * error) + freq_log.append(freq * fs / (2*np.pi)) # convert from angular velocity to Hz for logging + phase += freq + (alpha * error) + + # Optional: Adjust phase so its always between 0 and 2pi, recall that phase wraps around every 2pi + while phase >= 2*np.pi: + phase -= 2*np.pi + while phase < 0: + phase += 2*np.pi + + # Calc BER + rx_bits = (np.real(out) > 0).astype(int) + num_bit_errors = np.sum(rx_bits != bits[:len(rx_bits)]) + print(f"Number of bit errors: {num_bit_errors} out of {len(rx_bits)} bits, BER: {num_bit_errors/len(rx_bits):.4f}") + + # Plot freq over time to see how long it takes to hit the right offset + plt.figure(0) + plt.plot(freq_log,'.-') + plt.xlabel('Sample Index') + plt.ylabel('Frequency Offset Estimate (Hz)') + + # Appears to be synced after ~80 samples so lets plot the constellation of the remaining 20 samples + plt.figure(1) + plt.plot(np.real(out[80:]), np.imag(out[80:]), '.') + plt.xlabel('I') + plt.ylabel('Q') + plt.xlim(-1.5, 1.5) + plt.ylim(-1.5, 1.5) + plt.grid() + plt.show() + +.. raw:: html + +
+ + *************************** Frame-synchronisatie *************************** diff --git a/index-nl.rst b/index-nl.rst index 27ab7fc7..9c7adbad 100644 --- a/index-nl.rst +++ b/index-nl.rst @@ -26,5 +26,13 @@ content-nl/sync content-nl/rds content-nl/doa - content-nl/phaser + content-nl/2d_beamforming + content-nl/phaser + content-nl/cyclostationary + content-nl/pyqt + content-nl/detection content-nl/about_author + +.. raw:: html + + \ No newline at end of file