From b4fa20a1d7e4e9f58758b288807def35924f82a5 Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 17:44:30 +0300 Subject: [PATCH 01/42] Update Ukrainian translation of author page --- content-ukraine/about_author.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content-ukraine/about_author.rst b/content-ukraine/about_author.rst index 393f8245..9fa7599f 100644 --- a/content-ukraine/about_author.rst +++ b/content-ukraine/about_author.rst @@ -4,11 +4,11 @@ Про автора ################## -Доктор Марк Ліхтман є дослідником у галузі бездротового зв'язку, який спеціалізується на програмно-керованому радіо (SDR), машинному навчанні, LTE/5G-NR та частотоному моніторінгу. Він є доцентом університету Меріленду, де створив і викладав курс, який став основою для цього підручника. Його курс був елективом для старшокурсників комп'ютерних наук, які цікавились SDR/DSP. Цей курс допоміг йому зрозуміти, як краще зробити надзвичайно складний матеріал доступним і цікавим для студентів, які були хорошими програмістами, але мали мало або зовсім не мали знань про фізику. Не було нічого незвичайного в тому, щоб заняття починались з міні-хакатону, під час якого студентам доводилося виявити або декодувати приховані сигнали (передані Марком), використовуючи те, що вони нещодавно вивчили. +Доктор Марк Ліхтман — дослідник у галузі бездротового зв'язку, який спеціалізується на програмно-визначеному радіо (SDR), машинному навчанні, LTE/5G-NR та радіомоніторингу спектра. Він є ад'юнкт-професором Університету Меріленду, де створив і викладав курс, що став основою цього підручника. Цей курс був елективом для студентів старших курсів з комп'ютерних наук, які цікавилися SDR/DSP. Він допоміг Марку краще зрозуміти, як зробити надзвичайно складний матеріал доступним і захопливим для студентів, які чудово програмують, але майже не мають підготовки з бездротових технологій і сигналів. Нерідко заняття починалися з мініхакатону, під час якого студенти мали знайти або декодувати приховані сигнали (передані Марком) за допомогою щойно здобутих знань. -Марк також є одним з лідерів проєкту `GNU Radio project `_ , відкритого фреймворку SDR, який широко використовується в академічних колах та дослідженнях, пов'язаних з обороною. Хоча Python чудово підходить для навчання, швидких експериментів та розробки, він не дуже добре підходить для великих і складних з точки зору обчислень програм. ЗА допомогою GNU Radio можно реалізувати більш складні DSP додатки, і додатком GNU Radio або окремим його блоком дуже легко поділитися з іншими. +Марк також є одним із лідерів проєкту `GNU Radio project `_, відкритого SDR-фреймворку, який широко використовують в академічних та оборонних дослідженнях. GNU Radio дає змогу реалізовувати розвинуті DSP-додатки, а застосунок або окремий блок GNU Radio дуже легко передати іншим. -Наразі Марк проживає в районі Вашингтона разом зі своєю дружиною Ліндсі та їхніми численними котами і собаками. Його хобі включають столярство, лазерне різання, гру на кларнеті/саксофоні, вітрильний спорт, садівництво, конструювання/пілотування дронів, конструювання/катання на електричних скейтбордах та вдосконалення майстерності гри на йо-йо. +Наразі Марк мешкає в окрузі Колумбія разом із дружиною та численними котами й собаками. Його хобі включають столярні роботи, металообробку, лазерне різання, гру на кларнеті та саксофоні, вітрильний спорт, садівництво й пінбол. Електронна пошта: marc@pysdr.org From 06d8bb19f61dfa49168b6b79ee5984410b30d84d Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:13:11 +0300 Subject: [PATCH 02/42] Add ukrainian template --- _templates/homepage_uk.html | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 _templates/homepage_uk.html diff --git a/_templates/homepage_uk.html b/_templates/homepage_uk.html new file mode 100644 index 00000000..e255b108 --- /dev/null +++ b/_templates/homepage_uk.html @@ -0,0 +1,60 @@ +

PySDR: Посібник із SDR та DSP з використанням Python

+ +

+ автор — + + Dr. Marc Lichtman + + — + + pysdr@vt.edu + +

+ +

+ Ласкаво просимо до PySDR — безкоштовного онлайн-підручника (це не бібліотека Python!), який надає доступне введення до бездротового зв’язку та + програмно-визначеного радіо (SDR), використовуючи безліч діаграм, анімацій і прикладів коду на Python. Від ШПФ і фільтрів до цифрової модуляції, + а також приймання та передавання з SDR у Python — PySDR допоможе вам у всьому! +

+ +

+ Мета PySDR — зробити доступнішими теми, які традиційно подаються з великою математичною складовою і лише в обмеженій кількості університетів. + Увесь вміст, що використовується для створення PySDR, має відкритий код і доступний + тут. +

+ +

+ Див. + Розділ 1: Вступ, + щоб дізнатися про призначення підручника та його цільову аудиторію. +

+ +

+ Щоб швидко відчути, що таке обробка РЧ-сигналів, спробуйте погратися з наведеною нижче симуляцією, яка показує частотну та часову області сигналу, + що складається з тону та білого гаусового шуму. +

+ +
+ + + +
+
+ + + +
+
+ +
+
+
+
+ +
+ + + +
From def188a7a51bfbe67fe8347d3db6d76094e85097 Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:20:21 +0300 Subject: [PATCH 03/42] Update Ukrainian homepage structure --- index-ukraine.rst | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/index-ukraine.rst b/index-ukraine.rst index 22b6744f..0fd60e07 100644 --- a/index-ukraine.rst +++ b/index-ukraine.rst @@ -1,13 +1,15 @@ -================================================ -PySDR: Посібник з SDR та DSP за допомогою Python -================================================ +.. raw:: html + :file: _templates/homepage.html -автор :ref:`Доктор Марк Ліхтман` +.. raw:: html + +
+

Розгорнути повний зміст

.. toctree:: - :maxdepth: 2 + :maxdepth: 3 :numbered: 1 - + content-ukraine/intro content-ukraine/frequency_domain content-ukraine/sampling @@ -26,3 +28,7 @@ PySDR: Посібник з SDR та DSP за допомогою Python content-ukraine/doa content-ukraine/phaser content-ukraine/about_author + +.. raw:: html + +
From 30396fed0b36598434923d287af4ab9b5febcf62 Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:21:27 +0300 Subject: [PATCH 04/42] Update index-ukraine.rst --- index-ukraine.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index-ukraine.rst b/index-ukraine.rst index 0fd60e07..cee4d0a5 100644 --- a/index-ukraine.rst +++ b/index-ukraine.rst @@ -1,5 +1,5 @@ .. raw:: html - :file: _templates/homepage.html + :file: _templates/homepage_uk.html .. raw:: html From b9a1baf45a4e1a0ecb9df06ea93af4e01436db56 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Mon, 6 Oct 2025 18:25:45 +0300 Subject: [PATCH 05/42] Add to ukrainian index new chapters. --- index-ukraine.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/index-ukraine.rst b/index-ukraine.rst index cee4d0a5..664ddd2b 100644 --- a/index-ukraine.rst +++ b/index-ukraine.rst @@ -11,11 +11,14 @@ :numbered: 1 content-ukraine/intro - content-ukraine/frequency_domain + content-ukraine/frequency_doma content-ukraine/sampling - content-ukraine/digital_modulation + content-ukraine/digital_modula content-ukraine/pluto content-ukraine/usrp + content-ukraine/bladerf + content-ukraine/rtlsdr + content-ukraine/hackrf content-ukraine/noise content-ukraine/filters content-ukraine/link_budgets @@ -26,7 +29,10 @@ content-ukraine/sync content-ukraine/rds content-ukraine/doa + content-ukraine/2d_beamforming content-ukraine/phaser + content-ukraine/cyclostationar + content-ukraine/pyqt content-ukraine/about_author .. raw:: html From 364f66529365c5573de1071caf92d88105f96354 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Mon, 6 Oct 2025 18:29:07 +0300 Subject: [PATCH 06/42] Add not translated chapters to ukraine translation --- content-ukraine/2d_beamforming.rst | 565 ++++++++++++++++ content-ukraine/bladerf.rst | 411 ++++++++++++ content-ukraine/cyclostationary.rst | 966 ++++++++++++++++++++++++++++ content-ukraine/hackrf.rst | 274 ++++++++ content-ukraine/pyqt.rst | 881 +++++++++++++++++++++++++ 5 files changed, 3097 insertions(+) create mode 100644 content-ukraine/2d_beamforming.rst create mode 100644 content-ukraine/bladerf.rst create mode 100644 content-ukraine/cyclostationary.rst create mode 100644 content-ukraine/hackrf.rst create mode 100644 content-ukraine/pyqt.rst diff --git a/content-ukraine/2d_beamforming.rst b/content-ukraine/2d_beamforming.rst new file mode 100644 index 00000000..17fb01a9 --- /dev/null +++ b/content-ukraine/2d_beamforming.rst @@ -0,0 +1,565 @@ +.. _2d-beamforming-chapter: + +############## +2D Beamforming +############## + +This chapter extends the 1D beamforming/DOA chapter to 2D arrays. We will start with a simple rectangular array and develop the steering vector equation and MVDR beamformer, then we will work with some actual data from a 3x5 array. Lastly, we will use the interactive tool to explore the effects of different array geometries and element spacing. + +************************************* +Rectangular Arrays and 2D Beamforming +************************************* + +Rectangular arrays (a.k.a. planar arrays) involve a 2D array of elements. With an extra dimension we get some added complexity, but the same basic principles apply, and the hardest part will be visualizing the results (e.g. no more simple polar plots, now we'll need 3D surface plots). Even though our array is now 2D, that does not mean we have to start adding a dimension to every data structure we've been dealing with. For example, we will keep our weights as a 1D array of complex numbers. However, we will need to represent the positions of our elements in 2D. We will keep using :code:`theta` to refer to the azimuth angle, but now we will introduce a new angle, :code:`phi`, which is the elevation angle. There are many spherical coordinate conventions, but we will be using the following: + +.. image:: ../_images/Spherical_Coordinates.svg + :align: center + :target: ../_images/Spherical_Coordinates.svg + :alt: Spherical coordinate system showing theta and phi + +Which corresponds to: + +.. math:: + + x = \sin(\theta) \cos(\phi) + + y = \cos(\theta) \cos(\phi) + + z = \sin(\phi) + +We will also switch to using a generalized steering vector equation, which is not specific to any array geometry: + +.. math:: + + s = e^{2j \pi \boldsymbol{p} u / \lambda} + +where :math:`\boldsymbol{p}` is the set of element x/y/z positions in meters (size :code:`Nr` x 3) and :math:`u` is the direction we want to point at as a unit vector in x/y/z (size 3x1). In Python this looks like: + +.. 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) + +Let's try using this generalized steering vector equation with a simple ULA with 4 elements, to make the connection back to what we have previously learned. We will now represent :code:`d` in meters instead of relative to wavelength. We will place the elements along the y-axis: + +.. 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 + +The following graphic shows a top-down view of the ULA, with an example theta of 20 degrees. + +.. image:: ../_images/2d_beamforming_ula.svg + :align: center + :target: ../_images/2d_beamforming_ula.svg + :alt: ULA with theta of 20 degrees + +The only thing left is to connect our old :code:`theta` with this new unit vector approach. We can calculate :code:`dir` based on :code:`theta` pretty easily, we know that the x and z component of our unit vector will be 0 because we are still in 1D space, and based on our spherical coordinate convention the y component will be :code:`np.cos(theta)`, meaning the full code is :code:`dir = np.asmatrix([0, np.cos(theta_i), 0]).T`. At this point you should be able to connect our generalized steering vector equation with the ULA steering vector equation we have been using. Give this new code a try, pick a :code:`theta` between 0 and 360 degrees (remember to convert to radians!), and the steering vector should be a 4x1 array. + +Now let's move on to the 2D case. We will place our array in the X-Z plane, with boresight pointing horizontally towards the positive y-axis (:math:`\theta = 0`, :math:`\phi = 0`). We will use the same element spacing as before, but now we will have 16 elements total: + +.. 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 + +The top-down view of our rectangular 4x4 array: + +.. image:: ../_images/2d_beamforming_element_pos.svg + :align: center + :target: ../_images/2d_beamforming_element_pos.svg + :alt: Rectangular array element positions + +In order to point towards a certain theta and phi, we will need to convert those angles into a unit vector. We can use the same generalized steering vector equation as before, but now we will need to calculate the unit vector based on both theta and phi, using the equations at the beginning of this chapter: + +.. 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 ]] + +Now let's use our generalized steering vector function to calculate the steering vector: + +.. 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 + +At this point it's worth pointing out that we didn't actually change the dimensions of anything, going from 1D to 2D, we just have a non-zero x/y/z components, the steering vector equation is still the same and the weights are still a 1D array. It might be tempting to assemble your weights as a 2D array so that visually it matches the array geometry, but it's not necessary and best to keep it 1D. For every element, there is a corresponding weight, and the list of weights is in the same order as the list of element positions. + +Visualizing the beam pattern associated with these weights is a little more complicated because we need a 3D plot or a 2D heatmap. We will scan :code:`theta` and :code:`phi` to get a 2D array of power levels, and then plot that using :code:`imshow()`. The code below does just that, and the result is shown in the figure below, along with a dot at the angle we entered earlier: + +.. 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 of the beam pattern + +Let's simulate some actual samples now; we'll add two tone jammers arriving from different directions: + +.. 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 + +Just for fun let's calculate the MVDR beamformer weights towards the theta and phi we were using earlier (a unit vector in that direction is still saved as :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 + +Instead of looking at the beam pattern in the crummy 3D plot, we'll use an alternative method of checking if these weights make sense; we will evaluate the response of the weights towards different directions and calculate the power in dB. Let's start with the direction we were pointing: + +.. 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') + +This outputs 0 dB, which is what we expect because MVDR's goal is to achieve unit power in the desired direction. Now let's check the power in the directions of the two jammers, as well as a random direction and a direction that is one degree off of our desired direction (the same code is used, just update :code:`dir`). The results are shown in the table below: + +.. 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 + * - A random direction + - -10.591 dB + +Your results may vary due to the random noise being used to calculate the received samples, which get used to calculate :code:`R`. But the main take-away is that the jammers will be in a null and very low power, the 1 degree off from :code:`dir` will be slightly below 0 dB, but still in the main lobe, and then a random direction is going to be lower than 0 dB but higher than the jammers, and very different every run of the simulation. Note that with MVDR you get a gain of 0 dB for the main lobe, but if you were to use the conventional beamformer, you would get :math:`10 \log_{10}(Nr)`, so about 12 dB for our 16-element array, showing one of the trade-offs of using MVDR. + +The code for this section can be found `here `_. + +********************************************** +Processing Signals from an Actual 2D Array +********************************************** + +In this section we work with some actual data recorded from a 3x5 array made out of a `QUAD-MxFE `_ platform from Analog Devices which supports up to 16 transmit and receive channels (we only used 15 and only in receive mode). Two recordings are provided below, the first one contains one emitter located at boresight to the array, which we will use for calibration. The second recording contains two emitters at different directions, which we will use for beamforming and DOA testing. + +- `IQ recording of just C `_ (used for calibration, as C is at boresight) +- `IQ recording of B and D `_ (used for beamforming/DOA testing) + +The QUAD-MxFE was tuned to 2.8 GHz and all transmitters were using a simple tone within the observation bandwidth. What's interesting about this DSP is that it doesn't actually matter what the sample rate is, none of the array processing techniques we use depend on the sample rate, they just make the assumption that the signal is somewhere in the baseband signal. The DSP does depend on the center frequency, because the phase shift between elements depends on the frequency and angle of arrival. This is opposite of most other signal processing where the sample rate is important, but the center frequency is not. + +We can load these recordings into Python using the following 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 boresight) on + +The spacing between antennas was 0.051 meters. We can represent the element positions as a list of x,y,z coordinates in meters. We will place the array in the X-Z plane, as the array was mounted vertically (with boresight pointing horizontally). + +.. 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() + +The plot labels each element with its index, which corresponds to the order of the elements in the :code:`r` and :code:`r_cal` IQ samples that were recorded. + +.. image:: ../_images/2d_array_element_positions.svg + :align: center + :target: ../_images/2d_array_element_positions.svg + :alt: 2D array element positions + +Calibration is performed using only the :code:`r_cal` samples, which were recorded with just the transmitter at boresight on. The goal is to find the phase and magnitude offsets for each element. With perfect calibration, and assuming the transmitter was exactly at boresight, all of the individual receive elements should be receiving the same signal, all in phase with each other and at the same magnitude. But because of imperfections in the array/cables/antennas, each element will have a different phase and magnitude offset. The calibration process is to find these offsets, which we will later apply to the :code:`r` samples before attempting to do any array processing on them. + +There are many ways to perform calibration, but we will use a method that involves taking the eigenvalue decomposition of the covariance matrix. The covariance matrix is a square matrix of size :code:`Nr x Nr`, where :code:`Nr` is the number of receive elements. The eigenvector corresponding to the largest eigenvalue is the one that represents the received signal, hopefully, and we will use it to find the phase offsets for each element by simply taking the phase of each element of the eigenvector and normalizing it to the first element which we will treat as the reference element. The magnitude calibration does not actually use the eigenvector, but instead uses the mean magnitude of the received signal for each 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) + +Below shows the plot of the eigenvalue distribution, we want to make sure that there's just one large value, and the rest are small, representing one signal being received. Any interferers or multipath will degrade the calibration process. + +.. image:: ../_images/2d_array_eigenvalues.svg + :align: center + :target: ../_images/2d_array_eigenvalues.svg + :alt: 2D array eigenvalue distribution + +The calibration table is a list of complex numbers, one for each element, representing the phase and magnitude offsets (it is easier to represent it in rectangular form instead of polar). The first element is the reference element, and will always be 1.0 + 0.j. The rest of the elements are the offsets for each element corresponding to the same order we used for :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 can apply these offsets to any set of samples recorded from the array simply by multiplying each element of the samples by the corresponding element of the calibration table: + +.. code-block:: python + + # Apply cal offsets to r + for i in range(Nr): + r[i, :] *= cal_table[i] + +As a side note, this is why we calculated the offsets using :code:`mags[0] / mags` and :code:`phases[0] - phases`, if we had reversed that order then we would need to do a division in order to apply the offsets, but we prefer to do the multiplication instead. + +Next we will perform DOA estimation using the MUSIC algorithm. We will use the :code:`steering_vector()` and :code:`get_unit_vector()` functions we defined earlier to calculate the steering vector for each element of the array, and then use the MUSIC algorithm to estimate the DOA of the two emitters in the :code:`r` samples. The MUSIC algorithm was discussed in the previous chapter. + +.. 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 + +Our results are in 2D, because the array is 2D, so we must either use a 3D plot or a 2D heatmap plot. Let's try both. First, we will do a 3D plot that has elevation on one axis and azimuth on the other: + +.. 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 + +Depending on the situation it might be annoying to read off numbers from a 3D plot, so we can also do a 2D heatmap with :code:`imshow()`: + +.. 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 + +Using this 2D plot we can easily read off the estimated azimuth and elevation of the two emitters (and see that there was just two). Based on the test setup that was used to produce this recording, these results match reality, the *exact* azimuth and elevation of the emitters was never actually measured because that would require very specialized equipment. + +As an exercise, try using the conventional beamformer, as well as MVDR, and compare the results to MUSIC. + +This code in its entirety can be found `here `_. + +*********************** +Interactive Design Tool +*********************** + +The following interactive tool was created by `Jason Durbin `_, a free-lancing phased array engineer, who graciously allowed it to be embedded within PySDR; feel free to visit the `full project `_ or his `consulting business `_. This tool allows you to change a phased array's geometry, element spacing, steering position, add sidelobe tapering, and other features. + +Some details on this tool: Antenna elements are assumed to be isotropic. However, the directivity calculation assumes half-hemisphere radiation (e.g. no back lobes). Therefore, the computed directivity will be 3 dBi higher than using pure isotropic (i.e., the individual element gain is +3.0 dBi). The mesh can be made finer by increasing theta/phi, u/v, or azimuth/elevation points. Clicking (or long pressing) elements in the phase/attenuation plots allows you to manually set phase/attenuation ("be sure to select "enable override"). Additionally, the attenuation pop-up allows you to disable elements. Hovering (or touching) the 2D far field plot or geometry plots will show the value of the plot under the 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-ukraine/bladerf.rst b/content-ukraine/bladerf.rst new file mode 100644 index 00000000..b7ffec0e --- /dev/null +++ b/content-ukraine/bladerf.rst @@ -0,0 +1,411 @@ +.. _bladerf-chapter: + +################## +BladeRF in Python +################## + +The bladeRF 2.0 (a.k.a. bladeRF 2.0 micro) from the company `Nuand `_ is a USB 3.0-based SDR with two receive channels, two transmit channels, a tunable range of 47 MHz to 6 GHz, and the ability to sample up to 61 MHz or as high as 122 MHz when hacked. It uses the AD9361 RF integrated circuit (RFIC) just like the USRP B210 and PlutoSDR, so RF performance will be similar. The bladeRF 2.0 was released in 2021, maintains a small form factor at 2.5" x 4.5", and comes in two different FPGA sizes (xA4 and xA9). While this chapter focuses on the bladeRF 2.0, a lot of the code will also apply to the original bladeRF which `came out in 2013 `_. + +.. image:: ../_images/bladeRF_micro.png + :scale: 35 % + :align: center + :alt: bladeRF 2.0 glamour shot + +******************************** +bladeRF Architecture +******************************** + +At a high level, the bladeRF 2.0 is based on the AD9361 RFIC, combined with a Cyclone V FPGA (either the 49 kLE :code:`5CEA4` or 301 kLE :code:`5CEA9`), and a Cypress FX3 USB 3.0 controller that has a 200 MHz ARM9 core inside, loaded with custom firmware. The block diagram of the bladeRF 2.0 is shown below: + +.. image:: ../_images/bladeRF-2.0-micro-Block-Diagram-4.png + :scale: 80 % + :align: center + :alt: bladeRF 2.0 block diagram + +The FPGA controls the RFIC, performs digital filtering, and frames packets for transfer over USB (among other things). The `source code `_ for the FPGA image is written in VHDL and requires the free Quartus Prime Lite design software to compile custom images. Precompiled images are available `here `_. + +The `source code `_ for the Cypress FX3 firmware is open-source and includes code to: + +1. Load the FPGA image +2. Transfer IQ samples between the FPGA and host over USB 3.0 +3. Control GPIO of the FPGA over UART + +From a signal flow perspective, there are two receive channels and two transmit channels, and each channel has a low and high frequency input/output to the RFIC, depending on which band is being used. It is for this reason that a single pole double throw (SPDT) electronic RF switch is needed between the RFIC and SMA connectors. The bias tee is an onboard circuit that provides ~4.5V DC on the SMA connector, and is used to conveniently power an external amplifier or other RF components. This extra DC offset is on the RF side of the SDR so it does not interfere with the basic receiving/transmitting operation. + +JTAG is a type of debugging interface, allowing for testing and verifying designs during the development process. + +At the end of this chapter, we discuss the VCTCXO oscillator, PLL, and expansion port. + +******************************** +Software and Hardware Setup +******************************** + +Ubuntu (or Ubuntu within WSL) +############################# + +On Ubuntu and other Debian-based systems, you can install the bladeRF software with the following commands: + +.. code-block:: bash + + sudo apt update + sudo apt install cmake python3-pip libusb-1.0-0 + cd ~ + git clone --depth 1 https://github.com/Nuand/bladeRF.git + cd bladeRF/host + mkdir build && cd build + cmake .. + make -j8 + sudo make install + sudo ldconfig + cd ../libraries/libbladeRF_bindings/python + sudo python3 setup.py install + +This will install the libbladerf library, Python bindings, bladeRF command line tools, the firmware downloader, and the FPGA bitstream downloader. To check which version of the library you installed, use :code:`bladerf-tool version` (this guide was written using libbladeRF version v2.5.0). + +If you are using Ubuntu through WSL, on the Windows side you will need to forward the bladeRF USB device to WSL, first by installing the latest `usbipd utility msi `_ (this guide assumes you have usbipd-win 4.0.0 or higher), then opening PowerShell in administrator mode and running: + +.. code-block:: bash + + usbipd list + # (find the BUSID labeled bladeRF 2.0 and substitute it in the command below) + usbipd bind --busid 1-23 + usbipd attach --wsl --busid 1-23 + +On the WSL side, you should be able to run :code:`lsusb` and see a new item called :code:`Nuand LLC bladeRF 2.0 micro`. Note that you can add the :code:`--auto-attach` flag to the :code:`usbipd attach` command if you want it to auto reconnect. + +(Might not be needed) For both native Linux and WSL, we must install the udev rules so that we don't get permissions errors: + +.. code-block:: + + sudo nano /etc/udev/rules.d/88-nuand.rules + +and paste in the following line: + +.. code-block:: + + ATTRS{idVendor}=="2cf0", ATTRS{idProduct}=="5250", MODE="0666" + +To save and exit from nano, use: control-o, then Enter, then control-x. To refresh udev, run: + +.. code-block:: bash + + sudo udevadm control --reload-rules && sudo udevadm trigger + +If you are using WSL and it says :code:`Failed to send reload request: No such file or directory`, that means the udev service isn't running, and you will need to :code:`sudo nano /etc/wsl.conf` and add the lines: + +.. code-block:: bash + + [boot] + command="service udev start" + +then restart WSL using the following command in PowerShell with admin: :code:`wsl.exe --shutdown`. + +Unplug and replug your bladeRF (WSL users will have to reattach), and test permissions with: + +.. code-block:: bash + + bladerf-tool probe + bladerf-tool info + +and you'll know it worked if you see your bladeRF 2.0 listed, and you **don't** see :code:`Found a bladeRF via VID/PID, but could not open it due to insufficient permissions`. If it worked, note reported FPGA Version and Firmware Version. + +(Optionally) Install the latest firmware and FPGA images (v2.4.0 and v0.15.0 respectively when this guide was written) using: + +.. code-block:: bash + + cd ~/Downloads + wget https://www.nuand.com/fx3/bladeRF_fw_latest.img + bladerf-tool flash_fw bladeRF_fw_latest.img + + # for xA4 use: + wget https://www.nuand.com/fpga/hostedxA4-latest.rbf + bladerf-tool flash_fpga hostedxA4-latest.rbf + + # for xA9 use: + wget https://www.nuand.com/fpga/hostedxA9-latest.rbf + bladerf-tool flash_fpga hostedxA9-latest.rbf + +Unplug and plug in your bladeRF to cycle power. + +Now we will test its functionality by receiving 1M samples in the FM radio band, at 10 MHz sample rate, to a file /tmp/samples.sc16: + +.. code-block:: bash + + bladerf-tool rx --num-samples 1000000 /tmp/samples.sc16 100e6 10e6 + +a couple :code:`Hit stall for buffer` is expected, but you'll know if it worked if you see a 4MB /tmp/samples.sc16 file. + +Lastly, we will test the Python API with: + +.. code-block:: bash + + python3 + import bladerf + bladerf.BladeRF() + exit() + +You'll know it worked if you see something like :code:`)>` and no warnings/errors. + +Windows and macOS +################### + +For Windows users (who do not prefer to use WSL), see https://github.com/Nuand/bladeRF/wiki/Getting-Started%3A-Windows, and for macOS users, see https://github.com/Nuand/bladeRF/wiki/Getting-started:-Mac-OSX. + +******************************** +bladeRF Python API Basics +******************************** + +To start with, let's poll the bladeRF for some useful information, using the following script. **Do not name your script bladerf.py** or it will conflict with the bladeRF Python module itself! + +.. code-block:: python + + from bladerf import _bladerf + import numpy as np + import matplotlib.pyplot as plt + + sdr = _bladerf.BladeRF() + + print("Device info:", _bladerf.get_device_list()[0]) + print("libbladeRF version:", _bladerf.version()) # v2.5.0 + print("Firmware version:", sdr.get_fw_version()) # v2.4.0 + print("FPGA version:", sdr.get_fpga_version()) # v0.15.0 + + rx_ch = sdr.Channel(_bladerf.CHANNEL_RX(0)) # give it a 0 or 1 + print("sample_rate_range:", rx_ch.sample_rate_range) + print("bandwidth_range:", rx_ch.bandwidth_range) + print("frequency_range:", rx_ch.frequency_range) + print("gain_modes:", rx_ch.gain_modes) + print("manual gain range:", sdr.get_gain_range(_bladerf.CHANNEL_RX(0))) # ch 0 or 1 + +For the bladeRF 2.0 xA9, the output should look something like: + +.. code-block:: python + + Device info: Device Information + backend libusb + serial f80a27b1010448dfb7a003ef7fa98a59 + usb_bus 2 + usb_addr 5 + instance 0 + libbladeRF version: v2.5.0 ("2.5.0-git-624994d") + Firmware version: v2.4.0 ("2.4.0-git-a3d5c55f") + FPGA version: v0.15.0 ("0.15.0") + sample_rate_range: Range + min 520834 + max 61440000 + step 2 + scale 1.0 + + bandwidth_range: Range + min 200000 + max 56000000 + step 1 + scale 1.0 + + frequency_range: Range + min 70000000 + max 6000000000 + step 2 + scale 1.0 + + gain_modes: [, , , , ] + + manual gain range: Range + min -15 + max 60 + step 1 + scale 1.0 + +The bandwidth parameter sets the filter used by the SDR when performing the receive operation, so we typically set it to be equal or slightly less than the sample_rate/2. The gain modes are important to understand, the SDR uses either a manual gain mode where you provide the gain in dB, or automatic gain control (AGC) which has three different settings (fast, slow, hybrid). For applications such as spectrum monitoring, manual gain is advised (so you can see when signals come and go), but for applications such as receiving a specific signal you expect to exist, AGC will be more useful because it will automatically adjust the gain to allow the signal to fill the analog-to-digital converter (ADC). + +To set the main parameters of the SDR, we can add the following code: + +.. code-block:: python + + sample_rate = 10e6 + center_freq = 100e6 + gain = 50 # -15 to 60 dB + num_samples = int(1e6) + + rx_ch.frequency = center_freq + rx_ch.sample_rate = sample_rate + rx_ch.bandwidth = sample_rate/2 + rx_ch.gain_mode = _bladerf.GainMode.Manual + rx_ch.gain = gain + +******************************** +Receiving Samples in Python +******************************** + +Next, we will work off the previous code block to receive 1M samples in the FM radio band, at 10 MHz sample rate, just like we did before. Any antenna on the RX1 port should be able to receive FM, since it is so strong. The code below shows how the bladeRF synchronous stream API works; it must be configured and a receive buffer must be created, before the receiving begins. The :code:`while True:` loop will continue to receive samples until the number of samples requested is reached. The received samples are stored in a separate numpy array, so that we can process them after the loop finishes. + +.. code-block:: python + + # Setup synchronous stream + sdr.sync_config(layout = _bladerf.ChannelLayout.RX_X1, # or RX_X2 + fmt = _bladerf.Format.SC16_Q11, # int16s + num_buffers = 16, + buffer_size = 8192, + num_transfers = 8, + stream_timeout = 3500) + + # Create receive buffer + bytes_per_sample = 4 # don't change this, it will always use int16s + buf = bytearray(1024 * bytes_per_sample) + + # Enable module + print("Starting receive") + rx_ch.enable = True + + # Receive loop + x = np.zeros(num_samples, dtype=np.complex64) # storage for IQ samples + num_samples_read = 0 + while True: + if num_samples > 0 and num_samples_read == num_samples: + break + elif num_samples > 0: + num = min(len(buf) // bytes_per_sample, num_samples - num_samples_read) + else: + num = len(buf) // bytes_per_sample + sdr.sync_rx(buf, num) # Read into buffer + samples = np.frombuffer(buf, dtype=np.int16) + samples = samples[0::2] + 1j * samples[1::2] # Convert to complex type + samples /= 2048.0 # Scale to -1 to 1 (its using 12 bit ADC) + x[num_samples_read:num_samples_read+num] = samples[0:num] # Store buf in samples array + num_samples_read += num + + print("Stopping") + rx_ch.enable = False + print(x[0:10]) # look at first 10 IQ samples + print(np.max(x)) # if this is close to 1, you are overloading the ADC, and should reduce the gain + +A few :code:`Hit stall for buffer` is expected at the end. The last number printed shows the maximum sample received; you will want to adjust your gain to try to get that value around 0.5 to 0.8. If it is 0.999 that means your receiver is overloaded/saturated and the signal is going to be distorted (it will look smeared throughout the frequency domain). + +In order to visualize the received signal, let's display the IQ samples using a spectrogram (see :ref:`spectrogram-section` for more details on how spectrograms work). Add the following to the end of the previous code block: + +.. code-block:: python + + # Create spectrogram + fft_size = 2048 + num_rows = len(x) // fft_size # // is an integer division which rounds down + spectrogram = np.zeros((num_rows, fft_size)) + for i in range(num_rows): + spectrogram[i,:] = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(x[i*fft_size:(i+1)*fft_size])))**2) + extent = [(center_freq + sample_rate/-2)/1e6, (center_freq + sample_rate/2)/1e6, len(x)/sample_rate, 0] + plt.imshow(spectrogram, aspect='auto', extent=extent) + plt.xlabel("Frequency [MHz]") + plt.ylabel("Time [s]") + plt.show() + +.. image:: ../_images/bladerf-waterfall.svg + :align: center + :target: ../_images/bladerf-waterfall.svg + :alt: bladeRF spectrogram example + +Each vertical squiggly line is an FM radio signal. No clue what the pulsing on the right side is from, lowering the gain didn't make it go away. + + +******************************** +Transmitting Samples in Python +******************************** + +The process of transmitting samples with the bladeRF is very similar to receiving. The main difference is that we must generate the samples to transmit, and then write them to the bladeRF using the :code:`sync_tx` method which can handle our entire batch of samples at once (up to ~4B samples). The code below shows how to transmit a simple tone, and then repeat it 30 times. The tone is generated using numpy, and then scaled to be between -2048 and 2048 to fit the 12 bit digital-to-analog converter (DAC). The tone is then converted to bytes representing int16's and used as the transmit buffer. The synchronous stream API is used to transmit the samples, and the :code:`while True:` loop will continue to transmit samples until the number of repeats requested is reached. If you want to transmit samples from a file instead, simply use :code:`samples = np.fromfile('yourfile.iq', dtype=np.int16)` (or whatever datatype they are) to read the samples, and then convert them to bytes using :code:`samples.tobytes()`, although keep in mind the -2048 to 2048 range of the DAC. + +.. code-block:: python + + from bladerf import _bladerf + import numpy as np + + sdr = _bladerf.BladeRF() + tx_ch = sdr.Channel(_bladerf.CHANNEL_TX(0)) # give it a 0 or 1 + + sample_rate = 10e6 + center_freq = 100e6 + gain = 0 # -15 to 60 dB. for transmitting, start low and slowly increase, and make sure antenna is connected + num_samples = int(1e6) + repeat = 30 # number of times to repeat our signal + print('duration of transmission:', num_samples/sample_rate*repeat, 'seconds') + + # Generate IQ samples to transmit (in this case, a simple tone) + t = np.arange(num_samples) / sample_rate + f_tone = 1e6 + samples = np.exp(1j * 2 * np.pi * f_tone * t) # will be -1 to +1 + samples = samples.astype(np.complex64) + samples *= 2048.0 # Scale to -1 to 1 (its using 12 bit DAC) + samples = samples.view(np.int16) + buf = samples.tobytes() # convert our samples to bytes and use them as transmit buffer + + tx_ch.frequency = center_freq + tx_ch.sample_rate = sample_rate + tx_ch.bandwidth = sample_rate/2 + tx_ch.gain = gain + + # Setup synchronous stream + sdr.sync_config(layout=_bladerf.ChannelLayout.TX_X1, # or TX_X2 + fmt=_bladerf.Format.SC16_Q11, # int16s + num_buffers=16, + buffer_size=8192, + num_transfers=8, + stream_timeout=3500) + + print("Starting transmit!") + repeats_remaining = repeat - 1 + tx_ch.enable = True + while True: + sdr.sync_tx(buf, num_samples) # write to bladeRF + print(repeats_remaining) + if repeats_remaining > 0: + repeats_remaining -= 1 + else: + break + + print("Stopping transmit") + tx_ch.enable = False + +A few :code:`Hit stall for buffer`'s at the end is expected. + +In order to transmit and receive at the same time, you have to use threads, and you might as well just use Nuand's example `txrx.py `_ which does exactly that. + +*********************************** +Oscillators, PLLs, and Calibration +*********************************** + +All direct-conversion SDRs (including all AD9361-based SDRs like the USRP B2X0, Analog Devices Pluto, and bladeRF) rely on a single oscillator to provide a stable clock for the RF transceiver. Any offsets or jitter in the frequency produced by this oscillator will translate to frequency offset and frequency jitter in the received or transmitted signal. This oscillator is onboard, but can optionally be "disciplined" using a separate square or sine wave fed into the bladeRF through a U.FL connector on the board. + +Onboard the bladeRF is an `Abracon VCTCXO `_ (Voltage-controlled +temperature-compensated oscillator) with a frequency of 38.4 MHz. The "temperature-compensated" aspect means it is designed to be stable over a wide range of temperatures. The voltage controlled aspect means that a voltage level is used to cause slight tweaks to the oscillator frequency, and on the bladeRF this voltage is provided by a separate 10-bit digital-to-analog converter (DAC) as shown in green in the block diagram below. This means through software we can make fine adjustments to the frequency of the oscillator, and this is how we calibrate (a.k.a. trim) the bladeRF's VCTCXO. Luckily, the bladeRFs are calibrated at the factory, as we discuss later in this section, but if you have the test equipment available you can always fine-tune this value, especially as years go by and the oscillator's frequency drifts. + +.. image:: ../_images/bladeRF-2.0-micro-Block-Diagram-4-oscillator.png + :scale: 80 % + :align: center + :alt: bladeRF 2.0 glamour shot + +When using an external frequency reference (which can be nearly any frequency up to 300 MHz), the reference signal is fed directly into the `Analog Devices ADF4002 `_ PLL onboard the bladeRF. This PLL locks on to the reference signal and sends a signal to the VCTCXO (as shown in blue above) that is proportional to the difference in frequency and phase between the (scaled) reference input and VCTCXO output. Once the PLL is locked, this signal between the PLL and VCTCXO is a steady-state DC voltage that keeps the VCTCXO output at "exactly" 38.4 MHz (assuming the reference was correct), and phase-locked to the reference input. As part of using an external reference you must enable :code:`clock_ref` (either through Python or the CLI), and set the input reference frequency (a.k.a. :code:`refin_freq`), which is 10 MHz by default. Reasons to use an external reference include better frequency accuracy, and the ability to synchronize multiple SDRs to the same reference. + +Each bladeRF VCTCXO DAC trim value is calibrated at the factory to be within 1 Hz at 38.4 MHz at room temperature, and you can enter your serial number into `this page `_ to see what the factory calibrated value was (find your serial number on the board or using :code:`bladerf-tool probe`). A fresh board should be well within 0.5 ppm and likely closer to 0.1 ppm, according to Nuand. If you have test equipment to measure the frequency accuracy, or want to set it to the factory value, you can use the commands: + +.. code-block:: bash + + $ bladeRF-cli -i + bladeRF> flash_init_cal 301 0x2049 + +swapping :code:`301` with your bladeRF size and :code:`0x2049` with the hex format of your VCTCXO DAC trim value. You must power cycle for it to go into effect. + +*********************************** +Sampling at 122 MHz +*********************************** + +Coming Soon! + +*********************************** +Expansion Ports +*********************************** + +The bladeRF 2.0 includes an expansion port using a BSH-030 connector. More information on using this port coming soon! + +******************************** +Further Reading +******************************** + +#. `bladeRF Wiki `_ +#. `Nuand's txrx.py example `_ diff --git a/content-ukraine/cyclostationary.rst b/content-ukraine/cyclostationary.rst new file mode 100644 index 00000000..6c959a35 --- /dev/null +++ b/content-ukraine/cyclostationary.rst @@ -0,0 +1,966 @@ +.. _freq-domain-chapter: + +########################## +Cyclostationary Processing +########################## + +.. raw:: html + + Co-authored by Sam Brown + +In this chapter we demystify cyclostationary signal processing (a.k.a. CSP), a relatively niche area of RF signal processing that is used to analyze or detect (often in very low SNR!) signals that exhibit cyclostationary properties, such as most modern digital modulation schemes. We cover the Cyclic Autocorrelation Function (CAF), Spectral Correlation Function (SCF), Spectral Coherence Function (COH), conjugate versions of these functions, and how they can be applied. This chapter includes several full Python implementations, with examples that involve BPSK, QPSK, OFDM, and multiple combined signals. + +**************** +Introduction +**************** + +Cyclostationary signal processing (a.k.a., CSP or simply cyclostationary processing) is a set of techniques for exploiting the cyclostationary property found in many real-world communication signals. These are signals such as modulated signals like AM/FM/TV broadcast, cellular, and WiFi as well as radar signals, and other signals that exhibit periodicity in their statistics. A large swath of traditional signal processing techniques are based on the assumption that the signal is stationary, i.e., the statistics of the signal like the mean, variance and higher-order moments do not change over time. However, most real-world RF signals are cyclostationary, i.e., the statistics of the signal change *periodically* over time. CSP techniques exploit this cyclostationary property, and can be used to detect the presence of signals in noise, perform modulation recognition, and separate signals that are overlapping in both time and frequency. + +If after reading through this chapter and playing around in Python, you want to dive deeper into CSP, check out William Gardner's 1994 textbook `Cyclostationarity in Communications and Signal Processing `_, his 1987 textbook `Statistical Spectral Analysis `_, or Chad Spooner's `collection of blog posts `_. + +One resource that you will find here and in no other textbook: at the end of the SCF chapter you will be rewarded with an interactive JavaScript app that allows you to play around with the SCF of an example signal, to see how the SCF changes with different signal and SCF parameters, all in your browser! While these interactive demos are free for everyone, they are largely made possible by the support of PySDR's `Patreon `_ members. + +************************* +Review of Autocorrelation +************************* + +Even if you think you're familiar with the autocorrelation function, it is worth taking a moment to review it, because it is the foundation of CSP. The autocorrelation function is a measure of the similarity (a.k.a., correlation) between a signal and the time-shifted version of itself. Intuitively, it represents the degree to which a signal exhibits repetitive behavior. The autocorrelation of signal :math:`x(t)` is defined as: + +.. math:: + R_x(\tau) = E[x(t)x^*(t-\tau)] + +where :math:`E` is the expectation operator, :math:`\tau` is the time delay, and :math:`*` is the complex conjugate symbol. In discrete time, with a limited number of samples, which is what we care about, this becomes: + +.. 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] + +where :math:`N` is the number of samples in the signal. + +If the signal is periodic in some way, such as a QPSK signal's repeating symbol shape, then the autocorrelation evaluated over a range of tau will also be periodic. For example, if a QPSK signal has 8 samples per symbol, then when tau is an integer multiple of 8, there will be a much stronger "measure of the similarity" than other values of tau. The period of the autocorrelation is what we will ultimately be detecting as part of CSP techniques. + +************************************************ +The Cyclic Autocorrelation Function (CAF) +************************************************ + +As discussed in the previous section, we want to find out when there is periodicity in our autocorrelation. Recall the Fourier transform equation, where if we want to test how strong a certain frequency :math:`f` exists within some arbitrary signal :math:`x(t)`, we can do so with: + +.. math:: + X(f) = \int x(t) e^{-j2\pi ft} dt + +So if we want to find periodicity in our autocorrelation, we simply calculate: + +.. 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. + +or in discrete time: + +.. 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} + +which tests how strong frequency :math:`\alpha` is. We call the above equation the Cyclic Autocorrelation Function (CAF). Another way to think about the CAF is as a set of Fourier series coefficients that describe this periodicity. In other words, the CAF is the amplitude and phase of the harmonics present in a signal's autocorrelation. We use the term "cyclostationary" to refer to signals that possess a periodic or almost periodic autocorrelation. The CAF is an extension of the traditional autocorrelation function to cyclostationary signals. + +It can be seen that the CAF is a function of two variables, the delay :math:`\tau` (tau) and the cycle frequency :math:`\alpha`. Cycle frequencies in CSP represent the rates at which a signals' statistics change, which in the case of the CAF, is the second-order moment or variance. Therefore, cycle frequencies often correspond to prominent periodic behavior such as modulated symbols in communications signals. We will see how the symbol rate of a BPSK signal and its integer multiples (harmonics) manifest as cycle frequencies in the CAF. + +In Python, the CAF of baseband signal :code:`samples` at a given :code:`alpha` and :code:`tau` value can be computed using the following code snippet (we'll fill out the surrounding code shortly): + +.. 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 use :code:`np.roll()` to shift one of the sets of samples by tau, because you have to shift by an integer number of samples, so if we shifted both sets of samples in opposite directions we would skip every other shift. We also have to add a frequency shift to account for the fact that we're shifting by 1 sample at a time, and only on one side (instead of half a sample on both sides like the basic CAF equation). The frequency of the shift is :code:`alpha/2`. + +In order to play with the CAF in Python, we first need to simulate an example signal. For now we will use a rectangular BPSK signal (i.e., BPSK without pulse-shaping applied) with 20 samples per symbol, added to some white Gaussian noise (AWGN). We will apply a frequency offset to the BPSK signal, so that later we can show off how cyclostationary processing can be used to estimate the frequency offset as well as the cyclic frequency. This frequency offset is equivalent to your radio receiving a signal while not perfectly centered on it; either a little off or way off (but not too much to cause the signal to extend past the sampled bandwidth). + +The following code snippet simulates the IQ samples we will use for the remainder of the next two sections: + +.. 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 + +Because the absolute sample rate and symbol rate doesn't really matter anywhere in this chapter, we will use normalized frequency, which is effectively the same as saying our sample rate = 1 Hz. This means the signal must be between -0.5 to +0.5 Hz. Regardless, you *won't* see the variable :code:`sample_rate` show up in any of the code snippets, on purpose, instead we will work with samples per symbol (:code:`sps`). + +Just for fun, let's look at the power spectral density (i.e., FFT) of the signal itself, *before* any CSP is performed: + +.. image:: ../_images/psd_of_bpsk_used_for_caf.svg + :align: center + :target: ../_images/psd_of_bpsk_used_for_caf.svg + :alt: PSD of BPSK used for CAF + +It has the 0.2 Hz frequency shift that we applied, and the samples per symbol of 20 leads to a fairly narrow signal, but because we did not apply pulse shaping, the signal tapers off very slowly in frequency. + +Now we will compute the CAF at the correct alpha, and over a range of tau values (we'll use tau from -50 to +50 as a starting point). The correct alpha in our case is simply the samples per symbol inverted, or 1/20 = 0.05 Hz. To generate the CAF in Python, we will loop over tau: + +.. 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)))) + +Let's plot the real part of :code:`CAF` using :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 at correct alpha + +It looks a little funny, but keep in mind that tau represents the time domain, and the important part is that there is a lot of energy in the CAF at this alpha, because it's the alpha corresponding to a cyclic frequency within our signal. To prove this, let's look at the CAF at an incorrect alpha, say 0.08 Hz: + +.. image:: ../_images/caf_at_incorrect_alpha.svg + :align: center + :target: ../_images/caf_at_incorrect_alpha.svg + :alt: CAF at incorrect alpha + +Note the y-axis, there is way less energy in the CAF this time. The specific patterns we see above are less important at the moment, and will make more sense after we study the SCF in the next section. + +One thing we can do is calculate the CAF over a range of alphas, and at each alpha we can find the power in the CAF, by taking its magnitude and taking either the sum or average (doesn't make a difference in this case). Then if we plot these powers over alpha, we should see spikes at the cyclic frequencies within our signal. The following code adds a :code:`for` loop, and uses an alpha step size of 0.005 Hz (note that this will take a long time to run!): + +.. 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: CAF average over alpha + +Not only do we see the expected spike at 0.05 Hz, but we also see a spike at integer multiples of 0.05 Hz. This is because the CAF is a Fourier series, and the harmonics of the fundamental frequency are present in the CAF, especially when we are looking at PSK/QAM signals without pulse shaping. The energy at alpha = 0 is the total power in the power spectral density (PSD) of the signal, although we will typically null it out because 1) we often plot the PSD on its own and 2) it will throw off the dynamic range of our colormap when we start plotting 2D data with a colormap. + +While the CAF is interesting, we often want to view cyclic frequency *over RF frequency*, instead of just cyclic frequency on its own like we see above. This leads us to the Spectral Correlation Function (SCF), which we will discuss next. + +************************************************ +The Spectral Correlation Function (SCF) +************************************************ + +Just as the CAF shows us the periodicity in the autocorrelation of a signal, the SCF shows us the periodicity in the PSD of a signal. The autocorrelation and the PSD are in fact a Fourier transform pair, and therefore it should not come as a surprise that the CAF and the SCF are also a Fourier Transform pair. This relationship is known as the *Cyclic Wiener Relationship*. This fact should make even more sense when one considers that the CAF and SCF evaluated at a cycle frequency of :math:`\alpha=0` are the autocorrelation and PSD, respectively. + +One can simply take the Fourier transform of the CAF to obtain the SCF. Returning to our 20 sample-per-symbol BPSK signal, let's look at the SCF at the correct alpha (0.05 Hz). All we need to do is take the FFT of the CAF and plot the magnitude. The following code snippet goes along with the CAF code we wrote earlier when computing just one 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 of CAF + +Note that we can see the 0.2 Hz frequency offset that we applied when simulating the BPSK signal (this has nothing to do with the cyclic frequency or samples per symbol). This is why the CAF looked sinusoidal in the tau domain; it was primarily the RF frequency which in our example was relatively high. + +Unfortunately, doing this for thousands or millions of alphas is extremely computationally intensive. The other downside of just taking the FFT of the CAF is it does not involve any averaging. Efficient/practical computing of the SCF usually involves some form of averaging; either time-based or frequency-based, as we will discuss in the next two sections. + +Below is an interactive JavaScript app that implements an SCF, so that you can play around with different signal and SCF parameters to build your intuition. The frequency of the signal is a fairly straightforward knob, and shows how well the SCF can identify RF frequency. Try adding pulse shaping by unchecking the Rectangular Pulse option, and play around with different roll-off values. Note that using the default alpha-step, not all samples per symbols will lead to a visible spike in the SCF. You can try lowering alpha-step, although it will increase the processing time. + +.. raw:: html + +
+ + +
+ + + 0.2 +
+ + + 20 +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+ +
+ + + + + +******************************** +Frequency Smoothing Method (FSM) +******************************** + +Now that we have a good conceptual understanding of the SCF, let's look at how we can compute it efficiently. First, consider the periodogram which is simply the squared magnitude of the Fourier transform of a signal: + +.. math:: + + I(u,f) = \frac{1}{N}\left|X(u,f)\right|^2 + +We can obtain the cyclic periodogram through the product of two Fourier transforms shifted in frequency: + +.. math:: + + I(u,f,\alpha) = \frac{1}{N}X(u,f + \alpha/2) X^*(u,f - \alpha/2) + +Both of these represent estimates of the PSD and the SCF, but to obtain the true value of the SCF one must average over either time or frequency. Averaging over time is known as the 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 + +while averaging over frequency is known as the 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] + +where the function :math:`g_{\Delta}(f)` is a frequency smoothing function that averages over a small range of frequencies. + +Below is a minimal Python implementation of the FSM, which is a frequency-based averaging method for calculating the SCF of a signal. First it computes the cyclic periodogram by multiplying two shifted versions of the FFT, and then each slice is filtered with a window function whose length determines the resolution of the resulting SCF estimate. So, longer windows will produce smoother results with lower resolution while shorter ones will do the opposite. + +.. 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's calculate the SCF for the rectangular BPSK signal we used before, with 20 samples per symbol over a range of cyclic frequencies from 0 to 0.3 using a 0.001 step size: + +.. image:: ../_images/scf_freq_smoothing.svg + :align: center + :target: ../_images/scf_freq_smoothing.svg + :alt: SCF with the Frequency Smoothing Method (FSM), showing cyclostationary signal processing + +This method has the advantage that only one large FFT is required, but it also has the disadvantage that many convolution operations are required for the smoothing. Note the decimation that occurs after the convolve using :code:`[::Nw]`; this is optional but highly recommended to reduce the number of pixels you'll ultimately need to display, and because of the way the SCF is calculated we're not "throwing away" information by decimating by :code:`Nw`. + +*************************** +Time Smoothing Method (TSM) +*************************** + +Next we will look at an implementation of the TSM in Python. The code snippet below divides the signal into *num_windows* blocks, each of length *Nw* with an overlap of *Noverlap*. Note that the overlap functionality is not required, but tends to help make a nicer output. The signal is then multiplied by a window function (in this case, Hanning, but it can be any window) and the FFT is taken. The SCF is then calculated by averaging the result from each block. The window length plays the same exact role as in the FSM determining the resolution/smoothness trade-off. + + +.. 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 with the Time Smoothing Method (TSM), showing cyclostationary signal processing + +Looks roughly the same as the FSM! + +***************** +Pulse-Shaped BPSK +***************** + +Up until this point, we have only investigated CSP of a *rectangular* BPSK signal. However, in actual RF systems, we almost never see rectangular pulses, with the one exception being the BPSK chipping sequence within direct-sequence spread spectrum (DSSS) which tends to be approximately rectangular. + +Let's now look at a BPSK signal with a raised-cosine (RC) pulse shape, which is a common pulse shape used in digital communications, and is used to reduce the occupied bandwidth of the signal compared to rectangular BPSK. As discussed in the :ref:`pulse-shaping-chapter` chapter, the RC pulse shape in the time domain is given by: + +.. 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} + +The :math:`\beta` parameter determines how quickly the filter tapers off in the time domain, which will be inversely proportional with how quickly it tapers off in frequency: + +.. image:: ../_images/raised_cosine_freq.svg + :align: center + :target: ../_images/raised_cosine_freq.svg + :alt: The raised cosine filter in the frequency domain with a variety of roll-off values + +Note that :math:`\beta=0` corresponds to an infinitely long pulse shape and thus is not practical. Also note that :math:`\beta=1` does *not* correspond to a rectangular pulse shape. The roll-off factor is typically chosen to be between 0.2 and 0.4 in practice. + +We can simulate a BPSK signal with a raised-cosine pulse shaping using the following code snippet; note the first 5 lines and last 4 lines are the same as rectangular 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 + +Note that :code:`pulse_train` is simply our symbols with :code:`sps - 1` zeros after each one, in sequence, e.g.: + +.. 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... + +The plot below shows the pulse-shaped BPSK in the time domain, before noise, and before the frequency shift is added: + +.. image:: ../_images/pulse_shaped_BSPK.svg + :align: center + :target: ../_images/pulse_shaped_BSPK.svg + :alt: Pulse-shaped BPSK signal with a raised-cosine pulse shape + +Now let's calculate the SCF of this pulse-shaped BPSK signal with a roll-off of 0.3, 0.6, and 0.9. We will use the same frequency shift of 0.2 Hz, and the FSM implementation, with the same FSM parameters and symbol length as used in the rectangular BPSK example, to make it a fair comparison: + +: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 of pulse-shaped BPSK using the 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 of pulse-shaped BPSK using the 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 of pulse-shaped BPSK using the Frequency Smoothing Method (FSM) beta 0.9 + +In all three, we no longer get the sidelobes in the frequency axis, and in the cyclic frequency axis we don't get the same powerful harmonics of the fundamental cyclic frequency. This is because the raised-cosine pulse shape has a much better spectral containment than the rectangular pulse shape, and the sidelobes are much lower. As a result, pulse-shaped signals tend to have a much "cleaner" SCF than rectangular signals, resembling a single spike with a smearing above it. This will apply to all single carrier digitally modulated signals, not just BPSK. As beta gets larger we get a broader spike in the frequency axis because the signal takes up more bandwidth. + +******************************** +SNR and Number of Symbols +******************************** + +Coming Soon! We will cover how at a certain point, higher SNR doesn't help, and instead you need more symbols, and how packet-based waveforms will lead to a limited number of symbols per transmission. + +******************************** +QPSK and Higher-Order Modulation +******************************** + +Coming Soon! It will include QPSK, higher order PSK, QAM, and a brief intro into higher-order cyclic moments and cumulants. + +******************************** +Multiple Overlapping Signals +******************************** + +Up until now we have only looked at one signal at a time, but what if our received signal contains multiple individual signals that overlap in frequency, time, and even cyclic frequency (i.e., have the same samples per symbol)? If signals don't overlap in frequency at all, you can use simple filtering to separate them, and a PSD to detect them, assuming they are above the noise floor. If they don't overlap in time, then you can detect the rising and falling edge of each transmission, then use time-gating to separate the signal processing of each one. In CSP we are often focused on detecting the presence of signals at different cyclic frequencies that overlap in both time and frequency. + +Let's simulate three signals, each with different properties: + +* Signal 1: Rectangular BPSK with 20 samples per symbol and 0.2 Hz frequency offset +* Signal 2: Pulse-shaped BPSK with 20 samples per symbol, -0.1 Hz frequency offset, and 0.35 roll-off +* Signal 3: Pulse-shaped QPSK with 4 samples per symbol, 0.2 Hz frequency offset, and 0.21 roll-off + +As you can see, we have two signals that have the same cyclic frequency, and two with the same RF frequency. This will let us experiment with different degrees of parameter overlap. + +A fractional delay filter with an arbitrary (non-integer) delay is applied to each signal, so that there are no weird artifacts caused by the signals being simulated with aligned samples (learn more about this in the :ref:`sync-chapter` chapter). The rectangular BPSK signal is reduced in power compared to the other two, as rectangular-pulsed signals exhibit very strong cyclostationary properties so they tend to dominate the SCF. + +.. raw:: html + +
+ Expand for Python code simulating the three signals + +.. 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 + +
+ +Before we dive into the CSP, let's look at the PSD of this signal: + +.. image:: ../_images/psd_of_multiple_signals.svg + :align: center + :target: ../_images/psd_of_multiple_signals.svg + :alt: PSD of three different signals + +Signals 1 and 3, which are on the positive side of the PSD, overlap and you can barely see Signal 1 (which is narrower) sticking out. We can also get a feel for the noise level. + +We will now use the FSM to calculate the SCF of these combined signals: + +.. image:: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg + :align: center + :target: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg + :alt: SCF of three different signals using the Frequency Smoothing Method (FSM) + +Notice how Signal 1, even though it's rectangular pulse-shaped, has its harmonics mostly masked by the cone above Signal 3. Recall that in the PSD, Signal 1 was "hiding behind" Signal 3. Through CSP, we can detect that Signal 1 is present, and get a close approximation of its cyclic frequency, which can then be used to synchronize to it. This is the power of cyclostationary signal processing! + +************************ +Alternative CSP Features +************************ + +The SCF is not the only way to detect cyclostationarity in a signal, especially if you don't care about seeing cyclic frequency over RF frequency. One simple method (both in terms of conceptually and computational complexity) involves taking the **FFT of the magnitude** of the signal, and looking for spikes. In Python this is extremely simple: + +.. 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)) + +Note that this method is effectively the same as multiplying the signal by the complex conjugate of itself, then taking the FFT. + +Before plotting the metric we will null out the DC component, as it will contain a lot of energy and throw off the dynamic range. We will also get rid of half of the FFT output, because the input to the FFT is real, so the output is symmetric. We can then plot the metric and see the spikes: + +.. 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) + +You can then use a peak finding algorithm, such as SciPy's :code:`signal.find_peaks()`. Below we plot :code:`magnitude_metric` for each of the three signals used in the Multiple Overlapping Signals section, first individually, then combined: + +.. image:: ../_images/non_csp_metric.svg + :align: center + :target: ../_images/non_csp_metric.svg + :alt: Metric for detecting cyclostationarity in a signal without using a CAF or SCF + +The rectangular BPSK harmonics are unfortunately overlapping with the other signal's cyclic frequencies, but this shows one downside of this alternative approach: you can't view cyclic frequency over RF frequency like in the SCF. + +While this method exploits cyclostationarity in signals, it's typically not considered a "CSP technique", perhaps due to its simplicity... + +For finding the RF frequency of a signal, i.e., the carrier frequency offset, there is a similar trick. For BPSK signals, all you have to do is take the FFT of the signal squared (this will be a complex input to the FFT). It will show a spike at the carrier frequency offset multiplied by two. For QPSK signals, you can take the FFT of the signal to the 4th power, and it will show a spike at the carrier frequency offset multiplied by 4. + +.. 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 + +You can try this method out on your own simulated or captured signals, it's very useful outside of CSP. + +********************************* +Spectral Coherence Function (COH) +********************************* + +*TLDR: The spectral coherence function is a normalized version of the SCF that, in some situations, is worth using in place of the regular SCF.* + +Another measure of cyclostationarity, which can prove more insightful than the raw SCF in many cases, is the Spectral Coherence Function (COH). The COH takes the SCF and normalizes it such that the result lies between -1 and 1 (although we will be looking at magnitude which is between 0 and 1). This is useful because it isolates the information about the cyclostationarity of the signal from information about the signal's power spectrum, both of which are contained in the raw SCF. By normalizing, the power spectrum information is removed from the result leaving only the effects of cyclic correlation. + +To aide in one's understanding of the COH, it is helpful to review the concept of the `correlation coefficient `_ from statistics. The correlation coefficient :math:`\rho_{X,Y}` quantifies the degree to which two random variables :math:`X` and :math:`Y` are related, on a scale from -1 to 1. It is defined as the covariance divided by the product of the standard deviations: + +.. math:: + \rho_{X,Y} = \frac{E[(X-\mu_X)(Y-\mu_Y)]}{\sigma_X \sigma_Y} + +The COH extends this concept to spectral correlation such that it quantifies the degree to which the power spectral density (PSD) of a signal at one frequency is related to the PSD of the same signal at another frequency. These two frequencies are simply the frequency shifts that we apply as part of calculating the SCF. To calculate the COH, we first calculate the SCF as before, denoted :math:`S_X(f,\alpha)`, and then normalize by the product of two shifted PSD terms, analogous to normalizing by the product of standard deviations: + +.. math:: + \rho = C_x(f, \alpha) = \frac{S_X(f,\alpha)}{\sqrt{C_x^0(f + \alpha/2) C_x^0(f - \alpha/2)}} + +The denominator is the important/new part, the two terms :math:`C_x^0(f + \alpha/2)` and :math:`C_x^0(f - \alpha/2)` are simply the PSD shifted by :math:`\alpha/2` and :math:`-\alpha/2`. Another way to think about this is that the SCF is a cross-spectral density (a power spectrum that involves two input signals) while the normalizing terms in the denominator are the auto-spectral densities (power spectra that involve only one input signal). + +We will now apply this to our Python code, specifically the SCF using the frequency smoothing method (FSM). Because the FSM does the averaging in the frequency domain, we already have :math:`C_x^0(f + \alpha/2)` and :math:`C_x^0(f - \alpha/2)` at our disposal, in the Python code they are simply :code:`np.roll(X, -shift)` and :code:`np.roll(X, shift)` because :code:`X` is our signal after taking the FFT. So all we have to do is multiply them together, take the square root, and divide our SCF slice by that result (note that this happens within the for loop over alpha): + +.. code-block:: python + + COH_slice = SCF_slice / np.sqrt(np.roll(X, -shift) * np.roll(X, shift)) + +Lastly, we will repeat the same convolve and decimation that we did to calculate the final SCF slice + +.. code-block:: python + + COH[i, :] = np.convolve(COH_slice, window, mode='same')[::Nw] + +.. raw:: html + +
+ Expand for the full code to generate and plot both the SCF and COH + +.. 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 + +
+ +Now let us calculate the COH (as well as regular SCF) for a rectangular BPSK signal with 20 samples per symbol and 0.2 Hz frequency offset: + +.. image:: ../_images/scf_coherence.svg + :align: center + :target: ../_images/scf_coherence.svg + :alt: SCF and COH of a rectangular BPSK signal with 20 samples per symbol and 0.2 Hz frequency offset + +As you can see, the higher alphas are much more pronounced in the COH than in the SCF. Running the same code on the pulse-shaped BPSK signal we find there is not a ton of difference: + +.. image:: ../_images/scf_coherence_pulse_shaped.svg + :align: center + :target: ../_images/scf_coherence_pulse_shaped.svg + :alt: SCF and COH of a pulse-shaped BPSK signal with 20 samples per symbol and 0.2 Hz frequency offset + +Try generating both the SCF and COH for your application to see which one works best! + +********** +Conjugates +********** + +Up until this point, we have been using the following formulas for the CAF and the SCF where the complex conjugate (:math:`*` symbol) of the signal is used in the second term: + +.. 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 + +There is, however, an alternate form for the CAF and SCF in which there is no conjugate included. These forms are called the *conjugate CAF* and the *conjugate SCF*, respectively. The naming convention it's a little confusing, but the main thing to remember is that there's a "normal" version of the CAF/SCF, and a conjugate version. The conjugate version is useful when you want to extract more information from the signal, but it's not always necessary depending on the signal. The conjugate CAF and SCF are defined as: + +.. 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 + +which is the same as the original CAF and SCF, but without the conjugate. The discrete time versions are also all the same except for the conjugate being removed. + +To understand the significance of the conjugate forms, consider the quadrature representation of a real-valued bandpass signal: + +.. 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)` and :math:`x_Q(t)` are the in-phase (I) and quadrature (Q) components of the signal, respectively, and it is these IQ samples that we are ultimately processing with CSP at baseband. + +Using Euler's formula, :math:`e^{jx} = \cos(x) + j \sin(x)`, we can rewrite the above equation using complex exponentials: + +.. 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 can use complex envelope, which we will call :math:`z(t)`, to represent the real-valued signal :math:`y(t)`, assuming that the signal bandwidth is much smaller than the carrier frequency :math:`f_c` which is typically the case in RF applications: + +.. 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} + +This is known as the complex-baseband representation. + +Coming back to the CAF, let's try computing the portion of the CAF known as the "lag product", which is just the :math:`x(t + \tau/2) x(t - \tau/2)` part: + +.. 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) + +Although it may not be immediately obvious, this result contains four terms corresponding to the four combinations of conjugated and non-conjugated :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)} + +It turns out that the 1st and 4th ones are effectively the same thing as far as information we can obtain from them, as are the 2nd and 3rd. So there are really only two cases we care about, the conjugate case and the non-conjugate case. In summary, if one wishes to obtain the full extent of statistical information from :math:`y(t)`, each combination of conjugated and non-conjugated terms must be considered. + +In order to implement the conjugate SCF using the frequency smoothing method, there is one extra step beyond removing the :code:`conj()`, because we are doing one big FFT and then averaging in the frequency domain. There is a property of the Fourier transform that states that a complex conjugate in the time domain corresponds to the frequency domain being flipped and conjugated: + +.. math:: + x^*(t) \leftrightarrow X^*(-f) + +Now because we were already complex conjugating the second term in the normal SCF (recall that we were using the code :code:`SCF_slice = np.roll(X, -shift) * np.conj(np.roll(X, shift))`), when we complex conjugate it again it just goes away, so what we are left with is the following: + +.. code-block:: python + + SCF_slice = np.roll(X, -shift) * np.flip(np.roll(X, -shift - 1)) + +Note the added :code:`np.flip()`, and the :code:`roll()` needs to happen in the reverse direction. The full FSM implementation of the conjugate SCF is as follows: + +.. 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() + +Another big change with the conjugate SCF is that we want to calculate alphas between -1 and +1, whereas with the normal SCF we just did 0.0 to 0.5 due to symmetry. You will see why this is the case first-hand once we start looking at the conjugate SCF of example signals. + +Now what is the importance of doing the conjugate SCF? To demonstrate, let's look at the conjugate SCF of our basic rectangular BPSK signal with 20 samples per symbol (leading to a cyclic frequency of 0.05 Hz) and 0.2 Hz frequency offset: + +.. image:: ../_images/scf_conj_rect_bpsk.svg + :align: center + :target: ../_images/scf_conj_rect_bpsk.svg + :alt: Conjugate SCF of rectangular BPSK using the Frequency Smoothing Method (FSM) + +Here is the big take-away from this section: what you ultimately get in the conjugate SCF are spikes at the cyclic frequency +/- **twice** the carrier frequency offset, which we will refer to as :math:`f_c`. In the frequency axis it will be centered at 0 Hz instead of :math:`f_c`. Our frequency offset was 0.2 Hz, so we end up getting spikes at 0.4 Hz +/- the cyclic frequency of 0.05 Hz. If there is one thing to remember about the conjugate SCF, it is to expect spikes at: + +.. math:: + 2f_c \pm \alpha + +Let's now look at pulse-shaped BPSK with the same 0.2 Hz offset, 20 samples per symbol, and a 0.3 roll-off: + +.. image:: ../_images/scf_conj_pulseshaped_bpsk.svg + :align: center + :target: ../_images/scf_conj_pulseshaped_bpsk.svg + :alt: Conjugate SCF of raised cosine pulse-shaped BPSK using the Frequency Smoothing Method (FSM) + +Seems reasonable given the normal SCF pattern we saw with BPSK. + +Now for the fun part, let's look at the conjugate SCF of rectangular QPSK with the same 0.2 Hz and 20 samples per symbol: + +.. image:: ../_images/scf_conj_rect_qpsk.svg + :align: center + :target: ../_images/scf_conj_rect_qpsk.svg + :alt: Conjugate SCF of rectangular QPSK using the Frequency Smoothing Method (FSM) + +At first it might seem like there was a bug in our code, but take a look at the colorbar, which indicates what values the colors correspond to. When using :code:`plt.imshow()` with automatic scaling, you have to be aware that it's always going to scale the colors (in our case, purple through yellow) from the lowest value to the highest value of the 2D array we give it. In the case of our conjugate SCF of QPSK, the entire output is relatively low, because it turns out *there are no spikes in the conjugate SCF when using QPSK*. Here is the same QPSK output but using the scaling to match our previous BPSK examples: + +.. image:: ../_images/scf_conj_rect_qpsk_scaled.svg + :align: center + :target: ../_images/scf_conj_rect_qpsk_scaled.svg + :alt: Conjugate SCF of rectangular QPSK using the Frequency Smoothing Method (FSM) with scaling + +Note the range of the colorbar. + +The conjugate SCF for QPSK, as well as higher order PSK and QAM, is essentially zero/noise. This means we can use the conjugate SCF to detect the presence of BPSK (e.g., the chipping sequence in DSSS) even if there are a bunch of QPSK/QAM signals overlapping with it. This is a very powerful tool in the CSP toolbox! + +Let's try running the conjugate SCF on the three-signal scenario we've been using several times throughout this tutorial, which includes the following signals: + +* Signal 1: Rectangular BPSK with 20 samples per symbol and 0.2 Hz frequency offset +* Signal 2: Pulse-shaped BPSK with 20 samples per symbol, -0.1 Hz frequency offset, and 0.35 roll-off +* Signal 3: Pulse-shaped QPSK with 4 samples per symbol, 0.2 Hz frequency offset, and 0.21 roll-off + +.. image:: ../_images/scf_conj_multiple_signals.svg + :align: center + :target: ../_images/scf_conj_multiple_signals.svg + :alt: Conjugate SCF of three different signals using the Frequency Smoothing Method (FSM) + +Notice how we can see the two BPSK signals but the QPSK signal doesn't show up, or else we would see a spike at alpha = 0.65 and 0.15 Hz. It might be hard to see without zooming in, but there are spikes at 0.4 +/- 0.05 Hz and -0.2 +/- 0.05 Hz. + +******************************** +FFT Accumulation Method (FAM) +******************************** + +The FSM and TSM techniques presented earlier work great, especially when you want to calculate a specific set of cyclic frequencies (note how both implementations involve looping over cyclic frequency as the outer loop). However, there is an even more efficient SCF implementation known as the FFT Accumulation Method (FAM), which inherently calculates the full set of cyclic frequencies (i.e., the cyclic frequencies corresponding to every integer shift of the signal, the number of which depend on signal length). There is also a similar technique known as the `Strip Spectral Correlation Analyzer (SSCA) `_ which also calculates all cyclic frequencies at once, but is not covered in this chapter to avoid repetition. This class of techniques that calculate all cyclic frequencies are sometimes referred to as "blind estimators" because they tend to be used when no prior knowledge of cyclic frequencies is known (otherwise, you would have a good idea of which cyclic frequencies to calculate and could use the FSM or TSM methods). The FAM is a time-smoothing method (think of it like a fancy TSM), while the SSCA is like a fancy FSM. + +The minimal Python code to implement the FAM is actually fairly simple, although because we are no longer looping over alpha it is not as easy to tie back to the math. Just like the TSM, we break the signal into a bunch of time windows, with some overlap. A Hanning window is applied to each chunk of samples. There are two stages of FFTs performed as part of the FAM algorithm, and within the code note that the first FFT is performed on a 2D array, so it's doing a bunch of FFTs in one line of code. After a frequency shift, we do a second FFT to build the SCF (we then take the magnitude squared). For a more thorough explanation of the FAM, refer to the external resources at the end of this section. + +.. 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 with the FFT Accumulation Method (FAM), showing cyclostationary signal processing + +Let's zoom into the interesting part around 0.2 Hz and the low cyclic frequencies, to see more detail: + +.. image:: ../_images/scf_fam_zoomedin.svg + :align: center + :target: ../_images/scf_fam_zoomedin.svg + :alt: Zoomed in version of SCF with the FFT Accumulation Method (FAM), showing cyclostationary signal processing + +There is a clear hot spot at 0.05 Hz, and a low one at 0.1 Hz that may be tough to see with this colorscale. + +We can also squash the RF frequency axis and plot the SCF in 1D, in order to more easily see which cyclic frequencies are present: + +.. image:: ../_images/scf_fam_1d.svg + :align: center + :target: ../_images/scf_fam_1d.svg + :alt: Cyclic freq plot using the FFT Accumulation Method (FAM), showing cyclostationary signal processing + +One big gotcha with the FAM is that it will generate an enormous number of pixels, depending on your signal size, and when only one or two rows in the :code:`imshow()` contain the energy, they can sometimes be masked due to the scaling done to display it on your monitor. Make sure to note the size of the 2D SCF matrix, and if you want to reduce the number of pixels in the cyclic frequency axis, you can use a max pooling or mean pooling operation. Place this code after the SCF calculation and before plotting (you may need to :code:`pip install scikit-image`): + +.. 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) + +External Resources on 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. `Available here `_ +* Da Costa, Evandro Luiz. Detection and identification of cyclostationary signals. Diss. Naval Postgraduate School, 1996. `Available here `_ +* Chad's blog post on FAM: https://cyclostationary.blog/2018/06/01/csp-estimators-the-fft-accumulation-method/ + +******************************** +OFDM +******************************** + +Cyclostationarity is especially strong in OFDM signals due to OFDM's use of a cyclic prefix (CP), which is where the last several samples of each OFDM symbol is copied and added to the beginning of the OFDM symbol. This leads to a strong cyclic frequency corresponding to the OFDM symbol length (which is equal to the inverse of the subcarrier spacing, plus CP duration). + +Let's play around with an OFDM signal. Below is the simulation of an OFDM signal with a CP using 64 subcarriers, 25% CP, and QPSK modulation on each subcarrier. We'll interpolate by 2x to simulate receiving at a reasonable sample rate, so that means the OFDM symbol length in number of samples will be (64 + (64*0.25)) * 2 = 160 samples. That means we should get spikes at alphas that are an integer multiple of 1/160, or 0.00625, 0.0125, 0.01875, etc. We will simulate 100k samples which corresponds to 625 OFDM symbols (recall that each OFDM symbol is fairly long). + +.. code-block:: python + + from scipy.signal import resample + N = 100000 # 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 + +Using the FSM to calculate the SCF at a relatively high cyclic resolution of 0.0001: + +.. image:: ../_images/scf_freq_smoothing_ofdm.svg + :align: center + :target: ../_images/scf_freq_smoothing_ofdm.svg + :alt: SCF of OFDM using the Frequency Smoothing Method (FSM) + +Note the horizontal line towards the top, indicating there is a low cyclic frequency. Zooming into the lower cyclic frequencies, we can clearly see the cyclic frequency corresponding to the OFDM symbol length (alpha = 0.0125). Not sure why we only get a spike at 2x, and not 1x or 3x or 4x... Even dropping the resolution by another 10x doesn't show anything else besides the 2x, if anyone knows feel free to use the "Suggest an Edit" link at the bottom of this page. + +.. image:: ../_images/scf_freq_smoothing_ofdm_zoomed_in.svg + :align: center + :target: ../_images/scf_freq_smoothing_ofdm_zoomed_in.svg + :alt: SCF of OFDM using the Frequency Smoothing Method (FSM) zoomed into the lower cyclic freqs + +External resources on OFDM within the context of 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. `Available here `_ + +******************************************** +Signal Detection With Known Cyclic Frequency +******************************************** + +In some applications you may want to use CSP to detect a signal/waveform that is already known, such as variants of 802.11, LTE, 5G, etc. If you know the cyclic frequency of the signal, and you know your sample rate, then you really only need to calculate a single alpha and single tau. Coming soon will be an example of this type of problem using an RF recording of WiFi. diff --git a/content-ukraine/hackrf.rst b/content-ukraine/hackrf.rst new file mode 100644 index 00000000..cb2aa09a --- /dev/null +++ b/content-ukraine/hackrf.rst @@ -0,0 +1,274 @@ +.. _hackrf-chapter: + +#################### +HackRF One in Python +#################### + +The `HackRF One `_ from Great Scott Gadgets is a USB 2.0 SDR that can transmit or receive from 1 MHz to 6 GHz and has a sample rate from 2 to 20 MHz. It was released in 2014 and has had several minor refinements over the years. It is one of the only low-cost transmit-capable SDRs that goes down to 1 MHz, making it great for HF applications (e.g., ham radio) in addition to higher frequency fun. The max transmit power of 15 dBm is also higher than most other SDRs, see `this page `_ for full transmit power specs. It uses half-duplex operation, meaning it is either in transmit or receive mode at any given time, and it uses 8-bit ADC/DAC. + +.. image:: ../_images/hackrf1.jpeg + :scale: 60 % + :align: center + :alt: HackRF One + +******************************** +HackRF Architecture +******************************** + +The HackRF is based around the Analog Devices MAX2839 chip which is a 2.3GHz to 2.7GHz transceiver initially designed for WiMAX, combined with a MAX5864 RF front-end chip (essentially just the ADC and DAC) and a RFFC5072 wideband synthesizer/VCO (used to upconvert and downconvert the signal in frequency). This is in contrast to most other low-cost SDRs which use a single chip known as an RFIC. Aside from setting the frequency generated within the RFFC5072, all of the other parameters we will adjust like the attenuation and analog filtering are going to be in the MAX2839. Instead of using an FPGA or System on Chip (SoC) like many SDRs, the HackRF uses a Complex Programmable Logic Device (CPLD) which acts as simple glue logic, and a microcontroller, the ARM-based LPC4320, which does all of the onboard DSP and interfacing over USB with the host (both transfer of IQ samples in either direction and control of the SDR settings). The following beautiful block diagram from Great Scott Gadgets shows the architecture of the latest revision of the HackRF One: + +.. image:: ../_images/hackrf_block_diagram.webp + :align: center + :alt: HackRF One Block Diagram + :target: ../_images/hackrf_block_diagram.webp + +The HackRF One is highly expandable and hackable. Inside the plastic case are four headers (P9, P20, P22, and P28), specifics can be `found here `_, but note that 8 GPIO pins and 4 ADC inputs are on the P20 header, while SPI, I2C, and UART are on the P22 header. The P28 header can be used to trigger/synchronize transmit/receive operations with another device (e.g., TR-switch, external amp, or another HackRF), through the trigger input and output, with delay of less than one sample period. + +.. image:: ../_images/hackrf2.jpeg + :scale: 50 % + :align: center + :alt: HackRF One PCB + +The clock used for both the LO and ADC/DAC is derived from either the onboard 25 MHz oscillator, or from an external 10 MHz reference fed in over SMA. Regardless of which clock is used, the HackRF produces a 10 MHz clock signal on CLKOUT; a standard 3.3V 10 MHz square wave intended for a high impedance load. The CLKIN port is designed to take a similar 10 MHz 3.3V square wave, and the HackRF One will use the input clock instead of the internal crystal when a clock signal is detected (note, the transition to or from CLKIN only happens when a transmit or receive operation begins). + +******************************** +Software and Hardware Setup +******************************** + +The software install process involves two steps: first we will install the main HackRF library from Great Scott Gadgets, and then we will install the Python API. + +Installing the HackRF Library +############################# + +The following was tested to work on Ubuntu 22.04 (using commit hash 17f3943 in March '25): + +.. code-block:: bash + + git clone https://github.com/greatscottgadgets/hackrf.git + cd hackrf + git checkout 17f3943 + cd host + mkdir build + cd build + cmake .. + make + sudo make install + sudo ldconfig + sudo cp /usr/local/bin/hackrf* /usr/bin/. + +After installing :code:`hackrf` you will be able to run the following utilities: + +* :code:`hackrf_info` - Read device information from HackRF such as serial number and firmware version. +* :code:`hackrf_transfer` - Send and receive signals using HackRF. Input/output files are 8-bit signed quadrature samples. +* :code:`hackrf_sweep` - a command-line spectrum analyzer. +* :code:`hackrf_clock` - Read and write clock input and output configuration. +* :code:`hackrf_operacake` - Configure Opera Cake antenna switch connected to HackRF. +* :code:`hackrf_spiflash` - A tool to write new firmware to HackRF. See: Updating Firmware. +* :code:`hackrf_debug` - Read and write registers and other low-level configuration for debugging. + +If you are using Ubuntu through WSL, on the Windows side you will need to forward the HackRF USB device to WSL, first by installing the latest `usbipd utility msi `_ (this guide assumes you have usbipd-win 4.0.0 or higher), then opening PowerShell in administrator mode and running: + +.. code-block:: bash + + usbipd list + + usbipd bind --busid 1-10 + usbipd attach --wsl --busid 1-10 + +On the WSL side, you should be able to run :code:`lsusb` and see a new item called :code:`Great Scott Gadgets HackRF One`. Note that you can add the :code:`--auto-attach` flag to the :code:`usbipd attach` command if you want it to auto reconnect. Lastly, you have to add the udev rules using the following command: + +.. code-block:: bash + + echo 'ATTR{idVendor}=="1d50", ATTR{idProduct}=="6089", SYMLINK+="hackrf-one-%k", MODE="660", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/53-hackrf.rules + sudo udevadm trigger + +Then unplug and replug your HackRF One (and redo the :code:`usbipd attach` part). Note, I had permissions issues with the step below until I switched to using `WSL USB Manager `_ on the Windows side, to manage forwarding to WSL, which apparently also deals with the udev rules. + +Whether you're on native Linux or WSL, at this point you should be able to run :code:`hackrf_info` and see something like: + +.. code-block:: bash + + hackrf_info version: git-17f39433 + libhackrf version: git-17f39433 (0.9) + Found HackRF + Index: 0 + Serial number: 00000000000000007687865765a765 + Board ID Number: 2 (HackRF One) + Firmware Version: 2024.02.1 (API:1.08) + Part ID Number: 0xa000cb3c 0x004f4762 + Hardware Revision: r10 + Hardware appears to have been manufactured by Great Scott Gadgets. + Hardware supported by installed firmware: HackRF One + +Let's also make an IQ recording of the FM band, 10 MHz wide centered at 100 MHz, and we'll grab 1 million samples: + +.. code-block:: bash + + hackrf_transfer -r out.iq -f 100000000 -s 10000000 -n 1000000 -a 0 -l 30 -g 50 + +This utility produces a binary IQ file of int8 samples (2 bytes per IQ sample), which in our case should be 2MB. If you're curious, the signal recording can be read in Python using the following code: + +.. code-block:: python + + import numpy as np + samples = np.fromfile('out.iq', dtype=np.int8) + samples = samples[::2] + 1j * samples[1::2] + print(len(samples)) + print(samples[0:10]) + print(np.max(samples)) + +If your max is 127 (which means you saturated the ADC) then lower the two gain values at the end of the command. + +Installing the Python API +######################### + +Lastly, we must install the HackRF One `Python bindings `_, maintained by `GvozdevLeonid `_. This was tested to work in Ubuntu 22.04 on 11/04/2024 using the latest main branch. + +.. code-block:: bash + + sudo apt install libusb-1.0-0-dev + pip install python_hackrf==1.2.7 + +We can test the above install by running the following code, if there are no errors (there will also be no output) then everything should be good to go! + +.. code-block:: python + + from python_hackrf import pyhackrf # type: ignore + pyhackrf.pyhackrf_init() + sdr = pyhackrf.pyhackrf_open() + sdr.pyhackrf_set_sample_rate(10e6) + sdr.pyhackrf_set_antenna_enable(False) + sdr.pyhackrf_set_freq(100e6) + sdr.pyhackrf_set_amp_enable(False) + sdr.pyhackrf_set_lna_gain(30) # LNA gain - 0 to 40 dB in 8 dB steps + sdr.pyhackrf_set_vga_gain(50) # VGA gain - 0 to 62 dB in 2 dB steps + sdr.pyhackrf_close() + +For an actual test of receiving samples, see the example code below. + +******************************** +Tx and Rx Gain +******************************** + +Receive Side +############ + +The HackRF One on the receive side has three different gain stages: + +* RF (:code:`amp`, either 0 or 11 dB) +* IF (:code:`lna`, 0 to 40 dB in 8 dB steps) +* baseband (:code:`vga`, 0 to 62 dB in 2 dB steps) + +For receiving most signals, it is recommended to leave the RF amplifier off (0 dB), unless you are dealing with an extremely weak signal and there are definitely no strong signals nearby. The IF (LNA) gain is the most important gain stage to adjust, to maximize your SNR while avoiding saturation of the ADC, that is the first knob to adjust. The baseband gain can be left at a relatively high value, e.g., we will just leave it at 50 dB. + +Transmit Side +############# + +On the transmit side, there are two gain stages: + +* RF [either 0 or 11 dB] +* IF [0 to 47 dB in 1 dB steps] + +You will likely want the RF amplifier enabled, and then you can adjust the IF gain to suit your needs. + +************************************************** +Receiving IQ Samples within Python with the HackRF +************************************************** + +Currently the :code:`python_hackrf` Python package does not include any convenience functions for receiving samples, it is simply a set of Python bindings that map to the HackRF's C++ API. That means in order to receive IQ, we have to use a decent amount of code. The Python package is set up to use a callback function in order to receive more samples, this is a function that we must set up, but it will automatically get called whenever there are more samples ready from the HackRF. This callback function always needs to have three specific arguments, and it needs to return :code:`0` if we want another set of samples. In the code below, within each call to our callback function, we convert the samples to NumPy's complex type, scale them from -1 to +1, and then store them in a larger :code:`samples` array + +After running the code below, if in your time plot, the samples are reaching the ADC limits of -1 and +1, then reduce :code:`lna_gain` by 3 dB until it is clearly not hitting the limits. + +.. code-block:: python + + from python_hackrf import pyhackrf # type: ignore + import matplotlib.pyplot as plt + import numpy as np + import time + + # These settings should match the hackrf_transfer example used in the textbook, and the resulting waterfall should look about the same + recording_time = 1 # seconds + center_freq = 100e6 # Hz + sample_rate = 10e6 + baseband_filter = 7.5e6 + lna_gain = 30 # 0 to 40 dB in 8 dB steps + vga_gain = 50 # 0 to 62 dB in 2 dB steps + + pyhackrf.pyhackrf_init() + sdr = pyhackrf.pyhackrf_open() + + allowed_baseband_filter = pyhackrf.pyhackrf_compute_baseband_filter_bw_round_down_lt(baseband_filter) # calculate the supported bandwidth relative to the desired one + + sdr.pyhackrf_set_sample_rate(sample_rate) + sdr.pyhackrf_set_baseband_filter_bandwidth(allowed_baseband_filter) + sdr.pyhackrf_set_antenna_enable(False) # It seems this setting enables or disables power supply to the antenna port. False by default. the firmware auto-disables this after returning to IDLE mode + + sdr.pyhackrf_set_freq(center_freq) + sdr.pyhackrf_set_amp_enable(False) # False by default + sdr.pyhackrf_set_lna_gain(lna_gain) # LNA gain - 0 to 40 dB in 8 dB steps + sdr.pyhackrf_set_vga_gain(vga_gain) # VGA gain - 0 to 62 dB in 2 dB steps + + print(f'center_freq: {center_freq} sample_rate: {sample_rate} baseband_filter: {allowed_baseband_filter}') + + num_samples = int(recording_time * sample_rate) + samples = np.zeros(num_samples, dtype=np.complex64) + last_idx = 0 + + def rx_callback(device, buffer, buffer_length, valid_length): # this callback function always needs to have these four args + global samples, last_idx + + accepted = valid_length // 2 + accepted_samples = buffer[:valid_length].astype(np.int8) # -128 to 127 + accepted_samples = accepted_samples[0::2] + 1j * accepted_samples[1::2] # Convert to complex type (de-interleave the IQ) + accepted_samples /= 128 # -1 to +1 + samples[last_idx: last_idx + accepted] = accepted_samples + + last_idx += accepted + + return 0 + + sdr.set_rx_callback(rx_callback) + sdr.pyhackrf_start_rx() + print('is_streaming', sdr.pyhackrf_is_streaming()) + + time.sleep(recording_time) + + sdr.pyhackrf_stop_rx() + sdr.pyhackrf_close() + pyhackrf.pyhackrf_exit() + + samples = samples[100000:] # get rid of the first 100k samples just to be safe, due to transients + + fft_size = 2048 + num_rows = len(samples) // fft_size + spectrogram = np.zeros((num_rows, fft_size)) + for i in range(num_rows): + spectrogram[i, :] = 10 * np.log10(np.abs(np.fft.fftshift(np.fft.fft(samples[i * fft_size:(i+1) * fft_size]))) ** 2) + extent = [(center_freq + sample_rate / -2) / 1e6, (center_freq + sample_rate / 2) / 1e6, len(samples) / sample_rate, 0] + + plt.figure(0) + plt.imshow(spectrogram, aspect='auto', extent=extent) # type: ignore + plt.xlabel("Frequency [MHz]") + plt.ylabel("Time [s]") + + plt.figure(1) + plt.plot(np.real(samples[0:10000])) + plt.plot(np.imag(samples[0:10000])) + plt.xlabel("Samples") + plt.ylabel("Amplitude") + plt.legend(["Real", "Imaginary"]) + + plt.show() + +When using an antenna that can receive the FM band, you should get something like the following, with several FM stations visible in the waterfall plot: + +.. image:: ../_images/hackrf_time_screenshot.png + :align: center + :scale: 50 % + :alt: Time plot of the samples grabbed from HackRF + +.. image:: ../_images/hackrf_freq_screenshot.png + :align: center + :scale: 50 % + :alt: Spectrogram (frequency over time) plot of the samples grabbed from HackRF + diff --git a/content-ukraine/pyqt.rst b/content-ukraine/pyqt.rst new file mode 100644 index 00000000..d980f1ae --- /dev/null +++ b/content-ukraine/pyqt.rst @@ -0,0 +1,881 @@ +.. _pyqt-chapter: + +########################## +Real-Time GUIs with PyQt +########################## + +In this chapter we learn how to create real-time graphical user interfaces (GUIs) within Python by leveraging PyQt, the Python bindings for Qt. As part of this chapter we build a spectrum analyzer with time, frequency, and spectrogram/waterfall graphics, as well as input widgets for adjusting the various SDR parameters. The example supports the PlutoSDR, USRP, or simulation-only mode. + +**************** +Introduction +**************** + +Qt (pronounced "cute") is a framework for creating GUI applications that can run on Linux, Windows, macOS, and even Android. It is a very powerful framework that is used in many commercial applications, and is written in C++ for maximum performance. PyQt is the Python bindings for Qt, providing a way to create GUI applications in Python, while harnessing the performance of an efficient C++ based framework. In this chapter we will learn how to use PyQt to create a real-time spectrum analyzer that can be used with an SDR (or with a simulated signal). The spectrum analyzer will have time, frequency, and spectrogram/waterfall graphics, as well as input widgets for adjusting the various SDR parameters. We use `PyQtGraph `_, which is a separate library built on top of PyQt, to perform plotting. On the input side, we use sliders, combo-box, and push-buttons. The example supports the PlutoSDR, USRP, or simulation-only mode. Even though the example code uses PyQt6, every single line is identical to PyQt5 (besides the :code:`import`), very little changed between the two versions from an API perspective. Naturally, this chapter is extremely Python code heavy, as we explain through examples. By the end of this chapter you will have gained familiarity with the building blocks used to create your own custom interactive SDR application! + +**************** +Qt Overview +**************** + +Qt is a very large framework, and we will only be scratching the surface of what it can do. However, there are a few key concepts that are important to understand when working with Qt/PyQt: + +- **Widgets**: Widgets are the building blocks of a Qt application, and are used to create the GUI. There are many different types of widgets, including buttons, sliders, labels, and plots. Widgets can be arranged in layouts, which determine how they are positioned on the screen. + +- **Layouts**: Layouts are used to arrange widgets in a window. There are several types of layouts, including horizontal, vertical, grid, and form layouts. Layouts are used to create complex GUIs that are responsive to changes in window size. + +- **Signals and Slots**: Signals and slots are a way to communicate between different parts of a Qt application. A signal is emitted by an object when a particular event occurs, and is connected to a slot, which is a callback function that is called when the signal is emitted. Signals and slots are used to create an event-driven structure in a Qt application, and keep the GUI responsive. + +- **Style Sheets**: Style sheets are used to customize the appearance of widgets in a Qt application. Style sheets are written in a CSS-like language, and can be used to change the color, font, and size of widgets. + +- **Graphics**: Qt has a powerful graphics framework that can be used to create custom graphics in a Qt application. The graphics framework includes classes for drawing lines, rectangles, ellipses, and text, as well as classes for handling mouse and keyboard events. + +- **Multithreading**: Qt has built-in support for multithreading, and provides classes for creating worker threads that run in the background. Multithreading is used to run long-running operations in a Qt application without blocking the main GUI thread. + +- **OpenGL**: Qt has built-in support for OpenGL, and provides classes for creating 3D graphics in a Qt application. OpenGL is used to create applications that require high-performance 3D graphics. In this chapter we will only be focusing on 2D applications. + +************************* +Basic Application Layout +************************* + +Before we dive into the different Qt widgets, let's look at the layout of a typical Qt application. A Qt application is composed of a main window, which contains a central widget, which in turn contains the main content of the application. Using PyQt we can create a minimal Qt application, containing just a single QPushButton as follows: + +.. 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 + +Try running the code yourself, you will likely need to :code:`pip install PyQt6`. Note how the very last line is blocking, anything you add after that line wont run until you close the window. The QPushButton we create has its :code:`clicked` signal connected to a callback function that prints "beep" to the console. + +******************************* +Application with Worker Thread +******************************* + +There is one problem with the minimal example above- it doesn't leave us any spot to put SDR/DSP oriented code. The :code:`MainWindow`'s :code:`__init__` is where the GUI is configured and callbacks are defined, but you absolutely do not want to add any other code (such as SDR or DSP code) to it. The reason is that the GUI is single-threaded, and if you block the GUI thread with long-running code, the GUI will freeze/stutter, and we want the smoothest GUI possible. To get around this, we can use a worker thread to run the SDR/DSP code in the background. + +The example below extends the minimal example above to include a worker thread that runs code (in the :code:`run` function) nonstop. We don't use a :code:`while True:` though, because of the way PyQt works under the hood, we want our :code:`run` function to finish and start over periodically. In order to do this, the worker thread's :code:`end_of_run` signal (which we discuss more in the next section) is connected to a callback function that triggers the worker thread's :code:`run` function again. We also must initialize the worker thread in the :code:`MainWindow` code, which involves creating a new :code:`QThread` and assigning our custom worker to it. This code might seem complicated, but it is a very common pattern in PyQt applications and the main take-away is that the GUI-oriented code goes in :code:`MainWindow`, and the SDR/DSP-oriented code goes in the worker thread's :code:`run` function. + +.. 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 + +Try running the above code, you should see a "Starting run()" in the console every 1 second, and the push-button should still work (without any delay). Within the worker thread, all we are doing now is a print and a sleep, but soon we will be adding the SDR handling and DSP code to it. + +************************* +Signals and Slots +************************* + +In the above example, we used the :code:`end_of_run` signal to communicate between the worker thread and the GUI thread. This is a common pattern in PyQt applications, and is known as the "signals and slots" mechanism. A signal is emitted by an object (in this case, the worker thread) and is connected to a slot (in this case, the callback function :code:`end_of_run_callback` in the GUI thread). The signal can be connected to multiple slots, and the slot can be connected to multiple signals. The signal can also carry arguments, which are passed to the slot when the signal is emitted. Note that we can also reverse things; the GUI thread is able to send a signal to the worker thread's slot. The signal/slot mechanism is a powerful way to communicate between different parts of a PyQt application, creating an event-driven structure, and is used extensively in the example code that follows. Just remember that a slot is simply a callback function, and a signal is a way to signal that callback function. + +************************* +PyQtGraph +************************* + +PyQtGraph is a library built on top of PyQt and NumPy that provides fast and efficient plotting capabilities, as PyQt is too general purpose to come with plotting functionality. It is designed to be used in real-time applications, and is optimized for speed. It is similar in a lot of ways to Matplotlib, but meant for real-time applications instead of single plots. Using the simple example below you can compare the performance of PyQtGraph to Matplotlib, simply change the :code:`if True:` to :code:`False:`. On an Intel Core i9-10900K @ 3.70 GHz the PyQtGraph code updated at over 1000 FPS while the Matplotlib code updated at 40 FPS. That being said, if you find yourself benefiting from using Matplotlib (e.g., to save development time, or because you want a specific feature that PyQtGraph doesn't support), you can incorporate Matplotlib plots into a PyQt application, using the code below as a starting point. + +.. 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 + +
+ +As far as using PyQtGraph, we import it with :code:`import pyqtgraph as pg` and then we can create a Qt widget that represents a 1D plot as follows (this code goes in the :code:`MainWindow`'s :code:`__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 example + +You can see how it's relatively straightforward to set up a plot, and the result is simply another widget to add to your GUI. In addition to 1D plots, PyQtGraph also has an equivalent to Matplotlib's :code:`imshow()` which plots 2D using a colormap, which we will use for our real-time spectrogram/waterfall. One nice part about PyQtGraph is that the plots it creates are simply Qt widgets and we add other Qt elements (e.g. a rectangle of a certain size at a certain coordinate) using pure PyQt. This is because PyQtGraph makes use of PyQt's :code:`QGraphicsScene` class, which provides a surface for managing a large number of 2D graphical items, and nothing is stopping us from adding lines, rectangles, text, ellipses, polygons, and bitmaps, using straight PyQt. + +******* +Layouts +******* + +In the above examples, we used :code:`self.setCentralWidget()` to set the main widget of the window. This is a simple way to set the main widget, but it doesn't allow for more complex layouts. For more complex layouts, we can use layouts, which are a way to arrange widgets in a window. There are several types of layouts, including :code:`QHBoxLayout`, :code:`QVBoxLayout`, :code:`QGridLayout`, and :code:`QFormLayout`. The :code:`QHBoxLayout` and :code:`QVBoxLayout` arrange widgets horizontally and vertically, respectively. The :code:`QGridLayout` arranges widgets in a grid, and the :code:`QFormLayout` arranges widgets in a two-column layout, with labels in the first column and input widgets in the second column. + +To create a new layout and add widgets to it, try adding the following inside your :code:`MainWindow`'s :code:`__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 this example we are stacking the widgets horizontally, but by swapping :code:`QHBoxLayout` for :code:`QVBoxLayout` we can stack them vertically instead. The :code:`addWidget` function is used to add widgets to the layout, and the optional second argument is a stretch factor that determines how much space the widget should take up relative to the other widgets in the layout. + +:code:`QGridLayout` has extra parameters because you must specify the row and column of the widget, and you can optionally specify how many rows and columns the widget should span (default is 1 and 1). Here is an example of a :code:`QGridLayout`: + +.. 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 showing examples of QHBoxLayout, QVBoxLayout, and QGridLayout + +For our spectrum analyzer we will use the :code:`QGridLayout` for the overall layout, but we will also be adding :code:`QHBoxLayout` to stack widgets horizontally within a space in the grid. You can nest layouts simply by create a new layout and adding it to the top-level (or parent) layout, e.g.: + +.. code-block:: python + + layout = QGridLayout() + self.setLayout(layout) + inner_layout = QHBoxLayout() + layout.addLayout(inner_layout) + +******************* +:code:`QPushButton` +******************* + +The first actual widget we will cover is the :code:`QPushButton`, which is a simple button that can be clicked. We have already seen how to create a :code:`QPushButton` and connect its :code:`clicked` signal to a callback function. The :code:`QPushButton` has a few other signals, including :code:`pressed`, :code:`released`, and :code:`toggled`. The :code:`toggled` signal is emitted when the button is checked or unchecked, and is useful for creating toggle buttons. The :code:`QPushButton` also has a few properties, including :code:`text`, :code:`icon`, and :code:`checkable`. The :code:`QPushButton` also has a method called :code:`click()` which simulates a click on the button. For our SDR spectrum analyzer application we will be using buttons to trigger an auto-range for plots, using the current data to calculate the y limits. Because we have already used the :code:`QPushButton`, we won't go into more detail here, but you can find more information in the `QPushButton documentation `_. + +*************** +:code:`QSlider` +*************** + +The :code:`QSlider` is a widget that allows the user to select a value from a range of values. The :code:`QSlider` has a few properties, including :code:`minimum`, :code:`maximum`, :code:`value`, and :code:`orientation`. The :code:`QSlider` also has a few signals, including :code:`valueChanged`, :code:`sliderPressed`, and :code:`sliderReleased`. The :code:`QSlider` also has a method called :code:`setValue()` which sets the value of the slider, we will be using this a lot. The documentation page for `QSlider is here `_. + +For our spectrum analyzer application we will be using :code:`QSlider`'s to adjust the center frequency and gain of the SDR. Here is the snippet from the final application code that creates the 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) + +One very important thing to know about :code:`QSlider` is it uses integers, so by setting the range from 0 to 73 we are allowing the slider to choose integer values between those numbers (inclusive of start and end). The :code:`setTickInterval(2)` is purely a visual thing. It is for this reason that we will use kHz as the units for the frequency slider, so that we can have granularity down to the 1 kHz. + +Halfway into the code above you'll notice we create a :code:`QLabel`, which is just a text label for display purposes, but in order for it to display the current value of the slider we must create a slot (i.e., callback function) that updates the label. We connect this callback function to the :code:`sliderMoved` signal, which is automatically emitted whenever the slider is moved. We also call the callback function once to initialize the label with the current value of the slider (50 in our case). We also have to connect the :code:`sliderMoved` signal to a slot that lives within the worker thread, which will update the gain of the SDR (remember, we don't like to manage the SDR or do DSP in the main GUI thread). The callback function that defines this slot will be discussed later. + +***************** +:code:`QComboBox` +***************** + +The :code:`QComboBox` is a dropdown-style widget that allows the user to select an item from a list of items. The :code:`QComboBox` has a few properties, including :code:`currentText`, :code:`currentIndex`, and :code:`count`. The :code:`QComboBox` also has a few signals, including :code:`currentTextChanged`, :code:`currentIndexChanged`, and :code:`activated`. The :code:`QComboBox` also has a method called :code:`addItem()` which adds an item to the list, and :code:`insertItem()` which inserts an item at a specific index, although we will not be using them in our spectrum analyzer example. The documentation page for `QComboBox is here `_. + +For our spectrum analyzer application we will be using :code:`QComboBox` to select the sample rate from a list we pre-define. At the beginning of our code we define the possible sample rates using :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. Within the :code:`MainWindow`'s :code:`__init__` we create the :code:`QComboBox` as follows: + +.. 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) + +The only real difference between this and the slider is the :code:`addItems()` where you give it the list of strings to use as options, and :code:`setCurrentIndex()` which sets the starting value. + +**************** +Lambda Functions +**************** + +Recall in the above code where we did: + +.. 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 are creating a function that has only a single line of code inside of it, then passing that function (functions are objects too!) to :code:`connect()`. To simplify things, let's rewrite this code pattern using basic Python: + +.. code-block:: python + + def my_function(x): + print(x) + y.call_that_takes_in_function_obj(my_function) + +In this situation, we have a function that only has one line of code inside of it, and we only reference that function once; when we are setting the :code:`connect` callback. In these situations we can use a lambda function, which is a way to define a function in a single line. Here is the above code rewritten using a lambda function: + +.. code-block:: python + + y.call_that_takes_in_function_obj(lambda x: print(x)) + +If you have never used a lambda function before, this might seem foreign, and you certainly don't need to use them, but it gets rid of two lines of code and makes the code more concise. The way it works is, the temporary argument name comes from after "lambda", and then everything after the colon is the code that will operate on that variable. It supports multiple arguments as well, using commas, or even no arguments by using :code:`lambda : `. As an exercise, try rewriting the :code:`update_sample_rate_label` function above using a lambda function. + +*********************** +PyQtGraph's PlotWidget +*********************** + +PyQtGraph's :code:`PlotWidget` is a PyQt widget used to produce 1D plots, similar to Matplotlib's :code:`plt.plot(x,y)`. We will be using it for the time and frequency (PSD) domain plots, although it is also good for IQ plots (which our spectrum analyzer does not contain). For those curious, PlotWidget is a subclass of PyQt's `QGraphicsView `_ which is a widget for displaying the contents of a `QGraphicsScene `_, which is a surface for managing a large number of 2D graphical items in Qt. But the important thing to know about PlotWidget is that it is simply a widget containing a single `PlotItem `_, so from a documentation perspective you're better off just referring to the PlotItem docs: ``_. A PlotItem contains a ViewBox for displaying the data we want to plot, as well as AxisItems and labels for displaying the axes and title, as you may expect. + +The simplest example of using a PlotWidget is as follows (which must be added inside of the :code:`MainWindow`'s :code:`__init__`): + +.. code-block:: python + + import pyqtgraph as pg + plotWidget = pg.plot(title="My Title") + plotWidget.plot(x, y) + +where x and y are typically numpy arrays just like with Matplotlib's :code:`plt.plot()`. However, this represents a static plot where the data never changes. For our spectrum analyzer we want to update the data inside of our worker thread, so when we initialize our plot we don't even need to pass it any data yet, we just have to set it up. Here is how we initialize the Time Domain plot in our spectrum analyzer app: + +.. 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) + +You can see we are creating two different plots/curves, one for I and one for Q. The rest of the code should be self-explanatory. To be able to update the plot, we need to create a slot (i.e., callback function) within the :code:`MainWindow`'s :code:`__init__`: + +.. code-block:: python + + def time_plot_callback(samples): + time_plot_curve_i.setData(samples.real) + time_plot_curve_q.setData(samples.imag) + +We will connect this slot to the worker thread's signal that is emitted when new samples are available, as shown later. + +The final thing we will do in the :code:`MainWindow`'s :code:`__init__` is to add a couple buttons to the right of the plot that will trigger an auto-range of the plot. One will use the current min/max, and another will set the range to -1.1 to 1.1 (which is the ADC limits of many SDRs, plus a 10% margin). We will create an inner layout, specifically QVBoxLayout, to vertically stack these two buttons. Here is the code to add the buttons: + +.. 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) + +And what it ultimately looks like: + +.. image:: ../_images/pyqt_time_plot.png + :scale: 50 % + :align: center + :alt: PyQtGraph Time Plot + +We will use a similar pattern for the frequency domain (PSD) plot. + +********************* +PyQtGraph's ImageItem +********************* + +A spectrum analyzer is not complete without a waterfall (a.k.a. real-time spectrogram), and for that we will use PyQtGraph's ImageItem, which renders images with 1, 3 or 4 "channels". One channel just means you give it a 2D array of floats or ints, which then uses a lookup table (LUT) to apply a colormap and ultimately create the image. Alternatively, you can give it RGB (3 channels) or RGBA (4 channels). We will calculate our spectrogram as a 2D numpy array of floats, and pass it to the ImageItem directly. We will pick a colormap, and even make use of the built-in functionality for showing a graphical LUT that can display our data's value distribution and how the colormap is applied. + +The actual initialization of the waterfall plot is fairly straightforward, we use a PlotWidget as the container (so that we can still have our x and y axis displayed) and then add an ImageItem to it: + +.. 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) + +The slot/callback associated with updating the waterfall data, which goes in :code:`MainWindow`'s :code:`__init__`, is as follows: + +.. 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 + +Where spectrogram will be a 2D numpy array of floats. In addition to setting the image data, we will calculate a min and max for the colormap, based on the mean and variance of the data, which we will use later. The last part of the GUI code for the spectrogram is creating the colorbar, which also sets the colormap used: + +.. 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) + +The second line is important, it is what ultimately connects this colorbar to the ImageItem. This code is also where we choose the colormap, and set the starting levels (-30 dB to +20 dB in our case). Within the worker thread code you will see how the spectrogram 2D array is calculated/stored. Below is a screenshot of this part of the GUI, showing the incredible built-in functionality of the colorbar and LUT display, note that the sideways bell-shaped curve is the distribution of spectrogram values, which is very useful to see. + +.. image:: ../_images/pyqt_spectrogram.png + :scale: 50 % + :align: center + :alt: PyQtGraph Spectrogram and colorbar + +*********************** +Worker Thread +*********************** + +Recall towards the beginning of this chapter we learned how to create a separate thread, using a class we called SDRWorker with a run() function. This is where we will put all of our SDR and DSP code, with the exception of initialization of the SDR which we will do globally for now. The worker thread will also be responsible for updating the three plots, by emitting signals when new samples are available, to trigger the callback functions we have already created in :code:`MainWindow`, which ultimately updates the plots. The SDRWorker class can be split up into three sections: + +#. :code:`init()` - used to initialize any state, such as the spectrogram 2D array +#. PyQt Signals - we must define our custom signals that will be emitted +#. PyQt Slots - the callback functions that are triggered by GUI events like a slider moving +#. :code:`run()` - the main loop that runs nonstop + +*********************** +PyQt Signals +*********************** + +In the GUI code we didn't have to define any Signals, because they were built into the widgets we were using, like :code:`QSlider`s :code:`valueChanged`. Our SDRWorker class is custom, and any Signals we want to emit must be defined before we start calling run(). Here is the code for the SDRWorker class, which defines four signals we will be using, and their corresponding data types: + +.. 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 + +The first three signals send a single object; a numpy array. The last signal does not send any object with it. You can also send multiple objects at a time, simply use commas between data types, but we don't need to do that for our application here. Anywhere within run() we can emit a signal to the GUI thread, using just one line of code, for example: + +.. code-block:: python + + self.time_plot_update.emit(samples) + +There is one last step to make all of the signals/slots connections- in the GUI code (comes at the very end of :code:`MainWindow`'s :code:`__init__`) we must connect the worker thread's signals to the GUI's slots, for example: + +.. code-block:: python + + worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback + +Remember that :code:`worker` is the instance of the SDRWorker class that we created in the GUI code. So what we are doing above is connecting the worker thread's signal called :code:`time_plot_update` to the GUI's slot called :code:`time_plot_callback` that we defined earlier. Now is a good time to go back and review the code snippets we have shown so far, and see how they all fit together, to ensure you understand how the GUI and worker thread are communicating, as it is a crucial part of PyQt programming. + +*********************** +Worker Thread Slots +*********************** + +The worker thread's slots are the callback functions that are triggered by GUI events, like the gain slider moving. They are pretty straightforward, for example, this slot updates the SDR's gain value to the new value chosen by the slider: + +.. code-block:: python + + def update_gain(self, val): + print("Updated gain to:", val, 'dB') + sdr.set_rx_gain(val) + +*********************** +Worker Thread Run() +*********************** + +The :code:`run()` function is where all the fun DSP part happens! In our application, we will start each run function by receiving a set of samples from the SDR (or simulating some samples if you don't have an SDR). + +.. 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) + + ... + +As you can see, for the simulated example, we generate a tone with some white noise, and then truncate the samples from -1 to +1. + +Now for the DSP! We know we will need to take the FFT for the frequency domain plot and spectrogram. It turns out that we can simply use the PSD for that set of samples as one row of the spectrogram, so all we have to do is shift our spectrogram/waterfall up by a row, and add the new row to the bottom (or top, doesn't matter). For each of the plot updates, we emit the signal which contains the updated data to plot. We also signal the end of the :code:`run()` function so that the GUI thread immediately starts another call to :code:`run()` again. Overall, it's actually not much 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() + +Note how we don't send the entire batch of samples to the time plot, because it would be too many points to show, instead we only send the first 500 samples (configurable at the top of the script, not shown here). For the PSD plot, we use a running average of the PSD, by storing the previous PSD and adding 1% of the new PSD to it. This is a simple way to smooth out the PSD plot. Note that it doesn't matter the order you call :code:`emit()` for the signals, they could have all just as easily gone at the end of :code:`run()`. + +*********************** +Final Example Full Code +*********************** + +Up until this point we have been looking at snippets of the spectrum analyzer app, but now we will finally take a look at the full code and try running it. It currently supports the PlutoSDR, USRP, or simulation-mode. If you don't have a Pluto or USRP, simply leave the code as-is, and it should use simulation mode, otherwise change :code:`sdr_type`. In simulation mode, if you increase the gain all the way, you will notice the signal gets truncated in the time domain, which causes spurs to occur in the frequency domain. + +Feel free to use this code as a starting point for your own real-time SDR app! Below is also an animation of the app in action, using a Pluto to look at the 750 MHz cellular band, and then at 2.4 GHz WiFi. A higher quality version is available on YouTube `here `_. + +.. image:: ../_images/pyqt_animation.gif + :scale: 100 % + :align: center + :alt: Animated gif showing the PyQt spectrum analyzer app in action + +Known bugs (to help fix them `edit this `_): + +#. Waterfall x-axis doesn't update when changing center frequency (PSD plot does though) + +Full 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) From 03f30e7e3693adba57a0252ec1442f3499aa5468 Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:38:07 +0300 Subject: [PATCH 07/42] Translate Ukrainian bladeRF guide --- content-ukraine/bladerf.rst | 226 ++++++++++++++++++------------------ 1 file changed, 112 insertions(+), 114 deletions(-) diff --git a/content-ukraine/bladerf.rst b/content-ukraine/bladerf.rst index b7ffec0e..95571ea5 100644 --- a/content-ukraine/bladerf.rst +++ b/content-ukraine/bladerf.rst @@ -1,49 +1,49 @@ .. _bladerf-chapter: ################## -BladeRF in Python +BladeRF у Python ################## -The bladeRF 2.0 (a.k.a. bladeRF 2.0 micro) from the company `Nuand `_ is a USB 3.0-based SDR with two receive channels, two transmit channels, a tunable range of 47 MHz to 6 GHz, and the ability to sample up to 61 MHz or as high as 122 MHz when hacked. It uses the AD9361 RF integrated circuit (RFIC) just like the USRP B210 and PlutoSDR, so RF performance will be similar. The bladeRF 2.0 was released in 2021, maintains a small form factor at 2.5" x 4.5", and comes in two different FPGA sizes (xA4 and xA9). While this chapter focuses on the bladeRF 2.0, a lot of the code will also apply to the original bladeRF which `came out in 2013 `_. +bladeRF 2.0 (a.k.a. bladeRF 2.0 micro) від компанії `Nuand `_ — це SDR на базі USB 3.0 з двома каналами прийому, двома каналами передавання, робочим діапазоном налаштування від 47 МГц до 6 ГГц та можливістю дискретизації до 61 МГц або навіть до 122 МГц після модифікації. Він використовує радіочастотну інтегральну схему (RFIC) AD9361 так само, як USRP B210 та PlutoSDR, тому його радіочастотні характеристики будуть подібними. bladeRF 2.0 було випущено у 2021 році, він зберіг компактний форм-фактор 2.5" x 4.5" і постачається з двома варіантами FPGA (xA4 та xA9). Хоча цей розділ зосереджується на bladeRF 2.0, більшість наведеного коду також застосовна до оригінального bladeRF, який `з'явився у 2013 році `_. .. image:: ../_images/bladeRF_micro.png :scale: 35 % - :align: center + :align: center :alt: bladeRF 2.0 glamour shot ******************************** -bladeRF Architecture +Архітектура bladeRF ******************************** -At a high level, the bladeRF 2.0 is based on the AD9361 RFIC, combined with a Cyclone V FPGA (either the 49 kLE :code:`5CEA4` or 301 kLE :code:`5CEA9`), and a Cypress FX3 USB 3.0 controller that has a 200 MHz ARM9 core inside, loaded with custom firmware. The block diagram of the bladeRF 2.0 is shown below: +На високому рівні bladeRF 2.0 побудований на базі RFIC AD9361 у поєднанні з FPGA Cyclone V (або 49 kLE :code:`5CEA4`, або 301 kLE :code:`5CEA9`) та контролером Cypress FX3 USB 3.0 із ядром ARM9 на частоті 200 МГц, що працює під керуванням спеціальної прошивки. Нижче наведена блок-схема bladeRF 2.0: .. image:: ../_images/bladeRF-2.0-micro-Block-Diagram-4.png :scale: 80 % - :align: center + :align: center :alt: bladeRF 2.0 block diagram -The FPGA controls the RFIC, performs digital filtering, and frames packets for transfer over USB (among other things). The `source code `_ for the FPGA image is written in VHDL and requires the free Quartus Prime Lite design software to compile custom images. Precompiled images are available `here `_. +FPGA керує RFIC, виконує цифрову фільтрацію та формує пакети для передавання через USB (серед іншого). `Вихідний код `_ для образу FPGA написаний VHDL і вимагає безкоштовного програмного забезпечення Quartus Prime Lite для компіляції власних образів. Готові образи доступні `тут `_. -The `source code `_ for the Cypress FX3 firmware is open-source and includes code to: +`Вихідний код `_ для прошивки Cypress FX3 з відкритим кодом і містить реалізацію: -1. Load the FPGA image -2. Transfer IQ samples between the FPGA and host over USB 3.0 -3. Control GPIO of the FPGA over UART +1. Завантаження образу FPGA +2. Передавання IQ-вибірок між FPGA та хостом через USB 3.0 +3. Керування GPIO FPGA через UART -From a signal flow perspective, there are two receive channels and two transmit channels, and each channel has a low and high frequency input/output to the RFIC, depending on which band is being used. It is for this reason that a single pole double throw (SPDT) electronic RF switch is needed between the RFIC and SMA connectors. The bias tee is an onboard circuit that provides ~4.5V DC on the SMA connector, and is used to conveniently power an external amplifier or other RF components. This extra DC offset is on the RF side of the SDR so it does not interfere with the basic receiving/transmitting operation. +З точки зору потоку сигналу є два канали прийому та два канали передавання, і кожен канал має низькочастотний та високочастотний вхід/вихід до RFIC залежно від обраного діапазону. Саме тому між RFIC та SMA-роз'ємами потрібен електронний ВКП перемикач (single pole double throw, SPDT). Bias tee — це вбудована схема, що подає ~4,5 В постійного струму на SMA-роз'єм і дозволяє зручно живити зовнішній підсилювач чи інші ВЧ-компоненти. Ця додаткова напруга постійного струму знаходиться на радіочастотній стороні SDR, тому вона не заважає базовій роботі прийому/передавання. -JTAG is a type of debugging interface, allowing for testing and verifying designs during the development process. +JTAG — це інтерфейс налагодження, який дозволяє тестувати та перевіряти проєкти під час розробки. -At the end of this chapter, we discuss the VCTCXO oscillator, PLL, and expansion port. +Наприкінці цього розділу ми обговоримо генератор VCTCXO, PLL та розширювальний порт. ******************************** -Software and Hardware Setup +Налаштування програмного та апаратного забезпечення ******************************** -Ubuntu (or Ubuntu within WSL) +Ubuntu (або Ubuntu у WSL) ############################# -On Ubuntu and other Debian-based systems, you can install the bladeRF software with the following commands: +В Ubuntu та інших системах на базі Debian можна встановити програмне забезпечення bladeRF наступними командами: .. code-block:: bash @@ -60,56 +60,56 @@ On Ubuntu and other Debian-based systems, you can install the bladeRF software w cd ../libraries/libbladeRF_bindings/python sudo python3 setup.py install -This will install the libbladerf library, Python bindings, bladeRF command line tools, the firmware downloader, and the FPGA bitstream downloader. To check which version of the library you installed, use :code:`bladerf-tool version` (this guide was written using libbladeRF version v2.5.0). +Це встановить бібліотеку libbladerf, Python-біндінги, інструменти командного рядка bladeRF, засіб завантаження прошивки та засіб завантаження бітстрімів FPGA. Щоб перевірити встановлену версію бібліотеки, скористайтеся :code:`bladerf-tool version` (цей посібник написано для libbladeRF версії v2.5.0). -If you are using Ubuntu through WSL, on the Windows side you will need to forward the bladeRF USB device to WSL, first by installing the latest `usbipd utility msi `_ (this guide assumes you have usbipd-win 4.0.0 or higher), then opening PowerShell in administrator mode and running: +Якщо ви використовуєте Ubuntu через WSL, на стороні Windows необхідно переслати USB-пристрій bladeRF у WSL. Спочатку встановіть останній `msi-дистрибутив утиліти usbipd `_ (цей посібник припускає, що у вас usbipd-win 4.0.0 або новіший), потім відкрийте PowerShell від імені адміністратора та виконайте: .. code-block:: bash usbipd list - # (find the BUSID labeled bladeRF 2.0 and substitute it in the command below) + # (знайдіть BUSID з позначкою bladeRF 2.0 і підставте його в команду нижче) usbipd bind --busid 1-23 usbipd attach --wsl --busid 1-23 -On the WSL side, you should be able to run :code:`lsusb` and see a new item called :code:`Nuand LLC bladeRF 2.0 micro`. Note that you can add the :code:`--auto-attach` flag to the :code:`usbipd attach` command if you want it to auto reconnect. +У WSL ви маєте змогу виконати :code:`lsusb` і побачити новий елемент :code:`Nuand LLC bladeRF 2.0 micro`. Зауважте, що до команди :code:`usbipd attach` можна додати прапорець :code:`--auto-attach`, якщо потрібне автоматичне повторне підключення. -(Might not be needed) For both native Linux and WSL, we must install the udev rules so that we don't get permissions errors: +(Може не знадобитися) Для нативного Linux і WSL потрібно встановити правила udev, щоб уникнути помилок доступу: .. code-block:: sudo nano /etc/udev/rules.d/88-nuand.rules -and paste in the following line: +та вставити такий рядок: .. code-block:: ATTRS{idVendor}=="2cf0", ATTRS{idProduct}=="5250", MODE="0666" -To save and exit from nano, use: control-o, then Enter, then control-x. To refresh udev, run: +Щоб зберегти та вийти з nano, натисніть: control-o, потім Enter, потім control-x. Для оновлення udev виконайте: .. code-block:: bash sudo udevadm control --reload-rules && sudo udevadm trigger -If you are using WSL and it says :code:`Failed to send reload request: No such file or directory`, that means the udev service isn't running, and you will need to :code:`sudo nano /etc/wsl.conf` and add the lines: +Якщо ви використовуєте WSL і бачите повідомлення :code:`Failed to send reload request: No such file or directory`, це означає, що служба udev не запущена, і вам потрібно виконати :code:`sudo nano /etc/wsl.conf` та додати рядки: .. code-block:: bash [boot] command="service udev start" -then restart WSL using the following command in PowerShell with admin: :code:`wsl.exe --shutdown`. +Потім перезавантажте WSL такою командою в PowerShell з правами адміністратора: :code:`wsl.exe --shutdown`. -Unplug and replug your bladeRF (WSL users will have to reattach), and test permissions with: +Від'єднайте та знову під'єднайте bladeRF (користувачам WSL доведеться повторно приєднати пристрій) і перевірте права доступу командами: .. code-block:: bash bladerf-tool probe bladerf-tool info -and you'll know it worked if you see your bladeRF 2.0 listed, and you **don't** see :code:`Found a bladeRF via VID/PID, but could not open it due to insufficient permissions`. If it worked, note reported FPGA Version and Firmware Version. +Ви зрозумієте, що все спрацювало, якщо ваш bladeRF 2.0 буде в списку і ви **не** побачите повідомлення :code:`Found a bladeRF via VID/PID, but could not open it due to insufficient permissions`. Якщо все вдалося, зверніть увагу на версії FPGA та прошивки, що виводяться. -(Optionally) Install the latest firmware and FPGA images (v2.4.0 and v0.15.0 respectively when this guide was written) using: +(Опційно) Встановіть найновішу прошивку та образи FPGA (відповідно v2.4.0 та v0.15.0 на момент написання посібника) командами: .. code-block:: bash @@ -117,25 +117,25 @@ and you'll know it worked if you see your bladeRF 2.0 listed, and you **don't** wget https://www.nuand.com/fx3/bladeRF_fw_latest.img bladerf-tool flash_fw bladeRF_fw_latest.img - # for xA4 use: + # для xA4 використовуйте: wget https://www.nuand.com/fpga/hostedxA4-latest.rbf bladerf-tool flash_fpga hostedxA4-latest.rbf - # for xA9 use: + # для xA9 використовуйте: wget https://www.nuand.com/fpga/hostedxA9-latest.rbf bladerf-tool flash_fpga hostedxA9-latest.rbf -Unplug and plug in your bladeRF to cycle power. +Від'єднайте та знову під'єднайте bladeRF, щоб перезапустити живлення. -Now we will test its functionality by receiving 1M samples in the FM radio band, at 10 MHz sample rate, to a file /tmp/samples.sc16: +Тепер перевіримо працездатність, прийнявши 1 млн вибірок у FM-діапазоні радіо з частотою дискретизації 10 МГц у файл /tmp/samples.sc16: .. code-block:: bash bladerf-tool rx --num-samples 1000000 /tmp/samples.sc16 100e6 10e6 -a couple :code:`Hit stall for buffer` is expected, but you'll know if it worked if you see a 4MB /tmp/samples.sc16 file. +Кілька повідомлень :code:`Hit stall for buffer` наприкінці — це нормально, а успішним результатом буде поява файлу /tmp/samples.sc16 обсягом 4 МБ. -Lastly, we will test the Python API with: +Нарешті, перевіримо Python API: .. code-block:: bash @@ -144,18 +144,18 @@ Lastly, we will test the Python API with: bladerf.BladeRF() exit() -You'll know it worked if you see something like :code:`)>` and no warnings/errors. +Ви зрозумієте, що все працює, якщо побачите щось на кшталт :code:`)>` і не з'являться попередження чи помилки. -Windows and macOS +Windows і macOS ################### -For Windows users (who do not prefer to use WSL), see https://github.com/Nuand/bladeRF/wiki/Getting-Started%3A-Windows, and for macOS users, see https://github.com/Nuand/bladeRF/wiki/Getting-started:-Mac-OSX. +Користувачам Windows (які не бажають використовувати WSL) див. https://github.com/Nuand/bladeRF/wiki/Getting-Started%3A-Windows, а користувачам macOS — https://github.com/Nuand/bladeRF/wiki/Getting-started:-Mac-OSX. ******************************** -bladeRF Python API Basics +Основи Python API bladeRF ******************************** -To start with, let's poll the bladeRF for some useful information, using the following script. **Do not name your script bladerf.py** or it will conflict with the bladeRF Python module itself! +Почнімо з опитування bladeRF щодо корисної інформації за допомогою такого скрипта. **Не називайте свій скрипт bladerf.py**, інакше він конфліктуватиме з самим модулем Python bladeRF! .. code-block:: python @@ -164,23 +164,23 @@ To start with, let's poll the bladeRF for some useful information, using the fol import matplotlib.pyplot as plt sdr = _bladerf.BladeRF() - + print("Device info:", _bladerf.get_device_list()[0]) print("libbladeRF version:", _bladerf.version()) # v2.5.0 print("Firmware version:", sdr.get_fw_version()) # v2.4.0 print("FPGA version:", sdr.get_fpga_version()) # v0.15.0 - - rx_ch = sdr.Channel(_bladerf.CHANNEL_RX(0)) # give it a 0 or 1 + + rx_ch = sdr.Channel(_bladerf.CHANNEL_RX(0)) # задайте 0 або 1 print("sample_rate_range:", rx_ch.sample_rate_range) print("bandwidth_range:", rx_ch.bandwidth_range) print("frequency_range:", rx_ch.frequency_range) print("gain_modes:", rx_ch.gain_modes) - print("manual gain range:", sdr.get_gain_range(_bladerf.CHANNEL_RX(0))) # ch 0 or 1 + print("manual gain range:", sdr.get_gain_range(_bladerf.CHANNEL_RX(0))) # канал 0 або 1 -For the bladeRF 2.0 xA9, the output should look something like: +Для bladeRF 2.0 xA9 результат виглядатиме приблизно так: .. code-block:: python - + Device info: Device Information backend libusb serial f80a27b1010448dfb7a003ef7fa98a59 @@ -216,17 +216,17 @@ For the bladeRF 2.0 xA9, the output should look something like: step 1 scale 1.0 -The bandwidth parameter sets the filter used by the SDR when performing the receive operation, so we typically set it to be equal or slightly less than the sample_rate/2. The gain modes are important to understand, the SDR uses either a manual gain mode where you provide the gain in dB, or automatic gain control (AGC) which has three different settings (fast, slow, hybrid). For applications such as spectrum monitoring, manual gain is advised (so you can see when signals come and go), but for applications such as receiving a specific signal you expect to exist, AGC will be more useful because it will automatically adjust the gain to allow the signal to fill the analog-to-digital converter (ADC). +Параметр bandwidth встановлює фільтр, який використовує SDR під час прийому, тому зазвичай його задають рівним або трохи меншим за sample_rate/2. Режими підсилення важливо розуміти: SDR може працювати або в режимі ручного підсилення, коли ви задаєте значення в дБ, або в режимі автоматичного керування підсиленням (AGC), що має три налаштування (швидке, повільне, гібридне). Для задач на кшталт моніторингу спектра рекомендується ручний режим (щоб бачити, коли сигнали з'являються і зникають), а для задач на кшталт прийому конкретного сигналу, який ви очікуєте, AGC буде кориснішим, бо автоматично підбиратиме підсилення так, щоб сигнал максимально заповнював аналогово-цифровий перетворювач (ADC). -To set the main parameters of the SDR, we can add the following code: +Щоб задати основні параметри SDR, додайте такий код: .. code-block:: python sample_rate = 10e6 center_freq = 100e6 - gain = 50 # -15 to 60 dB + gain = 50 # від -15 до 60 дБ num_samples = int(1e6) - + rx_ch.frequency = center_freq rx_ch.sample_rate = sample_rate rx_ch.bandwidth = sample_rate/2 @@ -234,31 +234,31 @@ To set the main parameters of the SDR, we can add the following code: rx_ch.gain = gain ******************************** -Receiving Samples in Python +Приймання вибірок у Python ******************************** -Next, we will work off the previous code block to receive 1M samples in the FM radio band, at 10 MHz sample rate, just like we did before. Any antenna on the RX1 port should be able to receive FM, since it is so strong. The code below shows how the bladeRF synchronous stream API works; it must be configured and a receive buffer must be created, before the receiving begins. The :code:`while True:` loop will continue to receive samples until the number of samples requested is reached. The received samples are stored in a separate numpy array, so that we can process them after the loop finishes. +Далі, спираючись на попередній приклад, приймемо 1 млн вибірок у FM-діапазоні з частотою дискретизації 10 МГц, як ми робили раніше. Будь-яка антена на порту RX1 має приймати FM, бо сигнал дуже потужний. Код нижче демонструє роботу синхронного потокового API bladeRF: перед початком прийому його потрібно налаштувати і створити буфер. Цикл :code:`while True:` продовжить приймати вибірки, доки не буде досягнуто потрібної кількості. Отримані вибірки зберігаються в окремому масиві numpy, щоб ми могли обробити їх після завершення циклу. .. code-block:: python - # Setup synchronous stream - sdr.sync_config(layout = _bladerf.ChannelLayout.RX_X1, # or RX_X2 - fmt = _bladerf.Format.SC16_Q11, # int16s + # Налаштування синхронного потоку + sdr.sync_config(layout = _bladerf.ChannelLayout.RX_X1, # або RX_X2 + fmt = _bladerf.Format.SC16_Q11, # int16 num_buffers = 16, buffer_size = 8192, num_transfers = 8, stream_timeout = 3500) - - # Create receive buffer - bytes_per_sample = 4 # don't change this, it will always use int16s + + # Створення приймального буфера + bytes_per_sample = 4 # не змінюйте, завжди використовуються int16 buf = bytearray(1024 * bytes_per_sample) - - # Enable module + + # Увімкнення модуля print("Starting receive") rx_ch.enable = True - - # Receive loop - x = np.zeros(num_samples, dtype=np.complex64) # storage for IQ samples + + # Цикл прийому + x = np.zeros(num_samples, dtype=np.complex64) # сховище для IQ-вибірок num_samples_read = 0 while True: if num_samples > 0 and num_samples_read == num_samples: @@ -267,27 +267,27 @@ Next, we will work off the previous code block to receive 1M samples in the FM r num = min(len(buf) // bytes_per_sample, num_samples - num_samples_read) else: num = len(buf) // bytes_per_sample - sdr.sync_rx(buf, num) # Read into buffer + sdr.sync_rx(buf, num) # зчитування у буфер samples = np.frombuffer(buf, dtype=np.int16) - samples = samples[0::2] + 1j * samples[1::2] # Convert to complex type - samples /= 2048.0 # Scale to -1 to 1 (its using 12 bit ADC) - x[num_samples_read:num_samples_read+num] = samples[0:num] # Store buf in samples array + samples = samples[0::2] + 1j * samples[1::2] # перетворення у комплексний тип + samples /= 2048.0 # масштабування до -1...1 (використовується 12-бітний ADC) + x[num_samples_read:num_samples_read+num] = samples[0:num] # збереження буфера у масиві вибірок num_samples_read += num - + print("Stopping") rx_ch.enable = False - print(x[0:10]) # look at first 10 IQ samples - print(np.max(x)) # if this is close to 1, you are overloading the ADC, and should reduce the gain + print(x[0:10]) # подивіться на перші 10 IQ-вибірок + print(np.max(x)) # якщо значення близьке до 1, ADC перевантажено і слід зменшити підсилення -A few :code:`Hit stall for buffer` is expected at the end. The last number printed shows the maximum sample received; you will want to adjust your gain to try to get that value around 0.5 to 0.8. If it is 0.999 that means your receiver is overloaded/saturated and the signal is going to be distorted (it will look smeared throughout the frequency domain). +Кілька повідомлень :code:`Hit stall for buffer` наприкінці — це очікувано. Останнє виведене число показує максимальну отриману вибірку; вам варто налаштувати підсилення так, щоб це значення було в діапазоні 0.5–0.8. Якщо ж воно дорівнює 0.999, приймач перевантажений/насичений, і сигнал спотворюється (у частотній області він виглядатиме розмитим). -In order to visualize the received signal, let's display the IQ samples using a spectrogram (see :ref:`spectrogram-section` for more details on how spectrograms work). Add the following to the end of the previous code block: +Щоб візуалізувати отриманий сигнал, побудуймо спектрограму (детальніше про спектрограми див. :ref:`spectrogram-section`). Додайте наприкінці попереднього блоку коду: .. code-block:: python - # Create spectrogram + # Побудова спектрограми fft_size = 2048 - num_rows = len(x) // fft_size # // is an integer division which rounds down + num_rows = len(x) // fft_size # // — цілочисельне ділення з округленням донизу spectrogram = np.zeros((num_rows, fft_size)) for i in range(num_rows): spectrogram[i,:] = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(x[i*fft_size:(i+1)*fft_size])))**2) @@ -298,114 +298,112 @@ In order to visualize the received signal, let's display the IQ samples using a plt.show() .. image:: ../_images/bladerf-waterfall.svg - :align: center + :align: center :target: ../_images/bladerf-waterfall.svg :alt: bladeRF spectrogram example -Each vertical squiggly line is an FM radio signal. No clue what the pulsing on the right side is from, lowering the gain didn't make it go away. - +Кожна вертикальна хвиляста лінія — це сигнал FM-радіо. Невідомо, звідки береться пульсація праворуч; зменшення підсилення її не прибрало. ******************************** -Transmitting Samples in Python +Передавання вибірок у Python ******************************** -The process of transmitting samples with the bladeRF is very similar to receiving. The main difference is that we must generate the samples to transmit, and then write them to the bladeRF using the :code:`sync_tx` method which can handle our entire batch of samples at once (up to ~4B samples). The code below shows how to transmit a simple tone, and then repeat it 30 times. The tone is generated using numpy, and then scaled to be between -2048 and 2048 to fit the 12 bit digital-to-analog converter (DAC). The tone is then converted to bytes representing int16's and used as the transmit buffer. The synchronous stream API is used to transmit the samples, and the :code:`while True:` loop will continue to transmit samples until the number of repeats requested is reached. If you want to transmit samples from a file instead, simply use :code:`samples = np.fromfile('yourfile.iq', dtype=np.int16)` (or whatever datatype they are) to read the samples, and then convert them to bytes using :code:`samples.tobytes()`, although keep in mind the -2048 to 2048 range of the DAC. +Процес передавання вибірок на bladeRF дуже схожий на прийом. Головна відмінність у тому, що потрібно згенерувати вибірки для передавання, а потім записати їх у bladeRF за допомогою методу :code:`sync_tx`, який може обробити весь пакет вибірок одразу (до ~4 млрд вибірок). Наведений нижче код демонструє, як передати простий тон і повторити його 30 разів. Тон генерується за допомогою numpy, потім масштабується до діапазону від -2048 до 2048, щоб відповідати 12-бітному цифро-аналоговому перетворювачу (DAC). Далі тон перетворюється на байти, що представляють int16, і використовується як буфер передавання. Синхронний потоковий API використовується для передавання вибірок, а цикл :code:`while True:` триватиме, доки не буде зроблено потрібну кількість повторів. Якщо бажаєте передавати вибірки з файлу, просто виконайте :code:`samples = np.fromfile('yourfile.iq', dtype=np.int16)` (або з відповідним типом даних), щоб прочитати вибірки, а потім перетворіть їх у байти за допомогою :code:`samples.tobytes()`, пам'ятаючи про діапазон DAC від -2048 до 2048. .. code-block:: python from bladerf import _bladerf import numpy as np - + sdr = _bladerf.BladeRF() - tx_ch = sdr.Channel(_bladerf.CHANNEL_TX(0)) # give it a 0 or 1 - + tx_ch = sdr.Channel(_bladerf.CHANNEL_TX(0)) # задайте 0 або 1 + sample_rate = 10e6 center_freq = 100e6 - gain = 0 # -15 to 60 dB. for transmitting, start low and slowly increase, and make sure antenna is connected + gain = 0 # від -15 до 60 дБ. починайте з малого, поступово збільшуйте й переконайтеся, що антена під'єднана num_samples = int(1e6) - repeat = 30 # number of times to repeat our signal + repeat = 30 # кількість повторів сигналу print('duration of transmission:', num_samples/sample_rate*repeat, 'seconds') - - # Generate IQ samples to transmit (in this case, a simple tone) + + # Генерація IQ-вибірок для передавання (у цьому випадку простого тону) t = np.arange(num_samples) / sample_rate f_tone = 1e6 - samples = np.exp(1j * 2 * np.pi * f_tone * t) # will be -1 to +1 + samples = np.exp(1j * 2 * np.pi * f_tone * t) # значення від -1 до +1 samples = samples.astype(np.complex64) - samples *= 2048.0 # Scale to -1 to 1 (its using 12 bit DAC) + samples *= 2048.0 # масштабування до -1...1 (використовується 12-бітний DAC) samples = samples.view(np.int16) - buf = samples.tobytes() # convert our samples to bytes and use them as transmit buffer - + buf = samples.tobytes() # перетворення вибірок у байти та використання їх як буфера передавання + tx_ch.frequency = center_freq tx_ch.sample_rate = sample_rate tx_ch.bandwidth = sample_rate/2 tx_ch.gain = gain - - # Setup synchronous stream - sdr.sync_config(layout=_bladerf.ChannelLayout.TX_X1, # or TX_X2 - fmt=_bladerf.Format.SC16_Q11, # int16s + + # Налаштування синхронного потоку + sdr.sync_config(layout=_bladerf.ChannelLayout.TX_X1, # або TX_X2 + fmt=_bladerf.Format.SC16_Q11, # int16 num_buffers=16, buffer_size=8192, num_transfers=8, stream_timeout=3500) - + print("Starting transmit!") repeats_remaining = repeat - 1 tx_ch.enable = True while True: - sdr.sync_tx(buf, num_samples) # write to bladeRF + sdr.sync_tx(buf, num_samples) # запис у bladeRF print(repeats_remaining) if repeats_remaining > 0: repeats_remaining -= 1 else: break - + print("Stopping transmit") tx_ch.enable = False -A few :code:`Hit stall for buffer`'s at the end is expected. +Кілька повідомлень :code:`Hit stall for buffer` наприкінці — це нормально. -In order to transmit and receive at the same time, you have to use threads, and you might as well just use Nuand's example `txrx.py `_ which does exactly that. +Щоб одночасно передавати та приймати, необхідно використовувати потоки. У такому випадку краще взяти приклад Nuand `txrx.py `_, який робить саме це. *********************************** -Oscillators, PLLs, and Calibration +Генератори, PLL та калібрування *********************************** -All direct-conversion SDRs (including all AD9361-based SDRs like the USRP B2X0, Analog Devices Pluto, and bladeRF) rely on a single oscillator to provide a stable clock for the RF transceiver. Any offsets or jitter in the frequency produced by this oscillator will translate to frequency offset and frequency jitter in the received or transmitted signal. This oscillator is onboard, but can optionally be "disciplined" using a separate square or sine wave fed into the bladeRF through a U.FL connector on the board. +Усі SDR із прямим перетворенням (у тому числі всі пристрої на AD9361, як-от USRP B2X0, Analog Devices Pluto та bladeRF) покладаються на один генератор, що забезпечує стабільну тактову частоту для радіоприймача. Будь-які зміщення чи джитер частоти цього генератора перетворюються на частотне зміщення та джитер у прийнятому або переданому сигналі. Цей генератор розташований на платі, але його можна «дисциплінувати», підключивши окремий квадратний або синусоїдальний сигнал до bladeRF через роз'єм U.FL на платі. -Onboard the bladeRF is an `Abracon VCTCXO `_ (Voltage-controlled -temperature-compensated oscillator) with a frequency of 38.4 MHz. The "temperature-compensated" aspect means it is designed to be stable over a wide range of temperatures. The voltage controlled aspect means that a voltage level is used to cause slight tweaks to the oscillator frequency, and on the bladeRF this voltage is provided by a separate 10-bit digital-to-analog converter (DAC) as shown in green in the block diagram below. This means through software we can make fine adjustments to the frequency of the oscillator, and this is how we calibrate (a.k.a. trim) the bladeRF's VCTCXO. Luckily, the bladeRFs are calibrated at the factory, as we discuss later in this section, but if you have the test equipment available you can always fine-tune this value, especially as years go by and the oscillator's frequency drifts. +У bladeRF встановлено `VCTCXO Abracon `_ (керований напругою температурно-компенсований генератор) із частотою 38,4 МГц. «Температурна компенсація» означає, що він розрахований на стабільність у широкому діапазоні температур. Керування напругою означає, що частота генератора може змінюватися залежно від прикладеної напруги, і в bladeRF цю напругу подає окремий 10-бітний цифро-аналоговий перетворювач (DAC), показаний зеленим кольором на блок-схемі нижче. Це дає змогу програмно виконувати тонке підлаштування частоти генератора, і саме так ми калібруємо (тобто підлаштовуємо) VCTCXO bladeRF. На щастя, пристрої bladeRF калібруються на заводі, як ми обговоримо далі, але якщо у вас є відповідне вимірювальне обладнання, ви можете додатково відрегулювати це значення, особливо через роки, коли частота генератора може дрейфувати. .. image:: ../_images/bladeRF-2.0-micro-Block-Diagram-4-oscillator.png :scale: 80 % - :align: center + :align: center :alt: bladeRF 2.0 glamour shot -When using an external frequency reference (which can be nearly any frequency up to 300 MHz), the reference signal is fed directly into the `Analog Devices ADF4002 `_ PLL onboard the bladeRF. This PLL locks on to the reference signal and sends a signal to the VCTCXO (as shown in blue above) that is proportional to the difference in frequency and phase between the (scaled) reference input and VCTCXO output. Once the PLL is locked, this signal between the PLL and VCTCXO is a steady-state DC voltage that keeps the VCTCXO output at "exactly" 38.4 MHz (assuming the reference was correct), and phase-locked to the reference input. As part of using an external reference you must enable :code:`clock_ref` (either through Python or the CLI), and set the input reference frequency (a.k.a. :code:`refin_freq`), which is 10 MHz by default. Reasons to use an external reference include better frequency accuracy, and the ability to synchronize multiple SDRs to the same reference. +Під час використання зовнішнього частотного еталону (який може мати майже будь-яку частоту до 300 МГц) референсний сигнал подається безпосередньо на PLL `Analog Devices ADF4002 `_, що встановлена на bladeRF. Ця PLL захоплює еталонний сигнал і подає на VCTCXO (позначено синім) сигнал, пропорційний різниці частоти та фази між (масштабованим) еталонним входом і виходом VCTCXO. Коли PLL захоплює синхронізм, цей сигнал між PLL і VCTCXO стає стабільною постійною напругою, що підтримує вихід VCTCXO на «точно» 38,4 МГц (за умови, що еталон точний) і фазово синхронізує його з еталонним сигналом. Під час використання зовнішнього еталона потрібно ввімкнути :code:`clock_ref` (через Python або CLI) і задати частоту еталонного входу (:code:`refin_freq`), яка за замовчуванням дорівнює 10 МГц. Причини використовувати зовнішній еталон — це покращена точність частоти та можливість синхронізувати кілька SDR одним еталоном. -Each bladeRF VCTCXO DAC trim value is calibrated at the factory to be within 1 Hz at 38.4 MHz at room temperature, and you can enter your serial number into `this page `_ to see what the factory calibrated value was (find your serial number on the board or using :code:`bladerf-tool probe`). A fresh board should be well within 0.5 ppm and likely closer to 0.1 ppm, according to Nuand. If you have test equipment to measure the frequency accuracy, or want to set it to the factory value, you can use the commands: +Для кожного bladeRF значення підстроювання VCTCXO DAC калібрується на заводі з точністю до 1 Гц на частоті 38,4 МГц при кімнатній температурі, і ви можете ввести свій серійний номер на `цій сторінці `_, щоб дізнатися заводське значення (серійний номер вказано на платі або доступний через :code:`bladerf-tool probe`). За словами Nuand, нова плата має точність набагато кращу за 0.5 ppm і, ймовірно, ближче до 0.1 ppm. Якщо у вас є обладнання для вимірювання точності частоти або ви хочете встановити заводське значення, використовуйте команди: .. code-block:: bash $ bladeRF-cli -i bladeRF> flash_init_cal 301 0x2049 -swapping :code:`301` with your bladeRF size and :code:`0x2049` with the hex format of your VCTCXO DAC trim value. You must power cycle for it to go into effect. +Замінивши :code:`301` на розмір вашого bladeRF і :code:`0x2049` на шістнадцяткове представлення вашого значення підстроювання VCTCXO DAC. Щоб зміни набули чинності, потрібно перезапустити живлення. *********************************** -Sampling at 122 MHz +Дискретизація на 122 МГц *********************************** -Coming Soon! +Незабаром! *********************************** -Expansion Ports +Порти розширення *********************************** -The bladeRF 2.0 includes an expansion port using a BSH-030 connector. More information on using this port coming soon! +bladeRF 2.0 містить порт розширення з використанням роз'єма BSH-030. Докладніша інформація про використання цього порту з'явиться пізніше! ******************************** -Further Reading +Додаткові матеріали ******************************** -#. `bladeRF Wiki `_ -#. `Nuand's txrx.py example `_ +#. `Wiki bladeRF `_ +#. `Приклад Nuand txrx.py `_ From cc46e4348701cc9a038193a4eb0794c7a75f473f Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:41:15 +0300 Subject: [PATCH 08/42] Translate 2D beamforming chapter to Ukrainian --- content-ukraine/2d_beamforming.rst | 774 ++++++++++++++--------------- 1 file changed, 387 insertions(+), 387 deletions(-) diff --git a/content-ukraine/2d_beamforming.rst b/content-ukraine/2d_beamforming.rst index 17fb01a9..ab2239a7 100644 --- a/content-ukraine/2d_beamforming.rst +++ b/content-ukraine/2d_beamforming.rst @@ -1,23 +1,23 @@ .. _2d-beamforming-chapter: -############## -2D Beamforming -############## +######################## +2D формування променя +######################## -This chapter extends the 1D beamforming/DOA chapter to 2D arrays. We will start with a simple rectangular array and develop the steering vector equation and MVDR beamformer, then we will work with some actual data from a 3x5 array. Lastly, we will use the interactive tool to explore the effects of different array geometries and element spacing. +Цей розділ розширює матеріал про 1D формування променя/визначення напрямку приходу (DOA) на двовимірні решітки. Ми почнемо з простої прямокутної решітки та виведемо рівняння вектора наведення й формувач променя MVDR, після чого попрацюємо з реальними даними з решітки 3x5. Наостанок скористаємося інтерактивним інструментом, щоб дослідити вплив різних геометрій решіток і відстаней між елементами. -************************************* -Rectangular Arrays and 2D Beamforming -************************************* +****************************************** +Прямокутні решітки та 2D формування променя +****************************************** -Rectangular arrays (a.k.a. planar arrays) involve a 2D array of elements. With an extra dimension we get some added complexity, but the same basic principles apply, and the hardest part will be visualizing the results (e.g. no more simple polar plots, now we'll need 3D surface plots). Even though our array is now 2D, that does not mean we have to start adding a dimension to every data structure we've been dealing with. For example, we will keep our weights as a 1D array of complex numbers. However, we will need to represent the positions of our elements in 2D. We will keep using :code:`theta` to refer to the azimuth angle, but now we will introduce a new angle, :code:`phi`, which is the elevation angle. There are many spherical coordinate conventions, but we will be using the following: +Прямокутні решітки (відомі також як планарні решітки) складаються з двовимірного масиву елементів. Додатковий вимір додає трохи складності, але діють ті ж базові принципи, і найважчою частиною стане візуалізація результатів (наприклад, простих полярних графіків уже не буде, нам знадобляться 3D-поверхні). Хоч наша решітка тепер 2D, це не означає, що ми повинні додавати вимір до кожної структури даних, з якою працювали. Наприклад, вагові коефіцієнти ми й далі зберігатимемо як 1D масив комплексних чисел. Втім, позиції елементів нам доведеться представити у 2D. Ми й надалі використовуватимемо :code:`theta` для позначення азимутального кута, але введемо новий кут — :code:`phi`, тобто кут місця. Існує багато конвенцій сферичних координат, але ми використовуватимемо таку: .. image:: ../_images/Spherical_Coordinates.svg - :align: center + :align: center :target: ../_images/Spherical_Coordinates.svg - :alt: Spherical coordinate system showing theta and phi + :alt: Сферична система координат із позначеними θ та φ -Which corresponds to: +Що відповідає співвідношенням: .. math:: @@ -27,539 +27,539 @@ Which corresponds to: z = \sin(\phi) -We will also switch to using a generalized steering vector equation, which is not specific to any array geometry: +Ми також перейдемо до узагальненого рівняння вектора наведення, яке не прив’язане до конкретної геометрії решітки: .. math:: s = e^{2j \pi \boldsymbol{p} u / \lambda} -where :math:`\boldsymbol{p}` is the set of element x/y/z positions in meters (size :code:`Nr` x 3) and :math:`u` is the direction we want to point at as a unit vector in x/y/z (size 3x1). In Python this looks like: +де :math:`\boldsymbol{p}` — набір координат x/y/z елементів у метрах (розмір :code:`Nr` x 3), а :math:`u` — напрямок, у який ми хочемо спрямувати решітку, представлений одиничним вектором у координатах x/y/z (розмір 3x1). У Python це виглядає так: .. 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) + # Nrx3 3x1 + return np.exp(2j * np.pi * pos @ dir / wavelength) # виводить вектор Nr x 1 (стовпчик) -Let's try using this generalized steering vector equation with a simple ULA with 4 elements, to make the connection back to what we have previously learned. We will now represent :code:`d` in meters instead of relative to wavelength. We will place the elements along the y-axis: +Спробуймо використати це узагальнене рівняння вектора наведення для простої лінійної решітки (ULA) з чотирма елементами, щоб пов’язати його з тим, що ми вже вивчали. Тепер ми представлятимемо :code:`d` у метрах, а не відносно довжини хвилі. Розмістимо елементи вздовж осі y: .. code-block:: python Nr = 4 fc = 5e9 wavelength = 3e8 / fc - d = 0.5 * wavelength # in meters + d = 0.5 * wavelength # у метрах - # 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 + # Зберігатимемо позиції елементів у списку координат (x, y, z), навіть якщо це просто ULA вздовж осі y + pos = np.zeros((Nr, 3)) # Позиції елементів як список координат x, y, z у метрах for i in range(Nr): - pos[i,0] = 0 # x position - pos[i,1] = d * i # y position - pos[i,2] = 0 # z position + pos[i,0] = 0 # координата x + pos[i,1] = d * i # координата y + pos[i,2] = 0 # координата z -The following graphic shows a top-down view of the ULA, with an example theta of 20 degrees. +Наступна ілюстрація показує вигляд ULA зверху з прикладом кута θ у 20 градусів. .. image:: ../_images/2d_beamforming_ula.svg - :align: center + :align: center :target: ../_images/2d_beamforming_ula.svg - :alt: ULA with theta of 20 degrees + :alt: ULA з θ у 20 градусів -The only thing left is to connect our old :code:`theta` with this new unit vector approach. We can calculate :code:`dir` based on :code:`theta` pretty easily, we know that the x and z component of our unit vector will be 0 because we are still in 1D space, and based on our spherical coordinate convention the y component will be :code:`np.cos(theta)`, meaning the full code is :code:`dir = np.asmatrix([0, np.cos(theta_i), 0]).T`. At this point you should be able to connect our generalized steering vector equation with the ULA steering vector equation we have been using. Give this new code a try, pick a :code:`theta` between 0 and 360 degrees (remember to convert to radians!), and the steering vector should be a 4x1 array. +Залишилося лише пов’язати нашу стару :code:`theta` з новим підходом через одиничний вектор. Ми можемо досить просто обчислити :code:`dir` на основі :code:`theta`: знаємо, що компоненти x і z нашого одиничного вектора дорівнюватимуть 0, адже ми все ще в одномірному просторі, а згідно з нашою конвенцією сферичних координат компонент y дорівнюватиме :code:`np.cos(theta)`, тобто повний код виглядає як :code:`dir = np.asmatrix([0, np.cos(theta_i), 0]).T`. На цьому етапі ви маєте змогу пов’язати наше узагальнене рівняння вектора наведення з рівнянням вектора наведення для ULA, яке ми використовували раніше. Спробуйте цей новий код, виберіть :code:`theta` між 0 і 360 градусами (не забудьте перевести в радіани!), і вектор наведення повинен мати розмір 4x1. -Now let's move on to the 2D case. We will place our array in the X-Z plane, with boresight pointing horizontally towards the positive y-axis (:math:`\theta = 0`, :math:`\phi = 0`). We will use the same element spacing as before, but now we will have 16 elements total: +Тепер перейдемо до 2D-випадку. Розмістимо нашу решітку в площині X-Z з осьовою лінією, спрямованою горизонтально вздовж додатного напрямку осі y (:math:`\theta = 0`, :math:`\phi = 0`). Використаємо той самий крок між елементами, але тепер матимемо загалом 16 елементів: .. code-block:: python - # Now let's switch to 2D, using a 4x4 array with half wavelength spacing, so 16 elements total + # Тепер перейдемо до 2D, використовуючи решітку 4x4 з інтервалом у півдовжини хвилі, тобто 16 елементів 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 + + # Позиції елементів як список координат x, y, z у метрах, розміщуємо решітку в площині X-Z 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 + pos[i,0] = d * (i % 4) # координата x + pos[i,1] = 0 # координата y + pos[i,2] = d * (i // 4) # координата z -The top-down view of our rectangular 4x4 array: +Вигляд зверху нашої прямокутної решітки 4x4: .. image:: ../_images/2d_beamforming_element_pos.svg - :align: center + :align: center :target: ../_images/2d_beamforming_element_pos.svg - :alt: Rectangular array element positions + :alt: Розташування елементів прямокутної решітки -In order to point towards a certain theta and phi, we will need to convert those angles into a unit vector. We can use the same generalized steering vector equation as before, but now we will need to calculate the unit vector based on both theta and phi, using the equations at the beginning of this chapter: +Щоб спрямуватися на певні θ та φ, нам потрібно перетворити ці кути на одиничний вектор. Ми можемо використати те саме узагальнене рівняння вектора наведення, але тепер мусимо обчислити одиничний вектор на основі обох кутів, використовуючи формули з початку цього розділу: .. code-block:: python - # Let's point towards an arbitrary direction - theta = np.deg2rad(60) # azimith angle - phi = np.deg2rad(30) # elevation angle + # Спрямуймося у довільному напрямку + theta = np.deg2rad(60) # азимутальний кут + phi = np.deg2rad(30) # кут місця + + # Використовуючи нашу конвенцію сферичних координат, можемо обчислити одиничний вектор: + def get_unit_vector(theta, phi): # кути в радіанах + return np.asmatrix([np.sin(theta) * np.cos(phi), # компонент x + np.cos(theta) * np.cos(phi), # компонент y + np.sin(phi)]).T # компонент z - # 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 + # dir має розмір 3x1 # [[0.75 ] # [0.4330127] # [0.5 ]] -Now let's use our generalized steering vector function to calculate the steering vector: +Тепер скористаймося нашою функцією узагальненого вектора наведення, щоб обчислити сам вектор наведення: .. 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 -At this point it's worth pointing out that we didn't actually change the dimensions of anything, going from 1D to 2D, we just have a non-zero x/y/z components, the steering vector equation is still the same and the weights are still a 1D array. It might be tempting to assemble your weights as a 2D array so that visually it matches the array geometry, but it's not necessary and best to keep it 1D. For every element, there is a corresponding weight, and the list of weights is in the same order as the list of element positions. + # Застосуємо звичайний формувач променя, у якому ваги дорівнюють вектору наведення, та побудуємо діаграму спрямованості + w = s # вектор ваг 16x1 + +На цьому етапі варто зазначити, що ми не змінювали розмірності даних, переходячи з 1D у 2D: ми просто отримали ненульові компоненти x/y/z, рівняння вектора наведення лишилося таким самим, а ваги — все ще 1D масивом. Може виникнути спокуса сформувати ваги у вигляді 2D масиву, щоб візуально відповідати геометрії решітки, але в цьому немає потреби — краще залишити їх 1D. Для кожного елемента існує відповідна вага, і список ваг має той самий порядок, що й список позицій елементів. -Visualizing the beam pattern associated with these weights is a little more complicated because we need a 3D plot or a 2D heatmap. We will scan :code:`theta` and :code:`phi` to get a 2D array of power levels, and then plot that using :code:`imshow()`. The code below does just that, and the result is shown in the figure below, along with a dot at the angle we entered earlier: +Візуалізувати діаграму спрямованості для цих ваг трохи складніше, бо нам потрібен 3D-графік або 2D-теплокарта. Ми проскануємо :code:`theta` та :code:`phi`, щоб отримати 2D масив рівнів потужності, а потім побудуємо його за допомогою :code:`imshow()`. Наведений нижче код саме це й робить, а результат показано на рисунку нижче, разом із точкою в раніше заданому куті: .. 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 + resolution = 100 # кількість точок у кожному напрямку + theta_scan = np.linspace(-np.pi/2, np.pi/2, resolution) # азимутальні кути + phi_scan = np.linspace(-np.pi/4, np.pi/4, resolution) # кути місця + results = np.zeros((resolution, resolution)) # 2D масив для зберігання результатів 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 + a = steering_vector(pos, get_unit_vector(theta_i, phi_i)) # фактор решітки + results[i, j] = np.abs(w.conj().T @ a)[0,0] # потужність сигналу, лінійний масштаб виглядає краще 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.colorbar(label='Потужність [лінійна]') + plt.scatter(theta*180/np.pi, phi*180/np.pi, color='red', s=50) # Додаємо точку в правильному θ/φ + plt.xlabel('Азимутальний кут [градуси]') + plt.ylabel('Кут місця [градуси]') plt.show() .. image:: ../_images/2d_beamforming_2dplot.svg - :align: center + :align: center :target: ../_images/2d_beamforming_2dplot.svg - :alt: 3D plot of the beam pattern + :alt: 3D-графік діаграми спрямованості -Let's simulate some actual samples now; we'll add two tone jammers arriving from different directions: +Змоделюймо тепер реальні відліки; додамо два перешкодні тони, що приходять з різних напрямків: .. code-block:: python - N = 10000 # number of samples to simulate - + N = 10000 # кількість відліків для симуляції + 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 - + jammer1_tone = np.exp(2j*np.pi*0.1*np.arange(N)).reshape(1,-1) # формуємо рядок + 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 + jammer2_tone = np.exp(2j*np.pi*0.2*np.arange(N)).reshape(1,-1) # формуємо рядок + + noise = np.random.normal(0, 1, (Nr, N)) + 1j * np.random.normal(0, 1, (Nr, N)) # комплексний гаусів шум + r = jammer1_s @ jammer1_tone + jammer2_s @ jammer2_tone + noise # отримуємо матрицю відліків 16 x 10000 -Just for fun let's calculate the MVDR beamformer weights towards the theta and phi we were using earlier (a unit vector in that direction is still saved as :code:`dir`): +Просто для цікавості обчислимо ваги формувача променя MVDR у напрямку тих самих θ та φ, які ми використовували раніше (одиничний вектор цього напрямку все ще збережено в :code:`dir`): .. code-block:: python s = steering_vector(pos, dir) # 16 x 1 - R = np.cov(r) # Covariance matrix, 16 x 16 + R = np.cov(r) # коваріаційна матриця 16 x 16 Rinv = np.linalg.pinv(R) - w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # MVDR/Capon equation + w = (Rinv @ s)/(s.conj().T @ Rinv @ s) # рівняння MVDR/Капона -Instead of looking at the beam pattern in the crummy 3D plot, we'll use an alternative method of checking if these weights make sense; we will evaluate the response of the weights towards different directions and calculate the power in dB. Let's start with the direction we were pointing: +Замість того щоб дивитися на діаграму спрямованості у незручному 3D-графіку, скористаймося альтернативним способом перевірити адекватність цих ваг: оцінимо відгук ваг у різних напрямках і розрахуємо потужність у дБ. Почнімо з напрямку, куди ми спрямовувалися: .. 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 + # Потужність у напрямку наведення (theta=60, phi=30, цей напрямок і досі збережено в dir): + a = steering_vector(pos, dir) # фактор решітки + resp = w.conj().T @ a # скаляр print("Power in direction we are pointing:", 10*np.log10(np.abs(resp)[0,0]), 'dB') -This outputs 0 dB, which is what we expect because MVDR's goal is to achieve unit power in the desired direction. Now let's check the power in the directions of the two jammers, as well as a random direction and a direction that is one degree off of our desired direction (the same code is used, just update :code:`dir`). The results are shown in the table below: +Це виводить 0 дБ, що й очікувано, адже мета MVDR — забезпечити одиничну потужність у бажаному напрямку. Тепер перевірмо потужність у напрямках двох глушників, а також у випадковому напрямку та в напрямку, що відхиляється на один градус від бажаного (код той самий, просто оновлюйте :code:`dir`). Результати показано в таблиці нижче: .. 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 - * - A random direction - - -10.591 dB + * - Напрямок + - Підсилення + * - :code:`dir` (напрямок, використаний для пошуку ваг MVDR) + - 0 дБ + * - Глушник 1 + - -17.488 дБ + * - Глушник 2 + - -18.551 дБ + * - Відхилення на 1 градус від :code:`dir` і за :math:`\theta`, і за :math:`\phi` + - -0.00683 дБ + * - Випадковий напрямок + - -10.591 дБ -Your results may vary due to the random noise being used to calculate the received samples, which get used to calculate :code:`R`. But the main take-away is that the jammers will be in a null and very low power, the 1 degree off from :code:`dir` will be slightly below 0 dB, but still in the main lobe, and then a random direction is going to be lower than 0 dB but higher than the jammers, and very different every run of the simulation. Note that with MVDR you get a gain of 0 dB for the main lobe, but if you were to use the conventional beamformer, you would get :math:`10 \log_{10}(Nr)`, so about 12 dB for our 16-element array, showing one of the trade-offs of using MVDR. +Ваші результати можуть відрізнятися через випадковий шум, що використовується для формування прийнятих відліків і, відповідно, для обчислення :code:`R`. Але головний висновок у тому, що глушники потраплять у нуль і матимуть дуже низьку потужність, напрямок, зміщений на один градус від :code:`dir`, буде трохи нижче 0 дБ, але все ще в головній пелюстці, а випадковий напрямок буде нижче 0 дБ, але вищий за глушники і дуже різний у кожному запуску симуляції. Зверніть увагу, що з MVDR ви отримуєте підсилення 0 дБ у головній пелюстці, тоді як зі звичайним формувачем променя ви отримали б :math:`10 \log_{10}(Nr)`, тобто близько 12 дБ для нашої 16-елементної решітки, що демонструє одну з особливостей MVDR. -The code for this section can be found `here `_. +Код для цього розділу можна знайти `тут `_. ********************************************** -Processing Signals from an Actual 2D Array +Обробка сигналів із реальної 2D решітки ********************************************** -In this section we work with some actual data recorded from a 3x5 array made out of a `QUAD-MxFE `_ platform from Analog Devices which supports up to 16 transmit and receive channels (we only used 15 and only in receive mode). Two recordings are provided below, the first one contains one emitter located at boresight to the array, which we will use for calibration. The second recording contains two emitters at different directions, which we will use for beamforming and DOA testing. +У цій секції ми працюємо з реальними даними, записаними з решітки 3x5, створеної на основі платформи `QUAD-MxFE `_ від Analog Devices, яка підтримує до 16 каналів передавання та приймання (ми використали лише 15 і тільки в режимі приймача). Нижче наведено два записи: перший містить один випромінювач, розташований на осьовій лінії решітки, і використовується для калібрування. Другий запис містить два випромінювачі в різних напрямках, які ми використаємо для формування променя та тестування DOA. -- `IQ recording of just C `_ (used for calibration, as C is at boresight) -- `IQ recording of B and D `_ (used for beamforming/DOA testing) +- `IQ-запис лише випромінювача C `_ (використовується для калібрування, оскільки C розташовано на осьовій лінії) +- `IQ-запис випромінювачів B і D `_ (використовується для формування променя/DOA) -The QUAD-MxFE was tuned to 2.8 GHz and all transmitters were using a simple tone within the observation bandwidth. What's interesting about this DSP is that it doesn't actually matter what the sample rate is, none of the array processing techniques we use depend on the sample rate, they just make the assumption that the signal is somewhere in the baseband signal. The DSP does depend on the center frequency, because the phase shift between elements depends on the frequency and angle of arrival. This is opposite of most other signal processing where the sample rate is important, but the center frequency is not. +QUAD-MxFE було налаштовано на 2.8 ГГц, а всі передавачі використовували простий тон у межах смуги спостереження. Цікаво, що для цієї DSP частота дискретизації насправді неважлива: жодна з методик обробки решітки, які ми застосовуємо, не залежить від частоти дискретизації, вони лише припускають, що сигнал перебуває десь у сигналі базової смуги. DSP залежить від центральної частоти, адже фазовий зсув між елементами залежить від частоти й кута приходу. Це протилежно більшості інших видів обробки сигналів, де частота дискретизації важлива, а центральна — ні. -We can load these recordings into Python using the following code: +Ми можемо завантажити ці записи в Python за допомогою такого коду: .. 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 boresight) on + r = np.load("DandB_capture1.npy")[0:15] # 16-й елемент не підключено, але його все одно записали + r_cal = np.load("C_only_capture1.npy")[0:15] # лише калібрувальний сигнал (на осьовій лінії) -The spacing between antennas was 0.051 meters. We can represent the element positions as a list of x,y,z coordinates in meters. We will place the array in the X-Z plane, as the array was mounted vertically (with boresight pointing horizontally). +Відстань між антенами становила 0.051 метра. Ми можемо представити позиції елементів як список координат x, y, z у метрах. Розмістимо решітку в площині X-Z, оскільки її було змонтовано вертикально (з осьовою лінією, спрямованою горизонтально). .. 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() - -The plot labels each element with its index, which corresponds to the order of the elements in the :code:`r` and :code:`r_cal` IQ samples that were recorded. + fc = 2.8e9 # центральна частота в Гц + d = 0.051 # відстань між антенами в метрах + wavelength = 3e8 / fc + Nr = 15 + rows = 3 + cols = 5 + + # Позиції елементів як список координат x, y, z у метрах + pos = np.zeros((Nr, 3)) + for i in range(Nr): + pos[i,0] = d * (i % cols) # координата x + pos[i,1] = 0 # координата y + pos[i,2] = d * (i // cols) # координата z + + # Побудуємо та підпишемо позиції елементів + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(pos[:,0], pos[:,1], pos[:,2], 'o') + # Підписи індексів + for i in range(Nr): + ax.text(pos[i,0], pos[i,1], pos[i,2], str(i), fontsize=10) + plt.xlabel("Позиція X [м]") + plt.ylabel("Позиція Y [м]") + ax.set_zlabel("Позиція Z [м]") + plt.grid() + plt.show() + +На графіку кожен елемент позначений власним індексом, який відповідає порядку елементів у IQ-відліках :code:`r` та :code:`r_cal`. .. image:: ../_images/2d_array_element_positions.svg - :align: center + :align: center :target: ../_images/2d_array_element_positions.svg - :alt: 2D array element positions + :alt: Позиції елементів 2D-решітки -Calibration is performed using only the :code:`r_cal` samples, which were recorded with just the transmitter at boresight on. The goal is to find the phase and magnitude offsets for each element. With perfect calibration, and assuming the transmitter was exactly at boresight, all of the individual receive elements should be receiving the same signal, all in phase with each other and at the same magnitude. But because of imperfections in the array/cables/antennas, each element will have a different phase and magnitude offset. The calibration process is to find these offsets, which we will later apply to the :code:`r` samples before attempting to do any array processing on them. +Калібрування виконується, використовуючи лише відліки :code:`r_cal`, які були записані з увімкненим передавачем на осьовій лінії. Мета — знайти фазові та амплітудні зсуви для кожного елемента. За ідеального калібрування і за умови, що передавач точно на осьовій лінії, усі окремі приймальні елементи мають отримувати однаковий сигнал, синфазний і з однаковою амплітудою. Але через недоліки решітки/кабелів/антен кожен елемент матиме власні фазовий та амплітудний зсуви. Процес калібрування полягає у знаходженні цих зсувів, які ми згодом застосуємо до відліків :code:`r` перед будь-якою обробкою решітки. -There are many ways to perform calibration, but we will use a method that involves taking the eigenvalue decomposition of the covariance matrix. The covariance matrix is a square matrix of size :code:`Nr x Nr`, where :code:`Nr` is the number of receive elements. The eigenvector corresponding to the largest eigenvalue is the one that represents the received signal, hopefully, and we will use it to find the phase offsets for each element by simply taking the phase of each element of the eigenvector and normalizing it to the first element which we will treat as the reference element. The magnitude calibration does not actually use the eigenvector, but instead uses the mean magnitude of the received signal for each element. +Існує багато способів калібрування, але ми використаємо метод, що передбачає власне розкладання коваріаційної матриці. Коваріаційна матриця — це квадратна матриця розміру :code:`Nr x Nr`, де :code:`Nr` — кількість приймальних елементів. Власний вектор, що відповідає найбільшому власному значенню, представляє отриманий сигнал (сподіваємося), і ми використаємо його для пошуку фазових зсувів кожного елемента, просто взявши фазу кожного елементу власного вектора і нормалізувавши її за першим елементом, який ми вважатимемо еталонним. Амплітудне калібрування не використовує власний вектор, а натомість використовує середню амплітуду отриманого сигналу для кожного елементу. .. code-block:: python - # Calc covariance matrix, it's Nr x Nr - R_cal = r_cal @ r_cal.conj().T + # Обчислюємо коваріаційну матрицю, вона має розмір 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) + # власне розкладання, v[:,i] — власний вектор, що відповідає власному значенню 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() + # Побудуємо власні значення, щоб переконатися, що одне з них значно більше за інші + w_dB = 10*np.log10(np.abs(w)) + w_dB -= np.max(w_dB) # нормалізація + fig, (ax1) = plt.subplots(1, 1, figsize=(7, 3)) + ax1.plot(w_dB, '.-') + ax1.set_xlabel('Індекс') + ax1.set_ylabel('Власне значення [дБ]') + 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) + # Використовуємо максимальний власний вектор для калібрування + v_max = v[:, np.argmax(np.abs(w))] + mags = np.mean(np.abs(r_cal), axis=1) + mags = mags[0] / mags # нормалізуємо відносно першого елемента + phases = np.angle(v_max) + phases = phases[0] - phases # нормалізуємо відносно першого елемента + cal_table = mags * np.exp(1j * phases) + print("cal_table", cal_table) -Below shows the plot of the eigenvalue distribution, we want to make sure that there's just one large value, and the rest are small, representing one signal being received. Any interferers or multipath will degrade the calibration process. +На рисунку нижче показано розподіл власних значень; ми хочемо переконатися, що є лише одне велике значення, а решта малі, що відповідає одному прийнятому сигналу. Будь-які завади або багатопроменевість погіршуватимуть процес калібрування. .. image:: ../_images/2d_array_eigenvalues.svg - :align: center + :align: center :target: ../_images/2d_array_eigenvalues.svg - :alt: 2D array eigenvalue distribution + :alt: Розподіл власних значень 2D-решітки -The calibration table is a list of complex numbers, one for each element, representing the phase and magnitude offsets (it is easier to represent it in rectangular form instead of polar). The first element is the reference element, and will always be 1.0 + 0.j. The rest of the elements are the offsets for each element corresponding to the same order we used for :code:`pos`. +Таблиця калібрування — це список комплексних чисел, по одному для кожного елемента, що представляють фазові та амплітудні зсуви (їх простіше подавати у прямокутній формі, а не в полярній). Перший елемент — еталонний і завжди дорівнює 1.0 + 0.j. Решта елементів — це зсуви для кожного елемента у тому ж порядку, який ми використали для :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] + [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 can apply these offsets to any set of samples recorded from the array simply by multiplying each element of the samples by the corresponding element of the calibration table: +Ми можемо застосувати ці зсуви до будь-якого набору відліків, записаних решіткою, просто перемноживши кожен елемент відліків на відповідний елемент таблиці калібрування: .. code-block:: python - # Apply cal offsets to r - for i in range(Nr): - r[i, :] *= cal_table[i] + # Застосовуємо калібрувальні зсуви до r + for i in range(Nr): + r[i, :] *= cal_table[i] -As a side note, this is why we calculated the offsets using :code:`mags[0] / mags` and :code:`phases[0] - phases`, if we had reversed that order then we would need to do a division in order to apply the offsets, but we prefer to do the multiplication instead. +Як невеличкий відступ, саме тому ми обчислювали зсуви у вигляді :code:`mags[0] / mags` та :code:`phases[0] - phases`: якби ми зробили навпаки, то довелося б ділити значення під час застосування, а нам зручніше множити. -Next we will perform DOA estimation using the MUSIC algorithm. We will use the :code:`steering_vector()` and :code:`get_unit_vector()` functions we defined earlier to calculate the steering vector for each element of the array, and then use the MUSIC algorithm to estimate the DOA of the two emitters in the :code:`r` samples. The MUSIC algorithm was discussed in the previous chapter. +Далі виконаємо оцінювання DOA за допомогою алгоритму MUSIC. Ми використаємо функції :code:`steering_vector()` та :code:`get_unit_vector()`, визначені раніше, щоб обчислити вектор наведення для кожного елемента решітки, а потім застосуємо MUSIC для оцінки напрямку приходу двох випромінювачів у відліках :code:`r`. Алгоритм MUSIC розглядався в попередньому розділі. .. 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 - -Our results are in 2D, because the array is 2D, so we must either use a 3D plot or a 2D heatmap plot. Let's try both. First, we will do a 3D plot that has elevation on one axis and azimuth on the other: + # DOA з використанням MUSIC + resolution = 400 # кількість точок у кожному напрямку + theta_scan = np.linspace(-np.pi/2, np.pi/2, resolution) # азимутальні кути + phi_scan = np.linspace(-np.pi/4, np.pi/4, resolution) # кути місця + results = np.zeros((resolution, resolution)) # 2D масив для результатів + R = np.cov(r) # коваріаційна матриця 15 x 15 + Rinv = np.linalg.pinv(R) + expected_num_signals = 4 + w, v = np.linalg.eig(R) # власне розкладання, v[:,i] — власний вектор для w[i] + eig_val_order = np.argsort(np.abs(w)) + v = v[:, eig_val_order] # сортуємо власні вектори у цьому порядку + V = np.zeros((Nr, Nr - expected_num_signals), dtype=np.complex64) # шумовий підпростір — решта власних значень + 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 з’ясувати, чому потрібний множник -1, щоб збігалося з реальністю + 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) # Корисно для варіанта ABCD + results[i, j] = music_metric + +Наші результати двовимірні, адже решітка теж 2D, тому нам доведеться використати або 3D-графік, або 2D-теплокарту. Спробуймо обидва. Спочатку побудуємо 3D-графік, де на одній осі буде кут місця, а на іншій — азимут: .. 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() + # 3D-графік DOA у координатах азимут/кут місця + results = 10*np.log10(results) # переводимо в дБ + results[results < -20] = -20 # обрізаємо вісь z на певному рівні дБ + 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('Азимут (theta)') + ax.set_ylabel('Кут місця (phi)') + ax.set_zlabel('Потужність [дБ]') # 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 + :align: center :scale: 30% :target: ../_images/2d_array_3d_doa_plot.png - :alt: 3D DOA plot + :alt: 3D-графік DOA -Depending on the situation it might be annoying to read off numbers from a 3D plot, so we can also do a 2D heatmap with :code:`imshow()`: +Залежно від ситуації читати значення з 3D-графіка може бути незручно, тож можемо також побудувати 2D-теплокарту за допомогою :code:`imshow()`: .. 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() + # 2D-теплокарта азимут/кут місця (аналогічно до попереднього, але в 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='Потужність [лінійна]') + plt.xlabel('Theta (азимут, градуси)') + plt.ylabel('Phi (кут місця, градуси)') + plt.savefig('../_images/2d_array_2d_doa_plot.svg', bbox_inches='tight') + plt.show() .. image:: ../_images/2d_array_2d_doa_plot.svg - :align: center + :align: center :target: ../_images/2d_array_2d_doa_plot.svg - :alt: 2D DOA plot + :alt: 2D-графік DOA -Using this 2D plot we can easily read off the estimated azimuth and elevation of the two emitters (and see that there was just two). Based on the test setup that was used to produce this recording, these results match reality, the *exact* azimuth and elevation of the emitters was never actually measured because that would require very specialized equipment. +З цієї 2D-карти ми легко можемо зчитати оцінені азимут і кут місця двох випромінювачів (і переконатися, що їх було лише два). Відповідно до випробувальної установки, яка використовувалася для цього запису, результати відповідають реальності, хоча *точні* азимут і кут місця випромінювачів не вимірювалися, адже для цього потрібне спеціалізоване обладнання. -As an exercise, try using the conventional beamformer, as well as MVDR, and compare the results to MUSIC. +Як вправу, спробуйте застосувати звичайний формувач променя, а також MVDR, і порівняйте результати з MUSIC. -This code in its entirety can be found `here `_. +Повний код цієї частини можна знайти `тут `_. -*********************** -Interactive Design Tool -*********************** +************************* +Інтерактивний інструмент +************************* -The following interactive tool was created by `Jason Durbin `_, a free-lancing phased array engineer, who graciously allowed it to be embedded within PySDR; feel free to visit the `full project `_ or his `consulting business `_. This tool allows you to change a phased array's geometry, element spacing, steering position, add sidelobe tapering, and other features. +Наступний інтерактивний інструмент створив `Джейсон Дербін `_, інженер із фазованих решіток-фрилансер, який люб’язно дозволив вбудувати його в PySDR; ви можете відвідати `повну версію проєкту `_ або його `консалтинговий бізнес `_. Цей інструмент дозволяє змінювати геометрію фазованої решітки, крок між елементами, напрямок наведення, додавати аподизацію бічних пелюсток та інші можливості. -Some details on this tool: Antenna elements are assumed to be isotropic. However, the directivity calculation assumes half-hemisphere radiation (e.g. no back lobes). Therefore, the computed directivity will be 3 dBi higher than using pure isotropic (i.e., the individual element gain is +3.0 dBi). The mesh can be made finer by increasing theta/phi, u/v, or azimuth/elevation points. Clicking (or long pressing) elements in the phase/attenuation plots allows you to manually set phase/attenuation ("be sure to select "enable override"). Additionally, the attenuation pop-up allows you to disable elements. Hovering (or touching) the 2D far field plot or geometry plots will show the value of the plot under the cursor. +Декілька деталей про інструмент: антенні елементи вважаються ізотропними. Однак під час розрахунку направленості припускається випромінювання в напівсферу (тобто без задніх пелюсток). Тому обчислена направленість буде на 3 дБі вищою, ніж для ідеально ізотропних елементів (іншими словами, посилення окремого елемента становить +3.0 дБі). Сітку можна зробити щільнішою, збільшуючи кількість точок для theta/phi, u/v або азимуту/куту місця. Натискання (або довге натискання) на елементи графіків фази/атенюації дозволяє вручну встановлювати фазу/атенюацію («не забудьте увімкнути режим перевизначення»). Крім того, у вікні атенюації можна вимкнути окремі елементи. Наведення курсора (або торкання) 2D-графіка далекого поля чи графіків геометрії показує значення у точці під курсором. .. 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

-
- -
- -
-
+ + +
+
+
+

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

+
+ +
+ +
+
From d42d11b2873f1101589525d85ceacf2a0888db22 Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:44:07 +0300 Subject: [PATCH 09/42] Translate HackRF One chapter to Ukrainian --- content-ukraine/hackrf.rst | 155 +++++++++++++++++++------------------ 1 file changed, 78 insertions(+), 77 deletions(-) diff --git a/content-ukraine/hackrf.rst b/content-ukraine/hackrf.rst index cb2aa09a..644c91db 100644 --- a/content-ukraine/hackrf.rst +++ b/content-ukraine/hackrf.rst @@ -1,46 +1,46 @@ .. _hackrf-chapter: #################### -HackRF One in Python +HackRF One у Python #################### -The `HackRF One `_ from Great Scott Gadgets is a USB 2.0 SDR that can transmit or receive from 1 MHz to 6 GHz and has a sample rate from 2 to 20 MHz. It was released in 2014 and has had several minor refinements over the years. It is one of the only low-cost transmit-capable SDRs that goes down to 1 MHz, making it great for HF applications (e.g., ham radio) in addition to higher frequency fun. The max transmit power of 15 dBm is also higher than most other SDRs, see `this page `_ for full transmit power specs. It uses half-duplex operation, meaning it is either in transmit or receive mode at any given time, and it uses 8-bit ADC/DAC. +`HackRF One `_ від Great Scott Gadgets — це SDR з інтерфейсом USB 2.0, який може передавати або приймати сигнали в діапазоні від 1 МГц до 6 ГГц із частотою дискретизації від 2 до 20 МГц. Його випустили у 2014 році й з того часу декілька разів незначно оновлювали. Це один із небагатьох недорогих SDR з можливістю передавання, що працює від 1 МГц, тож він чудово підходить для застосувань у діапазоні КХ (наприклад, аматорський радіозв'язок), а також для експериментів на вищих частотах. Максимальна потужність передавача 15 дБм також вища, ніж у більшості інших SDR; повну специфікацію передавальної потужності дивіться `на цій сторінці `_. HackRF працює у напівдуплексному режимі, тобто в будь-який момент часу він або передає, або приймає, і використовує 8-бітові АЦП/ЦАП. .. image:: ../_images/hackrf1.jpeg :scale: 60 % - :align: center + :align: center :alt: HackRF One ******************************** -HackRF Architecture +Архітектура HackRF ******************************** -The HackRF is based around the Analog Devices MAX2839 chip which is a 2.3GHz to 2.7GHz transceiver initially designed for WiMAX, combined with a MAX5864 RF front-end chip (essentially just the ADC and DAC) and a RFFC5072 wideband synthesizer/VCO (used to upconvert and downconvert the signal in frequency). This is in contrast to most other low-cost SDRs which use a single chip known as an RFIC. Aside from setting the frequency generated within the RFFC5072, all of the other parameters we will adjust like the attenuation and analog filtering are going to be in the MAX2839. Instead of using an FPGA or System on Chip (SoC) like many SDRs, the HackRF uses a Complex Programmable Logic Device (CPLD) which acts as simple glue logic, and a microcontroller, the ARM-based LPC4320, which does all of the onboard DSP and interfacing over USB with the host (both transfer of IQ samples in either direction and control of the SDR settings). The following beautiful block diagram from Great Scott Gadgets shows the architecture of the latest revision of the HackRF One: +HackRF побудований довкола мікросхеми Analog Devices MAX2839, яка є трансивером у діапазоні 2,3–2,7 ГГц і першопочатково була розроблена для WiMAX, у поєднанні з мікросхемою радіочастотного тракту MAX5864 (фактично це просто АЦП і ЦАП) та широкосмуговим синтезатором/VCO RFFC5072 (використовується для перетворення сигналу на вищу або нижчу частоту). Це відрізняється від більшості інших недорогих SDR, які використовують єдину мікросхему типу RFIC. Окрім встановлення частоти, яку генерує RFFC5072, усі інші параметри, що ми налаштовуємо, як-от атенюацію чи аналогові фільтри, розташовані в MAX2839. Замість використання ПЛІС або системи на кристалі (SoC), як у багатьох SDR, HackRF застосовує програмовану логічну інтегральну схему (CPLD), що виконує функції «клейової» логіки, та мікроконтролер LPC4320 на базі ARM, який відповідає за всю вбудовану DSP-обробку та взаємодію з хостом через USB (передавання IQ-відліків в обох напрямках і керування налаштуваннями SDR). Нижче наведено чудову блок-схему від Great Scott Gadgets, яка демонструє архітектуру останньої ревізії HackRF One: .. image:: ../_images/hackrf_block_diagram.webp - :align: center - :alt: HackRF One Block Diagram + :align: center + :alt: Блок-схема HackRF One :target: ../_images/hackrf_block_diagram.webp -The HackRF One is highly expandable and hackable. Inside the plastic case are four headers (P9, P20, P22, and P28), specifics can be `found here `_, but note that 8 GPIO pins and 4 ADC inputs are on the P20 header, while SPI, I2C, and UART are on the P22 header. The P28 header can be used to trigger/synchronize transmit/receive operations with another device (e.g., TR-switch, external amp, or another HackRF), through the trigger input and output, with delay of less than one sample period. +HackRF One легко розширювати й модифікувати. Усередині пластикового корпуса розташовано чотири шлейфи (P9, P20, P22 і P28); подробиці можна `знайти тут `_, але зверніть увагу, що 8 виводів GPIO і 4 аналогові входи АЦП знаходяться на шлейфі P20, тоді як SPI, I2C та UART — на P22. Шлейф P28 можна використовувати для запуску/синхронізації операцій передавання/приймання з іншим пристроєм (наприклад, TR-перемикачем, зовнішнім підсилювачем або іншим HackRF) через входи та виходи тригера з затримкою менше одного періоду дискретизації. .. image:: ../_images/hackrf2.jpeg :scale: 50 % - :align: center - :alt: HackRF One PCB + :align: center + :alt: Друкована плата HackRF One -The clock used for both the LO and ADC/DAC is derived from either the onboard 25 MHz oscillator, or from an external 10 MHz reference fed in over SMA. Regardless of which clock is used, the HackRF produces a 10 MHz clock signal on CLKOUT; a standard 3.3V 10 MHz square wave intended for a high impedance load. The CLKIN port is designed to take a similar 10 MHz 3.3V square wave, and the HackRF One will use the input clock instead of the internal crystal when a clock signal is detected (note, the transition to or from CLKIN only happens when a transmit or receive operation begins). +Тактовий сигнал, що використовується і для гетеродина, і для АЦП/ЦАП, може надходити або з вбудованого генератора на 25 МГц, або з зовнішнього 10-МГц опорного генератора, поданого через SMA. Незалежно від джерела такту, HackRF видає на виході CLKOUT сигнал 10 МГц — стандартну прямокутну хвилю 3,3 В, розраховану на високоомне навантаження. Порт CLKIN призначений для подавання аналогічної 10-МГц прямокутної хвилі 3,3 В, і HackRF One використовуватиме зовнішній такт замість внутрішнього кварцу, щойно виявить сигнал на вході (зверніть увагу, перехід на CLKIN або назад відбувається лише під час початку операції передавання чи приймання). ******************************** -Software and Hardware Setup +Налаштування програмного й апаратного забезпечення ******************************** -The software install process involves two steps: first we will install the main HackRF library from Great Scott Gadgets, and then we will install the Python API. +Установлення програмного забезпечення відбувається у два етапи: спершу ми встановимо основну бібліотеку HackRF від Great Scott Gadgets, а потім — Python API. -Installing the HackRF Library +Встановлення бібліотеки HackRF ############################# -The following was tested to work on Ubuntu 22.04 (using commit hash 17f3943 in March '25): +Наведені нижче дії були перевірені на Ubuntu 22.04 (використовувався коміт 17f3943 у березні 2025 року): .. code-block:: bash @@ -56,35 +56,35 @@ The following was tested to work on Ubuntu 22.04 (using commit hash 17f3943 in M sudo ldconfig sudo cp /usr/local/bin/hackrf* /usr/bin/. -After installing :code:`hackrf` you will be able to run the following utilities: +Після встановлення :code:`hackrf` ви зможете запускати такі утиліти: -* :code:`hackrf_info` - Read device information from HackRF such as serial number and firmware version. -* :code:`hackrf_transfer` - Send and receive signals using HackRF. Input/output files are 8-bit signed quadrature samples. -* :code:`hackrf_sweep` - a command-line spectrum analyzer. -* :code:`hackrf_clock` - Read and write clock input and output configuration. -* :code:`hackrf_operacake` - Configure Opera Cake antenna switch connected to HackRF. -* :code:`hackrf_spiflash` - A tool to write new firmware to HackRF. See: Updating Firmware. -* :code:`hackrf_debug` - Read and write registers and other low-level configuration for debugging. +* :code:`hackrf_info` — зчитування інформації про пристрій HackRF, як-от серійний номер і версія прошивки. +* :code:`hackrf_transfer` — передавання та приймання сигналів за допомогою HackRF. Вхідні/вихідні файли містять 8-бітові підписані квадратичні відліки. +* :code:`hackrf_sweep` — консольний аналізатор спектра. +* :code:`hackrf_clock` — читання й запис конфігурації вхідного та вихідного тактових сигналів. +* :code:`hackrf_operacake` — налаштування комутатора антен Opera Cake, підключеного до HackRF. +* :code:`hackrf_spiflash` — інструмент для запису нової прошивки в HackRF. Див. оновлення прошивки. +* :code:`hackrf_debug` — читання й запис регістрів та іншої низькорівневої конфігурації для налагодження. -If you are using Ubuntu through WSL, on the Windows side you will need to forward the HackRF USB device to WSL, first by installing the latest `usbipd utility msi `_ (this guide assumes you have usbipd-win 4.0.0 or higher), then opening PowerShell in administrator mode and running: +Якщо ви використовуєте Ubuntu у WSL, на стороні Windows потрібно пробросити USB-пристрій HackRF у WSL. Спершу встановіть найновіший `usbipd utility msi `_ (у цьому посібнику передбачається, що у вас usbipd-win 4.0.0 або новіший), потім відкрийте PowerShell від імені адміністратора й виконайте: .. code-block:: bash usbipd list - + <знайдіть BUSID із позначкою HackRF One та підставте його в обидві команди нижче> usbipd bind --busid 1-10 usbipd attach --wsl --busid 1-10 -On the WSL side, you should be able to run :code:`lsusb` and see a new item called :code:`Great Scott Gadgets HackRF One`. Note that you can add the :code:`--auto-attach` flag to the :code:`usbipd attach` command if you want it to auto reconnect. Lastly, you have to add the udev rules using the following command: +У WSL ви маєте змогу запустити :code:`lsusb` і побачити новий запис :code:`Great Scott Gadgets HackRF One`. За потреби ви можете додати прапорець :code:`--auto-attach` до команди :code:`usbipd attach`, щоб налаштувати автоматичне повторне підключення. Нарешті, необхідно додати правило udev такою командою: .. code-block:: bash echo 'ATTR{idVendor}=="1d50", ATTR{idProduct}=="6089", SYMLINK+="hackrf-one-%k", MODE="660", TAG+="uaccess"' | sudo tee /etc/udev/rules.d/53-hackrf.rules sudo udevadm trigger -Then unplug and replug your HackRF One (and redo the :code:`usbipd attach` part). Note, I had permissions issues with the step below until I switched to using `WSL USB Manager `_ on the Windows side, to manage forwarding to WSL, which apparently also deals with the udev rules. +Після цього від'єднайте та знову під'єднайте ваш HackRF One (і повторіть :code:`usbipd attach`). Зверніть увагу, що в мене виникали проблеми з правами доступу на наступному кроці, доки я не перейшов на використання `WSL USB Manager `_ на стороні Windows для керування пробросом у WSL — схоже, він також автоматично налаштовує правила udev. -Whether you're on native Linux or WSL, at this point you should be able to run :code:`hackrf_info` and see something like: +Незалежно від того, працюєте ви на «чистому» Linux чи в WSL, на цьому етапі ви маєте змогу виконати :code:`hackrf_info` і побачити щось на кшталт: .. code-block:: bash @@ -100,13 +100,13 @@ Whether you're on native Linux or WSL, at this point you should be able to run : Hardware appears to have been manufactured by Great Scott Gadgets. Hardware supported by installed firmware: HackRF One -Let's also make an IQ recording of the FM band, 10 MHz wide centered at 100 MHz, and we'll grab 1 million samples: +Також зробімо запис IQ-сигналу FM-діапазону шириною 10 МГц із центром на 100 МГц, збережемо 1 мільйон відліків: .. code-block:: bash hackrf_transfer -r out.iq -f 100000000 -s 10000000 -n 1000000 -a 0 -l 30 -g 50 -This utility produces a binary IQ file of int8 samples (2 bytes per IQ sample), which in our case should be 2MB. If you're curious, the signal recording can be read in Python using the following code: +Ця утиліта створює двійковий IQ-файл із відліками типу int8 (2 байти на IQ-відлік), у нашому випадку його розмір має становити 2 МБ. Якщо цікаво, запис можна прочитати в Python за допомогою такого коду: .. code-block:: python @@ -117,19 +117,19 @@ This utility produces a binary IQ file of int8 samples (2 bytes per IQ sample), print(samples[0:10]) print(np.max(samples)) -If your max is 127 (which means you saturated the ADC) then lower the two gain values at the end of the command. +Якщо максимальне значення дорівнює 127 (це означає, що ви наситили АЦП), зменште два значення підсилення наприкінці команди. -Installing the Python API +Встановлення Python API ######################### -Lastly, we must install the HackRF One `Python bindings `_, maintained by `GvozdevLeonid `_. This was tested to work in Ubuntu 22.04 on 11/04/2024 using the latest main branch. +Нарешті, потрібно встановити `Python-зв'язки `_ для HackRF One, які підтримує `GvozdevLeonid `_. Ці інструкції були перевірені на Ubuntu 22.04 04.11.2024 з використанням останньої гілки main. .. code-block:: bash sudo apt install libusb-1.0-0-dev pip install python_hackrf==1.2.7 -We can test the above install by running the following code, if there are no errors (there will also be no output) then everything should be good to go! +Перевірити встановлення можна, виконавши наведений нижче код; якщо помилок немає (і вивід теж відсутній), усе працює як слід! .. code-block:: python @@ -140,44 +140,44 @@ We can test the above install by running the following code, if there are no err sdr.pyhackrf_set_antenna_enable(False) sdr.pyhackrf_set_freq(100e6) sdr.pyhackrf_set_amp_enable(False) - sdr.pyhackrf_set_lna_gain(30) # LNA gain - 0 to 40 dB in 8 dB steps - sdr.pyhackrf_set_vga_gain(50) # VGA gain - 0 to 62 dB in 2 dB steps + sdr.pyhackrf_set_lna_gain(30) # підсилення LNA — від 0 до 40 дБ із кроком 8 дБ + sdr.pyhackrf_set_vga_gain(50) # підсилення VGA — від 0 до 62 дБ із кроком 2 дБ sdr.pyhackrf_close() -For an actual test of receiving samples, see the example code below. +Для фактичної перевірки приймання відліків дивіться приклад коду нижче. ******************************** -Tx and Rx Gain +Підсилення передавача та приймача ******************************** -Receive Side +Приймальна частина ############ -The HackRF One on the receive side has three different gain stages: +HackRF One на прийомі має три каскади підсилення: -* RF (:code:`amp`, either 0 or 11 dB) -* IF (:code:`lna`, 0 to 40 dB in 8 dB steps) -* baseband (:code:`vga`, 0 to 62 dB in 2 dB steps) +* РЧ (:code:`amp`, або 0, або 11 дБ) +* ПЧ (:code:`lna`, від 0 до 40 дБ із кроком 8 дБ) +* базова смуга (:code:`vga`, від 0 до 62 дБ із кроком 2 дБ) -For receiving most signals, it is recommended to leave the RF amplifier off (0 dB), unless you are dealing with an extremely weak signal and there are definitely no strong signals nearby. The IF (LNA) gain is the most important gain stage to adjust, to maximize your SNR while avoiding saturation of the ADC, that is the first knob to adjust. The baseband gain can be left at a relatively high value, e.g., we will just leave it at 50 dB. +Для приймання більшості сигналів рекомендується залишати РЧ-підсилювач вимкненим (0 дБ), якщо тільки ви не працюєте з украй слабким сигналом і поруч точно немає потужних сигналів. Найважливіший каскад — підсилення ПЧ (LNA); саме його слід регулювати першим, щоб максимізувати SNR і не наситити АЦП. Підсилення базової смуги можна залишити відносно високим, наприклад 50 дБ. -Transmit Side +Передавальна частина ############# -On the transmit side, there are two gain stages: +На передаванні є два каскади підсилення: -* RF [either 0 or 11 dB] -* IF [0 to 47 dB in 1 dB steps] +* РЧ (або 0, або 11 дБ) +* ПЧ (від 0 до 47 дБ із кроком 1 дБ) -You will likely want the RF amplifier enabled, and then you can adjust the IF gain to suit your needs. +Зазвичай варто вмикати РЧ-підсилювач, а потім налаштовувати підсилення ПЧ відповідно до ваших потреб. ************************************************** -Receiving IQ Samples within Python with the HackRF +Приймання IQ-відліків у Python за допомогою HackRF ************************************************** -Currently the :code:`python_hackrf` Python package does not include any convenience functions for receiving samples, it is simply a set of Python bindings that map to the HackRF's C++ API. That means in order to receive IQ, we have to use a decent amount of code. The Python package is set up to use a callback function in order to receive more samples, this is a function that we must set up, but it will automatically get called whenever there are more samples ready from the HackRF. This callback function always needs to have three specific arguments, and it needs to return :code:`0` if we want another set of samples. In the code below, within each call to our callback function, we convert the samples to NumPy's complex type, scale them from -1 to +1, and then store them in a larger :code:`samples` array +Наразі пакет :code:`python_hackrf` не містить жодних зручних функцій для приймання відліків — це лише набір Python-зв'язків до C++ API HackRF. Це означає, що для отримання IQ-відліків нам доведеться написати доволі багато коду. Пакет Python використовує функцію зворотного виклику для передавання чергової порції відліків; ми маємо її реалізувати, але вона викликатиметься автоматично, щойно HackRF підготує нові дані. Ця функція зворотного виклику завжди повинна мати три конкретні аргументи і повертати :code:`0`, якщо ми хочемо отримати наступний блок відліків. У наведеному нижче коді в кожному виклику нашого зворотного виклику ми перетворюємо відліки на комплексний тип NumPy, масштабуємо їх у діапазон від –1 до +1 і зберігаємо в більшому масиві :code:`samples`. -After running the code below, if in your time plot, the samples are reaching the ADC limits of -1 and +1, then reduce :code:`lna_gain` by 3 dB until it is clearly not hitting the limits. +Після виконання коду, якщо на графіку часу відліки досягають меж АЦП –1 та +1, зменшуйте :code:`lna_gain` на 3 дБ, доки значення явно не перестануть впиратися в обмеження. .. code-block:: python @@ -186,27 +186,28 @@ After running the code below, if in your time plot, the samples are reaching the import numpy as np import time - # These settings should match the hackrf_transfer example used in the textbook, and the resulting waterfall should look about the same - recording_time = 1 # seconds - center_freq = 100e6 # Hz + # Ці налаштування мають збігатися з прикладом hackrf_transfer у підручнику, + # і отриманий водоспад повинен виглядати приблизно так само + recording_time = 1 # секунди + center_freq = 100e6 # Гц sample_rate = 10e6 baseband_filter = 7.5e6 - lna_gain = 30 # 0 to 40 dB in 8 dB steps - vga_gain = 50 # 0 to 62 dB in 2 dB steps + lna_gain = 30 # від 0 до 40 дБ із кроком 8 дБ + vga_gain = 50 # від 0 до 62 дБ із кроком 2 дБ pyhackrf.pyhackrf_init() sdr = pyhackrf.pyhackrf_open() - allowed_baseband_filter = pyhackrf.pyhackrf_compute_baseband_filter_bw_round_down_lt(baseband_filter) # calculate the supported bandwidth relative to the desired one + allowed_baseband_filter = pyhackrf.pyhackrf_compute_baseband_filter_bw_round_down_lt(baseband_filter) # обчислюємо підтримувану смугу відносно бажаної sdr.pyhackrf_set_sample_rate(sample_rate) sdr.pyhackrf_set_baseband_filter_bandwidth(allowed_baseband_filter) - sdr.pyhackrf_set_antenna_enable(False) # It seems this setting enables or disables power supply to the antenna port. False by default. the firmware auto-disables this after returning to IDLE mode + sdr.pyhackrf_set_antenna_enable(False) # схоже, цей параметр вмикає або вимикає живлення на антенному порту. False за замовчуванням, прошивка автоматично вимикає його після повернення до режиму IDLE sdr.pyhackrf_set_freq(center_freq) - sdr.pyhackrf_set_amp_enable(False) # False by default - sdr.pyhackrf_set_lna_gain(lna_gain) # LNA gain - 0 to 40 dB in 8 dB steps - sdr.pyhackrf_set_vga_gain(vga_gain) # VGA gain - 0 to 62 dB in 2 dB steps + sdr.pyhackrf_set_amp_enable(False) # False за замовчуванням + sdr.pyhackrf_set_lna_gain(lna_gain) # підсилення LNA — від 0 до 40 дБ із кроком 8 дБ + sdr.pyhackrf_set_vga_gain(vga_gain) # підсилення VGA — від 0 до 62 дБ із кроком 2 дБ print(f'center_freq: {center_freq} sample_rate: {sample_rate} baseband_filter: {allowed_baseband_filter}') @@ -214,13 +215,13 @@ After running the code below, if in your time plot, the samples are reaching the samples = np.zeros(num_samples, dtype=np.complex64) last_idx = 0 - def rx_callback(device, buffer, buffer_length, valid_length): # this callback function always needs to have these four args + def rx_callback(device, buffer, buffer_length, valid_length): # ця функція зворотного виклику завжди має приймати саме ці чотири аргументи global samples, last_idx accepted = valid_length // 2 - accepted_samples = buffer[:valid_length].astype(np.int8) # -128 to 127 - accepted_samples = accepted_samples[0::2] + 1j * accepted_samples[1::2] # Convert to complex type (de-interleave the IQ) - accepted_samples /= 128 # -1 to +1 + accepted_samples = buffer[:valid_length].astype(np.int8) # від -128 до 127 + accepted_samples = accepted_samples[0::2] + 1j * accepted_samples[1::2] # перетворюємо на комплексний тип (деінтерливуємо IQ) + accepted_samples /= 128 # від -1 до +1 samples[last_idx: last_idx + accepted] = accepted_samples last_idx += accepted @@ -237,7 +238,7 @@ After running the code below, if in your time plot, the samples are reaching the sdr.pyhackrf_close() pyhackrf.pyhackrf_exit() - samples = samples[100000:] # get rid of the first 100k samples just to be safe, due to transients + samples = samples[100000:] # на всяк випадок відкидаємо перші 100 тис. відліків через перехідні процеси fft_size = 2048 num_rows = len(samples) // fft_size @@ -248,27 +249,27 @@ After running the code below, if in your time plot, the samples are reaching the plt.figure(0) plt.imshow(spectrogram, aspect='auto', extent=extent) # type: ignore - plt.xlabel("Frequency [MHz]") - plt.ylabel("Time [s]") + plt.xlabel("Частота [МГц]") + plt.ylabel("Час [с]") plt.figure(1) plt.plot(np.real(samples[0:10000])) plt.plot(np.imag(samples[0:10000])) - plt.xlabel("Samples") - plt.ylabel("Amplitude") - plt.legend(["Real", "Imaginary"]) + plt.xlabel("Відліки") + plt.ylabel("Амплітуда") + plt.legend(["Дійсна", "Уявна"]) plt.show() -When using an antenna that can receive the FM band, you should get something like the following, with several FM stations visible in the waterfall plot: +Під час використання антени, здатної приймати FM-діапазон, ви маєте отримати щось подібне до наведеного нижче, із декількома FM-станціями, видимими на водоспаді: .. image:: ../_images/hackrf_time_screenshot.png - :align: center + :align: center :scale: 50 % - :alt: Time plot of the samples grabbed from HackRF + :alt: Часовий графік відліків, отриманих із HackRF .. image:: ../_images/hackrf_freq_screenshot.png - :align: center + :align: center :scale: 50 % - :alt: Spectrogram (frequency over time) plot of the samples grabbed from HackRF + :alt: Водоспад (частота в часі) для відліків, отриманих із HackRF From c34516395af8ba19d9c5b0e74f603406650d9d09 Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:44:49 +0300 Subject: [PATCH 10/42] Translate cyclostationary chapter into Ukrainian --- content-ukraine/cyclostationary.rst | 425 ++++++++++++++-------------- 1 file changed, 213 insertions(+), 212 deletions(-) diff --git a/content-ukraine/cyclostationary.rst b/content-ukraine/cyclostationary.rst index 6c959a35..258355c0 100644 --- a/content-ukraine/cyclostationary.rst +++ b/content-ukraine/cyclostationary.rst @@ -1,86 +1,86 @@ .. _freq-domain-chapter: ########################## -Cyclostationary Processing +Циклостаціонарна обробка ########################## .. raw:: html - Co-authored by Sam Brown + У співавторстві з Sam Brown -In this chapter we demystify cyclostationary signal processing (a.k.a. CSP), a relatively niche area of RF signal processing that is used to analyze or detect (often in very low SNR!) signals that exhibit cyclostationary properties, such as most modern digital modulation schemes. We cover the Cyclic Autocorrelation Function (CAF), Spectral Correlation Function (SCF), Spectral Coherence Function (COH), conjugate versions of these functions, and how they can be applied. This chapter includes several full Python implementations, with examples that involve BPSK, QPSK, OFDM, and multiple combined signals. +У цьому розділі ми розкриваємо суть обробки циклостаціонарних сигналів (cyclostationary signal processing, або CSP) — відносно нішової галузі радіочастотної (RF) обробки сигналів, яка використовується для аналізу або виявлення (часто за дуже низького SNR!) сигналів із циклостаціонарними властивостями, зокрема більшості сучасних схем цифрової модуляції. Ми розглянемо циклічну автокореляційну функцію (CAF), спектральну кореляційну функцію (SCF), функцію спектральної когерентності (COH), їхні спряжені варіанти та способи застосування. Розділ містить кілька повних реалізацій на Python з прикладами, що охоплюють BPSK, QPSK, OFDM та кілька одночасних сигналів. **************** -Introduction +Вступ **************** -Cyclostationary signal processing (a.k.a., CSP or simply cyclostationary processing) is a set of techniques for exploiting the cyclostationary property found in many real-world communication signals. These are signals such as modulated signals like AM/FM/TV broadcast, cellular, and WiFi as well as radar signals, and other signals that exhibit periodicity in their statistics. A large swath of traditional signal processing techniques are based on the assumption that the signal is stationary, i.e., the statistics of the signal like the mean, variance and higher-order moments do not change over time. However, most real-world RF signals are cyclostationary, i.e., the statistics of the signal change *periodically* over time. CSP techniques exploit this cyclostationary property, and can be used to detect the presence of signals in noise, perform modulation recognition, and separate signals that are overlapping in both time and frequency. +Циклостаціонарна обробка сигналів (CSP або просто циклостаціонарна обробка) — це набір технік, що дозволяє використовувати циклостаціонарну властивість, притаманну багатьом реальним комунікаційним сигналам. Це можуть бути модульовані сигнали, як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистика яких має періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклостаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклостаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. -If after reading through this chapter and playing around in Python, you want to dive deeper into CSP, check out William Gardner's 1994 textbook `Cyclostationarity in Communications and Signal Processing `_, his 1987 textbook `Statistical Spectral Analysis `_, or Chad Spooner's `collection of blog posts `_. +Якщо після читання цього розділу та експериментів у Python ви захочете глибше зануритися в CSP, перегляньте підручник Вільяма Гарднера 1994 року `Cyclostationarity in Communications and Signal Processing `_, його підручник 1987 року `Statistical Spectral Analysis `_, або `збірку публікацій у блозі Чада Спунера `_. -One resource that you will find here and in no other textbook: at the end of the SCF chapter you will be rewarded with an interactive JavaScript app that allows you to play around with the SCF of an example signal, to see how the SCF changes with different signal and SCF parameters, all in your browser! While these interactive demos are free for everyone, they are largely made possible by the support of PySDR's `Patreon `_ members. +Ресурс, який ви знайдете лише тут і ні в жодному підручнику: наприкінці розділу про SCF на вас чекає інтерактивний JavaScript-додаток, що дає змогу експериментувати зі SCF прикладного сигналу та спостерігати, як SCF змінюється за різних параметрів сигналу та самої SCF — усе це просто у вашому браузері! Хоча ці інтерактивні демонстрації безкоштовні для всіх, здебільшого вони стають можливими завдяки підтримці учасників `Patreon PySDR `_. ************************* -Review of Autocorrelation +Огляд автокореляції ************************* -Even if you think you're familiar with the autocorrelation function, it is worth taking a moment to review it, because it is the foundation of CSP. The autocorrelation function is a measure of the similarity (a.k.a., correlation) between a signal and the time-shifted version of itself. Intuitively, it represents the degree to which a signal exhibits repetitive behavior. The autocorrelation of signal :math:`x(t)` is defined as: +Навіть якщо ви вважаєте, що знайомі з автокореляційною функцією, варто зробити паузу й пригадати її, адже вона лежить в основі CSP. Автокореляційна функція — це міра подібності (кореляції) між сигналом і його копією, зсуненою в часі. Інтуїтивно вона відображає, наскільки сигнал демонструє повторювану поведінку. Автокореляція сигналу :math:`x(t)` визначається так: .. math:: R_x(\tau) = E[x(t)x^*(t-\tau)] -where :math:`E` is the expectation operator, :math:`\tau` is the time delay, and :math:`*` is the complex conjugate symbol. In discrete time, with a limited number of samples, which is what we care about, this becomes: +де :math:`E` — оператор математичного сподівання, :math:`\tau` — часовий зсув, а :math:`*` позначає комплексне спряження. У дискретному часі з обмеженою кількістю відліків, що нас і цікавить, маємо: .. 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] -where :math:`N` is the number of samples in the signal. +де :math:`N` — кількість відліків сигналу. -If the signal is periodic in some way, such as a QPSK signal's repeating symbol shape, then the autocorrelation evaluated over a range of tau will also be periodic. For example, if a QPSK signal has 8 samples per symbol, then when tau is an integer multiple of 8, there will be a much stronger "measure of the similarity" than other values of tau. The period of the autocorrelation is what we will ultimately be detecting as part of CSP techniques. +Якщо сигнал має певну періодичність, наприклад періодичну форму символів у QPSK, то автокореляція, обчислена на проміжку різних :math:`\tau`, теж буде періодичною. Наприклад, якщо QPSK-сигнал має 8 відліків на символ, то для :math:`\tau`, кратних 8, міра «подібності» значно вища, ніж для інших значень :math:`\tau`. Період автокореляції — це те, що зрештою виявляють методи CSP. ************************************************ -The Cyclic Autocorrelation Function (CAF) +Циклічна автокореляційна функція (CAF) ************************************************ -As discussed in the previous section, we want to find out when there is periodicity in our autocorrelation. Recall the Fourier transform equation, where if we want to test how strong a certain frequency :math:`f` exists within some arbitrary signal :math:`x(t)`, we can do so with: +Як зазначено вище, ми прагнемо визначити, чи є періодичність в автокореляції. Пригадаємо формулу перетворення Фур’є: якщо ми хочемо з’ясувати, наскільки сильно в деякому сигналі :math:`x(t)` присутня певна частота :math:`f`, ми можемо обчислити: .. math:: X(f) = \int x(t) e^{-j2\pi ft} dt -So if we want to find periodicity in our autocorrelation, we simply calculate: +Тож, щоб знайти періодичність в автокореляції, просто обчислимо: .. 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. -or in discrete time: +або в дискретному часі: .. 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} -which tests how strong frequency :math:`\alpha` is. We call the above equation the Cyclic Autocorrelation Function (CAF). Another way to think about the CAF is as a set of Fourier series coefficients that describe this periodicity. In other words, the CAF is the amplitude and phase of the harmonics present in a signal's autocorrelation. We use the term "cyclostationary" to refer to signals that possess a periodic or almost periodic autocorrelation. The CAF is an extension of the traditional autocorrelation function to cyclostationary signals. +Це дозволяє перевірити, наскільки сильною є частота :math:`\alpha`. Наведений вище вираз називається циклічною автокореляційною функцією (CAF). Інший спосіб інтерпретації CAF — це набір коефіцієнтів ряду Фур’є, які описують згадану періодичність. Іншими словами, CAF — це амплітуда та фаза гармонік, присутніх в автокореляції сигналу. Ми використовуємо термін «циклостаціонарний», щоб описати сигнали, автокореляція яких є періодичною або майже періодичною. CAF є розширенням традиційної автокореляційної функції для циклостаціонарних сигналів. -It can be seen that the CAF is a function of two variables, the delay :math:`\tau` (tau) and the cycle frequency :math:`\alpha`. Cycle frequencies in CSP represent the rates at which a signals' statistics change, which in the case of the CAF, is the second-order moment or variance. Therefore, cycle frequencies often correspond to prominent periodic behavior such as modulated symbols in communications signals. We will see how the symbol rate of a BPSK signal and its integer multiples (harmonics) manifest as cycle frequencies in the CAF. +Видно, що CAF залежить від двох змінних: затримки :math:`\tau` (tau) та циклічної частоти :math:`\alpha`. Циклічні частоти в CSP відображають швидкість зміни статистики сигналу, що у випадку CAF означає момент другого порядку або дисперсію. Тож циклічні частоти часто відповідають виразним періодичним явищам, таким як символи, що модулюються в комунікаційних сигналах. Побачимо, як символова швидкість сигналу BPSK та її цілі кратні (гармоніки) проявляються як циклічні частоти у CAF. -In Python, the CAF of baseband signal :code:`samples` at a given :code:`alpha` and :code:`tau` value can be computed using the following code snippet (we'll fill out the surrounding code shortly): +У Python CAF базового сигналу :code:`samples` для заданих :code:`alpha` та :code:`tau` можна обчислити за допомогою такого фрагмента (ми незабаром додамо обрамлювальний код): .. code-block:: python - + CAF = (np.exp(1j * np.pi * alpha * tau) * - np.sum(samples * np.conj(np.roll(samples, tau)) * + np.sum(samples * np.conj(np.roll(samples, tau)) * np.exp(-2j * np.pi * alpha * np.arange(N)))) -We use :code:`np.roll()` to shift one of the sets of samples by tau, because you have to shift by an integer number of samples, so if we shifted both sets of samples in opposite directions we would skip every other shift. We also have to add a frequency shift to account for the fact that we're shifting by 1 sample at a time, and only on one side (instead of half a sample on both sides like the basic CAF equation). The frequency of the shift is :code:`alpha/2`. +Ми використовуємо :code:`np.roll()` для зсуву одного набору відліків на :code:`tau`, адже потрібно зміщувати на ціле число відліків. Якби ми зсували обидва набори у протилежних напрямках, ми пропускали б кожне друге зміщення. Також необхідно додати частотний зсув, щоб компенсувати те, що ми зміщуємо на 1 відлік за раз і лише з одного боку (замість половини відліку в обидва боки, як у базовому рівнянні CAF). Частота цього зсуву дорівнює :code:`alpha/2`. -In order to play with the CAF in Python, we first need to simulate an example signal. For now we will use a rectangular BPSK signal (i.e., BPSK without pulse-shaping applied) with 20 samples per symbol, added to some white Gaussian noise (AWGN). We will apply a frequency offset to the BPSK signal, so that later we can show off how cyclostationary processing can be used to estimate the frequency offset as well as the cyclic frequency. This frequency offset is equivalent to your radio receiving a signal while not perfectly centered on it; either a little off or way off (but not too much to cause the signal to extend past the sampled bandwidth). +Щоб погратися з CAF у Python, спершу змоделюємо приклад сигналу. Поки що використаємо прямокутний сигнал BPSK (тобто BPSK без формування імпульсу) з 20 відліками на символ та додамо білий гаусів шум (AWGN). Ми навмисне внесемо частотний зсув у сигнал BPSK, аби пізніше продемонструвати, як циклостаціонарна обробка допомагає оцінювати і частотний зсув, і циклічну частоту. Цей зсув відповідає ситуації, коли приймач не ідеально налаштований на частоту сигналу: або трохи хибить, або суттєво, але не настільки, щоб сигнал виходив за межі смуги дискретизації. -The following code snippet simulates the IQ samples we will use for the remainder of the next two sections: +Наведений нижче код генерує IQ-відліки, які ми використовуватимемо впродовж двох наступних розділів: .. 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 @@ -88,18 +88,18 @@ The following code snippet simulates the IQ samples we will use for the remainde noise = np.random.randn(N) + 1j*np.random.randn(N) # complex white Gaussian noise samples = bpsk + 0.1*noise # add noise to the signal -Because the absolute sample rate and symbol rate doesn't really matter anywhere in this chapter, we will use normalized frequency, which is effectively the same as saying our sample rate = 1 Hz. This means the signal must be between -0.5 to +0.5 Hz. Regardless, you *won't* see the variable :code:`sample_rate` show up in any of the code snippets, on purpose, instead we will work with samples per symbol (:code:`sps`). +Оскільки абсолютні швидкість дискретизації та швидкість символів у цьому розділі не відіграють ролі, ми використовуємо нормалізовані частоти, що еквівалентно припущенню, що частота дискретизації = 1 Гц. Це означає, що сигнал мусить лежати в діапазоні від -0.5 до +0.5 Гц. Тому ви *не* побачите змінної :code:`sample_rate` у коді: ми працюємо через кількість відліків на символ (:code:`sps`). -Just for fun, let's look at the power spectral density (i.e., FFT) of the signal itself, *before* any CSP is performed: +Для розігріву погляньмо на щільність спектральної потужності (PSD, тобто FFT) сигналу до будь-якої обробки CSP: .. image:: ../_images/psd_of_bpsk_used_for_caf.svg - :align: center + :align: center :target: ../_images/psd_of_bpsk_used_for_caf.svg - :alt: PSD of BPSK used for CAF + :alt: PSD прямокутного сигналу BPSK, що використовується для CAF -It has the 0.2 Hz frequency shift that we applied, and the samples per symbol of 20 leads to a fairly narrow signal, but because we did not apply pulse shaping, the signal tapers off very slowly in frequency. +На графіку видно частотний зсув 0.2 Гц, який ми додали, і те, що 20 відліків на символ формують доволі вузький сигнал, але через відсутність формування імпульсу спектр спадає дуже повільно. -Now we will compute the CAF at the correct alpha, and over a range of tau values (we'll use tau from -50 to +50 as a starting point). The correct alpha in our case is simply the samples per symbol inverted, or 1/20 = 0.05 Hz. To generate the CAF in Python, we will loop over tau: +Тепер обчислимо CAF для правильного :math:`\alpha` та діапазону :math:`\tau` (візьмемо від -50 до +50). Правильне :math:`\alpha` у нашому випадку — це обернена величина кількості відліків на символ, тобто 1/20 = 0.05 Гц. Щоб отримати CAF у Python, проітеруємося за :math:`\tau`: .. code-block:: python @@ -109,26 +109,26 @@ Now we will compute the CAF at the correct alpha, and over a range of tau values 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.sum(samples * np.conj(np.roll(samples, taus[i])) * np.exp(-2j * np.pi * alpha_of_interest * np.arange(N)))) -Let's plot the real part of :code:`CAF` using :code:`plt.plot(taus, np.real(CAF))`: +Побудуємо дійсну частину :code:`CAF` за допомогою :code:`plt.plot(taus, np.real(CAF))`: .. image:: ../_images/caf_at_correct_alpha.svg - :align: center + :align: center :target: ../_images/caf_at_correct_alpha.svg - :alt: CAF at correct alpha + :alt: CAF для правильного значення alpha -It looks a little funny, but keep in mind that tau represents the time domain, and the important part is that there is a lot of energy in the CAF at this alpha, because it's the alpha corresponding to a cyclic frequency within our signal. To prove this, let's look at the CAF at an incorrect alpha, say 0.08 Hz: +Вигляд трохи дивний, але зважайте, що :math:`\tau` представляє часову вісь, а найважливіше — велика енергія CAF для цього :math:`\alpha`, адже воно відповідає циклічній частоті нашого сигналу. Щоб переконатися, розгляньмо CAF для «неправильного» :math:`\alpha`, скажімо 0.08 Гц: .. image:: ../_images/caf_at_incorrect_alpha.svg - :align: center + :align: center :target: ../_images/caf_at_incorrect_alpha.svg - :alt: CAF at incorrect alpha + :alt: CAF для неправильного значення alpha -Note the y-axis, there is way less energy in the CAF this time. The specific patterns we see above are less important at the moment, and will make more sense after we study the SCF in the next section. +Зверніть увагу на вісь Y — енергії CAF тепер значно менше. Конкретні шаблони поки не такі важливі; вони стануть зрозумілішими після вивчення SCF у наступному розділі. -One thing we can do is calculate the CAF over a range of alphas, and at each alpha we can find the power in the CAF, by taking its magnitude and taking either the sum or average (doesn't make a difference in this case). Then if we plot these powers over alpha, we should see spikes at the cyclic frequencies within our signal. The following code adds a :code:`for` loop, and uses an alpha step size of 0.005 Hz (note that this will take a long time to run!): +Ще один підхід — обчислити CAF у діапазоні :math:`\alpha`, а для кожного :math:`\alpha` знайти потужність CAF, взявши модуль і суму (або середнє — тут не суттєво). Потім, якщо побудувати цю потужність залежно від :math:`\alpha`, побачимо сплески на циклічних частотах сигналу. Наступний код додає цикл :code:`for` та використовує крок :math:`\alpha` 0.005 Гц (зверніть увагу, що виконання триватиме довго!): .. code-block:: python @@ -137,7 +137,7 @@ One thing we can do is calculate the CAF over a range of alphas, and at each alp 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.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) @@ -145,21 +145,21 @@ One thing we can do is calculate the CAF over a range of alphas, and at each alp plt.ylabel('CAF Power') .. image:: ../_images/caf_avg_over_alpha.svg - :align: center + :align: center :target: ../_images/caf_avg_over_alpha.svg - :alt: CAF average over alpha + :alt: Середня потужність CAF залежно від alpha -Not only do we see the expected spike at 0.05 Hz, but we also see a spike at integer multiples of 0.05 Hz. This is because the CAF is a Fourier series, and the harmonics of the fundamental frequency are present in the CAF, especially when we are looking at PSK/QAM signals without pulse shaping. The energy at alpha = 0 is the total power in the power spectral density (PSD) of the signal, although we will typically null it out because 1) we often plot the PSD on its own and 2) it will throw off the dynamic range of our colormap when we start plotting 2D data with a colormap. +Бачимо очікуваний пік на 0.05 Гц, а також на цілих кратних 0.05 Гц. Це тому, що CAF — це ряд Фур’є, і гармоніки основної частоти присутні в CAF, особливо для PSK/QAM без формування імпульсу. Енергія на :math:`\alpha = 0` відповідає загальній потужності у PSD сигналу, хоча зазвичай ми її занулюємо, адже 1) PSD часто будують окремо і 2) вона псує динамічний діапазон колірної карти, коли ми починаємо відображати 2D-дані. -While the CAF is interesting, we often want to view cyclic frequency *over RF frequency*, instead of just cyclic frequency on its own like we see above. This leads us to the Spectral Correlation Function (SCF), which we will discuss next. +Хоч CAF цікавий, зазвичай нам хочеться побачити циклічну частоту *як функцію RF-частоти*, а не лише циклічну частоту, як у графіку вище. Це приводить нас до спектральної кореляційної функції (SCF), яку розглянемо далі. ************************************************ -The Spectral Correlation Function (SCF) +Спектральна кореляційна функція (SCF) ************************************************ -Just as the CAF shows us the periodicity in the autocorrelation of a signal, the SCF shows us the periodicity in the PSD of a signal. The autocorrelation and the PSD are in fact a Fourier transform pair, and therefore it should not come as a surprise that the CAF and the SCF are also a Fourier Transform pair. This relationship is known as the *Cyclic Wiener Relationship*. This fact should make even more sense when one considers that the CAF and SCF evaluated at a cycle frequency of :math:`\alpha=0` are the autocorrelation and PSD, respectively. +Подібно до того, як CAF показує періодичність в автокореляції сигналу, SCF демонструє періодичність у PSD сигналу. Автокореляція та PSD є парою перетворення Фур’є, тож не дивно, що CAF і SCF також є парою перетворення Фур’є. Це співвідношення називають *циклічним співвідношенням Вінера* (Cyclic Wiener Relationship). Воно стає ще зрозумілішим, якщо згадати, що CAF і SCF при :math:`\alpha = 0` відповідають автокореляції та PSD відповідно. -One can simply take the Fourier transform of the CAF to obtain the SCF. Returning to our 20 sample-per-symbol BPSK signal, let's look at the SCF at the correct alpha (0.05 Hz). All we need to do is take the FFT of the CAF and plot the magnitude. The following code snippet goes along with the CAF code we wrote earlier when computing just one alpha: +SCF можна отримати простим перетворенням Фур’є CAF. Повернімося до нашого BPSK із 20 відліками на символ і розгляньмо SCF для правильного :math:`\alpha` (0.05 Гц). Все, що треба, — взяти FFT від CAF та побудувати модуль. Наведений нижче код доповнює попередній приклад, де ми обчислювали одне значення :math:`\alpha`: .. code-block:: python @@ -170,15 +170,15 @@ One can simply take the Fourier transform of the CAF to obtain the SCF. Returni plt.ylabel('SCF') .. image:: ../_images/fft_of_caf.svg - :align: center + :align: center :target: ../_images/fft_of_caf.svg - :alt: FFT of CAF + :alt: FFT від CAF -Note that we can see the 0.2 Hz frequency offset that we applied when simulating the BPSK signal (this has nothing to do with the cyclic frequency or samples per symbol). This is why the CAF looked sinusoidal in the tau domain; it was primarily the RF frequency which in our example was relatively high. +Зверніть увагу, що видно частотний зсув 0.2 Гц, який ми внесли під час симуляції BPSK (він не пов’язаний із циклічною частотою чи кількістю відліків на символ). Саме тому CAF у часовій області виглядав синусоїдальним — домінувала RF-частота, яка у нашому прикладі досить висока. -Unfortunately, doing this for thousands or millions of alphas is extremely computationally intensive. The other downside of just taking the FFT of the CAF is it does not involve any averaging. Efficient/practical computing of the SCF usually involves some form of averaging; either time-based or frequency-based, as we will discuss in the next two sections. +На жаль, повторювати цю операцію для тисяч або мільйонів :math:`\alpha` надзвичайно витратно обчислювально. Інший недолік простого FFT від CAF — відсутність усереднення. Практичні алгоритми обчислення SCF зазвичай включають певне усереднення — за часом або частотою, як ми побачимо у двох наступних розділах. -Below is an interactive JavaScript app that implements an SCF, so that you can play around with different signal and SCF parameters to build your intuition. The frequency of the signal is a fairly straightforward knob, and shows how well the SCF can identify RF frequency. Try adding pulse shaping by unchecking the Rectangular Pulse option, and play around with different roll-off values. Note that using the default alpha-step, not all samples per symbols will lead to a visible spike in the SCF. You can try lowering alpha-step, although it will increase the processing time. +Нижче наведено інтерактивний JavaScript-додаток, що реалізує SCF та дозволяє експериментувати з різними параметрами сигналу і SCF, формуючи інтуїцію. Частота сигналу — доволі очевидний регулятор, він показує, наскільки добре SCF може визначити RF-частоту. Спробуйте вимкнути прямокутні імпульси (Rectangular Pulse) і попрацювати з різними коефіцієнтами згладжування (roll-off). Зауважте, що з типовим кроком по :math:`\alpha` не всі значення відліків на символ призведуть до видимого піку в SCF. Ви можете зменшити крок, але це збільшить час обробки. .. raw:: html @@ -232,34 +232,34 @@ Below is an interactive JavaScript app that implements an SCF, so that you can p ******************************** -Frequency Smoothing Method (FSM) +Метод згладжування за частотою (FSM) ******************************** -Now that we have a good conceptual understanding of the SCF, let's look at how we can compute it efficiently. First, consider the periodogram which is simply the squared magnitude of the Fourier transform of a signal: +Тепер, коли ми маємо гарне інтуїтивне уявлення про SCF, розгляньмо, як обчислити її ефективно. Спершу пригадаємо періодограму — квадрат модуля перетворення Фур’є сигналу: .. math:: I(u,f) = \frac{1}{N}\left|X(u,f)\right|^2 - -We can obtain the cyclic periodogram through the product of two Fourier transforms shifted in frequency: + +Циклічну періодограму можна отримати, перемноживши два спектри Фур’є, зсунуті за частотою: .. math:: I(u,f,\alpha) = \frac{1}{N}X(u,f + \alpha/2) X^*(u,f - \alpha/2) -Both of these represent estimates of the PSD and the SCF, but to obtain the true value of the SCF one must average over either time or frequency. Averaging over time is known as the Time Smoothing Method (TSM): +Обидва вирази є оцінками PSD та SCF, але щоб отримати істинне значення SCF, потрібно усереднити або за часом, або за частотою. Усереднення за часом відоме як метод згладжування за часом (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 -while averaging over frequency is known as the Frequency Smoothing Method (FSM): +а усереднення за частотою називається методом згладжування за частотою (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] -where the function :math:`g_{\Delta}(f)` is a frequency smoothing function that averages over a small range of frequencies. +де функція :math:`g_{\Delta}(f)` виконує згладжування за невеликим діапазоном частот. -Below is a minimal Python implementation of the FSM, which is a frequency-based averaging method for calculating the SCF of a signal. First it computes the cyclic periodogram by multiplying two shifted versions of the FFT, and then each slice is filtered with a window function whose length determines the resolution of the resulting SCF estimate. So, longer windows will produce smoother results with lower resolution while shorter ones will do the opposite. +Нижче наведено мінімальну реалізацію FSM на Python — частотно-орієнтований метод усереднення для обчислення SCF сигналу. Спершу обчислюється циклічна періодограма через множення двох зсунених FFT, а потім кожен зріз фільтрується вікном, довжина якого визначає роздільну здатність отриманої оцінки SCF. Отже, довші вікна дають більш згладжений результат із нижчою роздільністю, коротші — навпаки. .. code-block:: python @@ -269,7 +269,7 @@ Below is a minimal Python implementation of the FSM, which is a frequency-based 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)): @@ -285,20 +285,20 @@ Below is a minimal Python implementation of the FSM, which is a frequency-based plt.ylabel('Cyclic Frequency [Normalized Hz]') plt.show() -Let's calculate the SCF for the rectangular BPSK signal we used before, with 20 samples per symbol over a range of cyclic frequencies from 0 to 0.3 using a 0.001 step size: +Обчислимо SCF для прямокутного BPSK, який ми використовували раніше, із 20 відліками на символ, у діапазоні циклічних частот від 0 до 0.3 з кроком 0.001: .. image:: ../_images/scf_freq_smoothing.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing.svg - :alt: SCF with the Frequency Smoothing Method (FSM), showing cyclostationary signal processing + :alt: SCF, обчислена методом згладжування за частотою (FSM) -This method has the advantage that only one large FFT is required, but it also has the disadvantage that many convolution operations are required for the smoothing. Note the decimation that occurs after the convolve using :code:`[::Nw]`; this is optional but highly recommended to reduce the number of pixels you'll ultimately need to display, and because of the way the SCF is calculated we're not "throwing away" information by decimating by :code:`Nw`. +Цей метод вимагає лише одного великого FFT, але потребує численних операцій згортки для згладжування. Зверніть увагу на проріджування після згортки :code:`[::Nw]`; воно не обов’язкове, але дуже бажане, щоб зменшити кількість пікселів для відображення, і завдяки способу обчислення SCF ми не «викидаємо» інформацію, проріджуючи на :code:`Nw`. *************************** -Time Smoothing Method (TSM) +Метод згладжування за часом (TSM) *************************** -Next we will look at an implementation of the TSM in Python. The code snippet below divides the signal into *num_windows* blocks, each of length *Nw* with an overlap of *Noverlap*. Note that the overlap functionality is not required, but tends to help make a nicer output. The signal is then multiplied by a window function (in this case, Hanning, but it can be any window) and the FFT is taken. The SCF is then calculated by averaging the result from each block. The window length plays the same exact role as in the FSM determining the resolution/smoothness trade-off. +Далі розглянемо реалізацію TSM у Python. Наведений нижче код ділить сигнал на *num_windows* блоків, кожен довжини *Nw* з перекриттям *Noverlap*. Зверніть увагу, що перекриття не є обов’язковим, але зазвичай дає приємніший результат. Сигнал множиться на віконну функцію (у цьому прикладі — вікно Ганна, але можна використовувати будь-яке) і береться FFT. Потім SCF обчислюється шляхом усереднення результатів для кожного блоку. Довжина вікна відіграє таку саму роль, як і в FSM, визначаючи компроміс між роздільністю та згладженістю. .. code-block:: python @@ -329,33 +329,33 @@ Next we will look at an implementation of the TSM in Python. The code snippet be plt.show() .. image:: ../_images/scf_time_smoothing.svg - :align: center + :align: center :target: ../_images/scf_time_smoothing.svg - :alt: SCF with the Time Smoothing Method (TSM), showing cyclostationary signal processing + :alt: SCF, обчислена методом згладжування за часом (TSM) -Looks roughly the same as the FSM! +Результат дуже схожий на FSM! ***************** -Pulse-Shaped BPSK +BPSK із формуванням імпульсу ***************** -Up until this point, we have only investigated CSP of a *rectangular* BPSK signal. However, in actual RF systems, we almost never see rectangular pulses, with the one exception being the BPSK chipping sequence within direct-sequence spread spectrum (DSSS) which tends to be approximately rectangular. +Досі ми розглядали CSP лише для *прямокутного* сигналу BPSK. Проте в реальних RF-системах майже ніколи не зустрінеш прямокутних імпульсів (виняток — чипова послідовність BPSK у DSSS, яка приблизно прямокутна). -Let's now look at a BPSK signal with a raised-cosine (RC) pulse shape, which is a common pulse shape used in digital communications, and is used to reduce the occupied bandwidth of the signal compared to rectangular BPSK. As discussed in the :ref:`pulse-shaping-chapter` chapter, the RC pulse shape in the time domain is given by: +Розгляньмо тепер сигнал BPSK із формуванням імпульсу за допомогою фільтра з піднятим косинусом (raised-cosine, RC) — це поширений варіант у цифрових системах, що дозволяє зменшити зайняту смугу порівняно з прямокутним BPSK. Як пояснюється в розділі :ref:`pulse-shaping-chapter`, RC-імпульс у часовій області описується: .. 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} -The :math:`\beta` parameter determines how quickly the filter tapers off in the time domain, which will be inversely proportional with how quickly it tapers off in frequency: +Параметр :math:`\beta` визначає, наскільки швидко фільтр спадає в часі, що обернено пропорційно швидкості спадання у частоті: .. image:: ../_images/raised_cosine_freq.svg - :align: center + :align: center :target: ../_images/raised_cosine_freq.svg - :alt: The raised cosine filter in the frequency domain with a variety of roll-off values + :alt: Частотна характеристика фільтра з піднятим косинусом для різних коефіцієнтів roll-off -Note that :math:`\beta=0` corresponds to an infinitely long pulse shape and thus is not practical. Also note that :math:`\beta=1` does *not* correspond to a rectangular pulse shape. The roll-off factor is typically chosen to be between 0.2 and 0.4 in practice. +Зверніть увагу: :math:`\beta=0` відповідає нескінченно довгому імпульсу, тож такий варіант непрактичний. Також :math:`\beta=1` *не* означає прямокутний імпульс. На практиці коефіцієнт roll-off зазвичай вибирають у діапазоні 0.2–0.4. -We can simulate a BPSK signal with a raised-cosine pulse shaping using the following code snippet; note the first 5 lines and last 4 lines are the same as rectangular BPSK: +Змоделювати сигнал BPSK із формуванням імпульсу RC можна наступним кодом; зауважте, що перші 5 рядків і останні 4 — ті самі, що й для прямокутного BPSK: .. code-block:: python @@ -375,13 +375,13 @@ We can simulate a BPSK signal with a raised-cosine pulse shaping using the follo 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 -Note that :code:`pulse_train` is simply our symbols with :code:`sps - 1` zeros after each one, in sequence, e.g.: +Змінна :code:`pulse_train` — це наші символи, між якими вставлено :code:`sps - 1` нулів, напр.: .. code-block:: bash @@ -390,70 +390,70 @@ Note that :code:`pulse_train` is simply our symbols with :code:`sps - 1` zeros a 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... -The plot below shows the pulse-shaped BPSK in the time domain, before noise, and before the frequency shift is added: +Нижче показано сигнал BPSK із формуванням імпульсу в часовій області, до додавання шуму і частотного зсуву: .. image:: ../_images/pulse_shaped_BSPK.svg - :align: center + :align: center :target: ../_images/pulse_shaped_BSPK.svg - :alt: Pulse-shaped BPSK signal with a raised-cosine pulse shape + :alt: Сигнал BPSK із формуванням імпульсу RC -Now let's calculate the SCF of this pulse-shaped BPSK signal with a roll-off of 0.3, 0.6, and 0.9. We will use the same frequency shift of 0.2 Hz, and the FSM implementation, with the same FSM parameters and symbol length as used in the rectangular BPSK example, to make it a fair comparison: +Обчислимо SCF цього сигналу з коефіцієнтом roll-off 0.3, 0.6 та 0.9. Використаємо той самий частотний зсув 0.2 Гц і реалізацію FSM з тими самими параметрами, що й у прикладі з прямокутним BPSK, для чесного порівняння: :code:`beta = 0.3`: .. image:: ../_images/scf_freq_smoothing_pulse_shaped_bpsk.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing_pulse_shaped_bpsk.svg - :alt: SCF of pulse-shaped BPSK using the Frequency Smoothing Method (FSM) beta 0.3 + :alt: SCF сигналу BPSK із формуванням імпульсу (FSM), beta = 0.3 :code:`beta = 0.6`: .. image:: ../_images/scf_freq_smoothing_pulse_shaped_bpsk2.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing_pulse_shaped_bpsk2.svg - :alt: SCF of pulse-shaped BPSK using the Frequency Smoothing Method (FSM) beta 0.6 + :alt: SCF сигналу BPSK із формуванням імпульсу (FSM), beta = 0.6 :code:`beta = 0.9`: .. image:: ../_images/scf_freq_smoothing_pulse_shaped_bpsk3.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing_pulse_shaped_bpsk3.svg - :alt: SCF of pulse-shaped BPSK using the Frequency Smoothing Method (FSM) beta 0.9 + :alt: SCF сигналу BPSK із формуванням імпульсу (FSM), beta = 0.9 -In all three, we no longer get the sidelobes in the frequency axis, and in the cyclic frequency axis we don't get the same powerful harmonics of the fundamental cyclic frequency. This is because the raised-cosine pulse shape has a much better spectral containment than the rectangular pulse shape, and the sidelobes are much lower. As a result, pulse-shaped signals tend to have a much "cleaner" SCF than rectangular signals, resembling a single spike with a smearing above it. This will apply to all single carrier digitally modulated signals, not just BPSK. As beta gets larger we get a broader spike in the frequency axis because the signal takes up more bandwidth. +В усіх трьох випадках ми більше не бачимо бічних пелюсток на осі частоти, а на осі циклічної частоти відсутні потужні гармоніки базової циклічної частоти. Це тому, що RC-фільтр забезпечує набагато краще обмеження спектра порівняно з прямокутними імпульсами, тож бічні пелюстки значно слабші. У результаті сигнали з формуванням імпульсу мають набагато «чистішу» SCF, схожу на один пік із розмиттям над ним. Це стосується всіх одноносійних цифрових сигналів, не лише BPSK. Зі збільшенням :math:`\beta` пік на осі частоти розширюється, оскільки сигнал займає більшу смугу. ******************************** -SNR and Number of Symbols +SNR та кількість символів ******************************** -Coming Soon! We will cover how at a certain point, higher SNR doesn't help, and instead you need more symbols, and how packet-based waveforms will lead to a limited number of symbols per transmission. +Незабаром! Ми розглянемо, чому після певного порогу збільшення SNR не допомагає — натомість потрібна більша кількість символів, і як пакетні хвилі призводять до обмеженої кількості символів у передачі. ******************************** -QPSK and Higher-Order Modulation +QPSK та модуляції вищих порядків ******************************** -Coming Soon! It will include QPSK, higher order PSK, QAM, and a brief intro into higher-order cyclic moments and cumulants. +Незабаром! У розділі буде QPSK, вищі порядки PSK, QAM та короткий вступ до циклічних моментів і кумулянтів вищих порядків. ******************************** -Multiple Overlapping Signals +Кілька перекривних сигналів ******************************** -Up until now we have only looked at one signal at a time, but what if our received signal contains multiple individual signals that overlap in frequency, time, and even cyclic frequency (i.e., have the same samples per symbol)? If signals don't overlap in frequency at all, you can use simple filtering to separate them, and a PSD to detect them, assuming they are above the noise floor. If they don't overlap in time, then you can detect the rising and falling edge of each transmission, then use time-gating to separate the signal processing of each one. In CSP we are often focused on detecting the presence of signals at different cyclic frequencies that overlap in both time and frequency. +Досі ми розглядали по одному сигналу, але що, якщо в отриманому сигналі одночасно присутні кілька сигналів, які перекриваються за частотою, часом і навіть циклічною частотою (тобто мають однакову кількість відліків на символ)? Якщо сигнали зовсім не перекриваються в частоті, можна застосувати просте фільтрування та PSD для їх виявлення (за умови, що вони вище шумового порога). Якщо вони не перекриваються в часі, можна визначити моменти увімкнення/вимкнення кожної передачі й обробляти кожну окремо. У CSP нас зазвичай цікавить виявлення сигналів на різних циклічних частотах, які перекриваються одночасно і за часом, і за частотою. -Let's simulate three signals, each with different properties: +Змоделюємо три сигнали з різними властивостями: -* Signal 1: Rectangular BPSK with 20 samples per symbol and 0.2 Hz frequency offset -* Signal 2: Pulse-shaped BPSK with 20 samples per symbol, -0.1 Hz frequency offset, and 0.35 roll-off -* Signal 3: Pulse-shaped QPSK with 4 samples per symbol, 0.2 Hz frequency offset, and 0.21 roll-off +* Сигнал 1: прямокутний BPSK із 20 відліками на символ і частотним зсувом 0.2 Гц +* Сигнал 2: BPSK із формуванням імпульсу, 20 відліків на символ, частотний зсув -0.1 Гц, коефіцієнт roll-off 0.35 +* Сигнал 3: QPSK із формуванням імпульсу, 4 відліки на символ, частотний зсув 0.2 Гц, коефіцієнт roll-off 0.21 -As you can see, we have two signals that have the same cyclic frequency, and two with the same RF frequency. This will let us experiment with different degrees of parameter overlap. +Отже, маємо два сигнали з однаковою циклічною частотою та два — з однаковою RF-частотою. Це дозволить дослідити різні ступені перекриття параметрів. -A fractional delay filter with an arbitrary (non-integer) delay is applied to each signal, so that there are no weird artifacts caused by the signals being simulated with aligned samples (learn more about this in the :ref:`sync-chapter` chapter). The rectangular BPSK signal is reduced in power compared to the other two, as rectangular-pulsed signals exhibit very strong cyclostationary properties so they tend to dominate the SCF. +До кожного сигналу додається фільтр дробової затримки з довільною (нецілою) затримкою, щоб уникнути артефактів, пов’язаних із синхронним розташуванням відліків (докладніше про це в розділі :ref:`sync-chapter`). Потужність прямокутного BPSK зменшено порівняно з двома іншими, оскільки сигнали з прямокутними імпульсами мають дуже виражені циклостаціонарні властивості й схильні домінувати в SCF. .. raw:: html
- Expand for Python code simulating the three signals + Показати код Python для симуляції трьох сигналів .. code-block:: python @@ -510,29 +510,29 @@ A fractional delay filter with an arbitrary (non-integer) delay is applied to ea
-Before we dive into the CSP, let's look at the PSD of this signal: +Перш ніж перейти до CSP, подивімося на PSD цього сигналу: .. image:: ../_images/psd_of_multiple_signals.svg - :align: center + :align: center :target: ../_images/psd_of_multiple_signals.svg - :alt: PSD of three different signals + :alt: PSD трьох різних сигналів -Signals 1 and 3, which are on the positive side of the PSD, overlap and you can barely see Signal 1 (which is narrower) sticking out. We can also get a feel for the noise level. +Сигнали 1 і 3, розташовані на додатній частоті, перекриваються, і вузький сигнал 1 ледве виглядає. Також за графіком видно рівень шуму. -We will now use the FSM to calculate the SCF of these combined signals: +Тепер використаємо FSM для обчислення SCF суми цих сигналів: .. image:: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg - :alt: SCF of three different signals using the Frequency Smoothing Method (FSM) + :alt: SCF трьох сигналів, обчислена методом згладжування за частотою (FSM) -Notice how Signal 1, even though it's rectangular pulse-shaped, has its harmonics mostly masked by the cone above Signal 3. Recall that in the PSD, Signal 1 was "hiding behind" Signal 3. Through CSP, we can detect that Signal 1 is present, and get a close approximation of its cyclic frequency, which can then be used to synchronize to it. This is the power of cyclostationary signal processing! +Зауважте, що сигнал 1, хоч і з прямокутними імпульсами, переважно маскується «конусом» над сигналом 3. На PSD сигнал 1 «ховався» за сигналом 3. Завдяки CSP ми можемо виявити присутність сигналу 1 та приблизно визначити його циклічну частоту, яку потім можна використати для синхронізації. Ось у чому сила циклостаціонарної обробки! ************************ -Alternative CSP Features +Альтернативні ознаки CSP ************************ -The SCF is not the only way to detect cyclostationarity in a signal, especially if you don't care about seeing cyclic frequency over RF frequency. One simple method (both in terms of conceptually and computational complexity) involves taking the **FFT of the magnitude** of the signal, and looking for spikes. In Python this is extremely simple: +SCF — не єдиний спосіб виявляти циклостаціонарність сигналу, особливо якщо вам не потрібно розглядати циклічну частоту як функцію RF-частоти. Проста (і концептуально, і обчислювально) техніка передбачає взяття **FFT від модуля** сигналу й пошук піків. У Python це виглядає так: .. code-block:: python @@ -540,9 +540,9 @@ The SCF is not the only way to detect cyclostationarity in a signal, especially #samples_mag = samples * np.conj(samples) # pretty much the same as line above magnitude_metric = np.abs(np.fft.fft(samples_mag)) -Note that this method is effectively the same as multiplying the signal by the complex conjugate of itself, then taking the FFT. +Зверніть увагу, що цей метод еквівалентний множенню сигналу на власне комплексне спряження з наступним взяттям FFT. -Before plotting the metric we will null out the DC component, as it will contain a lot of energy and throw off the dynamic range. We will also get rid of half of the FFT output, because the input to the FFT is real, so the output is symmetric. We can then plot the metric and see the spikes: +Перед побудовою графіка занулімо DC-компонент, бо вона містить багато енергії й псує динамічний діапазон. Також відкиньмо половину виходу FFT, оскільки вхід реальний, а отже результат симетричний. Після цього можна побудувати графік і побачити піки: .. code-block:: python @@ -551,18 +551,18 @@ Before plotting the metric we will null out the DC component, as it will contain f = np.linspace(-0.5, 0.5, len(samples)) plt.plot(f, magnitude_metric) -You can then use a peak finding algorithm, such as SciPy's :code:`signal.find_peaks()`. Below we plot :code:`magnitude_metric` for each of the three signals used in the Multiple Overlapping Signals section, first individually, then combined: +Далі можна застосувати алгоритм пошуку піків, наприклад :code:`signal.find_peaks()` зі SciPy. На рисунку нижче показано :code:`magnitude_metric` для кожного з трьох сигналів із попереднього розділу (спершу окремо, потім разом): .. image:: ../_images/non_csp_metric.svg - :align: center + :align: center :target: ../_images/non_csp_metric.svg - :alt: Metric for detecting cyclostationarity in a signal without using a CAF or SCF + :alt: Метрика для виявлення циклостаціонарності без використання CAF чи SCF -The rectangular BPSK harmonics are unfortunately overlapping with the other signal's cyclic frequencies, but this shows one downside of this alternative approach: you can't view cyclic frequency over RF frequency like in the SCF. +Гармоніки прямокутного BPSK, на жаль, перекриваються з циклічними частотами інших сигналів — це демонструє недолік цього альтернативного підходу: він не дозволяє розглядати циклічну частоту як функцію RF-частоти, як це робить SCF. -While this method exploits cyclostationarity in signals, it's typically not considered a "CSP technique", perhaps due to its simplicity... +Хоч цей метод і використовує циклостаціонарність сигналів, його зазвичай не відносять до «технік CSP», можливо, через простоту... -For finding the RF frequency of a signal, i.e., the carrier frequency offset, there is a similar trick. For BPSK signals, all you have to do is take the FFT of the signal squared (this will be a complex input to the FFT). It will show a spike at the carrier frequency offset multiplied by two. For QPSK signals, you can take the FFT of the signal to the 4th power, and it will show a spike at the carrier frequency offset multiplied by 4. +Для пошуку RF-частоти сигналу, тобто зсуву несучої, існує схожий прийом. Для сигналів BPSK достатньо взяти FFT від сигналу у квадраті (вхід FFT буде комплексним). Це дасть пік на частоті, що дорівнює подвоєному зсуву несучої. Для QPSK можна взяти FFT від сигналу в четвертій степені й отримати пік на частоті, що дорівнює зсуву несучої, помноженому на 4. .. code-block:: python @@ -574,35 +574,35 @@ For finding the RF frequency of a signal, i.e., the carrier frequency offset, th 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 -You can try this method out on your own simulated or captured signals, it's very useful outside of CSP. +Спробуйте цей метод на своїх симульованих чи записаних сигналах — він дуже корисний і поза CSP. ********************************* -Spectral Coherence Function (COH) +Функція спектральної когерентності (COH) ********************************* -*TLDR: The spectral coherence function is a normalized version of the SCF that, in some situations, is worth using in place of the regular SCF.* +*Коротко: функція спектральної когерентності — це нормалізована версія SCF, яка в деяких випадках є кориснішою за звичайну SCF.* -Another measure of cyclostationarity, which can prove more insightful than the raw SCF in many cases, is the Spectral Coherence Function (COH). The COH takes the SCF and normalizes it such that the result lies between -1 and 1 (although we will be looking at magnitude which is between 0 and 1). This is useful because it isolates the information about the cyclostationarity of the signal from information about the signal's power spectrum, both of which are contained in the raw SCF. By normalizing, the power spectrum information is removed from the result leaving only the effects of cyclic correlation. +Ще одна міра циклостаціонарності, що в багатьох випадках може дати більше інформації, ніж «сире» SCF, — це функція спектральної когерентності (COH). COH нормалізує SCF так, що результат лежить у діапазоні від -1 до 1 (ми розглядатимемо модуль, тобто 0–1). Це корисно, адже із результату вилучається інформація про спектр потужності сигналу, яку містить «сире» SCF. Нормалізація залишає лише впливи циклічної кореляції. -To aide in one's understanding of the COH, it is helpful to review the concept of the `correlation coefficient `_ from statistics. The correlation coefficient :math:`\rho_{X,Y}` quantifies the degree to which two random variables :math:`X` and :math:`Y` are related, on a scale from -1 to 1. It is defined as the covariance divided by the product of the standard deviations: +Щоб краще зрозуміти COH, згадаємо `коефіцієнт кореляції `_ зі статистики. Коефіцієнт кореляції :math:`\rho_{X,Y}` вимірює зв’язок між двома випадковими величинами :math:`X` і :math:`Y` у діапазоні -1…1. Він визначається як ковариація, поділена на добуток стандартних відхилень: .. math:: \rho_{X,Y} = \frac{E[(X-\mu_X)(Y-\mu_Y)]}{\sigma_X \sigma_Y} -The COH extends this concept to spectral correlation such that it quantifies the degree to which the power spectral density (PSD) of a signal at one frequency is related to the PSD of the same signal at another frequency. These two frequencies are simply the frequency shifts that we apply as part of calculating the SCF. To calculate the COH, we first calculate the SCF as before, denoted :math:`S_X(f,\alpha)`, and then normalize by the product of two shifted PSD terms, analogous to normalizing by the product of standard deviations: +COH розширює цю концепцію на спектральну кореляцію: він оцінює, наскільки PSD сигналу на одній частоті пов’язана з PSD того самого сигналу на іншій частоті. Ці дві частоти — це частотні зсуви, які ми застосовуємо під час обчислення SCF. Щоб обчислити COH, спершу обчислюємо SCF (позначимо його :math:`S_X(f,\alpha)`), а потім нормалізуємо, поділивши на добуток двох зсунених PSD, аналогічно до поділу на добуток стандартних відхилень: .. math:: \rho = C_x(f, \alpha) = \frac{S_X(f,\alpha)}{\sqrt{C_x^0(f + \alpha/2) C_x^0(f - \alpha/2)}} -The denominator is the important/new part, the two terms :math:`C_x^0(f + \alpha/2)` and :math:`C_x^0(f - \alpha/2)` are simply the PSD shifted by :math:`\alpha/2` and :math:`-\alpha/2`. Another way to think about this is that the SCF is a cross-spectral density (a power spectrum that involves two input signals) while the normalizing terms in the denominator are the auto-spectral densities (power spectra that involve only one input signal). +Знаменник — ключовий новий елемент: :math:`C_x^0(f + \alpha/2)` та :math:`C_x^0(f - \alpha/2)` — це PSD, зсунуті на :math:`\alpha/2` та :math:`-\alpha/2`. Іншими словами, SCF — це крос-спектральна густина (спектр потужності з двома вхідними сигналами), а нормувальні члени в знаменнику — автоспектральні густини (спектри потужності для одного сигналу). -We will now apply this to our Python code, specifically the SCF using the frequency smoothing method (FSM). Because the FSM does the averaging in the frequency domain, we already have :math:`C_x^0(f + \alpha/2)` and :math:`C_x^0(f - \alpha/2)` at our disposal, in the Python code they are simply :code:`np.roll(X, -shift)` and :code:`np.roll(X, shift)` because :code:`X` is our signal after taking the FFT. So all we have to do is multiply them together, take the square root, and divide our SCF slice by that result (note that this happens within the for loop over alpha): +Застосуймо це до нашого коду, зокрема до SCF, обчисленого методом FSM. Оскільки FSM виконує усереднення в частотній області, ми вже маємо :math:`C_x^0(f + \alpha/2)` та :math:`C_x^0(f - \alpha/2)`, які в коді відповідають :code:`np.roll(X, -shift)` та :code:`np.roll(X, shift)`, адже :code:`X` — це FFT сигналу. Тож залишилось перемножити їх, взяти корінь і поділити зріз SCF на цей результат (зверніть увагу, що це відбувається всередині циклу за :math:`\alpha`): .. code-block:: python COH_slice = SCF_slice / np.sqrt(np.roll(X, -shift) * np.roll(X, shift)) -Lastly, we will repeat the same convolve and decimation that we did to calculate the final SCF slice +Нарешті повторимо згортку та проріджування, як і для SCF: .. code-block:: python @@ -611,7 +611,7 @@ Lastly, we will repeat the same convolve and decimation that we did to calculate .. raw:: html
- Expand for the full code to generate and plot both the SCF and COH + Показати повний код для побудови SCF та COH .. code-block:: python @@ -619,9 +619,9 @@ Lastly, we will repeat the same convolve and decimation that we did to calculate 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) @@ -653,65 +653,66 @@ Lastly, we will repeat the same convolve and decimation that we did to calculate
-Now let us calculate the COH (as well as regular SCF) for a rectangular BPSK signal with 20 samples per symbol and 0.2 Hz frequency offset: +Обчислимо COH (та звичайну SCF) для прямокутного BPSK із 20 відліками на символ і частотним зсувом 0.2 Гц: .. image:: ../_images/scf_coherence.svg - :align: center + :align: center :target: ../_images/scf_coherence.svg - :alt: SCF and COH of a rectangular BPSK signal with 20 samples per symbol and 0.2 Hz frequency offset + :alt: SCF і COH прямокутного сигналу BPSK із 20 відліками на символ і зсувом 0.2 Гц -As you can see, the higher alphas are much more pronounced in the COH than in the SCF. Running the same code on the pulse-shaped BPSK signal we find there is not a ton of difference: +Бачимо, що в COH значно виразніші високі значення :math:`\alpha`, ніж у SCF. Якщо запустити той самий код для BPSK із формуванням імпульсу, різниця буде не такою помітною: .. image:: ../_images/scf_coherence_pulse_shaped.svg - :align: center + :align: center :target: ../_images/scf_coherence_pulse_shaped.svg - :alt: SCF and COH of a pulse-shaped BPSK signal with 20 samples per symbol and 0.2 Hz frequency offset + :alt: SCF і COH сигналу BPSK з формуванням імпульсу, 20 відліків на символ, зсув 0.2 Гц -Try generating both the SCF and COH for your application to see which one works best! +Спробуйте обчислити SCF і COH для вашої задачі, щоб визначити, який варіант підходить краще! ********** -Conjugates +Спряжені варіанти ********** -Up until this point, we have been using the following formulas for the CAF and the SCF where the complex conjugate (:math:`*` symbol) of the signal is used in the second term: +Досі ми використовували такі формули для CAF і SCF, де в другому множнику застосовується комплексне спряження (:math:`*`): .. 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 -There is, however, an alternate form for the CAF and SCF in which there is no conjugate included. These forms are called the *conjugate CAF* and the *conjugate SCF*, respectively. The naming convention it's a little confusing, but the main thing to remember is that there's a "normal" version of the CAF/SCF, and a conjugate version. The conjugate version is useful when you want to extract more information from the signal, but it's not always necessary depending on the signal. The conjugate CAF and SCF are defined as: +Однак існує альтернативна форма CAF і SCF без спряження. Їх називають *спряженою CAF* та *спряженою SCF*. Назва трохи заплутана, але головне пам’ятати, що існує «звичайна» версія CAF/SCF і спряжена версія. Спряжені варіанти дозволяють отримати більше інформації із сигналу, але не завжди потрібні залежно від ситуації. Визначення спряжених функцій: .. 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 -which is the same as the original CAF and SCF, but without the conjugate. The discrete time versions are also all the same except for the conjugate being removed. +Це ті самі вирази, що й для оригінальних CAF і SCF, але без спряження. Дискретні версії відрізняються лише відсутністю спряження. -To understand the significance of the conjugate forms, consider the quadrature representation of a real-valued bandpass signal: +Щоб зрозуміти сенс спряжених форм, розгляньмо квадратурне представлення реального смугового сигналу: .. 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)` and :math:`x_Q(t)` are the in-phase (I) and quadrature (Q) components of the signal, respectively, and it is these IQ samples that we are ultimately processing with CSP at baseband. +де :math:`x_I(t)` та :math:`x_Q(t)` — відповідно інфазна та квадратурна компоненти сигналу, тобто IQ-відліки, які ми обробляємо на базовій частоті. -Using Euler's formula, :math:`e^{jx} = \cos(x) + j \sin(x)`, we can rewrite the above equation using complex exponentials: +Використовуючи формулу Ейлера :math:`e^{jx} = \cos(x) + j \sin(x)`, перепишемо вище наведений вираз через комплексні експоненти: .. 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 can use complex envelope, which we will call :math:`z(t)`, to represent the real-valued signal :math:`y(t)`, assuming that the signal bandwidth is much smaller than the carrier frequency :math:`f_c` which is typically the case in RF applications: +Можемо представити реальний сигнал :math:`y(t)` через комплексну огинаючу :math:`z(t)`, припускаючи, що смуга сигналу значно вужча за несучу :math:`f_c`, що типовою для RF-застосувань: .. 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} -This is known as the complex-baseband representation. +Це називається комплексно-базовим представленням. -Coming back to the CAF, let's try computing the portion of the CAF known as the "lag product", which is just the :math:`x(t + \tau/2) x(t - \tau/2)` part: +Повернімося до CAF і спробуймо обчислити «лаговий добуток», тобто частину :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) + \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) -Although it may not be immediately obvious, this result contains four terms corresponding to the four combinations of conjugated and non-conjugated :math:`z(t)`: +Хоч це неочевидно одразу, результат містить чотири доданки — усі комбінації спряжених і неспряжених :math:`z(t)`: .. math:: z(t + \tau/2) z(t - \tau/2) e^{(\ldots)} \\ @@ -719,20 +720,20 @@ Although it may not be immediately obvious, this result contains four terms corr z^*(t + \tau/2) z(t - \tau/2) e^{(\ldots)} \\ z^*(t + \tau/2) z^*(t - \tau/2) e^{(\ldots)} -It turns out that the 1st and 4th ones are effectively the same thing as far as information we can obtain from them, as are the 2nd and 3rd. So there are really only two cases we care about, the conjugate case and the non-conjugate case. In summary, if one wishes to obtain the full extent of statistical information from :math:`y(t)`, each combination of conjugated and non-conjugated terms must be considered. +Виявляється, що перший та четвертий доданки по суті несуть однакову інформацію, як і другий із третім. Тож залишаються дві справді важливі комбінації — зі спряженням і без нього. Підсумовуючи: щоб отримати всю статистичну інформацію зі :math:`y(t)`, потрібно розглянути всі комбінації спряжених та неспряжених членів. -In order to implement the conjugate SCF using the frequency smoothing method, there is one extra step beyond removing the :code:`conj()`, because we are doing one big FFT and then averaging in the frequency domain. There is a property of the Fourier transform that states that a complex conjugate in the time domain corresponds to the frequency domain being flipped and conjugated: +Щоб реалізувати спряжену SCF методом FSM, потрібно зробити ще один крок понад просте видалення :code:`conj()`, адже ми беремо один великий FFT і усереднюємо в частотній області. Існує властивість перетворення Фур’є: комплексне спряження у часовій області відповідає перевернутому й спряженому спектру: .. math:: x^*(t) \leftrightarrow X^*(-f) -Now because we were already complex conjugating the second term in the normal SCF (recall that we were using the code :code:`SCF_slice = np.roll(X, -shift) * np.conj(np.roll(X, shift))`), when we complex conjugate it again it just goes away, so what we are left with is the following: +Оскільки в звичайній SCF ми вже брали комплексне спряження другого множника (пригадайте код :code:`SCF_slice = np.roll(X, -shift) * np.conj(np.roll(X, shift))`), при додатковому спряженні воно просто зникає, і залишається таке: .. code-block:: python SCF_slice = np.roll(X, -shift) * np.flip(np.roll(X, -shift - 1)) -Note the added :code:`np.flip()`, and the :code:`roll()` needs to happen in the reverse direction. The full FSM implementation of the conjugate SCF is as follows: +Зверніть увагу на доданий :code:`np.flip()` та те, що зсув :code:`roll()` відбувається в протилежному напрямку. Повна реалізація FSM для спряженої SCF виглядає так: .. code-block:: python @@ -742,7 +743,7 @@ Note the added :code:`np.flip()`, and the :code:`roll()` needs to happen in the 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)): @@ -757,67 +758,67 @@ Note the added :code:`np.flip()`, and the :code:`roll()` needs to happen in the plt.ylabel('Cyclic Frequency [Normalized Hz]') plt.show() -Another big change with the conjugate SCF is that we want to calculate alphas between -1 and +1, whereas with the normal SCF we just did 0.0 to 0.5 due to symmetry. You will see why this is the case first-hand once we start looking at the conjugate SCF of example signals. +Ще одна важлива відмінність спряженої SCF — необхідність обчислювати :math:`\alpha` у діапазоні від -1 до +1, тоді як у звичайній SCF ми використовували 0.0–0.5 через симетрію. Ви зрозумієте чому, коли побачите приклади спряженої SCF. -Now what is the importance of doing the conjugate SCF? To demonstrate, let's look at the conjugate SCF of our basic rectangular BPSK signal with 20 samples per symbol (leading to a cyclic frequency of 0.05 Hz) and 0.2 Hz frequency offset: +Що ж нам дає спряжена SCF? Для початку подивімося на спряжену SCF нашого базового прямокутного BPSK із 20 відліками на символ (циклічна частота 0.05 Гц) і частотним зсувом 0.2 Гц: .. image:: ../_images/scf_conj_rect_bpsk.svg - :align: center + :align: center :target: ../_images/scf_conj_rect_bpsk.svg - :alt: Conjugate SCF of rectangular BPSK using the Frequency Smoothing Method (FSM) + :alt: Спряжена SCF прямокутного BPSK, обчислена методом FSM -Here is the big take-away from this section: what you ultimately get in the conjugate SCF are spikes at the cyclic frequency +/- **twice** the carrier frequency offset, which we will refer to as :math:`f_c`. In the frequency axis it will be centered at 0 Hz instead of :math:`f_c`. Our frequency offset was 0.2 Hz, so we end up getting spikes at 0.4 Hz +/- the cyclic frequency of 0.05 Hz. If there is one thing to remember about the conjugate SCF, it is to expect spikes at: +Головний висновок: у спряженій SCF з’являються піки на циклічній частоті +/- **подвійний** зсув несучої, позначимо його :math:`f_c`. На осі частоти вони зосереджені навколо 0 Гц, а не :math:`f_c`. У нашому прикладі :math:`f_c = 0.2` Гц, тож спостерігаємо піки на 0.4 +/- 0.05 Гц. Якщо запам’ятати щось одне про спряжену SCF, то це таке співвідношення: .. math:: 2f_c \pm \alpha -Let's now look at pulse-shaped BPSK with the same 0.2 Hz offset, 20 samples per symbol, and a 0.3 roll-off: +Розгляньмо BPSK із формуванням імпульсу з тими ж параметрами (зсув 0.2 Гц, 20 відліків на символ, roll-off 0.3): .. image:: ../_images/scf_conj_pulseshaped_bpsk.svg - :align: center + :align: center :target: ../_images/scf_conj_pulseshaped_bpsk.svg - :alt: Conjugate SCF of raised cosine pulse-shaped BPSK using the Frequency Smoothing Method (FSM) + :alt: Спряжена SCF BPSK із формуванням імпульсу RC, обчислена методом FSM -Seems reasonable given the normal SCF pattern we saw with BPSK. +Результат цілком відповідає очікуваному з урахуванням звичайної SCF для BPSK. -Now for the fun part, let's look at the conjugate SCF of rectangular QPSK with the same 0.2 Hz and 20 samples per symbol: +А тепер найцікавіше — розглянемо спряжену SCF прямокутного QPSK з тими ж 0.2 Гц і 20 відліками на символ: .. image:: ../_images/scf_conj_rect_qpsk.svg - :align: center + :align: center :target: ../_images/scf_conj_rect_qpsk.svg - :alt: Conjugate SCF of rectangular QPSK using the Frequency Smoothing Method (FSM) + :alt: Спряжена SCF прямокутного QPSK, обчислена методом FSM -At first it might seem like there was a bug in our code, but take a look at the colorbar, which indicates what values the colors correspond to. When using :code:`plt.imshow()` with automatic scaling, you have to be aware that it's always going to scale the colors (in our case, purple through yellow) from the lowest value to the highest value of the 2D array we give it. In the case of our conjugate SCF of QPSK, the entire output is relatively low, because it turns out *there are no spikes in the conjugate SCF when using QPSK*. Here is the same QPSK output but using the scaling to match our previous BPSK examples: +Спершу може здатися, що в коді помилка, але погляньте на кольорову шкалу — вона показує, які значення відповідають кольорам. Якщо використовувати :code:`plt.imshow()` зі стандартним масштабуванням, треба пам’ятати, що кольори (у нас — від фіолетового до жовтого) завжди масштабуються від мінімального до максимального значення вхідного 2D-масиву. У випадку спряженої SCF QPSK весь результат дуже малий, адже *піки відсутні*. Ось той самий результат, але зі шкалою, як у попередніх прикладах BPSK: .. image:: ../_images/scf_conj_rect_qpsk_scaled.svg - :align: center + :align: center :target: ../_images/scf_conj_rect_qpsk_scaled.svg - :alt: Conjugate SCF of rectangular QPSK using the Frequency Smoothing Method (FSM) with scaling + :alt: Спряжена SCF прямокутного QPSK (FSM) із переналаштованою шкалою -Note the range of the colorbar. +Зверніть увагу на діапазон кольорової шкали. -The conjugate SCF for QPSK, as well as higher order PSK and QAM, is essentially zero/noise. This means we can use the conjugate SCF to detect the presence of BPSK (e.g., the chipping sequence in DSSS) even if there are a bunch of QPSK/QAM signals overlapping with it. This is a very powerful tool in the CSP toolbox! +Спряжена SCF для QPSK, а також для PSK та QAM вищих порядків, фактично дорівнює нулю/шуму. Це означає, що спряжену SCF можна використати для виявлення сигналів BPSK (наприклад, чипової послідовності в DSSS), навіть якщо одночасно присутні численні сигнали QPSK/QAM. Дуже потужний інструмент у наборі CSP! -Let's try running the conjugate SCF on the three-signal scenario we've been using several times throughout this tutorial, which includes the following signals: +Розгляньмо спряжену SCF для сценарію з трьома сигналами, який ми використовували раніше: -* Signal 1: Rectangular BPSK with 20 samples per symbol and 0.2 Hz frequency offset -* Signal 2: Pulse-shaped BPSK with 20 samples per symbol, -0.1 Hz frequency offset, and 0.35 roll-off -* Signal 3: Pulse-shaped QPSK with 4 samples per symbol, 0.2 Hz frequency offset, and 0.21 roll-off +* Сигнал 1: прямокутний BPSK із 20 відліками на символ і частотним зсувом 0.2 Гц +* Сигнал 2: BPSK із формуванням імпульсу, 20 відліків на символ, частотний зсув -0.1 Гц, коефіцієнт roll-off 0.35 +* Сигнал 3: QPSK із формуванням імпульсу, 4 відліки на символ, частотний зсув 0.2 Гц, коефіцієнт roll-off 0.21 .. image:: ../_images/scf_conj_multiple_signals.svg - :align: center + :align: center :target: ../_images/scf_conj_multiple_signals.svg - :alt: Conjugate SCF of three different signals using the Frequency Smoothing Method (FSM) + :alt: Спряжена SCF трьох сигналів, обчислена методом FSM -Notice how we can see the two BPSK signals but the QPSK signal doesn't show up, or else we would see a spike at alpha = 0.65 and 0.15 Hz. It might be hard to see without zooming in, but there are spikes at 0.4 +/- 0.05 Hz and -0.2 +/- 0.05 Hz. +Бачимо обидва сигнали BPSK, а сигнал QPSK не проявляється — інакше ми б побачили пік на :math:`\alpha = 0.65` та 0.15 Гц. Якщо придивитися, видно піки на 0.4 +/- 0.05 Гц і -0.2 +/- 0.05 Гц. ******************************** -FFT Accumulation Method (FAM) +Метод накопичення FFT (FAM) ******************************** -The FSM and TSM techniques presented earlier work great, especially when you want to calculate a specific set of cyclic frequencies (note how both implementations involve looping over cyclic frequency as the outer loop). However, there is an even more efficient SCF implementation known as the FFT Accumulation Method (FAM), which inherently calculates the full set of cyclic frequencies (i.e., the cyclic frequencies corresponding to every integer shift of the signal, the number of which depend on signal length). There is also a similar technique known as the `Strip Spectral Correlation Analyzer (SSCA) `_ which also calculates all cyclic frequencies at once, but is not covered in this chapter to avoid repetition. This class of techniques that calculate all cyclic frequencies are sometimes referred to as "blind estimators" because they tend to be used when no prior knowledge of cyclic frequencies is known (otherwise, you would have a good idea of which cyclic frequencies to calculate and could use the FSM or TSM methods). The FAM is a time-smoothing method (think of it like a fancy TSM), while the SSCA is like a fancy FSM. +Методи FSM і TSM чудово працюють, особливо коли потрібно обчислити конкретний набір циклічних частот (зверніть увагу, що в обох реалізаціях зовнішнім циклом є перебір :math:`\alpha`). Проте існує ще ефективніша реалізація SCF — метод накопичення FFT (FFT Accumulation Method, FAM), який автоматично обчислює *всі* циклічні частоти (тобто ті, що відповідають кожному цілочисельному зсуву сигналу; їх кількість залежить від довжини сигналу). Існує схожий підхід, відомий як `Strip Spectral Correlation Analyzer (SSCA) `_, який також обчислює всі циклічні частоти за раз, але щоб уникнути повторів, ми не розглядатимемо його тут. Цей клас методів іноді називають «сліпими оцінювачами», адже їх використовують, коли наперед невідомо, які циклічні частоти очікуються (інакше можна було б оцінити потрібні :math:`\alpha` за допомогою FSM чи TSM). FAM належить до методів згладжування за часом (сприймайте його як удосконалений TSM), тоді як SSCA — як удосконалений FSM. -The minimal Python code to implement the FAM is actually fairly simple, although because we are no longer looping over alpha it is not as easy to tie back to the math. Just like the TSM, we break the signal into a bunch of time windows, with some overlap. A Hanning window is applied to each chunk of samples. There are two stages of FFTs performed as part of the FAM algorithm, and within the code note that the first FFT is performed on a 2D array, so it's doing a bunch of FFTs in one line of code. After a frequency shift, we do a second FFT to build the SCF (we then take the magnitude squared). For a more thorough explanation of the FAM, refer to the external resources at the end of this section. +Мінімальна реалізація FAM на Python досить проста, хоча через відсутність явного циклу за :math:`\alpha` складніше співвіднести її з математикою. Як і в TSM, ми ділимо сигнал на часові вікна з певним перекриттям та застосовуємо вікно Ганна. Алгоритм FAM виконує два етапи FFT; зверніть увагу, що перше FFT застосовується до 2D-масиву, тож за один рядок виконується багато окремих FFT. Після частотного зсуву виконується друге FFT для побудови SCF (потім беремо квадрат модуля). Детальніше про FAM можна прочитати в матеріалах наприкінці розділу. .. code-block:: python @@ -861,27 +862,27 @@ The minimal Python code to implement the FAM is actually fairly simple, although SCF[a-Mp:a+Mp, i] = np.abs(XF2[(P//2-Mp):(P//2+Mp)])**2 .. image:: ../_images/scf_fam.svg - :align: center + :align: center :target: ../_images/scf_fam.svg - :alt: SCF with the FFT Accumulation Method (FAM), showing cyclostationary signal processing + :alt: SCF, обчислена методом накопичення FFT (FAM) -Let's zoom into the interesting part around 0.2 Hz and the low cyclic frequencies, to see more detail: +Збільшимо ділянку навколо 0.2 Гц і низьких циклічних частот, щоб побачити деталі: .. image:: ../_images/scf_fam_zoomedin.svg - :align: center + :align: center :target: ../_images/scf_fam_zoomedin.svg - :alt: Zoomed in version of SCF with the FFT Accumulation Method (FAM), showing cyclostationary signal processing + :alt: Збільшений фрагмент SCF, обчисленої методом FAM -There is a clear hot spot at 0.05 Hz, and a low one at 0.1 Hz that may be tough to see with this colorscale. +Помітний яскравий максимум на 0.05 Гц і менш виразний на 0.1 Гц (його може бути важко розгледіти з такою шкалою). -We can also squash the RF frequency axis and plot the SCF in 1D, in order to more easily see which cyclic frequencies are present: +Можна також «сплющити» вісь RF-частоти та побудувати SCF в 1D, щоб легше побачити присутні циклічні частоти: .. image:: ../_images/scf_fam_1d.svg - :align: center + :align: center :target: ../_images/scf_fam_1d.svg - :alt: Cyclic freq plot using the FFT Accumulation Method (FAM), showing cyclostationary signal processing + :alt: Циклічні частоти, отримані методом FAM -One big gotcha with the FAM is that it will generate an enormous number of pixels, depending on your signal size, and when only one or two rows in the :code:`imshow()` contain the energy, they can sometimes be masked due to the scaling done to display it on your monitor. Make sure to note the size of the 2D SCF matrix, and if you want to reduce the number of pixels in the cyclic frequency axis, you can use a max pooling or mean pooling operation. Place this code after the SCF calculation and before plotting (you may need to :code:`pip install scikit-image`): +Одне з підводних каменів FAM — дуже велика кількість пікселів (залежно від довжини сигналу). Якщо енергія зосереджена лише в кількох рядках :code:`imshow()`, їх може бути важко побачити через масштабування при відображенні на екрані. Зверніть увагу на розмір 2D-матриці SCF; якщо хочете зменшити кількість пікселів за циклічною частотою, можна застосувати max pooling або average pooling. Розмістіть наступний код після обчислення SCF і до відображення (можливо, доведеться виконати :code:`pip install scikit-image`): .. code-block:: python @@ -891,19 +892,19 @@ One big gotcha with the FAM is that it will generate an enormous number of pixel SCF = skimage.measure.block_reduce(SCF, block_size=(16, 1), func=np.max) # type: ignore print("New shape of SCF:", SCF.shape) -External Resources on FAM: +Додаткові джерела про 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. `Available here `_ -* Da Costa, Evandro Luiz. Detection and identification of cyclostationary signals. Diss. Naval Postgraduate School, 1996. `Available here `_ -* Chad's blog post on FAM: https://cyclostationary.blog/2018/06/01/csp-estimators-the-fft-accumulation-method/ +* 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. `Доступно тут `_ +* Da Costa, Evandro Luiz. Detection and identification of cyclostationary signals. Diss. Naval Postgraduate School, 1996. `Доступно тут `_ +* Публікація Chada про FAM: https://cyclostationary.blog/2018/06/01/csp-estimators-the-fft-accumulation-method/ ******************************** OFDM ******************************** -Cyclostationarity is especially strong in OFDM signals due to OFDM's use of a cyclic prefix (CP), which is where the last several samples of each OFDM symbol is copied and added to the beginning of the OFDM symbol. This leads to a strong cyclic frequency corresponding to the OFDM symbol length (which is equal to the inverse of the subcarrier spacing, plus CP duration). +Циклостаціонарність особливо виражена в OFDM-сигналах через використання циклічного префікса (CP), коли кілька останніх відліків кожного OFDM-символу копіюються на його початок. Це створює сильну циклічну частоту, що відповідає довжині OFDM-символу (яка дорівнює оберненій величині міжсубдіапазонного інтервалу плюс тривалість CP). -Let's play around with an OFDM signal. Below is the simulation of an OFDM signal with a CP using 64 subcarriers, 25% CP, and QPSK modulation on each subcarrier. We'll interpolate by 2x to simulate receiving at a reasonable sample rate, so that means the OFDM symbol length in number of samples will be (64 + (64*0.25)) * 2 = 160 samples. That means we should get spikes at alphas that are an integer multiple of 1/160, or 0.00625, 0.0125, 0.01875, etc. We will simulate 100k samples which corresponds to 625 OFDM symbols (recall that each OFDM symbol is fairly long). +Змоделюймо OFDM-сигнал. Нижче наведено код, який генерує OFDM із CP, використовуючи 64 піднесучі, 25% CP і QPSK на кожній піднесучій. Ми інтерполюємо сигнал удвічі, щоб змоделювати приймання з достатньо високою швидкістю дискретизації, тому довжина OFDM-символу в відліках становитиме (64 + (64*0.25)) * 2 = 160. Це означає, що очікуємо піки на :math:`\alpha`, кратних 1/160 (0.00625, 0.0125, 0.01875 тощо). Симулюємо 100 тис. відліків, що відповідає 625 OFDM-символам (кожен символ доволі довгий). .. code-block:: python @@ -941,26 +942,26 @@ Let's play around with an OFDM signal. Below is the simulation of an OFDM signa n = np.sqrt(np.var(samples) * 10**(-SNR_dB/10) / 2) * (np.random.randn(N) + 1j*np.random.randn(N)) samples = samples + n -Using the FSM to calculate the SCF at a relatively high cyclic resolution of 0.0001: +Використаймо FSM для обчислення SCF з високою роздільністю за циклічною частотою (крок 0.0001): .. image:: ../_images/scf_freq_smoothing_ofdm.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing_ofdm.svg - :alt: SCF of OFDM using the Frequency Smoothing Method (FSM) + :alt: SCF OFDM-сигналу, обчислена методом FSM -Note the horizontal line towards the top, indicating there is a low cyclic frequency. Zooming into the lower cyclic frequencies, we can clearly see the cyclic frequency corresponding to the OFDM symbol length (alpha = 0.0125). Not sure why we only get a spike at 2x, and not 1x or 3x or 4x... Even dropping the resolution by another 10x doesn't show anything else besides the 2x, if anyone knows feel free to use the "Suggest an Edit" link at the bottom of this page. +Зверніть увагу на горизонтальну смугу у верхній частині — вона вказує на низьку циклічну частоту. Якщо наблизити нижній діапазон :math:`\alpha`, добре видно циклічну частоту, що відповідає довжині OFDM-символу (:math:`\alpha = 0.0125`). Не зовсім зрозуміло, чому ми бачимо пік лише на подвоєній частоті, а не на одинарній/потрійній/четвертній... Навіть зменшення роздільності в 10 разів не показує інших піків; якщо знаєте відповідь — натисніть «Suggest an Edit» внизу сторінки. .. image:: ../_images/scf_freq_smoothing_ofdm_zoomed_in.svg - :align: center + :align: center :target: ../_images/scf_freq_smoothing_ofdm_zoomed_in.svg - :alt: SCF of OFDM using the Frequency Smoothing Method (FSM) zoomed into the lower cyclic freqs + :alt: SCF OFDM-сигналу (FSM) у збільшеній області низьких циклічних частот -External resources on OFDM within the context of CSP: +Додаткові джерела про OFDM у контексті 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. `Available here `_ +#. 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. `Доступно тут `_ ******************************************** -Signal Detection With Known Cyclic Frequency +Виявлення сигналів із відомою циклічною частотою ******************************************** -In some applications you may want to use CSP to detect a signal/waveform that is already known, such as variants of 802.11, LTE, 5G, etc. If you know the cyclic frequency of the signal, and you know your sample rate, then you really only need to calculate a single alpha and single tau. Coming soon will be an example of this type of problem using an RF recording of WiFi. +В окремих застосунках CSP використовують для виявлення сигналу/хвилі, який вже відомий, наприклад варіантів 802.11, LTE, 5G тощо. Якщо відома циклічна частота сигналу та частота дискретизації, достатньо обчислити один :math:`\alpha` і один :math:`\tau`. Найближчим часом ми додамо приклад такої задачі на основі RF-запису WiFi. From e784ee16f31ab76c2bc5482c59d4486d26e9aaeb Mon Sep 17 00:00:00 2001 From: distribtech Date: Mon, 6 Oct 2025 18:45:50 +0300 Subject: [PATCH 11/42] Translate PyQt chapter to Ukrainian --- content-ukraine/pyqt.rst | 301 ++++++++++++--------------------------- 1 file changed, 89 insertions(+), 212 deletions(-) diff --git a/content-ukraine/pyqt.rst b/content-ukraine/pyqt.rst index d980f1ae..2c6de657 100644 --- a/content-ukraine/pyqt.rst +++ b/content-ukraine/pyqt.rst @@ -1,42 +1,42 @@ .. _pyqt-chapter: -########################## -Real-Time GUIs with PyQt -########################## +############################### +Графічні інтерфейси реального часу з PyQt +############################### -In this chapter we learn how to create real-time graphical user interfaces (GUIs) within Python by leveraging PyQt, the Python bindings for Qt. As part of this chapter we build a spectrum analyzer with time, frequency, and spectrogram/waterfall graphics, as well as input widgets for adjusting the various SDR parameters. The example supports the PlutoSDR, USRP, or simulation-only mode. +У цьому розділі ми навчимося створювати графічні інтерфейси користувача (GUI) реального часу на Python, використовуючи PyQt — Python-обгортку до Qt. У межах розділу ми збудуємо аналізатор спектра з графіками часу, частоти та спектрограми/«водоспаду», а також віджетами введення для налаштування різних параметрів SDR. Приклад підтримує PlutoSDR, USRP або режим лише моделювання. **************** -Introduction +Вступ **************** -Qt (pronounced "cute") is a framework for creating GUI applications that can run on Linux, Windows, macOS, and even Android. It is a very powerful framework that is used in many commercial applications, and is written in C++ for maximum performance. PyQt is the Python bindings for Qt, providing a way to create GUI applications in Python, while harnessing the performance of an efficient C++ based framework. In this chapter we will learn how to use PyQt to create a real-time spectrum analyzer that can be used with an SDR (or with a simulated signal). The spectrum analyzer will have time, frequency, and spectrogram/waterfall graphics, as well as input widgets for adjusting the various SDR parameters. We use `PyQtGraph `_, which is a separate library built on top of PyQt, to perform plotting. On the input side, we use sliders, combo-box, and push-buttons. The example supports the PlutoSDR, USRP, or simulation-only mode. Even though the example code uses PyQt6, every single line is identical to PyQt5 (besides the :code:`import`), very little changed between the two versions from an API perspective. Naturally, this chapter is extremely Python code heavy, as we explain through examples. By the end of this chapter you will have gained familiarity with the building blocks used to create your own custom interactive SDR application! +Qt (вимовляється як «к'ют») — це фреймворк для створення GUI-додатків, які можуть працювати в Linux, Windows, macOS і навіть Android. Це дуже потужний фреймворк, який використовується в багатьох комерційних застосунках, і написаний на C++ для максимальної продуктивності. PyQt — це Python-обгортка до Qt, яка дає змогу створювати GUI-додатки на Python, водночас користуючись ефективністю фреймворку на C++. У цьому розділі ми навчимося використовувати PyQt для створення аналізатора спектра реального часу, який може працювати з SDR (або із змодельованим сигналом). Аналізатор спектра матиме графіки часу, частоти та спектрограми/«водоспаду», а також елементи введення для налаштування різних параметрів SDR. Для побудови графіків ми використовуємо `PyQtGraph `_, це окрема бібліотека поверх PyQt. Для введення використовуємо повзунки, випадаючі списки та кнопки. У прикладі використано PyQt6, але кожен рядок коду (окрім :code:`import`) ідентичний PyQt5, з погляду API зміни мінімальні. Не дивно, що цей розділ значною мірою складається з Python-коду й прикладів. До кінця розділу ви познайомитеся з будівельними блоками, потрібними для створення власного інтерактивного SDR-додатка! **************** -Qt Overview +Огляд Qt **************** -Qt is a very large framework, and we will only be scratching the surface of what it can do. However, there are a few key concepts that are important to understand when working with Qt/PyQt: +Qt — дуже великий фреймворк, і ми лише злегка торкнемося його можливостей. Проте є кілька ключових концепцій, які важливо розуміти, працюючи з Qt/PyQt: -- **Widgets**: Widgets are the building blocks of a Qt application, and are used to create the GUI. There are many different types of widgets, including buttons, sliders, labels, and plots. Widgets can be arranged in layouts, which determine how they are positioned on the screen. +- **Віджети**: Віджети — це будівельні блоки додатка Qt, вони використовуються для створення GUI. Існує багато різновидів віджетів, зокрема кнопки, повзунки, написи й графіки. Віджети можна розміщувати у лейаутах, які визначають їх положення на екрані. -- **Layouts**: Layouts are used to arrange widgets in a window. There are several types of layouts, including horizontal, vertical, grid, and form layouts. Layouts are used to create complex GUIs that are responsive to changes in window size. +- **Лейаути**: Лейаути використовують для компонування віджетів у вікні. Є кілька типів лейаутів: горизонтальні, вертикальні, сіткові та форм-лейаути. Лейаути допомагають створювати складні GUI, які реагують на зміну розміру вікна. -- **Signals and Slots**: Signals and slots are a way to communicate between different parts of a Qt application. A signal is emitted by an object when a particular event occurs, and is connected to a slot, which is a callback function that is called when the signal is emitted. Signals and slots are used to create an event-driven structure in a Qt application, and keep the GUI responsive. +- **Сигнали та слоти**: Сигнали та слоти — це спосіб взаємодії різних частин додатка Qt. Об'єкт випромінює сигнал, коли відбувається певна подія, а сигнал з'єднано зі слотом — функцією зворотного виклику, яку викликають, коли сигнал випромінюється. Сигнали й слоти забезпечують подієво-орієнтовану структуру в Qt і тримають GUI відгукливим. -- **Style Sheets**: Style sheets are used to customize the appearance of widgets in a Qt application. Style sheets are written in a CSS-like language, and can be used to change the color, font, and size of widgets. +- **Таблиці стилів**: Таблиці стилів використовують, щоб налаштувати вигляд віджетів у Qt-додатку. Вони пишуться в стилі CSS і дозволяють змінювати колір, шрифт та розміри віджетів. -- **Graphics**: Qt has a powerful graphics framework that can be used to create custom graphics in a Qt application. The graphics framework includes classes for drawing lines, rectangles, ellipses, and text, as well as classes for handling mouse and keyboard events. +- **Графіка**: Qt має потужний графічний фреймворк для створення користувацької графіки. Він містить класи для малювання ліній, прямокутників, еліпсів і тексту, а також обробки подій миші та клавіатури. -- **Multithreading**: Qt has built-in support for multithreading, and provides classes for creating worker threads that run in the background. Multithreading is used to run long-running operations in a Qt application without blocking the main GUI thread. +- **Багатопоточність**: Qt має вбудовану підтримку багатопоточності та надає класи для створення робочих потоків, що працюють у фоновому режимі. Багатопоточність дозволяє виконувати тривалі операції, не блокуючи основний GUI-потік. -- **OpenGL**: Qt has built-in support for OpenGL, and provides classes for creating 3D graphics in a Qt application. OpenGL is used to create applications that require high-performance 3D graphics. In this chapter we will only be focusing on 2D applications. +- **OpenGL**: Qt має вбудовану підтримку OpenGL і надає класи для створення 3D-графіки. OpenGL використовують у застосунках, що потребують високопродуктивної 3D-графіки. У цьому розділі ми зосередимося лише на 2D. ************************* -Basic Application Layout +Базова структура застосунку ************************* -Before we dive into the different Qt widgets, let's look at the layout of a typical Qt application. A Qt application is composed of a main window, which contains a central widget, which in turn contains the main content of the application. Using PyQt we can create a minimal Qt application, containing just a single QPushButton as follows: +Перш ніж занурюватися у різні віджети Qt, розглянемо структуру типового Qt-застосунку. Qt-застосунок складається з головного вікна, яке містить центральний віджет, а той у свою чергу містить основний вміст застосунку. За допомогою PyQt ми можемо створити мінімальний Qt-застосунок із єдиною кнопкою QPushButton так: .. code-block:: python @@ -46,7 +46,7 @@ Before we dive into the different Qt widgets, let's look at the layout of a typi class MainWindow(QMainWindow): def __init__(self): super().__init__() - + # Example GUI component example_button = QPushButton('Push Me') def on_button_click(): @@ -60,15 +60,15 @@ Before we dive into the different Qt widgets, let's look at the layout of a typi window.show() # Windows are hidden by default app.exec() # Start the event loop -Try running the code yourself, you will likely need to :code:`pip install PyQt6`. Note how the very last line is blocking, anything you add after that line wont run until you close the window. The QPushButton we create has its :code:`clicked` signal connected to a callback function that prints "beep" to the console. +Спробуйте запустити цей код, імовірно, доведеться виконати :code:`pip install PyQt6`. Зверніть увагу, що останній рядок блокує виконання; усе, що ви додасте після нього, не запуститься, поки ви не закриєте вікно. Кнопка QPushButton, яку ми створили, має сигнал :code:`clicked`, під’єднаний до функції зворотного виклику, яка друкує «beep» у консолі. ******************************* -Application with Worker Thread +Застосунок із робочим потоком ******************************* -There is one problem with the minimal example above- it doesn't leave us any spot to put SDR/DSP oriented code. The :code:`MainWindow`'s :code:`__init__` is where the GUI is configured and callbacks are defined, but you absolutely do not want to add any other code (such as SDR or DSP code) to it. The reason is that the GUI is single-threaded, and if you block the GUI thread with long-running code, the GUI will freeze/stutter, and we want the smoothest GUI possible. To get around this, we can use a worker thread to run the SDR/DSP code in the background. +У мінімальному прикладі вище є одна проблема: він не залишає місця для SDR/DSP-коду. Метод :code:`__init__` класу :code:`MainWindow` відповідає за конфігурування GUI та визначення зворотних викликів, але додавати туди інший код (наприклад, SDR чи DSP) не варто. Причина в тому, що GUI однопотоковий, і якщо ви заблокуєте GUI-потік довготривалим кодом, інтерфейс «замерзне» або почне «смикатися», а нам потрібна максимально плавна робота. Щоб це обійти, можна використати робочий потік, який виконуватиме SDR/DSP у фоні. -The example below extends the minimal example above to include a worker thread that runs code (in the :code:`run` function) nonstop. We don't use a :code:`while True:` though, because of the way PyQt works under the hood, we want our :code:`run` function to finish and start over periodically. In order to do this, the worker thread's :code:`end_of_run` signal (which we discuss more in the next section) is connected to a callback function that triggers the worker thread's :code:`run` function again. We also must initialize the worker thread in the :code:`MainWindow` code, which involves creating a new :code:`QThread` and assigning our custom worker to it. This code might seem complicated, but it is a very common pattern in PyQt applications and the main take-away is that the GUI-oriented code goes in :code:`MainWindow`, and the SDR/DSP-oriented code goes in the worker thread's :code:`run` function. +Наступний приклад розширює мінімальний код, додаючи робочий потік, що запускає функцію :code:`run` безперервно. Ми не використовуємо :code:`while True:`, адже через те, як PyQt працює «під капотом», нам потрібно, щоб :code:`run` завершувалась і періодично запускалася знову. Щоб це реалізувати, сигнал :code:`end_of_run` робочого потоку (обговоримо його у наступному розділі) з'єднано з функцією зворотного виклику, яка повторно запускає :code:`run`. Також ми маємо ініціалізувати робочий потік у коді :code:`MainWindow`, створивши новий :code:`QThread` і призначивши йому нашого робітника. Цей код може виглядати складно, але це дуже поширений шаблон у PyQt-додатках, і головна ідея полягає в тому, що GUI-код живе в :code:`MainWindow`, а SDR/DSP-код — у методі :code:`run` робочого потоку. .. code-block:: python @@ -95,7 +95,7 @@ The example below extends the minimal example above to include a worker thread t self.sdr_thread = QThread() worker = SDRWorker() worker.moveToThread(self.sdr_thread) - + # Example GUI component example_button = QPushButton('Push Me') def on_button_click(): @@ -116,19 +116,19 @@ The example below extends the minimal example above to include a worker thread t window.show() # Windows are hidden by default app.exec() # Start the event loop -Try running the above code, you should see a "Starting run()" in the console every 1 second, and the push-button should still work (without any delay). Within the worker thread, all we are doing now is a print and a sleep, but soon we will be adding the SDR handling and DSP code to it. +Запустіть цей код: у консолі що секунду з’являтиметься «Starting run()», і кнопка все ще працюватиме без затримок. Поки що в робочому потоці ми лише друкуємо та «спимо», але скоро додамо керування SDR та DSP. ************************* -Signals and Slots +Сигнали та слоти ************************* -In the above example, we used the :code:`end_of_run` signal to communicate between the worker thread and the GUI thread. This is a common pattern in PyQt applications, and is known as the "signals and slots" mechanism. A signal is emitted by an object (in this case, the worker thread) and is connected to a slot (in this case, the callback function :code:`end_of_run_callback` in the GUI thread). The signal can be connected to multiple slots, and the slot can be connected to multiple signals. The signal can also carry arguments, which are passed to the slot when the signal is emitted. Note that we can also reverse things; the GUI thread is able to send a signal to the worker thread's slot. The signal/slot mechanism is a powerful way to communicate between different parts of a PyQt application, creating an event-driven structure, and is used extensively in the example code that follows. Just remember that a slot is simply a callback function, and a signal is a way to signal that callback function. +У прикладі вище ми використали сигнал :code:`end_of_run`, щоб організувати взаємодію між робочим потоком і GUI-потоком. Це типовий шаблон у PyQt і він відомий як механізм «сигналів і слотів». Об’єкт випромінює сигнал (у нашому випадку робочий потік) і з’єднується зі слотом (функцією зворотного виклику :code:`end_of_run_callback` у GUI-потоці). Сигнал можна під’єднати до кількох слотів, і слот може обробляти кілька сигналів. Сигнал може передавати аргументи, які отримує слот. Зверніть увагу, що можна організувати взаємодію і в протилежному напрямку: GUI-потік здатен надсилати сигнал у слот робочого потоку. Механізм сигналів і слотів — потужний спосіб організувати взаємодію частин PyQt-застосунку, створюючи подієву структуру, і ми активно використовуємо його в подальшому прикладі. Просто пам'ятайте, що слот — це функція зворотного виклику, а сигнал — це спосіб викликати цю функцію. ************************* PyQtGraph ************************* -PyQtGraph is a library built on top of PyQt and NumPy that provides fast and efficient plotting capabilities, as PyQt is too general purpose to come with plotting functionality. It is designed to be used in real-time applications, and is optimized for speed. It is similar in a lot of ways to Matplotlib, but meant for real-time applications instead of single plots. Using the simple example below you can compare the performance of PyQtGraph to Matplotlib, simply change the :code:`if True:` to :code:`False:`. On an Intel Core i9-10900K @ 3.70 GHz the PyQtGraph code updated at over 1000 FPS while the Matplotlib code updated at 40 FPS. That being said, if you find yourself benefiting from using Matplotlib (e.g., to save development time, or because you want a specific feature that PyQtGraph doesn't support), you can incorporate Matplotlib plots into a PyQt application, using the code below as a starting point. +PyQtGraph — це бібліотека поверх PyQt та NumPy, яка надає швидкі та ефективні можливості побудови графіків, адже сам PyQt занадто загальний і не містить функціоналу для графіків. Її створено для використання в реальному часі, і вона оптимізована на швидкість. У багатьох аспектах вона схожа на Matplotlib, але орієнтована на реальний час, а не на статичні графіки. У наведеному нижче простому прикладі ви можете порівняти продуктивність PyQtGraph і Matplotlib, просто змініть :code:`if True:` на :code:`False:`. На Intel Core i9-10900K @ 3.70 GHz код з PyQtGraph оновлювався з частотою понад 1000 FPS, а код з Matplotlib — 40 FPS. Водночас, якщо вам вигідніше використовувати Matplotlib (наприклад, щоб зекономити час розробки чи скористатися функцією, якої нема в PyQtGraph), можна вбудувати графіки Matplotlib у Qt-застосунок, використавши наведений код як відправну точку. .. raw:: html @@ -186,7 +186,7 @@ PyQtGraph is a library built on top of PyQt and NumPy that provides fast and eff 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([]) @@ -214,7 +214,7 @@ PyQtGraph is a library built on top of PyQt and NumPy that provides fast and eff -As far as using PyQtGraph, we import it with :code:`import pyqtgraph as pg` and then we can create a Qt widget that represents a 1D plot as follows (this code goes in the :code:`MainWindow`'s :code:`__init__`): +Щоб скористатися PyQtGraph, імпортуємо його як :code:`import pyqtgraph as pg`, після чого можемо створити Qt-віджет для 1D-графіка так (цей код додається у :code:`__init__` :code:`MainWindow`): .. code-block:: python @@ -226,19 +226,19 @@ As far as using PyQtGraph, we import it with :code:`import pyqtgraph as pg` and self.setCentralWidget(time_plot) .. image:: ../_images/pyqtgraph_example.png - :scale: 80 % + :scale: 80 % :align: center :alt: PyQtGraph example -You can see how it's relatively straightforward to set up a plot, and the result is simply another widget to add to your GUI. In addition to 1D plots, PyQtGraph also has an equivalent to Matplotlib's :code:`imshow()` which plots 2D using a colormap, which we will use for our real-time spectrogram/waterfall. One nice part about PyQtGraph is that the plots it creates are simply Qt widgets and we add other Qt elements (e.g. a rectangle of a certain size at a certain coordinate) using pure PyQt. This is because PyQtGraph makes use of PyQt's :code:`QGraphicsScene` class, which provides a surface for managing a large number of 2D graphical items, and nothing is stopping us from adding lines, rectangles, text, ellipses, polygons, and bitmaps, using straight PyQt. +Ви бачите, що налаштувати графік доволі просто, а результат — ще один віджет, який можна додати до GUI. Окрім 1D-графіків, PyQtGraph має еквівалент :code:`imshow()` з Matplotlib для побудови 2D-даних за допомогою колірної карти, і ми використаємо його для реальної спектрограми/«водоспаду». Приємний момент у PyQtGraph полягає в тому, що створені графіки — це просто Qt-віджети, і ми можемо додавати інші елементи Qt (наприклад, прямокутник потрібного розміру в певних координатах) чистим PyQt. Причина в тому, що PyQtGraph використовує клас PyQt :code:`QGraphicsScene`, який забезпечує поверхню для керування великою кількістю 2D-графічних об’єктів, і ніщо не заважає нам додавати лінії, прямокутники, текст, еліпси, багатокутники та растрові зображення безпосередньо PyQt. ******* -Layouts +Лейаути ******* -In the above examples, we used :code:`self.setCentralWidget()` to set the main widget of the window. This is a simple way to set the main widget, but it doesn't allow for more complex layouts. For more complex layouts, we can use layouts, which are a way to arrange widgets in a window. There are several types of layouts, including :code:`QHBoxLayout`, :code:`QVBoxLayout`, :code:`QGridLayout`, and :code:`QFormLayout`. The :code:`QHBoxLayout` and :code:`QVBoxLayout` arrange widgets horizontally and vertically, respectively. The :code:`QGridLayout` arranges widgets in a grid, and the :code:`QFormLayout` arranges widgets in a two-column layout, with labels in the first column and input widgets in the second column. +У наведених вище прикладах ми використовували :code:`self.setCentralWidget()` для встановлення головного віджета вікна. Це простий спосіб задати центральний віджет, але він не дозволяє створювати складніші компоновки. Для цього ми можемо використати лейаути — структури, що розташовують віджети у вікні. Є кілька типів лейаутів: :code:`QHBoxLayout`, :code:`QVBoxLayout`, :code:`QGridLayout` та :code:`QFormLayout`. :code:`QHBoxLayout` і :code:`QVBoxLayout` розташовують віджети відповідно горизонтально та вертикально. :code:`QGridLayout` розміщує віджети у сітці, а :code:`QFormLayout` створює двоколонну компоновку з написами в першій колонці та віджетами введення в другій. -To create a new layout and add widgets to it, try adding the following inside your :code:`MainWindow`'s :code:`__init__`: +Щоб створити новий лейаут і додати до нього віджети, спробуйте вставити в :code:`__init__` :code:`MainWindow` такий код: .. code-block:: python @@ -248,9 +248,9 @@ To create a new layout and add widgets to it, try adding the following inside yo layout.addWidget(QPushButton("Right-Most"), 2) self.setLayout(layout) -In this example we are stacking the widgets horizontally, but by swapping :code:`QHBoxLayout` for :code:`QVBoxLayout` we can stack them vertically instead. The :code:`addWidget` function is used to add widgets to the layout, and the optional second argument is a stretch factor that determines how much space the widget should take up relative to the other widgets in the layout. +У цьому прикладі ми розміщуємо віджети горизонтально, але замінивши :code:`QHBoxLayout` на :code:`QVBoxLayout`, можна розмістити їх вертикально. Функція :code:`addWidget` додає віджети до лейауту, а необов’язковий другий аргумент задає коефіцієнт розтягування, який визначає, скільки місця займе віджет відносно інших. -:code:`QGridLayout` has extra parameters because you must specify the row and column of the widget, and you can optionally specify how many rows and columns the widget should span (default is 1 and 1). Here is an example of a :code:`QGridLayout`: +:code:`QGridLayout` має додаткові параметри, оскільки треба вказати рядок і колонку віджета, а також (необов’язково) кількість рядків і колонок, які він має займати (за замовчуванням по 1). Ось приклад :code:`QGridLayout`: .. code-block:: python @@ -265,11 +265,11 @@ In this example we are stacking the widgets horizontally, but by swapping :code: self.setLayout(layout) .. image:: ../_images/qt_layouts.svg - :align: center + :align: center :target: ../_images/qt_layouts.svg - :alt: Qt Layouts showing examples of QHBoxLayout, QVBoxLayout, and QGridLayout + :alt: Компоновки Qt з прикладами QHBoxLayout, QVBoxLayout та QGridLayout -For our spectrum analyzer we will use the :code:`QGridLayout` for the overall layout, but we will also be adding :code:`QHBoxLayout` to stack widgets horizontally within a space in the grid. You can nest layouts simply by create a new layout and adding it to the top-level (or parent) layout, e.g.: +Для нашого аналізатора спектра ми використаємо :code:`QGridLayout` як основний лейаут, але також додаватимемо :code:`QHBoxLayout`, щоб розміщувати віджети горизонтально в певних комірках сітки. Ви можете вкладати лейаути, просто створивши новий і додавши його до батьківського, наприклад: .. code-block:: python @@ -282,15 +282,15 @@ For our spectrum analyzer we will use the :code:`QGridLayout` for the overall la :code:`QPushButton` ******************* -The first actual widget we will cover is the :code:`QPushButton`, which is a simple button that can be clicked. We have already seen how to create a :code:`QPushButton` and connect its :code:`clicked` signal to a callback function. The :code:`QPushButton` has a few other signals, including :code:`pressed`, :code:`released`, and :code:`toggled`. The :code:`toggled` signal is emitted when the button is checked or unchecked, and is useful for creating toggle buttons. The :code:`QPushButton` also has a few properties, including :code:`text`, :code:`icon`, and :code:`checkable`. The :code:`QPushButton` also has a method called :code:`click()` which simulates a click on the button. For our SDR spectrum analyzer application we will be using buttons to trigger an auto-range for plots, using the current data to calculate the y limits. Because we have already used the :code:`QPushButton`, we won't go into more detail here, but you can find more information in the `QPushButton documentation `_. +Перший віджет, який ми розглянемо — :code:`QPushButton`, проста кнопка, на яку можна натискати. Ми вже бачили, як створити :code:`QPushButton` і під'єднати її сигнал :code:`clicked` до функції зворотного виклику. :code:`QPushButton` має також сигнали :code:`pressed`, :code:`released` та :code:`toggled`. Сигнал :code:`toggled` випромінюється, коли кнопку позначають або знімають позначку, і корисний для кнопок-перемикачів. Серед властивостей :code:`QPushButton` — :code:`text`, :code:`icon` і :code:`checkable`. Також є метод :code:`click()`, який імітує натискання. У нашому аналізаторі ми використовуватимемо кнопки, щоб запускати автоматичне масштабування графіків за поточними даними. Оскільки ми вже бачили :code:`QPushButton`, не заглиблюватимемось, деталі дивіться в `документації QPushButton `_. *************** :code:`QSlider` *************** -The :code:`QSlider` is a widget that allows the user to select a value from a range of values. The :code:`QSlider` has a few properties, including :code:`minimum`, :code:`maximum`, :code:`value`, and :code:`orientation`. The :code:`QSlider` also has a few signals, including :code:`valueChanged`, :code:`sliderPressed`, and :code:`sliderReleased`. The :code:`QSlider` also has a method called :code:`setValue()` which sets the value of the slider, we will be using this a lot. The documentation page for `QSlider is here `_. +:code:`QSlider` — це віджет, який дозволяє користувачу обрати значення з певного діапазону. Він має властивості :code:`minimum`, :code:`maximum`, :code:`value` та :code:`orientation`. Серед сигналів — :code:`valueChanged`, :code:`sliderPressed` та :code:`sliderReleased`. Також є метод :code:`setValue()`, який встановлює значення повзунка; ми використовуватимемо його часто. Документацію можна знайти `тут `_. -For our spectrum analyzer application we will be using :code:`QSlider`'s to adjust the center frequency and gain of the SDR. Here is the snippet from the final application code that creates the gain slider: +У нашому застосунку аналізатора спектра ми використовуватимемо :code:`QSlider` для налаштування центральної частоти та підсилення SDR. Ось фрагмент кінцевого коду, який створює повзунок підсилення: .. code-block:: python @@ -309,17 +309,17 @@ For our spectrum analyzer application we will be using :code:`QSlider`'s to adju layout.addWidget(gain_slider, 5, 0) layout.addWidget(gain_label, 5, 1) -One very important thing to know about :code:`QSlider` is it uses integers, so by setting the range from 0 to 73 we are allowing the slider to choose integer values between those numbers (inclusive of start and end). The :code:`setTickInterval(2)` is purely a visual thing. It is for this reason that we will use kHz as the units for the frequency slider, so that we can have granularity down to the 1 kHz. +Дуже важливо пам’ятати, що :code:`QSlider` працює з цілими числами, тож, задаючи діапазон 0…73, ми дозволяємо повзунку обирати лише цілі значення. :code:`setTickInterval(2)` — це суто візуальний ефект. Саме тому ми використовуємо кілогерци як одиниці для повзунка частоти, щоб отримати крок у 1 кГц. -Halfway into the code above you'll notice we create a :code:`QLabel`, which is just a text label for display purposes, but in order for it to display the current value of the slider we must create a slot (i.e., callback function) that updates the label. We connect this callback function to the :code:`sliderMoved` signal, which is automatically emitted whenever the slider is moved. We also call the callback function once to initialize the label with the current value of the slider (50 in our case). We also have to connect the :code:`sliderMoved` signal to a slot that lives within the worker thread, which will update the gain of the SDR (remember, we don't like to manage the SDR or do DSP in the main GUI thread). The callback function that defines this slot will be discussed later. +У середині коду ви, мабуть, помітили створення :code:`QLabel`. Це просто текстова мітка, але щоб вона показувала поточне значення повзунка, нам потрібно створити слот (тобто функцію зворотного виклику), який оновлює текст. Ми з’єднуємо цей зворотний виклик із сигналом :code:`sliderMoved`, який автоматично випромінюється під час переміщення повзунка. Також ми викликаємо функцію один раз, щоб ініціалізувати мітку поточним значенням (у нашому випадку 50). Крім того, треба під’єднати :code:`sliderMoved` до слота в робочому потоці, який оновить підсилення SDR (пам’ятайте, ми не хочемо керувати SDR чи виконувати DSP у головному GUI-потоці). Цю функцію ми розглянемо пізніше. ***************** :code:`QComboBox` ***************** -The :code:`QComboBox` is a dropdown-style widget that allows the user to select an item from a list of items. The :code:`QComboBox` has a few properties, including :code:`currentText`, :code:`currentIndex`, and :code:`count`. The :code:`QComboBox` also has a few signals, including :code:`currentTextChanged`, :code:`currentIndexChanged`, and :code:`activated`. The :code:`QComboBox` also has a method called :code:`addItem()` which adds an item to the list, and :code:`insertItem()` which inserts an item at a specific index, although we will not be using them in our spectrum analyzer example. The documentation page for `QComboBox is here `_. +:code:`QComboBox` — це випадаючий список, який дозволяє користувачу обрати елемент зі списку. Він має властивості :code:`currentText`, :code:`currentIndex` та :code:`count`. Серед сигналів — :code:`currentTextChanged`, :code:`currentIndexChanged` та :code:`activated`. Також є метод :code:`addItem()`, який додає елемент до списку, та :code:`insertItem()`, що вставляє елемент у певну позицію, хоча ми їх не використовуватимемо в нашому прикладі. Документація доступна `тут `_. -For our spectrum analyzer application we will be using :code:`QComboBox` to select the sample rate from a list we pre-define. At the beginning of our code we define the possible sample rates using :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. Within the :code:`MainWindow`'s :code:`__init__` we create the :code:`QComboBox` as follows: +У нашому аналізаторі спектра ми використовуємо :code:`QComboBox`, щоб обирати частоту дискретизації зі списку, який ми заздалегідь визначили. На початку коду ми задаємо можливі частоти як :code:`sample_rates = [56, 40, 20, 10, 5, 2, 1, 0.5]`. У :code:`__init__` :code:`MainWindow` створюємо :code:`QComboBox` так: .. code-block:: python @@ -336,13 +336,13 @@ For our spectrum analyzer application we will be using :code:`QComboBox` to sele layout.addWidget(sample_rate_combobox, 6, 0) layout.addWidget(sample_rate_label, 6, 1) -The only real difference between this and the slider is the :code:`addItems()` where you give it the list of strings to use as options, and :code:`setCurrentIndex()` which sets the starting value. +Основна відмінність від повзунка — це виклик :code:`addItems()`, куди передаємо список рядків як опції, та :code:`setCurrentIndex()`, який задає початкове значення. **************** -Lambda Functions +Лямбда-функції **************** -Recall in the above code where we did: +Згадайте фрагмент коду вище: .. code-block:: python @@ -350,7 +350,7 @@ Recall in the above code where we did: sample_rate_label.setText("Sample Rate: " + str(sample_rates[val]) + " MHz") sample_rate_combobox.currentIndexChanged.connect(update_sample_rate_label) -We are creating a function that has only a single line of code inside of it, then passing that function (functions are objects too!) to :code:`connect()`. To simplify things, let's rewrite this code pattern using basic Python: +Ми створюємо функцію з одним рядком коду всередині й передаємо цю функцію (адже функції в Python — теж об’єкти) у :code:`connect()`. Щоб спростити, перепишемо цей шаблон базовою Python-нотацією: .. code-block:: python @@ -358,29 +358,29 @@ We are creating a function that has only a single line of code inside of it, the print(x) y.call_that_takes_in_function_obj(my_function) -In this situation, we have a function that only has one line of code inside of it, and we only reference that function once; when we are setting the :code:`connect` callback. In these situations we can use a lambda function, which is a way to define a function in a single line. Here is the above code rewritten using a lambda function: +У цьому випадку у нас є функція з одним рядком коду, і ми посилаємось на неї лише один раз — коли передаємо у :code:`connect`. У таких ситуаціях можна використати лямбда-функцію — спосіб визначити функцію в одному рядку. Ось попередній код, переписаний з лямбда-функцією: .. code-block:: python y.call_that_takes_in_function_obj(lambda x: print(x)) -If you have never used a lambda function before, this might seem foreign, and you certainly don't need to use them, but it gets rid of two lines of code and makes the code more concise. The way it works is, the temporary argument name comes from after "lambda", and then everything after the colon is the code that will operate on that variable. It supports multiple arguments as well, using commas, or even no arguments by using :code:`lambda : `. As an exercise, try rewriting the :code:`update_sample_rate_label` function above using a lambda function. +Якщо ви не працювали з лямбда-функціями, це може виглядати незвично, і користуватися ними не обов’язково, але вони забирають два рядки коду та роблять його компактнішим. Синтаксис такий: після слова «lambda» задаємо тимчасові імена аргументів, а після двокрапки — код, який їх обробляє. Підтримується кілька аргументів через кому або навіть відсутність аргументів (:code:`lambda : `). Як вправу, спробуйте переписати функцію :code:`update_sample_rate_label` вище за допомогою лямбда-функції. *********************** -PyQtGraph's PlotWidget +PlotWidget із PyQtGraph *********************** -PyQtGraph's :code:`PlotWidget` is a PyQt widget used to produce 1D plots, similar to Matplotlib's :code:`plt.plot(x,y)`. We will be using it for the time and frequency (PSD) domain plots, although it is also good for IQ plots (which our spectrum analyzer does not contain). For those curious, PlotWidget is a subclass of PyQt's `QGraphicsView `_ which is a widget for displaying the contents of a `QGraphicsScene `_, which is a surface for managing a large number of 2D graphical items in Qt. But the important thing to know about PlotWidget is that it is simply a widget containing a single `PlotItem `_, so from a documentation perspective you're better off just referring to the PlotItem docs: ``_. A PlotItem contains a ViewBox for displaying the data we want to plot, as well as AxisItems and labels for displaying the axes and title, as you may expect. +:code:`PlotWidget` у PyQtGraph — це віджет Qt для побудови 1D-графіків, подібно до :code:`plt.plot(x,y)` у Matplotlib. Ми використовуватимемо його для графіків у часовій та частотній (PSD) областях, хоча він також підходить для IQ-графіків (яких у нашому аналізаторі немає). Для цікавих читачів: PlotWidget є підкласом `QGraphicsView `_, віджета для відображення вмісту `QGraphicsScene `_, яка є поверхнею для роботи з великою кількістю 2D-графічних елементів у Qt. Але важливо знати, що PlotWidget — це просто віджет, який містить один `PlotItem `_, тож найкраще звертатися до документації PlotItem: ``_. PlotItem містить ViewBox для відображення даних, а також AxisItem та підписи, як ви й очікували. -The simplest example of using a PlotWidget is as follows (which must be added inside of the :code:`MainWindow`'s :code:`__init__`): +Найпростіший приклад використання PlotWidget виглядає так (код має бути доданий у :code:`__init__` :code:`MainWindow`): .. code-block:: python - import pyqtgraph as pg - plotWidget = pg.plot(title="My Title") - plotWidget.plot(x, y) + import pyqtgraph as pg + plotWidget = pg.plot(title="My Title") + plotWidget.plot(x, y) -where x and y are typically numpy arrays just like with Matplotlib's :code:`plt.plot()`. However, this represents a static plot where the data never changes. For our spectrum analyzer we want to update the data inside of our worker thread, so when we initialize our plot we don't even need to pass it any data yet, we just have to set it up. Here is how we initialize the Time Domain plot in our spectrum analyzer app: +де x та y зазвичай є масивами NumPy, так само, як і у Matplotlib :code:`plt.plot()`. Однак це статичний графік, дані не змінюються. У нашому аналізаторі ми хочемо оновлювати дані в робочому потоці, тож, ініціалізуючи графік, можна поки що не передавати дані, а лише налаштувати його. Ось як ми ініціалізуємо графік у часовій області в нашому застосунку: .. code-block:: python @@ -388,11 +388,11 @@ where x and y are typically numpy arrays just like with Matplotlib's :code:`plt. 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([]) + time_plot_curve_i = time_plot.plot([]) + time_plot_curve_q = time_plot.plot([]) layout.addWidget(time_plot, 1, 0) -You can see we are creating two different plots/curves, one for I and one for Q. The rest of the code should be self-explanatory. To be able to update the plot, we need to create a slot (i.e., callback function) within the :code:`MainWindow`'s :code:`__init__`: +Бачимо, що ми створюємо два графіки/криві: одну для I, іншу для Q. Інший код має бути зрозумілим. Щоб оновлювати графік, нам потрібен слот (функція зворотного виклику) у :code:`__init__` :code:`MainWindow`: .. code-block:: python @@ -400,137 +400,14 @@ You can see we are creating two different plots/curves, one for I and one for Q. time_plot_curve_i.setData(samples.real) time_plot_curve_q.setData(samples.imag) -We will connect this slot to the worker thread's signal that is emitted when new samples are available, as shown later. +Ми з'єднаємо цей слот із сигналом робочого потоку, який випромінюється, коли доступні нові вибірки, як показано далі. -The final thing we will do in the :code:`MainWindow`'s :code:`__init__` is to add a couple buttons to the right of the plot that will trigger an auto-range of the plot. One will use the current min/max, and another will set the range to -1.1 to 1.1 (which is the ADC limits of many SDRs, plus a 10% margin). We will create an inner layout, specifically QVBoxLayout, to vertically stack these two buttons. Here is the code to add the buttons: +Останнє, що ми зробимо в :code:`__init__` :code:`MainWindow`, — додамо кілька кнопок праворуч від графіка для автоматичного масштабування. Одна кнопка встановить діапазон за поточними мінімумом/максимумом, інша задасть межі -1.1…1.1 (обмеження АЦП багатьох SDR із 10% запасом). Ми створимо внутрішній лейаут, конкретно QVBoxLayout, щоб вертикально розмістити ці кнопки. Ось код, який додає кнопки: .. 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) - -And what it ultimately looks like: - -.. image:: ../_images/pyqt_time_plot.png - :scale: 50 % - :align: center - :alt: PyQtGraph Time Plot - -We will use a similar pattern for the frequency domain (PSD) plot. - -********************* -PyQtGraph's ImageItem -********************* - -A spectrum analyzer is not complete without a waterfall (a.k.a. real-time spectrogram), and for that we will use PyQtGraph's ImageItem, which renders images with 1, 3 or 4 "channels". One channel just means you give it a 2D array of floats or ints, which then uses a lookup table (LUT) to apply a colormap and ultimately create the image. Alternatively, you can give it RGB (3 channels) or RGBA (4 channels). We will calculate our spectrogram as a 2D numpy array of floats, and pass it to the ImageItem directly. We will pick a colormap, and even make use of the built-in functionality for showing a graphical LUT that can display our data's value distribution and how the colormap is applied. - -The actual initialization of the waterfall plot is fairly straightforward, we use a PlotWidget as the container (so that we can still have our x and y axis displayed) and then add an ImageItem to it: - -.. 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) - -The slot/callback associated with updating the waterfall data, which goes in :code:`MainWindow`'s :code:`__init__`, is as follows: - -.. 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 - -Where spectrogram will be a 2D numpy array of floats. In addition to setting the image data, we will calculate a min and max for the colormap, based on the mean and variance of the data, which we will use later. The last part of the GUI code for the spectrogram is creating the colorbar, which also sets the colormap used: - -.. 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) - -The second line is important, it is what ultimately connects this colorbar to the ImageItem. This code is also where we choose the colormap, and set the starting levels (-30 dB to +20 dB in our case). Within the worker thread code you will see how the spectrogram 2D array is calculated/stored. Below is a screenshot of this part of the GUI, showing the incredible built-in functionality of the colorbar and LUT display, note that the sideways bell-shaped curve is the distribution of spectrogram values, which is very useful to see. - -.. image:: ../_images/pyqt_spectrogram.png - :scale: 50 % - :align: center - :alt: PyQtGraph Spectrogram and colorbar - -*********************** -Worker Thread -*********************** - -Recall towards the beginning of this chapter we learned how to create a separate thread, using a class we called SDRWorker with a run() function. This is where we will put all of our SDR and DSP code, with the exception of initialization of the SDR which we will do globally for now. The worker thread will also be responsible for updating the three plots, by emitting signals when new samples are available, to trigger the callback functions we have already created in :code:`MainWindow`, which ultimately updates the plots. The SDRWorker class can be split up into three sections: - -#. :code:`init()` - used to initialize any state, such as the spectrogram 2D array -#. PyQt Signals - we must define our custom signals that will be emitted -#. PyQt Slots - the callback functions that are triggered by GUI events like a slider moving -#. :code:`run()` - the main loop that runs nonstop - -*********************** -PyQt Signals -*********************** - -In the GUI code we didn't have to define any Signals, because they were built into the widgets we were using, like :code:`QSlider`s :code:`valueChanged`. Our SDRWorker class is custom, and any Signals we want to emit must be defined before we start calling run(). Here is the code for the SDRWorker class, which defines four signals we will be using, and their corresponding data types: - -.. 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 - -The first three signals send a single object; a numpy array. The last signal does not send any object with it. You can also send multiple objects at a time, simply use commas between data types, but we don't need to do that for our application here. Anywhere within run() we can emit a signal to the GUI thread, using just one line of code, for example: - -.. code-block:: python - - self.time_plot_update.emit(samples) - -There is one last step to make all of the signals/slots connections- in the GUI code (comes at the very end of :code:`MainWindow`'s :code:`__init__`) we must connect the worker thread's signals to the GUI's slots, for example: - -.. code-block:: python - - worker.time_plot_update.connect(time_plot_callback) # connect the signal to the callback - -Remember that :code:`worker` is the instance of the SDRWorker class that we created in the GUI code. So what we are doing above is connecting the worker thread's signal called :code:`time_plot_update` to the GUI's slot called :code:`time_plot_callback` that we defined earlier. Now is a good time to go back and review the code snippets we have shown so far, and see how they all fit together, to ensure you understand how the GUI and worker thread are communicating, as it is a crucial part of PyQt programming. - -*********************** -Worker Thread Slots -*********************** - -The worker thread's slots are the callback functions that are triggered by GUI events, like the gain slider moving. They are pretty straightforward, for example, this slot updates the SDR's gain value to the new value chosen by the slider: - -.. code-block:: python - - def update_gain(self, val): - print("Updated gain to:", val, 'dB') - sdr.set_rx_gain(val) - -*********************** -Worker Thread Run() -*********************** - -The :code:`run()` function is where all the fun DSP part happens! In our application, we will start each run function by receiving a set of samples from the SDR (or simulating some samples if you don't have an SDR). - -.. code-block:: python - - # Main loop +... def run(self): if sdr_type == "pluto": samples = sdr.rx()/2**11 # Receive samples @@ -544,19 +421,19 @@ The :code:`run()` function is where all the fun DSP part happens! In our applic # 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) - + ... -As you can see, for the simulated example, we generate a tone with some white noise, and then truncate the samples from -1 to +1. +Як бачите, для змодельованого режиму ми генеруємо тон із білим шумом і обмежуємо вибірки в діапазоні -1…+1. -Now for the DSP! We know we will need to take the FFT for the frequency domain plot and spectrogram. It turns out that we can simply use the PSD for that set of samples as one row of the spectrogram, so all we have to do is shift our spectrogram/waterfall up by a row, and add the new row to the bottom (or top, doesn't matter). For each of the plot updates, we emit the signal which contains the updated data to plot. We also signal the end of the :code:`run()` function so that the GUI thread immediately starts another call to :code:`run()` again. Overall, it's actually not much code: +Тепер перейдемо до DSP! Ми знаємо, що нам потрібне FFT для графіка частотної області та спектрограми. Насправді ми можемо використати PSD для одного набору вибірок як один рядок спектрограми, тож достатньо зсунути спектрограму/«водоспад» на один рядок і додати новий рядок знизу (або зверху — неважливо). Для кожного оновлення графіків ми випромінюємо сигнал із даними для відображення. Ми також випромінюємо сигнал завершення :code:`run()`, щоб GUI негайно запускав його знову. Загалом, це не так уже й багато коду: .. 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) @@ -568,26 +445,26 @@ Now for the DSP! We know we will need to take the FFT for the frequency domain self.end_of_run.emit() # emit the signal to keep the loop going # end of run() -Note how we don't send the entire batch of samples to the time plot, because it would be too many points to show, instead we only send the first 500 samples (configurable at the top of the script, not shown here). For the PSD plot, we use a running average of the PSD, by storing the previous PSD and adding 1% of the new PSD to it. This is a simple way to smooth out the PSD plot. Note that it doesn't matter the order you call :code:`emit()` for the signals, they could have all just as easily gone at the end of :code:`run()`. +Зауважте, що ми не надсилаємо на графік часу весь пакет вибірок, адже це надто багато точок, натомість відправляємо перші 500 (це налаштовується на початку скрипта, тут не показано). Для графіка PSD ми використовуємо ковзне середнє: зберігаємо попередній PSD і додаємо до нього 1% нового. Це простий спосіб згладити графік. Порядок викликів :code:`emit()` не має значення — всі вони могли бути наприкінці :code:`run()`. *********************** -Final Example Full Code +Повний код фінального прикладу *********************** -Up until this point we have been looking at snippets of the spectrum analyzer app, but now we will finally take a look at the full code and try running it. It currently supports the PlutoSDR, USRP, or simulation-mode. If you don't have a Pluto or USRP, simply leave the code as-is, and it should use simulation mode, otherwise change :code:`sdr_type`. In simulation mode, if you increase the gain all the way, you will notice the signal gets truncated in the time domain, which causes spurs to occur in the frequency domain. +До цього моменту ми розглядали окремі фрагменти застосунку аналізатора спектра, а тепер подивимося на повний код і спробуємо його запустити. Наразі підтримуються PlutoSDR, USRP або режим моделювання. Якщо у вас немає Pluto чи USRP, залиште код як є, і він використає режим моделювання, інакше змініть :code:`sdr_type`. У режимі моделювання, якщо збільшити підсилення до максимуму, ви помітите, що сигнал у часовій області зрізається, що призводить до появи спурів у частотній області. -Feel free to use this code as a starting point for your own real-time SDR app! Below is also an animation of the app in action, using a Pluto to look at the 750 MHz cellular band, and then at 2.4 GHz WiFi. A higher quality version is available on YouTube `here `_. +Сміливо використовуйте цей код як відправну точку для власного SDR-додатку реального часу! Нижче також наведено анімацію роботи застосунку: Pluto використовується для перегляду стільникового діапазону 750 МГц, а потім — Wi-Fi на 2.4 ГГц. Версію вищої якості можна переглянути на YouTube `тут `_. .. image:: ../_images/pyqt_animation.gif :scale: 100 % :align: center - :alt: Animated gif showing the PyQt spectrum analyzer app in action + :alt: Анімація роботи застосунку аналізатора спектра PyQt -Known bugs (to help fix them `edit this `_): +Відомі вади (щоб допомогти їх виправити, `відредагуйте цей файл `_): -#. Waterfall x-axis doesn't update when changing center frequency (PSD plot does though) +#. Вісь x «водоспаду» не оновлюється під час зміни центральної частоти (натомість оновлюється графік PSD) -Full code: +Повний код: .. code-block:: python @@ -666,7 +543,7 @@ Full code: 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 @@ -688,7 +565,7 @@ Full code: # Main loop def run(self): start_t = time.time() - + if sdr_type == "pluto": samples = sdr.rx()/2**11 # Receive samples elif sdr_type == "usrp": @@ -703,11 +580,11 @@ Full code: 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) @@ -739,8 +616,8 @@ Full code: 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([]) + 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 @@ -756,11 +633,11 @@ Full code: # 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_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 @@ -844,23 +721,23 @@ Full code: 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) + 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) From 22a102139ce0b387b551f9f8e5b8c20cde984f2b Mon Sep 17 00:00:00 2001 From: mrbloom Date: Tue, 7 Oct 2025 12:17:08 +0300 Subject: [PATCH 12/42] change index --- index-ukraine.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index-ukraine.rst b/index-ukraine.rst index 664ddd2b..4e24f6fc 100644 --- a/index-ukraine.rst +++ b/index-ukraine.rst @@ -11,9 +11,9 @@ :numbered: 1 content-ukraine/intro - content-ukraine/frequency_doma + content-ukraine/frequency_domain content-ukraine/sampling - content-ukraine/digital_modula + content-ukraine/digital_modulation content-ukraine/pluto content-ukraine/usrp content-ukraine/bladerf From 51b3559a35a385c9b7077f75d0cba16517a8e766 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Wed, 8 Oct 2025 12:46:50 +0300 Subject: [PATCH 13/42] change index --- content-ukraine/rds.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content-ukraine/rds.rst b/content-ukraine/rds.rst index fbd2d9a5..a00fe6d6 100644 --- a/content-ukraine/rds.rst +++ b/content-ukraine/rds.rst @@ -1,8 +1,8 @@ .. _rds-chapter: -################### -Наскрізний приклад -################### +######################### +Приклад повної реалізації +######################### У цій главі ми об’єднуємо багато концепцій, про які ми раніше вивчали, і розглядаємо повний приклад отримання та декодування справжнього цифрового сигналу. Ми розглянемо систему радіоданих (RDS), яка є комунікаційним протоколом для вбудовування невеликих обсягів інформації в FM-радіопередачі, наприклад назву станції та пісні. Нам доведеться демодулювати FM, зсув частоти, фільтрувати, знищувати, повторно дискретизувати, синхронізувати, декодувати та аналізувати байти. Зразок файлу IQ надається для тестування або якщо у вас під рукою немає SDR. From 90062c7496254302410d01fecb63c9be889515a7 Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 13:02:36 +0300 Subject: [PATCH 14/42] Complete Ukrainian translation of RDS chapter --- content-ukraine/rds.rst | 100 ++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/content-ukraine/rds.rst b/content-ukraine/rds.rst index a00fe6d6..2496dcc6 100644 --- a/content-ukraine/rds.rst +++ b/content-ukraine/rds.rst @@ -95,14 +95,14 @@ RDS-інформація, яка передається FM-станцією (н #. Декодування 1 та 0 у групи байт #. Синтаксичний аналіз груп байтів у наш кінцевий результат -While this may seem like a lot of steps, RDS is actually one of the simplest wireless digital communications protocols out there. A modern wireless protocol like WiFi or 5G requires a whole textbook to cover just the high-level PHY/MAC layer information. +Хоча це може виглядати як багато кроків, RDS насправді є одним із найпростіших протоколів бездротового цифрового зв’язку. Сучасний бездротовий протокол, наприклад WiFi або 5G, потребує цілої книги, щоб охопити навіть інформацію високого рівня про рівні PHY/MAC. -We will now dive into the Python code used to receive RDS. This code has been tested to work using an `FM radio recording you can find here `_, although you should be able to feed in your own signal as long as its received at a high enough SNR, simply tune to the station's center frequency and sample at a rate of 250 kHz. To maximize the received signal power (e.g., if you are indoors), it helps to use a half-wave dipole antenna of the correct length (~1.5 meters), not the 2.4 GHz antennas that come with the Pluto. That being said, FM is a very loud signal, and if you are near a window or outside, the 2.4 GHz antennas will likely be enough to pick up the stronger radio stations. +Тепер ми зануримося в Python-код, який використовується для прийому RDS. Цей код був протестований із `записом FM-радіо, який можна знайти тут `_, хоча ви можете подати і власний сигнал за умови, що він прийнятий з достатньо високим SNR: просто налаштуйтеся на центральну частоту станції та дискретизуйте зі швидкістю 250 кГц. Щоб максимізувати потужність прийнятого сигналу (наприклад, якщо ви перебуваєте в приміщенні), корисно використати напівхвильову дипольну антену належної довжини (~1,5 метра), а не 2,4 ГГц-антени, що постачаються з Pluto. Водночас FM — дуже гучний сигнал, і якщо ви біля вікна або на вулиці, антен 2,4 ГГц, ймовірно, буде достатньо, щоб прийняти сильніші радіостанції. -In this section we will present small portions of the code individually, with discussion, but the same code is provided at the end of this chapter in one large block. Each section will present a block of code, and then explain what it is doing. +У цьому розділі ми будемо показувати невеликі фрагменти коду окремо, з поясненнями, але той самий код наведений наприкінці цієї глави одним великим блоком. Кожен підрозділ представить блок коду та пояснить, що він робить. ******************************** -Acquiring a Signal +Отримання сигналу ******************************** .. code-block:: python @@ -116,10 +116,10 @@ Acquiring a Signal sample_rate = 250e3 center_freq = 99.5e6 -We read in our test recording, which was sampled at 250 kHz and centered on an FM station received at a high SNR. Make sure to update the file path to reflect your system and where you saved the recording. If you have an SDR already set up and working from within Python, feel free to receive a live signal, although it helps to have first tested the entire code with a `known-to-work IQ recording `_. Throughout this code we will use :code:`x` to store the current signal being manipulated. +Ми зчитуємо наш тестовий запис, який дискретизовано на 250 кГц і центровано на FM-станції, прийнятій із високим SNR. Обов’язково оновіть шлях до файлу відповідно до вашої системи та місця збереження запису. Якщо у вас вже налаштований SDR, що працює з Python, можете приймати живий сигнал, хоча корисно спершу перевірити весь код на `відомому робочому IQ-записі `_. Протягом цього коду ми використовуватимемо :code:`x` для зберігання поточного сигналу, з яким виконуємо обробку. ******************************** -FM Demodulation +FM-демодуляція ******************************** .. code-block:: python @@ -127,14 +127,14 @@ FM Demodulation # Quadrature Demod x = 0.5 * np.angle(x[0:-1] * np.conj(x[1:])) # see https://wiki.gnuradio.org/index.php/Quadrature_Demod -As discussed at the beginning of this chapter, several individual signals are combined in frequency and FM modulated to create what is actually transmitted through the air. So the first step is to undo that FM modulation. Another way to think about it is the information is stored in the frequency variation of the signal we receive, and we want to demodulate it so the information is now in the amplitude not frequency. Note that the output of this demodulation is a real signal, even though we fed in a complex signal. +Як ми обговорювали на початку цієї глави, кілька окремих сигналів поєднуються за частотою та FM-модулюються, утворюючи те, що фактично передається в ефір. Тож перший крок — зняти цю FM-модуляцію. Іншими словами, інформація закодована у зміні частоти прийнятого сигналу, і ми хочемо демодулювати його так, щоб інформація опинилася в амплітуді, а не у частоті. Зауважте, що результат цієї демодуляції є дійсним сигналом, навіть якщо ми подавали комплексний сигнал. -What this single line of Python is doing, is first calculating the product of our signal with a delayed and conjugated version of our signal. Next, it finds the phase of each sample in that result, which is the moment at which it goes from complex to real. To prove to ourselves that this gives us the information contained in the frequency variations, consider a tone at frequency :math:`f` with some arbitrary phase :math:`\phi`, which we can represent as :math:`e^{j2 \pi (f t + \phi)}`. When dealing in discrete time, which uses an integer :math:`n` instead of :math:`t`, this becomes :math:`e^{j2 \pi (f n + \phi)}`. The conjugated and delayed version is :math:`e^{-j2 \pi (f (n-1) + \phi)}`. Multiplying these two together leads to :math:`e^{j2 \pi f}`, which is great because :math:`\phi` is gone, and when we calculate the phase of that expression we are left with just :math:`f`. +Що робить цей єдиний рядок Python, так це спершу обчислює добуток нашого сигналу на затриману та спряжену версію цього сигналу. Потім він знаходить фазу кожного відліку в цьому результаті — саме в цей момент сигнал переходить від комплексного до дійсного. Щоб переконатися, що таким чином ми повертаємо інформацію, закладену у зміні частоти, розглянемо тон із частотою :math:`f` та довільною фазою :math:`\phi`, який можна подати як :math:`e^{j2 \pi (f t + \phi)}`. У дискретному часі, де використовуємо цілий :math:`n` замість :math:`t`, це стає :math:`e^{j2 \pi (f n + \phi)}`. Спряжена та затримана версія матиме вигляд :math:`e^{-j2 \pi (f (n-1) + \phi)}`. Добуток цих двох виразів дає :math:`e^{j2 \pi f}`, що чудово, адже :math:`\phi` зникає, і коли ми обчислюємо фазу цього виразу, залишається лише :math:`f`. -One convenient side effect of FM modulation is that amplitude variations of the received signal does not actually change the volume of the audio, unlike AM radio. +Одним із зручних побічних ефектів FM-модуляції є те, що зміни амплітуди прийнятого сигналу не впливають на гучність аудіо, на відміну від AM-радіо. ******************************** -Frequency Shift +Зсув частоти ******************************** .. code-block:: python @@ -145,10 +145,10 @@ Frequency Shift t = np.arange(N)/sample_rate # time vector x = x * np.exp(2j*np.pi*f_o*t) # down shift -Next we frequency shift down by 57 kHz, using the :math:`e^{j2 \pi f_ot}` trick we learned in the :ref:`sync-chapter` chapter where :code:`f_o` is the frequency shift in Hz and :code:`t` is just a time vector, the fact it starts at 0 isn't important, what matters is that it uses the right sample period (which is inverse of sample rate). As an aside, because it's a real signal being fed in, it doesn't actually matter if you use a -57 or +57 kHz because the negative frequencies match the positive, so either way we are going to get our RDS shifted to 0 Hz. +Далі ми зсуваємо частоту вниз на 57 кГц, використовуючи прийом :math:`e^{j2 \pi f_ot}`, з яким ми познайомилися в розділі :ref:`sync-chapter`, де :code:`f_o` — це зсув частоти в герцах, а :code:`t` — просто часовий вектор; те, що він починається з 0, неважливо, головне — правильний період дискретизації (обернений до частоти дискретизації). До речі, оскільки на вході ми маємо дійсний сигнал, не має значення, використаємо ми -57 чи +57 кГц, бо від’ємні частоти симетричні до додатних, тож у будь-якому випадку RDS зміститься до 0 Гц. ******************************** -Filter to Isolate RDS +Фільтрація для виділення RDS ******************************** .. code-block:: python @@ -157,12 +157,12 @@ Filter to Isolate RDS taps = firwin(numtaps=101, cutoff=7.5e3, fs=sample_rate) x = np.convolve(x, taps, 'valid') -Now we must filter out everything besides RDS. Since we have RDS centered at 0 Hz, that means a low-pass filter is the one we want. We use :code:`firwin()` to design an FIR filter (i.e., find the taps), which just needs to know how many taps we want the filter to be, and the cutoff frequency. The sample rate must also be provided or else the cutoff frequency doesn't make sense to firwin. The result is a symmetric low-pass filter, so we know the taps are going to be real numbers, and we can apply the filter to our signal using a convolution. We choose :code:`'valid'` to get rid of the edge effects of doing convolution, although in this case it doesn't really matter because we are feeding in such a long signal that a few weird samples on either edge isn't going to throw anything off. +Тепер нам потрібно відфільтрувати все, окрім RDS. Оскільки RDS ми вже вирівняли по центру 0 Гц, нам потрібен фільтр низьких частот. Ми використовуємо :code:`firwin()` для синтезу FIR-фільтра (тобто обчислення коефіцієнтів), якому потрібно знати лише бажану кількість коефіцієнтів і частоту зрізу. Також слід вказати частоту дискретизації, інакше firwin не зможе правильно інтерпретувати частоту зрізу. Отриманий фільтр є симетричним ФНЧ, тож його коефіцієнти дійсні, і ми можемо застосувати його до сигналу за допомогою згортки. Ми обираємо режим :code:`'valid'`, щоб позбутися крайових ефектів згортки, хоча в даному випадку це не критично, адже сигнал дуже довгий і кілька дивних відліків на краях нічого не зіпсують. -Side note: At some point I will update the filter above to use a proper matched filter (root-raised cosine I believe is what RDS uses), for conceptual sake, but I got the same error rates using the firwin() approach as GNU Radio's proper matched filter, so it's clearly not a strict requirement. +Примітка: згодом я оновлю цей фільтр на справжній узгоджений (з кореневою піднятою косинусоїдою, яку, як я вважаю, використовує RDS) задля повноти викладу, але підхід із firwin() дає ті самі показники помилок, що й коректний узгоджений фільтр у GNU Radio, тож це явно не сувора вимога. ******************************** -Decimate by 10 +Децимація на 10 ******************************** .. code-block:: python @@ -171,14 +171,14 @@ Decimate by 10 x = x[::10] sample_rate = 25e3 -Any time you filter down to a small fraction of your bandwidth (e.g., we started with 125 kHz of *real* bandwidth and saved only 7.5 kHz of that), it makes sense to decimate. Recall the beginning of the :ref:`sampling-chapter` chapter where we learned about the Nyquist Rate and being able to fully store band-limited information as long as we sampled at twice the highest frequency. Well now that we used our low-pass filter, our highest frequency is about 7.5 kHz, so we only need a sample rate of 15 kHz. Just to be safe we'll add some margin and use a new sample rate of 25 kHz (this ends up working well mathematically later on). +Щоразу, коли ви відсікаєте більшу частину смуги пропускання (наприклад, ми починали зі 125 кГц *реальної* смуги і залишили лише 7,5 кГц), має сенс виконати децимацію. Згадайте початок розділу :ref:`sampling-chapter`, де ми вчили про частоту Найквіста та можливість повністю відтворити смуговий сигнал, якщо дискретизувати принаймні з подвійною максимальною частотою. Тепер, після ФНЧ, наша максимальна частота приблизно 7,5 кГц, тож нам достатньо частоти дискретизації 15 кГц. Для запасу використаємо 25 кГц (згодом це ще й зручно з математичної точки зору). -We perform the decimation by simply throwing out 9 out of every 10 samples, since we previously were at a sample rate of 250 kHz and we want it to now be at 25 kHz. This might seem confusing at first, because throwing out 90% of the samples feels like you are throwing out information, but if you review the :ref:`sampling-chapter` chapter you will see why we are not actually losing anything, because we filtered properly (which acted as our anti-aliasing filter) and reduced our maximum frequency and thus signal bandwidth. +Ми виконуємо децимацію, просто відкидаючи 9 з кожних 10 відліків, адже раніше частота дискретизації була 250 кГц, а тепер нам потрібно 25 кГц. Це може спершу збивати з пантелику, ніби ми викидаємо 90% інформації, але якщо знову перечитати розділ :ref:`sampling-chapter`, ви побачите, що ми нічого не втрачаємо: ми належно відфільтрували сигнал (виконавши роль антиаліасингового фільтра) та зменшили максимальну частоту, а отже й смугу сигналу. -From a code perspective this is probably the simplest step out of them all, but make sure to update your :code:`sample_rate` variable to reflect the new sample rate. +З погляду коду це, мабуть, найпростіший крок із усіх, але не забудьте оновити змінну :code:`sample_rate`, щоб вона відображала нову частоту дискретизації. ******************************** -Resample to 19 kHz +Перевиділення до 19 кГц ******************************** .. code-block:: python @@ -187,16 +187,16 @@ Resample to 19 kHz x = resample_poly(x, 19, 25) # up, down sample_rate = 19e3 -In the :ref:`pulse-shaping-chapter` chapter we solidified the concept of "samples per symbol", and learned the convenience of having an integer number of samples per symbol (a fractional value is valid, just not convenient). As mentioned earlier, RDS uses BPSK transmitting 1187.5 symbols per second. If we continue to use our signal as-is, sampled at 25 kHz, we'll have 21.052631579 samples per symbol (pause and think about the math if that doesn't make sense). So what we really want is a sample rate that is an integer multiple of 1187.5 Hz, but we can't go too low or we won't be able to "store" our full signal's bandwidth. In the previous subsection we talked about how we need a sample rate of 15 kHz or higher, and we chose 25 kHz just to give us some margin. +У розділі :ref:`pulse-shaping-chapter` ми закріпили поняття "відліки на символ" і побачили зручність цілої кількості відліків на символ (дробові значення теж можливі, але працювати з ними незручно). Як уже згадувалося, RDS використовує BPSK зі швидкістю 1187,5 символів за секунду. Якщо залишити наш сигнал із частотою дискретизації 25 кГц, отримаємо 21,052631579 відліків на символ (зупиніться й перевірте обчислення, якщо це здається дивним). Отже, нам потрібна частота дискретизації, що є цілим кратним 1187,5 Гц, але не можна знижувати її надто сильно, інакше ми не "вмістимо" всю смугу сигналу. У попередньому підрозділі ми говорили, що нам потрібна частота принаймні 15 кГц, і вибрали 25 кГц для запасу. -Finding the best sample rate to resample to comes down to how many samples per symbol we want, and we can work backwards. Hypothetically, let us consider targeting 10 samples per symbol. The RDS symbol rate of 1187.5 multiplied by 10 would give us a sample rate of 11.875 kHz, which unfortunately is not high enough for Nyquist. How about 13 samples per symbol? 1187.5 multiplied by 13 gives us 15437.5 Hz, which is above 15 kHz, but quite the uneven number. How about the next power of 2, so 16 samples per symbol? 1187.5 multiplied by 16 is exactly 19 kHz! The even number is less of a coincidence and more of a protocol design choice. +Пошук найкращої частоти, до якої слід перевиділити, зводиться до бажаної кількості відліків на символ; працюємо у зворотному напрямку. Припустімо, що ми хочемо 10 відліків на символ. Помноживши швидкість символів RDS 1187,5 на 10, отримуємо 11,875 кГц — на жаль, цього недостатньо для Найквіста. А якщо взяти 13 відліків на символ? 1187,5 × 13 = 15 437,5 Гц — більше ніж 15 кГц, але число незручне. Наступний ступінь двійки — 16 відліків на символ. 1187,5 × 16 = рівно 19 кГц! Така "красивість" числа — це не випадковість, а особливість протоколу. -To resample from 25 kHz to 19 kHz, we use :code:`resample_poly()` which upsamples by an integer value, filters, then downsamples by an integer value. This is convenient because instead of entering in 25000 and 19000 we can use 25 and 19. If we had used 13 samples per symbol by using a sample rate of 15437.5 Hz, we wouldn't be able to use :code:`resample_poly()` and the resampling process would be much more complicated. +Щоб перевиділити з 25 кГц до 19 кГц, ми використовуємо :code:`resample_poly()`, який спершу збільшує частоту дискретизації на цілий множник, фільтрує, а потім зменшує її на інший цілий множник. Це зручно, адже замість 25000 і 19000 можна працювати з 25 та 19. Якби ми обрали 13 відліків на символ із частотою 15 437,5 Гц, :code:`resample_poly()` застосувати не вийшло б, і процес перевиділення був би значно складнішим. -Once again, always remember to update your :code:`sample_rate` variable when performing an operation that changes it. +І знову ж таки, не забувайте оновлювати змінну :code:`sample_rate` після кожної операції, що змінює частоту дискретизації. *********************************** -Time Synchronization (Symbol-Level) +Синхронізація в часі (рівень символів) *********************************** .. code-block:: python @@ -222,17 +222,17 @@ Time Synchronization (Symbol-Level) i_out += 1 # increment output index x = out[2:i_out] # remove the first two, and anything after i_out (that was never filled out) -We are finally ready for our symbol/time synchronization, here we will use the exact same Mueller and Muller clock synchronization code from the :ref:`sync-chapter` chapter, reference it if you want to learn more about how it works. We set the sample per symbol (:code:`sps`) to 16 as discussed earlier. A mu gain value of 0.01 was found via experimentation to work well. The output should now be one sample per symbol, i.e., our output is our "soft symbols", with possible frequency offset included. The following constellation plot animation is used to verify we are getting BPSK symbols (with a frequency offset causing rotation): +Нарешті ми готові до синхронізації символів/часу. Тут ми використаємо той самий алгоритм синхронізації годинника Мюллера—Мюллера з розділу :ref:`sync-chapter`; зверніться до нього, якщо хочете краще зрозуміти принцип роботи. Кількість відліків на символ (:code:`sps`) встановлюємо рівною 16, як обговорювали раніше. Значення підсилення μ = 0.01 було підібрано експериментально і працює добре. Тепер вихід має містити один відлік на символ, тобто ми отримуємо "м’які символи", у яких може залишатися невеликий частотний зсув. Наступна анімація сузір’я дозволяє переконатися, що ми справді бачимо символи BPSK (із обертанням через частотний зсув). .. image:: ../_images/constellation-animated.gif :scale: 80 % :align: center :alt: Animation of BPSK rotating because fine frequency sync hasn't been performed yet -If you are using your own FM signal and are not getting two distinct clusters of complex samples at this point, it means either the symbol sync above failed to achieve sync, or there is something wrong with one of the previous steps. You don't need to animate the constellation, but if you plot it, make sure to avoid plotting all the samples, because it will just look like a circle. If you plot only 100 or 200 samples at a time, you will get a better feel for whether they are in two clusters or not, even if they are spinning. +Якщо ви використовуєте власний FM-сигнал і не бачите на цьому етапі двох окремих скупчень комплексних відліків, це означає або те, що синхронізація символів не спрацювала, або якась з попередніх стадій виконана некоректно. Анімація сузір’я не обов’язкова, але якщо будуєте графік, не відображайте всі відліки одразу — вони утворять суцільне коло. Побудувавши лише 100–200 відліків за раз, ви краще зрозумієте, чи справді утворюються два скупчення, навіть якщо вони обертаються. ******************************** -Fine Frequency Synchronization +Точна частотна синхронізація ******************************** .. code-block:: python @@ -263,14 +263,14 @@ Fine Frequency Synchronization phase += 2*np.pi x = out -We will also copy the fine frequency synchronization Python code from the :ref:`sync-chapter` chapter, which uses a Costas loop to remove any residual frequency offset, as well as align our BPSK to the real (I) axis, by forcing Q to be as close to zero as possible. Anything left in Q is likely due to the noise in the signal, assuming the Costas loop was tuned properly. Just for fun let's view the same animation as above except after the frequency synchronization has been performed (no more spinning!): +Ми також використаємо код точної частотної синхронізації з розділу :ref:`sync-chapter`, де застосовується цикл Костаса для усунення залишкового частотного зсуву та вирівнювання сигналу BPSK уздовж дійсної (I) осі, змушуючи компоненту Q наближатися до нуля. Якщо цикл Костаса налаштовано належним чином, усе, що залишається в Q, — це шум сигналу. Для наочності погляньмо на ту саму анімацію сузір’я після виконання частотної синхронізації (жодного обертання!). .. image:: ../_images/constellation-animated-postcostas.gif :scale: 80 % :align: center :alt: Animation of the frequency sync process using a Costas Loop -Additionally, we can look at the estimated frequency error over time to see the Costas loop working, note how we logged it in the code above. It appears that there was about 13 Hz of frequency offset, either due to the transmitter's oscillator/LO being off or the receiver's LO (most likely the receiver). If you are using your own FM signal, you may need to tweak :code:`alpha` and :code:`beta` until the curve looks similar, it should achieve synchronization fairly quickly (e.g., a few hundred symbols) and maintain it with minimal oscillation. The pattern you see below after it finds its steady state is frequency jitter, not oscillation. +Крім того, можна подивитися на оцінку частотної помилки в часі, щоб побачити, як працює цикл Костаса; зверніть увагу, що ми зберігаємо ці дані в коді вище. Здається, залишковий зсув становив приблизно 13 Гц — або через неточність генератора передавача, або приймача (імовірніше, приймача). Якщо ви працюєте зі своїм сигналом, можливо, доведеться підлаштувати коефіцієнти :code:`alpha` і :code:`beta`, щоб крива виглядала подібно: синхронізація має досягатися досить швидко (наприклад, за кілька сотень символів) і підтримуватися без значних коливань. Візерунок, який ви бачите після стабілізації, — це джиттер частоти, а не коливання. .. image:: ../_images/freq_error.png :scale: 40 % @@ -278,7 +278,7 @@ Additionally, we can look at the estimated frequency error over time to see the :alt: The frequency sync process using a Costas Loop showing the estimated frequency offset over time ******************************** -Demodulate the BPSK +Демодуляція BPSK ******************************** .. code-block:: python @@ -286,10 +286,10 @@ Demodulate the BPSK # Demod BPSK bits = (np.real(x) > 0).astype(int) # 1's and 0's -Demodulating the BPSK at this point is very easy, recall that each sample represents one soft symbol, so all we have to do is check whether each sample is above or below 0. The :code:`.astype(int)` is just so we can work with an array of ints instead of an array of bools. You may wonder whether above/below zero represents a 1 or 0. As you will see in the next step, it doesn't matter! +На цьому етапі демодуляція BPSK дуже проста: кожен відлік відповідає одному м’якому символу, тож нам лишається лише перевірити, чи відлік більший або менший за 0. Виклик :code:`.astype(int)` дозволяє працювати з масивом цілих чисел замість булевих значень. Може виникнути питання, що саме означає значення вище чи нижче нуля — 1 чи 0. Як ми побачимо на наступному кроці, це не має значення! ******************************** -Differential Decoding +Диференціальне декодування ******************************** .. code-block:: python @@ -298,7 +298,7 @@ Differential Decoding bits = (bits[1:] - bits[0:-1]) % 2 bits = bits.astype(np.uint8) # for decoder -The BPSK signal used differential coding when it was created, which means that each 1 and 0 of the original data was transformed such that a change from 1 to 0 or 0 to 1 got mapped to a 1, and no change got mapped to a 0. The nice benefit of using differential coding is so you don't have to worry about 180 degree rotations in receiving the BPSK, because whether we consider a 1 to be greater than zero or less than zero is no longer an impact, what matters is changing between 1 and 0. This concept might be easier to understand by looking at example data, below shows the first 10 symbols before and after the differential decoding: +Під час формування сигналу BPSK застосовувалося диференціальне кодування, тобто кожна одиниця й нуль вихідних даних перетворювалися так, що перехід з 1 у 0 або з 0 в 1 відповідав значенню 1, а відсутність зміни — значенню 0. Перевага диференціального кодування полягає в тому, що нам не потрібно хвилюватися про поворот сигналу на 180 градусів: немає значення, вважаємо ми 1 більшою чи меншою за нуль — важливим є лише факт переходу між 1 та 0. Щоб краще це відчути, подивімося на приклад нижче, який показує перші 10 символів до та після диференціального декодування: .. code-block:: python @@ -306,14 +306,14 @@ The BPSK signal used differential coding when it was created, which means that e [- 0 0 0 1 1 1 0 1 0] # after differential decoding ******************************** -RDS Decoding +Декодування RDS ******************************** -We finally have our bits of information, and we are ready to decode what they mean! The massive block of code provided below is what we will use to decode the 1's and 0's into groups of bytes. This part would make a lot more sense if we first created the transmitter portion of RDS, but for now just know that in RDS, bytes are grouped into groups of 12 bytes, where the first 8 represent the data and the last 4 act as a sync word (called "offset words"). The last 4 bytes are not needed by the next step (the parser) so we don't include them in the output. This block of code takes in the 1's and 0's created above (in the form of a 1D array of uint8's) and outputs a list of lists of bytes (a list of 8 bytes where those 8 bytes are in a list). This makes it convenient for the next step, which will iterate through the list of 8 bytes, one group of 8 at a time. +Ми нарешті отримали біти інформації й готові розібратися, що вони означають! Великий блок коду нижче перетворює наші 1 та 0 на групи байтів. Було б набагато зрозуміліше, якби ми спершу створили передавальну частину RDS, але наразі достатньо знати, що байти RDS згруповані по 12: перші 8 містять дані, а останні 4 виконують роль синхрослова (так званих "offset words"). Останні 4 байти наступному кроку (парсеру) не потрібні, тому ми їх не передаємо. Цей код отримує наші 1 та 0 (масив типу uint8) і повертає список списків із 8 байтів, що зручно для наступного етапу, де ми оброблятимемо групи по 8 байтів за раз. -Most of the actual decoding code below revolves around syncing (at the byte level, not symbol) and error checking. It works in blocks of 104 bits, each block is either received correctly or in error (using CRC to check), and every 50 blocks it checks whether more than 35 of them were received with error, in which case it resets everything and attempts to sync again. The CRC is performed using a 10-bit check, with polynomial :math:`x^{10}+x^8+x^7+x^5+x^4+x^3+1`; this occurs when :code:`reg` is xor'ed with 0x5B9 which is the binary equivalent of that polynomial. In Python, the bitwise operators for [and, or, not, xor] are :code:`& | ~ ^` respectively, exactly the same as C++. A left bit shift is :code:`x << y` (same as multiplying x by 2**y), and a right bit shift is :code:`x >> y` (same as dividing x by 2**y), also like in C++. +Більшість коду нижче присвячена синхронізації (на рівні байтів, а не символів) і перевірці помилок. Дані обробляються блоками по 104 біти: кожен блок або приймається правильно, або містить помилку (це перевіряється за допомогою CRC). Після кожних 50 блоків перевіряється, чи не було більше 35 помилкових; якщо так, усі змінні скидаються й алгоритм намагається синхронізуватися знову. CRC виконується з використанням 10-бітного полінома :math:`x^{10}+x^8+x^7+x^5+x^4+x^3+1`, що реалізовано як XOR регістра :code:`reg` зі значенням 0x5B9 — двійковим представленням цього полінома. У Python побітові оператори [and, or, not, xor] — це :code:`& | ~ ^`, як і в C++. Зсув вліво :code:`x << y` дорівнює множенню на :math:`2^y`, а зсув вправо :code:`x >> y` еквівалентний діленню на :math:`2^y`, також як у C++. -Note, you **do not** need to go through all of this code, or any of it, especially if you are focusing on learning the physical (PHY) layer side of DSP and SDR, as this does *not* represent signal processing. This code is simply an implementation of a RDS decoder, and essentially none of it can be reused for other protocols, because it's so specific to the way RDS works. If you are already somewhat exhausted by this chapter, feel free to just skip this enormous block of code that has one fairly simple job but does it in a complex manner. +Зауважте, що вам **не обов’язково** вчитуватися в увесь цей код, особливо якщо ви зосереджені на вивченні фізичного рівня DSP/SDR, адже тут немає сигнал-обробки. Це просто реалізація декодера RDS, і практично нічого з нього не можна повторно використати для інших протоколів, настільки специфічним є сам RDS. Якщо ви вже втомилися від цієї глави, сміливо пропускайте цей величезний блок коду: він виконує відносно просте завдання, але досить громіздко. .. code-block:: python @@ -440,7 +440,7 @@ Note, you **do not** need to go through all of this code, or any of it, especial blocks_counter = 0 wrong_blocks_counter = 0 -Below shows an example output from this decoding step, note how in this example it synced fairly quickly but then loses sync a couple times for some reason, although it's still able to parse all of the data as we'll see. If you are using the downloadable 1M samples file, you will only see the first few lines below. The actual contents of these bytes just look like random numbers/characters depending on how you display them, but in the next step we will parse them into human readable information! +Нижче наведено приклад результатів цього етапу декодування: зверніть увагу, що в цьому випадку синхронізація встановлюється доволі швидко, але згодом кілька разів губиться, хоча дані все одно вдається розібрати. Якщо ви використовуєте завантажуваний файл на 1 М відліків, побачите лише перші кілька рядків. Самі байти виглядають як випадкові числа чи символи залежно від способу відображення, але вже на наступному кроці ми перетворимо їх на зрозумілу інформацію! .. code-block:: console @@ -484,12 +484,12 @@ Below shows an example output from this decoding step, note how in this example Still Sync-ed (Got 32 bad blocks on 50 total) ******************************** -RDS Parsing +Розбір RDS ******************************** -Now that we have bytes, in groups of 8, we can extract the final data, i.e., the final output that is human understandable. This is known as parsing the bytes, and just like the decoder in the previous section, it is simply an implementation of the RDS protocol, and is really not that important to understand. Luckily it's not a ton of code, if you don't include the two tables defined at the start, which are simply the lookup tables for the type of FM channel and the coverage area. +Тепер, коли ми маємо байти у групах по вісім, можемо витягти фінальні дані — тобто отримати результат, зрозумілий людині. Цей процес називається розбором байтів і, як і декодер у попередньому розділі, є просто реалізацією протоколу RDS, тож заглиблюватися в нього не так уже й важливо. На щастя, тут небагато коду, якщо не рахувати двох таблиць на початку, що слугують довідниками типів FM-каналу та зони покриття. -For those who want to learn how this code works, I'll provide some added information. The protocol uses this concept of an A/B flag, which means some messages are marked A and others B, and the parsing changes based on which one (whether it's A or B is stored in the third bit of the second byte). It also uses different "group" types which are analogous to message type, and in this code we are only parsing message type 2, which is the message type that has the radio text in it, which is the interesting part, it's the text that scrolls across the screen in your car. We will still be able to parse the channel type and region, as they are stored in every message. Lastly, note that :code:`radiotext` is a string that gets initialized to all spaces, gets filled out slowly as bytes are parsed, and then resets to all spaces if a specific set of bytes is received. If you are curious what other message types exist, the list is: ["BASIC", "PIN/SL", "RT", "AID", "CT", "TDC", "IH", "RP", "TMC", "EWS", "EON"]. The message "RT" is radiotext which is the only one we decode. The RDS GNU Radio block decodes "BASIC" as well, but for the stations I used for testing it didn't contain much interesting information, and would have added a lot of lines to the code below. +Тим, хто хоче зрозуміти роботу цього коду, наведу кілька додаткових пояснень. Протокол використовує так званий прапорець A/B: одні повідомлення позначені як A, інші як B, і спосіб розбору залежить від цього прапорця (він зберігається в третьому біті другого байта). Існують також різні типи "груп", аналогічні типам повідомлень; у цьому коді ми обробляємо лише групу типу 2, що містить так званий радіотекст — саме той рядок, який прокручується на дисплеї вашого автомобіля. Водночас ми все ще можемо визначити тип каналу та регіон, адже ці поля є в кожному повідомленні. Нарешті, зверніть увагу на змінну :code:`radiotext`: це рядок, який спочатку заповнений пробілами, поступово наповнюється символами під час розбору й скидається до пробілів, коли надходить певна комбінація байтів. Якщо цікаво, які ще типи повідомлень існують, ось перелік: ["BASIC", "PIN/SL", "RT", "AID", "CT", "TDC", "IH", "RP", "TMC", "EWS", "EON"]. Ми декодуємо лише "RT" (radiotext). Блок RDS у GNU Radio також розбирає "BASIC", але на станціях, які я використовував для тестування, у ньому не було нічого цікавого, а додавання його сюди суттєво збільшило б код. .. code-block:: python @@ -597,7 +597,7 @@ For those who want to learn how this code works, I'll provide some added informa pass #print("unsupported group_type:", group_type) -Below shows the output of the parsing step for an example FM station. Note how it has to build the radiotext string over multiple messages, and then it periodically clears out the string and starts again. If you are using the 1M sample downloaded file, you will only see the first few lines below. +Нижче показано результат етапу розбору для прикладної FM-станції. Зверніть увагу, що радіотекст формується протягом кількох повідомлень, а потім періодично очищується й починається спочатку. Якщо ви використовуєте завантажений файл на 1 М відліків, то побачите лише перші кілька рядків. .. code-block:: console @@ -628,10 +628,10 @@ Below shows the output of the parsing step for an example FM station. Note how ******************************** -Wrap-Up and Final Code +Підсумок та фінальний код ******************************** -You did it! Below is all of the code above, concatenated, it should work with the `test FM radio recording you can find here `_, although you should be able to feed in your own signal as long as its received at a high enough SNR, simply tune to the station's center frequency and sample at a rate of 250 kHz. If you find you had to make tweaks to get it to work with your own recording or live SDR, let me know what you had to do, you can submit it as a GitHub PR at `the textbook's GitHub page `_. You can also find a version of this code with dozens of debug plotting/printing included, that I originally used to make this chapter, `here `_. +Ви впоралися! Нижче наведено весь код із цієї глави, зібраний докупи. Він має працювати з `тестовим записом FM-радіо `_, але ви також можете використати власний сигнал за умови достатнього SNR: просто налаштуйтеся на центральну частоту станції й дискретизуйте зі швидкістю 250 кГц. Якщо вам довелося щось підправити, щоб код запрацював із вашим записом або живим SDR, дайте знати — можете створити pull request у `репозиторії підручника `_. Також доступна версія цього коду з десятками відлагоджувальних графіків і виводів, яку я використовував під час написання глави, `за цим посиланням `_. .. raw:: html @@ -966,9 +966,9 @@ You did it! Below is all of the code above, concatenated, it should work with t -Once again, the example FM recording known to work with this code `can be found here `_. +Нагадаю, що приклад запису FM, з яким гарантовано працює цей код, `доступний за цим посиланням `_. -For those interested in demodulating the actual audio signal, just add the following lines right after the "Acquiring a Signal" section (special thanks to `Joel Cordeiro `_ for the code): +Якщо ви хочете демодулювати власне аудіосигнал, додайте наведені нижче рядки одразу після розділу "Отримання сигналу" (окрема подяка `Джоелу Кордейру `_ за цей код): .. code-block:: python @@ -997,20 +997,20 @@ For those interested in demodulating the actual audio signal, just add the follo # Save to wav file, you can open this in Audacity for example wavfile.write('fm.wav', int(sample_rate_audio), x) -The most complicated part is the de-emphasis filter, `which you can learn about here `_, although it's actually an optional step if you are OK with audio that has a poor bass/treble balance. For those curious, here is what the frequency response of the `IIR `_ de-emphasis filter looks like, it doesn't fully filter out any frequencies, it's more of a "shaping" filter. +Найскладніший етап — фільтр деемфази, `про який можна почитати тут `_. Втім, цей етап необов’язковий, якщо ви готові змиритися з дещо перекошеним балансом низьких та високих частот. Тим, кому цікаво, нижче показано частотну характеристику фільтра деемфази типу `IIR `_: він не повністю відсікає якісь частоти, радше формує спектр. .. image:: ../_images/fm_demph_filter_freq_response.svg :align: center :target: ../_images/fm_demph_filter_freq_response.svg ******************************** -Acknowledgments +Подяки ******************************** -Most of the steps above used to receive RDS were adapted from the GNU Radio implementation of RDS, which lives in the GNU Radio Out-of-Tree Module called `gr-rds `_, originally created by Dimitrios Symeonidis and maintained by Bastian Bloessl, and I would like to acknowledge the work of these authors. In order to create this chapter, I started with using gr-rds in GNU Radio, with a working FM recording, and slowly converted each of the blocks (including many built-in blocks) to Python. It took quite a bit of time, there are some nuances to the built-in blocks that are easy to miss, and going from stream-style signal processing (i.e., using a work function that processes a few thousand samples at a time in a stateful manner) to a block of Python is not always straightforward. GNU Radio is an amazing tool for this kind of prototyping and I would never have been able to create all of this working Python code without it. +Більшість кроків, описаних вище для прийому RDS, були запозичені з реалізації RDS у GNU Radio — позадеревному модулі `gr-rds `_, який спершу створив Дімітріос Сіменідіс, а нині підтримує Бастіан Блессл. Я хочу відзначити їхню роботу. Під час написання цієї глави я почав із запуску gr-rds у GNU Radio з робочим FM-записом і поступово переніс кожен блок (включно з багатьма вбудованими) у Python. Це забрало чимало часу: у стандартних блоків є нюанси, які легко проґавити, а перехід від потокової обробки (коли функція `work` обробляє кілька тисяч відліків за раз у стані) до суцільного блоку Python не завжди очевидний. GNU Radio — неймовірний інструмент для такого прототипування, і без нього я б ніколи не створив весь цей робочий Python-код. ******************************** -Further Reading +Додаткові матеріали ******************************** #. https://en.wikipedia.org/wiki/Radio_Data_System From 741643721329bdf8be3835ab5e0b9ef365b6b195 Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 13:12:46 +0300 Subject: [PATCH 15/42] Update Ukrainian Phaser chapter --- content-ukraine/phaser.rst | 174 ++++++++++++++++++++++++++++++++++--- 1 file changed, 163 insertions(+), 11 deletions(-) diff --git a/content-ukraine/phaser.rst b/content-ukraine/phaser.rst index 25471463..9ca03251 100644 --- a/content-ukraine/phaser.rst +++ b/content-ukraine/phaser.rst @@ -1,7 +1,7 @@ .. _phaser-chapter: #################################### -Фазовані решітки з фазером +Практична робота з Phaser #################################### У цій главі ми використовуємо `Analog Devices Phaser `_, (також відомий як CN0566 або ADALM-PHASER), який є 8-канальною недорогою фазованою решіткою SDR, що поєднує в собі PlutoSDR, Raspberry Pi і формувач променя ADAR1000, призначений для роботи на частоті близько 10,25 ГГц. Ми розглянемо етапи налаштування та калібрування, а потім розглянемо кілька прикладів формування променя на Python. Для тих, хто не має фазообертача, ми додали скріншоти та анімації того, що побачить користувач. @@ -11,12 +11,6 @@ :align: center :alt: Фазер (CN0566) від Analog Devices -***************************** -Вступ до фазованих решіток -***************************** - ----Короткий вступ до фазованих решіток та порівняння з цифровим формуванням променя - ***************************** Огляд апаратного забезпечення ***************************** @@ -236,7 +230,7 @@ Phaser на Python Те, що ви побачите на цьому етапі, залежатиме від того, чи увімкнений ваш HB100 і куди він спрямований. Якщо ви тримаєте його на відстані кількох футів від фазера і спрямовуєте до центру, ви побачите щось на зразок цього: .. image:: ../_images/phaser_rx_psd.png - Масштаб: 100 % + :scale: 100 % :align: center :alt: Початковий приклад фазера @@ -263,7 +257,7 @@ Phaser на Python powers = [] # основний результат DOA angle_of_arrivals = [] - для фази в np.arange(-180, 180, 2): # розгортка на кут + for phase in np.arange(-180, 180, 2): # розгортка на кут print(phase) # встановити різницю фаз між сусідніми каналами пристроїв for i in range(8): @@ -339,7 +333,7 @@ Phaser на Python phaser.elements.get(i + 1).rx_phase = channel_phase # встановлюємо коефіцієнти підсилення, включаючи gain_cal, за допомогою яких можна застосувати конусність. спробуйте кожен з них! - gain_list = [127] * 8 # прямокутне вікно [127, 127, 127, 127, 127, 127, 127, 127, 127, 127] + gain_list = [127] * 8 # прямокутне вікно [127, 127, 127, 127, 127, 127, 127, 127] #gain_list = np.rint(np.hamming(8) * 127) # [ 10, 32, 82, 121, 121, 82, 32, 10] #gain_list = np.rint(np.hanning(10)[1:-1] * 127) # [ 15, 52, 95, 123, 123, 95, 52, 15] #gain_list = np.rint(np.blackman(10)[1:-1] * 127) # [ 6, 33, 80, 121, 121, 80, 33, 6] @@ -367,7 +361,7 @@ Phaser на Python plt.pause(0.001) plt.clf() except KeyboardInterrupt: - sys.exit() # вийти з python + sys.exit() # вийти з python Ви повинні побачити версію попередньої вправи у реальному часі. Спробуйте перемикати :code:`gain_list`, щоб погратися з різними вікнами. Ось приклад прямокутного вікна (тобто без функції розгортання вікна): @@ -384,3 +378,161 @@ Phaser на Python :alt: Анімація формування променя за допомогою фазера і вікна Hamming Зверніть увагу на відсутність бічних граней для вікна Hamming. Насправді, кожне вікно, крім Прямокутного, значно зменшить бічні пелюстки, але натомість головна пелюстка стане трохи ширшою. + +************************ +Монопульсове відстеження +************************ + +До цього моменту ми виконували окремі розгортки, щоб знайти кут приходу тестового передавача (HB100). Але припустімо, що ми хочемо безперервно приймати сигнал зв'язку чи радара, який може рухатися й змінювати кут приходу з часом. Цей процес називається відстеженням і передбачає, що у нас вже є приблизна оцінка кута приходу (тобто початкова розгортка виявила потрібний сигнал). Ми використаємо монопульсове відстеження для адаптивного оновлення ваг, щоб головна пелюстка з часом залишалася спрямованою на сигнал, хоча варто зазначити, що існують й інші методи відстеження, окрім монопульсу. + +Запатентована у 1943 році Робертом Пейджем з Naval Research Laboratory (NRL), базова ідея монопульсового відстеження полягає у використанні двох променів, обидва дещо зміщені від поточного кута приходу (або принаймні нашої оцінки), але розташовані по різні боки, як показано на діаграмі нижче. + +.. image:: ../_images/monopulse.svg + :align: center + :target: ../_images/monopulse.svg + :alt: Діаграма монопульсового променя, що показує два промені та сумарний промінь + +Потім ми беремо суму і різницю (тобто «дельту») цих двох променів у цифровому вигляді, що означає необхідність використання двох цифрових каналів Phaser, роблячи цей підхід гібридною решіткою (хоча суму та різницю цілком можна виконати й в аналоговому домені за допомогою власного обладнання). Сумарний промінь відповідає променю, центр якого знаходиться у поточній оцінці кута приходу, як показано вище, тобто цей промінь можна використовувати для демодуляції/декодування потрібного сигналу. Дельта-промінь, як ми його називатимемо, важче уявити, але він матиме нуль у точці оціненого кута приходу. Ми можемо використовувати відношення між сумарним променем і дельтою (воно ж помилка), щоб виконувати відстеження. Цей процес найпростіше пояснити коротким фрагментом Python; згадайте, що функція :code:`rx()` повертає пакет відліків з обох каналів, тож у коді нижче :code:`data[0]` — це перший канал Pluto (перша група з чотирьох елементів Phaser), а :code:`data[1]` — другий канал (друга група з чотирьох елементів). Щоб створити два промені, ми будемо окремо керувати кожною з двох груп. Суму, дельту та помилку можна обчислити так: + +.. code-block:: python + + data = phaser.sdr.rx() + sum_beam = data[0] + data[1] + delta_beam = data[0] - data[1] + error = np.mean(np.real(delta_beam / sum_beam)) + +Знак помилки підказує, з якого боку насправді надходить сигнал, а її величина вказує, наскільки далеко ми промахнулися. Ми можемо використати цю інформацію, щоб оновити оцінку кута приходу та ваги. Повторюючи цей процес у реальному часі, ми можемо відстежувати сигнал. + +Легше зрозуміти, чому це працює, якщо пригадати, що зсув фази на 180 градусів еквівалентний множенню на -1, тож дельта-промінь по суті є сумою першого променя з другою групою, зсуненою на 180 градусів. Якщо сигнал переважно у другому промені, то він матиме зсув фази 180 градусів порівняно з сигналом, отриманим сумарним променем. Також пам’ятайте, що при діленні двох комплексних чисел береться відношення їхніх амплітуд і різниця фаз. Тож якщо сигнал переважно у другому промені, помилка буде від’ємною, а її величина буде пропорційною тому, наскільки сигнал у другому промені домінує над першим. + +Тепер перейдемо до повного прикладу на Python. Ми почнемо з копіювання коду, який використовували раніше для розгортки на 180 градусів. Єдине, що додамо, — витягнемо фазу, за якої отримана максимальна потужність: + +.. code-block:: python + + # Одноразово розгортаємо фазу, щоб отримати початкову оцінку кута приходу (за кодом вище) + # ... + current_phase = phase_angles[np.argmax(powers)] + print("max_phase:", current_phase) + +Далі ми створимо два промені: спробуємо на 5 градусів нижче та на 5 градусів вище від поточної оцінки (зверніть увагу, що це в одиницях фази, а не кута, хоча ці величини подібні). Наступний код по суті складається з двох копій попереднього коду для встановлення фазових шифтерів кожного каналу, за винятком того, що перші 4 елементи використовуються для нижнього променя, а останні 4 — для верхнього: + +.. code-block:: python + + # Створюємо два промені по обидва боки від поточної оцінки + phase_offset = np.radians(5) # СПРОБУЙТЕ ЗМІНИТИ ЦЕ — задайте зміщення від центру в градусах + phase_lower = current_phase - phase_offset + phase_upper = current_phase + phase_offset + # перші 4 елементи використовуються для нижнього променя + for i in range(0, 4): + channel_phase = (phase_lower * i + phase_cal[i]) % 360.0 + phaser.elements.get(i + 1).rx_phase = channel_phase + # останні 4 елементи використовуються для верхнього променя + for i in range(4, 8): + channel_phase = (phase_upper * i + phase_cal[i]) % 360.0 + phaser.elements.get(i + 1).rx_phase = channel_phase + phaser.latch_rx_settings() # застосувати налаштування + +Перш ніж виконувати власне відстеження, протестуймо наведений вище код, залишивши ваги променів сталими й рухаючи HB100 ліворуч і праворуч (після завершення ініціалізації для пошуку стартового кута): + +.. code-block:: python + + print("START MOVING THE HB100 A LITTLE LEFT AND RIGHT") + error_log = [] + for i in range(1000): + data = phaser.sdr.rx() # отримуємо пакет відліків + sum_beam = data[0] + data[1] + delta_beam = data[0] - data[1] + error = np.mean(np.real(delta_beam / sum_beam)) + error_log.append(error) + print(error) + time.sleep(0.01) + + plt.plot(error_log) + plt.plot([0,len(error_log)], [0,0], 'r--') + plt.xlabel("Час") + plt.ylabel("Помилка") + plt.show() + +.. image:: ../_images/monopulse_waving.svg + :align: center + :target: ../_images/monopulse_waving.svg + :alt: Відображення функції помилки для монопульсового відстеження без оновлення ваг + +У цьому прикладі я рухаю HB100. Спочатку тримаю його нерухомо, поки виконується розгортка на 180 градусів, потім трохи відводжу вправо і рухаю, далі переміщую вліво від початкової точки й також злегка коливаю. Приблизно в момент часу 400 на графіку я повертаю його в інший бік і ненадовго утримую там, перш ніж знову трохи помахати. Висновок полягає в тому, що чим далі HB100 від стартового кута, тим більшою стає помилка, а знак помилки показує, з якого боку від стартового кута знаходиться HB100. + +Тепер використаємо значення помилки для оновлення ваг. Ми приберемо попередній цикл for і створимо новий цикл, що охоплює весь процес. Для ясності нижче наведено повний приклад коду, за винятком початкової частини з розгорткою на 180 градусів: + +.. code-block:: python + + # Одноразово розгортаємо фазу, щоб отримати початкову оцінку кута приходу + # ... + current_phase = phase_angles[np.argmax(powers)] + print("max_phase:", current_phase) + + # Тепер оновлюємо current_phase на основі помилки + print("START MOVING THE HB100 A LITTLE LEFT AND RIGHT") + phase_log = [] + error_log = [] + for ii in range(500): + # Створюємо два промені по обидва боки від поточної оцінки з заданим зсувом + phase_offset = np.radians(5) + phase_lower = current_phase - phase_offset + phase_upper = current_phase + phase_offset + # перші 4 елементи використовуються для нижнього променя + for i in range(0, 4): + channel_phase = (phase_lower * i + phase_cal[i]) % 360.0 + phaser.elements.get(i + 1).rx_phase = channel_phase + # останні 4 елементи використовуються для верхнього променя + for i in range(4, 8): + channel_phase = (phase_upper * i + phase_cal[i]) % 360.0 + phaser.elements.get(i + 1).rx_phase = channel_phase + phaser.latch_rx_settings() # застосувати налаштування + + data = phaser.sdr.rx() # отримуємо пакет відліків + sum_beam = data[0] + data[1] + delta_beam = data[0] - data[1] + error = np.mean(np.real(delta_beam / sum_beam)) + error_log.append(error) + print(error) + + # Оновлюємо оцінений кут приходу на основі помилки + current_phase += -10 * error # підібрано вручну, щоб система відстежувала з приємною швидкістю + steer_angle = np.degrees(np.arcsin(max(min(1, (3e8 * np.radians(current_phase)) / (2 * np.pi * signal_freq * phaser.element_spacing)), -1))) + phase_log.append(steer_angle) # приємніше будувати графік за кутом, а не за фазою + + time.sleep(0.01) + + fig, [ax0, ax1] = plt.subplots(2, 1, figsize=(8, 10)) + + ax0.plot(phase_log) + ax0.plot([0,len(phase_log)], [0,0], 'r--') + ax0.set_xlabel("Час") + ax0.set_ylabel("Оцінка фази [градуси]") + + ax1.plot(error_log) + ax1.plot([0,len(error_log)], [0,0], 'r--') + ax1.set_xlabel("Час") + ax1.set_ylabel("Помилка") + + plt.show() + +.. image:: ../_images/monopulse_tracking.svg + :align: center + :target: ../_images/monopulse_tracking.svg + :alt: Демонстрація монопульсового відстеження з Phaser і HB100, який рухають перед ним + +Ви можете побачити, що помилка по суті є похідною від оцінки фази; оскільки відстеження працює, оцінка фази загалом відповідає реальному куту приходу. Це не дуже очевидно лише з цих графіків, але коли відбувається різка зміна, системі потрібна невелика частка секунди, щоб підлаштуватися й наздогнати. Мета полягає в тому, щоб зміна кута приходу ніколи не була настільки швидкою, аби сигнал виходив за межі головних пелюсток двох променів. + +Набагато легше візуалізувати цей процес, коли решітка лише одновимірна, але практичні випадки застосування монопульсового відстеження майже завжди двовимірні (використовується площинна/2D-решітка замість лінійної, як у Phaser). Для 2D-випадку створюються чотири промені замість двох, і після обробки маємо один сумарний промінь і чотири дельта-промені для керування в обох вимірах. + +************************ +Радар із Phaser +************************ + +Скоро буде! + +************************ +Висновок +************************ + +Увесь код, використаний для створення ілюстрацій у цьому розділі, доступний на сторінці підручника в GitHub. From 66f00e61bdbc672fdbaa3cbd9f52fdd82b37ba04 Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 13:14:27 +0300 Subject: [PATCH 16/42] Update Ukrainian PlutoSDR chapter --- content-ukraine/pluto.rst | 301 +++++++++++++++++++++++++++++++++++--- 1 file changed, 280 insertions(+), 21 deletions(-) diff --git a/content-ukraine/pluto.rst b/content-ukraine/pluto.rst index e1ad1985..35f0f703 100644 --- a/content-ukraine/pluto.rst +++ b/content-ukraine/pluto.rst @@ -9,7 +9,23 @@ PlutoSDR на Python :align: center :alt: PlutoSDR від Analog Devices -У цьому розділі ми навчимося використовувати API Python для `PlutoSDR `_, який є недорогим SDR від Analog Devices. Ми розглянемо кроки встановлення PlutoSDR для запуску драйверів/програмного забезпечення, а потім обговоримо передачу та прийом даних за допомогою PlutoSDR у Python. +У цьому розділі ми навчимося використовувати API Python для `PlutoSDR `_, який є недорогим SDR від Analog Devices. Ми розглянемо кроки встановлення PlutoSDR для запуску драйверів/програмного забезпечення, а потім обговоримо передачу та прийом даних за допомогою PlutoSDR у Python. Нарешті, ми покажемо, як використовувати `Maia SDR `_ та `IQEngine `_, щоб перетворити PlutoSDR на потужний аналізатор спектра! + +************************ +Огляд PlutoSDR +************************ + +PlutoSDR (також відомий як ADALM-PLUTO) — недорогий SDR (трохи більше $200), здатний передавати та приймати сигнали в діапазоні від 70 МГц до 6 ГГц. Це чудовий SDR для тих, хто вже «виріс» з RTL-SDR за $20. Pluto використовує інтерфейс USB 2.0, що обмежує частоту дискретизації приблизно 5 МГц, якщо ви хочете отримувати 100% відліків у часі. Водночас він може дискретизувати до 61 МГц і дозволяє захоплювати неперервні серії довжиною до приблизно 10 мільйонів відліків за раз, тому Pluto здатен охоплювати величезну смугу спектра одним махом. Технічно це пристрій 2x2, але другий канал передавача та приймача доступні лише через роз'єми U.FL усередині корпусу, і вони використовують ті самі генератори, тож ви не зможете приймати на двох різних частотах одночасно. Нижче наведено блок-схему Pluto, а також AD936x — радіочастотну інтегральну схему (RFIC) всередині Pluto. + +.. image:: ../_images/adi-adalm-pluto-diagram-large.jpg + :scale: 60 % + :align: center + :alt: Блок-схема PlutoSDR + +.. image:: ../_images/ad9361.svg + :align: center + :target: ../_images/ad9361.svg + :alt: Блок-схема RFIC AD9361/AD9363 усередині PlutoSDR *********************************************** Встановлення програмного забезпечення/драйверів @@ -54,8 +70,9 @@ PlutoSDR на Python .. code-block:: bash + sudo apt-get update sudo apt-get install build-essential git libxml2-dev bison flex libcdk5-dev cmake python3-pip libusb-1.0-0-dev libavahi-client-dev libavahi-common-dev libaio-dev - cd ~{{}} + cd ~ git clone --branch v0.23 https://github.com/analogdevicesinc/libiio.git cd libiio mkdir build @@ -64,8 +81,8 @@ PlutoSDR на Python make -j$(nproc) sudo make install sudo ldconfig - - cd ~{{}}} + + cd ~ git clone https://github.com/analogdevicesinc/libad9361-iio.git cd libad9361-iio mkdir build @@ -73,8 +90,8 @@ PlutoSDR на Python cmake .. make -j$(nproc) sudo make install - - cd ~{{}} + + cd ~ git clone --branch v0.0.14 https://github.com/analogdevicesinc/pyadi-iio.git cd pyadi-iio pip3 install --upgrade pip @@ -84,13 +101,28 @@ PlutoSDR на Python Тестування драйверів PlutoSDR ############################# -Якщо з якихось причин IP-адреса за замовчуванням 192.168.2.1 не працює, тому що у вас вже є підмережа 192.168.2.0, або тому що ви хочете підключити кілька Pluto одночасно, ви можете змінити IP-адресу, виконавши такі дії: +Відкрийте новий термінал (у вашій віртуальній машині) і введіть такі команди: + +.. code-block:: bash -1. Відредагуйте файл config.txt на запам'ятовуючому пристрої PlutoSDR (тобто на USB-накопичувачі, який з'являється після підключення Pluto). Введіть нову IP-адресу, яку ви хочете. -2. Вийміть пристрій зберігання даних (не відключайте Pluto!). В Ubuntu 22 поруч з пристроєм PlutoSDR у файловому провіднику є символ виймання. -3. Зачекайте кілька секунд, а потім перезавантажте пристрій, від'єднавши і знову підключивши його до мережі. Поверніться до файлу config.txt і перевірте, чи збереглися ваші зміни. + python3 + import adi + sdr = adi.Pluto('ip:192.168.2.1') # або IP-адреса вашого Pluto + sdr.sample_rate = int(2.5e6) + sdr.rx() + +Якщо ви дійшли до цього етапу без помилок, переходьте до наступних кроків. + +Зміна IP-адреси Pluto +#################################### -Зверніть увагу, що ця процедура також використовується для прошивання іншого образу прошивки на Pluto. Докладнішу інформацію можна знайти на сторінці https://wiki.analog.com/university/tools/pluto/users/firmware. +Якщо з якихось причин IP-адреса за замовчуванням 192.168.2.1 не працює, тому що у вас вже є підмережа 192.168.2.0 або ви хочете підключити кілька Pluto одночасно, ви можете змінити IP-адресу, виконавши такі дії: + +1. Відредагуйте файл ``config.txt`` на пристрої масового зберігання PlutoSDR (тобто на USB-накопичувачі, який з'являється після підключення Pluto). Введіть нову IP-адресу. +2. Безпечно витягніть пристрій масового зберігання (не відключайте Pluto!). В Ubuntu 22 у файловому менеджері є піктограма витягнення поруч із пристроєм PlutoSDR. +3. Зачекайте кілька секунд, а потім вимкніть і знову увімкніть Pluto, від'єднавши його і підключивши знову. Поверніться до ``config.txt``, щоб переконатися, що зміни збереглися. + +Зауважте, що цю процедуру також використовують для прошивання іншого образу прошивки на Pluto. Докладніше див. https://wiki.analog.com/university/tools/pluto/users/firmware. Як "зламати" PlutoSDR, щоб збільшити радіус дії ############################################### @@ -110,16 +142,16 @@ PlutoSDR має обмежений діапазон центральних ча .. code-block:: bash - fw_setenv attr_name сумісний + fw_setenv attr_name compatible fw_setenv attr_val ad9364 - перезавантажити + reboot А для версій 0.32 і вище використовуйте .. code-block:: bash - - fw_setenv сумісний ad9364 - перезавантаження + + fw_setenv compatible ad9364 + reboot Тепер ви зможете налаштовуватися на частоту до 6 ГГц і до 70 МГц, не кажучи вже про використання частоти дискретизації до 56 МГц! Ура! @@ -140,7 +172,7 @@ PlutoSDR має обмежений діапазон центральних ча center_freq = 100e6 # Hz num_samps = 10000 # кількість відліків, що повертаються за один виклик rx() - sdr = adi.Pluto() + sdr = adi.Pluto('ip:192.168.2.1') sdr.gain_control_mode_chan0 = 'manual' sdr.rx_hardwaregain_chan0 = 70.0 # дБ sdr.rx_lo = int(center_freq) @@ -168,7 +200,7 @@ Pluto можна налаштувати на фіксоване або авто Якщо ви хочете увімкнути АРУ, ви повинні вибрати один з двох режимів: -1. :code:`sdr.gain_control_mode_mode_chan0 = "slow_attack"``. +1. :code:`sdr.gain_control_mode_chan0 = "slow_attack"` 2. :code:`sdr.gain_control_mode_chan0 = "fast_attack"`. А з увімкненим АРУ ви не вказуєте значення для :code:`rx_hardwaregain_chan0`. Він буде проігнорований, оскільки Плутон сам підлаштовує коефіцієнт підсилення під сигнал. Плутон має два режими АРУ: швидка атака і повільна атака, як показано у наведеному вище коді. Різниця між ними інтуїтивно зрозуміла, якщо подумати. Режим швидкої атаки швидше реагує на сигнали. Іншими словами, значення коефіцієнта підсилення змінюється швидше, коли рівень сигналу змінюється. Пристосування до рівня потужності сигналу може бути важливим, особливо для дуплексних систем з часовим розділенням каналів (TDD), які використовують ту саму частоту для передавання і приймання. Встановлення регулятора підсилення в режим швидкої атаки для цього сценарію обмежує згасання сигналу. У будь-якому з цих режимів, якщо немає сигналу, а є лише шум, АРУ максимально збільшить налаштування посилення; коли сигнал з'являється, він ненадовго насичує приймач, доки АРУ не зможе відреагувати і зменшити посилення. Ви завжди можете перевірити поточний рівень підсилення у реальному часі за допомогою: @@ -207,7 +239,7 @@ Pluto можна налаштувати на фіксоване або авто samples *= 2**14 # PlutoSDR очікує, що відліки будуть між -2^14 та +2^14, а не між -1 та +1, як у деяких SDR # Передамо нашу партію відліків 100 разів, таким чином, це має бути 1 секунда відліків сумарно, якщо USB витримає - для i в range(100): + for i in range(100): sdr.tx(samples) # передаємо пакет семплів один раз Ось кілька зауважень щодо цього коду. По-перше, ви хочете змоделювати ваші IQ-зразки так, щоб вони були між -1 і 1, але потім перед передачею ми повинні масштабувати їх на 2^14 через те, як Analog Devices реалізували функцію :code:`tx()`. Якщо ви не впевнені, які ваші min/max значення, просто роздрукуйте їх за допомогою :code:`print(np.min(samples), np.max(samples))` або напишіть інструкцію if, щоб переконатися, що вони ніколи не будуть вищими за 1 або нижчими за -1 (припускаючи, що код йде перед масштабуванням на 2^14). Що стосується коефіцієнта підсилення передачі, то діапазон становить від -90 до 0 дБ, тобто 0 дБ - це найвища потужність передачі. Ми завжди хочемо починати з низької потужності передачі, а потім збільшувати її, якщо це необхідно, тому за замовчуванням ми встановили коефіцієнт підсилення на -50 дБ, що є нижньою межею діапазону. Не встановлюйте його на 0 дБ лише тому, що ваш сигнал не з'являється; можливо, щось ще не так, і ви не хочете підсмажити свій приймач. @@ -231,7 +263,7 @@ Pluto можна налаштувати на фіксоване або авто Інший спосіб подивитися на це - сказати: "Ну, це не пристрої Частини 15, але давайте дотримуватися правил Частини 15, як якщо б це були пристрої Частини 15". Для діапазону 915 МГц ISM правила такі: "Напруженість поля будь-яких випромінювань, що випромінюються в зазначеному діапазоні частот, не повинна перевищувати 500 мікровольт/метр на відстані 30 метрів. Межа випромінювання в цьому пункті базується на вимірювальних приладах, що використовують середній детектор". Отже, як бачите, це не так просто, як максимальна потужність передачі у ватах. Тепер, якщо у вас є ліцензія на аматорське радіо (ham), FCC дозволяє вам використовувати певні діапазони, відведені для аматорського радіо. Існують певні правила, яких слід дотримуватися, і максимальні потужності передачі, але, принаймні, ці цифри вказані у ватах -ефективної випромінюваної потужності. Ця інфографіка показує, які діапазони доступні для використання залежно від класу вашої ліцензії (Технік, Загальна і Додаткова). Я б рекомендував усім, хто зацікавлений у передаванні з використанням SDR, отримати ліцензію на радіоаматорську діяльність, див. `ARRL's Getting Licence page `_ для отримання додаткової інформації. +ефективної випромінюваної потужності. `Ця інфографіка `_ показує, які діапазони доступні для використання залежно від класу вашої ліцензії (Технік, General та Extra). Я б рекомендував усім, хто зацікавлений у передаванні з використанням SDR, отримати ліцензію радіоаматора, див. `ARRL's Getting Licensed page `_ для отримання додаткової інформації. Якщо хтось має більш детальну інформацію про те, що дозволено, а що ні, будь ласка, напишіть мені. @@ -344,7 +376,7 @@ Pluto можна налаштувати на фіксоване або авто import numpy as np import adi import matplotlib.pyplot as plt - час імпорту + import time sample_rate = 10e6 # Hz center_freq = 100e6 # Hz @@ -397,3 +429,230 @@ Pluto можна налаштувати на фіксоване або авто +****** +Pluto+ +****** + +Pluto+ (також відомий як Pluto Plus) — неофіційна вдосконалена версія оригінального PlutoSDR, яку переважно можна придбати на AliExpress. Він має порт Gigabit Ethernet, обидва канали RX і обидва канали TX, виведені на SMA, слот MicroSD, VCTCXO з точністю 0,5 ppm та вхід зовнішнього тактового сигналу через роз'єм U.FL на друкованій платі. + +.. image:: ../_images/pluto_plus.png + :scale: 70 % + :align: center + :alt: Pluto Plus + +Порт Ethernet — це величезне оновлення, адже він суттєво збільшує частоту дискретизації, яку можна отримати під час прийому або передавання із 100% робочим циклом. Pluto та Pluto+ за замовчуванням використовують 16 біт для I та Q, хоча АЦП має лише 12 біт, тому це 4 байти на IQ-відлік. Gigabit Ethernet з ефективністю 90% забезпечує приблизно 900 Мбіт/с або 112,5 МБ/с, а це відповідає максимально можливій частоті дискретизації близько 28 МГц, якщо ви хочете отримувати всі відліки протягом тривалого часу (наприклад, більше секунди). Для порівняння, USB 3.0 може досягати приблизно 56 МГц, а USB 2.0 — близько 5 МГц. Існує також обмеження на те, скільки даних Python може прийняти, залежно від продуктивності вашого комп'ютера, а також від конкретної задачі DSP, яку ви хочете виконувати над відліками (або швидкості запису на диск, якщо ви просто зберігаєте їх у файл). Реалістичніші частоти дискретизації для застосунків на Python із Pluto+ через Ethernet лежать ближче до 10 МГц. + +.. image:: ../_images/pluto_plus_pcb.jpg + :scale: 30 % + :align: center + :alt: Фото плати Pluto Plus + +Щоб задати IP-адресу для Ethernet-порту, підключіть Pluto+ через USB і відкрийте пристрій масового зберігання, відредагувавши ``config.txt`` та заповнивши секцію :code:`[USB_ETHERNET]`. Перезавантажте Pluto+. Після цього ви зможете підключатися до Pluto+ по SSH через Ethernet, використовуючи введену IP-адресу. Якщо все працює, можете переключити кабель micro USB до порту 5 В, щоб він лише живив Pluto+, а всі дані передавалися через Ethernet. Пам'ятайте, що навіть звичайний PlutoSDR (і Pluto+) може дискретизувати до 61 МГц смуги та отримувати неперервні блоки приблизно по 10 млн відліків за раз, якщо робити паузи між блоками, що дозволяє будувати потужні системи спектрального моніторингу. + +Код на Python для Pluto+ буде таким самим, як і для PlutoSDR, лише замініть :code:`192.168.2.1` на IP-адресу Ethernet, яку ви задали. Спробуйте приймати відліки у циклі, підраховуючи їх кількість, щоб побачити, наскільки високо можна підняти частоту дискретизації, все ще отримуючи приблизно стільки відліків на секунду, скільки задає частота дискретизації. Підказка: збільшення ``rx_buffer_size`` до дуже великого значення допомагає підвищити пропускну здатність. + +************ +AntSDR E200 +************ + +AntSDR E200, який далі називатимемо просто AntSDR, — недорогий SDR на базі AD936X, дуже схожий на Pluto та Pluto+, що його виробляє шанхайська компанія MicroPhase. Як і Pluto+, він використовує гігабітний Ethernet, хоча AntSDR не має опції передавання даних через USB. Унікальність AntSDR у тому, що він може працювати як Pluto, використовуючи бібліотеку IIO, або як USRP, використовуючи бібліотеку UHD. За замовчуванням він постачається в режимі Pluto, але перехід до режиму USRP/UHD — це проста заміна прошивки. Обидва варіанти прошивок практично повністю запозичені в Analog Devices/Ettus із мінімальними змінами для підтримки апаратного забезпечення AntSDR. Ще одна особливість — можливість придбати плату як із чипом 9363, так і 9361; хоча це функціонально однакові мікросхеми, на заводі 9361 відбирають за вищими ВЧ-характеристиками на верхніх частотах. Зауважте, що Pluto та Pluto+ постачаються лише з 9363. У специфікації AntSDR для версії на 9363 вказано максимум 3,8 ГГц і 20 МГц частоти дискретизації, але на практиці це не так: він досягає повних 6 ГГц і приблизно 60 МГц частоти дискретизації (хоча через інтерфейс 1GbE не завжди вдається передати 100% відліків). Як і інші Pluto, AntSDR — це пристрій 2x2, причому другі канали передавача та приймача доступні через роз'єми U.FL на платі. Інші ВЧ-характеристики та технічні параметри дуже схожі або ідентичні Pluto/Pluto+. Придбати AntSDR можна на `Crowd Supply `_ та на AliExpress. + +.. image:: ../_images/AntSDR.png + :scale: 80 % + :align: center + :alt: SDR AntSDR E200 у додатковому корпусі + +Невеликий DIP-перемикач на AntSDR визначає, завантажуватися з SD-карти чи з вбудованої флеш-пам'яті Quad SPI (QSPI). На момент написання E200 постачається з прошивкою Pluto у QSPI та прошивкою USRP/UHD на SD-карті, тож перемикач дозволяє миттєво перемикатися між режимами без додаткових дій. + +Блок-схему E200 наведено нижче. + +.. image:: ../_images/AntSDR_E200_block_diagram.png + :scale: 80 % + :align: center + :alt: Блок-схема AntSDR E200 + +Налаштування та використання AntSDR у режимі Pluto схоже на Pluto+: зверніть увагу, що IP-адреса за замовчуванням — 192.168.1.10, і пристрій не має USB-підключення для передавання даних, тож немає пристрою масового зберігання для оновлення прошивки чи зміни налаштувань. Натомість для оновлення прошивки можна використати SD-карту, а для зміни налаштувань — SSH. Крім того, якщо ви можете підключитися по SSH, змінити IP-адресу пристрою можна командою :code:`fw_setenv ipaddr_eth 192.168.2.1`, підставивши бажану адресу. Прошивку Pluto/IIO можна знайти тут: https://github.com/MicroPhase/antsdr-fw-patch, а прошивку USRP/UHD — тут: https://github.com/MicroPhase/antsdr_uhd. + +Якщо на SD-карті немає драйвера USRP/UHD або ви хочете встановити найновішу версію, дотримуйтеся `цих інструкцій `_, щоб встановити прошивку USRP/UHD на AntSDR, а також хостові драйвери на свій комп'ютер. Це дещо модифікована версія стандартного коду UHD для хоста. Після встановлення можна використовувати :code:`uhd_find_devices` та :code:`uhd_usrp_probe` як зазвичай (див. розділ :ref:`usrp-chapter` для додаткової інформації та прикладів коду, що працюватиме з AntSDR у режимі USRP). Ось команди, які використовувалися для встановлення хостового коду на Ubuntu 22: + +.. code-block:: bash + + sudo apt-get update + sudo apt-get install autoconf automake build-essential ccache cmake cpufrequtils doxygen ethtool \ + g++ git inetutils-tools libboost-all-dev libncurses5 libncurses5-dev libusb-1.0-0 libusb-1.0-0-dev \ + libusb-dev python3-dev python3-mako python3-numpy python3-requests python3-scipy python3-setuptools \ + python3-ruamel.yaml + cd ~ + git clone git@github.com:MicroPhase/antsdr_uhd.git + cd host + mkdir build + cd build + cmake -DENABLE_X400=OFF -DENABLE_N320=OFF -DENABLE_X300=OFF -DENABLE_USRP2=OFF -DENABLE_USRP1=OFF -DENABLE_N300=OFF -DENABLE_E320=OFF -DENABLE_E300=OFF ../ + (NOTE - at this point, make sure in the "enabled components" you see ANT and LibUHD - Python API) + make -j8 + sudo make install + sudo ldconfig + export PYTHONPATH="${PYTHONPATH}:/usr/local/lib/python3/dist-packages" + sudo sysctl -w net.core.rmem_max=1000000 + sudo sysctl -w net.core.wmem_max=1000000 + +На самому пристрої використовувалася прошивка USRP із SD-карти, що постачалася разом з AntSDR, для цього потрібно перевести DIP-перемикач під Ethernet-портом у положення «SD». + +AntSDR можна виявити та опитати за допомогою таких команд: + +.. code-block:: bash + + uhd_find_devices --args addr=192.168.1.10 + uhd_usrp_probe --args addr=192.168.1.10 + +Нижче наведено приклад виводу у разі успішної роботи: + +.. code-block:: bash + + $ uhd_find_devices --args addr=192.168.1.10 + [INFO] [UHD] linux; GNU C++ version 11.3.0; Boost_107400; UHD_4.1.0.0-0-d2f0b1b1 + -------------------------------------------------- + -- UHD Device 0 + -------------------------------------------------- + Device Address: + serial: 0223D80FF0D767EBC6D3AAAA6793E64D + addr: 192.168.1.10 + name: ANTSDR-E200 + product: E200 v1 + type: ant + + $ uhd_usrp_probe --args addr=192.168.1.10 + [INFO] [UHD] linux; GNU C++ version 11.3.0; Boost_107400; UHD_4.1.0.0-0-d2f0b1b1 + [INFO] [ANT] Detected Device: ANTSDR + [INFO] [ANT] Initialize CODEC control... + [INFO] [ANT] Initialize Radio control... + [INFO] [ANT] Performing register loopback test... + [INFO] [ANT] Register loopback test passed + [INFO] [ANT] Performing register loopback test... + [INFO] [ANT] Register loopback test passed + [INFO] [ANT] Setting master clock rate selection to 'automatic'. + [INFO] [ANT] Asking for clock rate 16.000000 MHz... + [INFO] [ANT] Actually got clock rate 16.000000 MHz. + _____________________________________________________ + / + | Device: B-Series Device + | _____________________________________________________ + | / + | | Mboard: B210 + | | magic: 45568 + | | eeprom_revision: v0.1 + | | eeprom_compat: 1 + | | product: MICROPHASE + | | name: ANT + | | serial: 0223D80FF0D767EBC6D3AAAA6793E64D + | | FPGA Version: 16.0 + | | + | | Time sources: none, internal, external + | | Clock sources: internal, external + | | Sensors: ref_locked + | | _____________________________________________________ + | | / + | | | RX DSP: 0 + | | | + | | | Freq range: -8.000 to 8.000 MHz + | | _____________________________________________________ + | | / + | | | RX DSP: 1 + | | | + | | | Freq range: -8.000 to 8.000 MHz + | | _____________________________________________________ + | | / + | | | RX Dboard: A + | | | _____________________________________________________ + | | | / + | | | | RX Frontend: A + | | | | Name: FE-RX1 + | | | | Antennas: TX/RX, RX2 + | | | | Sensors: temp, rssi, lo_locked + | | | | Freq range: 50.000 to 6000.000 MHz + | | | | Gain range PGA: 0.0 to 76.0 step 1.0 dB + | | | | Bandwidth range: 200000.0 to 56000000.0 step 0.0 Hz + | | | | Connection Type: IQ + | | | | Uses LO offset: No + | | | _____________________________________________________ + | | | / + | | | | RX Frontend: B + | | | | Name: FE-RX2 + | | | | Antennas: TX/RX, RX2 + | | | | Sensors: temp, rssi, lo_locked + | | | | Freq range: 50.000 to 6000.000 MHz + | | | | Gain range PGA: 0.0 to 76.0 step 1.0 dB + | | | | Bandwidth range: 200000.0 to 56000000.0 step 0.0 Hz + | | | | Connection Type: IQ + | | | | Uses LO offset: No + | | | _____________________________________________________ + | | | / + | | | | RX Codec: A + | | | | Name: B210 RX dual ADC + | | | | Gain Elements: None + | | _____________________________________________________ + | | / + | | | TX DSP: 0 + | | | + | | | Freq range: -8.000 to 8.000 MHz + | | _____________________________________________________ + | | / + | | | TX DSP: 1 + | | | + | | | Freq range: -8.000 to 8.000 MHz + | | _____________________________________________________ + | | / + | | | TX Dboard: A + | | | _____________________________________________________ + | | | / + | | | | TX Frontend: A + | | | | Name: FE-TX1 + | | | | Antennas: TX/RX + | | | | Sensors: temp, lo_locked + | | | | Freq range: 50.000 to 6000.000 MHz + | | | | Gain range PGA: 0.0 to 89.8 step 0.2 dB + | | | | Bandwidth range: 200000.0 to 56000000.0 step 0.0 Hz + | | | | Connection Type: IQ + | | | | Uses LO offset: No + | | | _____________________________________________________ + | | | / + | | | | TX Frontend: B + | | | | Name: FE-TX2 + | | | | Antennas: TX/RX + | | | | Sensors: temp, lo_locked + | | | | Freq range: 50.000 to 6000.000 MHz + | | | | Gain range PGA: 0.0 to 89.8 step 0.2 dB + | | | | Bandwidth range: 200000.0 to 56000000.0 step 0.0 Hz + +Нарешті, ви можете перевірити роботу Python API за допомогою такого фрагмента коду (у терміналі Python або в скрипті): + +.. code-block:: python + + import uhd + usrp = uhd.usrp.MultiUSRP("addr=192.168.1.10") + samples = usrp.recv_num_samps(10000, 100e6, 1e6, [0], 50) + print(samples[0:10]) + +Це має прийняти 10 000 відліків із центральною частотою 100 МГц, частотою дискретизації 1 МГц і підсиленням 50 дБ. Код виведе IQ-значення перших 10 відліків, щоб переконатися, що все працює. Для подальших кроків і додаткових прикладів зверніться до розділу :ref:`usrp-chapter`. + +Якщо при :code:`import uhd` ви бачите ModuleNotFoundError, додайте до файлу ``.bashrc`` такий рядок: + +.. code-block:: bash + + export PYTHONPATH="${PYTHONPATH}:/usr/local/lib/python3/dist-packages" + +************ +AntSDR E310 +************ + +Окрім E200, MicroPhase також випускає модель AntSDR E310. AntSDR E310 дуже схожий на E200, але має другий канал приймача та другий канал передавача, виведені на SMA-роз'єми спереду, і наразі підтримує лише режим Pluto/IIO (режиму USRP немає). Він використовує ту саму FPGA, що й E200. Ще одна відмінність — додатковий порт USB-C, який працює як інтерфейс USB OTG (наприклад, для підключення USB-накопичувача). AntSDR E310 доступний лише на `AliExpress `_ (на Crowd Supply, як E200, його немає). На момент написання E310 коштує приблизно стільки ж, скільки й E200, тож якщо ви не плануєте використовувати «режим USRP» і цінуєте додаткові канали на SMA навіть за трохи більших габаритів, E310 — гарний вибір. + +.. image:: ../_images/AntSDR_E310.png + :scale: 80 % + :align: center + :alt: SDR AntSDR E310 у додатковому корпусі + +.. image:: ../_images/AntSDR_Comparison.jpg + :scale: 70 % + :align: center + :alt: AntSDR E200 та E310 поруч From 7c00191487cbf2a0d030687cf2a89f626d93f0d4 Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 13:15:35 +0300 Subject: [PATCH 17/42] Add Ukrainian translation for RTL-SDR chapter --- content-ukraine/rtlsdr.rst | 217 +++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 content-ukraine/rtlsdr.rst diff --git a/content-ukraine/rtlsdr.rst b/content-ukraine/rtlsdr.rst new file mode 100644 index 00000000..22e9a10a --- /dev/null +++ b/content-ukraine/rtlsdr.rst @@ -0,0 +1,217 @@ +.. _rtlsdr-chapter: + +################## +RTL-SDR у Python +################## + +RTL-SDR є беззаперечно найдешевшим SDR (близько 30 доларів) і чудово підходить для початку знайомства. Хоча він працює лише на прийом і може налаштовуватися лише до ~1,75 ГГц, існує безліч застосувань, де його можна використати. У цьому розділі ми навчимося налаштовувати програмне забезпечення RTL-SDR та користуватися його Python API. + +.. image:: ../_images/rtlsdrs.svg + :align: center + :target: ../_images/rtlsdrs.svg + :alt: Приклади RTL-SDR + +******************************** +Передумови RTL-SDR +******************************** + +RTL-SDR з’явився приблизно у 2010 році, коли ентузіасти виявили, що можуть зламати недорогі DVB-T-адаптери з чипом Realtek RTL2832U. DVB-T — це стандарт цифрового телебачення, що переважно використовується в Європі, але цікавинкою RTL2832U було те, що до сирих IQ-відліків можна було отримати прямий доступ, що дозволило застосовувати чип для побудови універсального приймального SDR. + +Чип RTL2832U містить аналого-цифровий перетворювач (ADC) та USB-контролер, але його необхідно поєднати з ВЧ-тюнером. Популярні тюнери — Rafael Micro R820T, R828D та Elonics E4000. Діапазон налаштування залежить від тюнера і зазвичай становить приблизно 50–1700 МГц. Максимальна частота дискретизації, своєю чергою, визначається RTL2832U та шиною USB вашого комп’ютера й зазвичай становить близько 2,4 МГц без значних втрат відліків. Варто пам’ятати, що ці тюнери дуже дешеві й мають слабку чутливість RF, тож для прийому слабких сигналів часто доводиться додавати малошумний підсилювач (LNA) та смуговий фільтр. + +RTL2832U завжди використовує 8-бітні відліки, тому хост-машина отримує два байти на один IQ-відлік. Преміальні RTL-SDR зазвичай постачаються з термостабілізованим генератором (TCXO) замість дешевшого кварцового, що забезпечує кращу частотну стабільність. Інша опціональна функція — це bias tee (bias-T): вбудоване коло, яке подає ~4,5 В постійної напруги на SMA-роз’єм для зручного живлення зовнішнього LNA чи інших RF-компонентів. Ця додаткова постійна напруга подається на ВЧ-сторону SDR, тому не заважає основній роботі з прийому. + +Для тих, хто цікавиться визначенням напряму приходу (DOA) чи іншими задачами формування променя, `KrakenSDR `_ — це фазово-узгоджений SDR, побудований з п’яти RTL-SDR, що мають спільні генератор та тактовий сигнал. + +******************************** +Встановлення програмного забезпечення +******************************** + +Ubuntu (або Ubuntu у WSL) +############################# + +В Ubuntu 20, 22 та інших системах на базі Debian ви можете встановити програмне забезпечення RTL-SDR за допомогою такої команди. + +.. code-block:: bash + + sudo apt install rtl-sdr + +Це встановить бібліотеку librtlsdr та інструменти командного рядка, зокрема :code:`rtl_sdr`, :code:`rtl_tcp`, :code:`rtl_fm` і :code:`rtl_test`. + +Далі встановіть Python-обгортку для librtlsdr командою: + +.. code-block:: bash + + sudo pip install pyrtlsdr + +Якщо ви використовуєте Ubuntu через WSL, на стороні Windows завантажте останню версію `Zadig `_ і встановіть драйвер "WinUSB" для RTL-SDR (може бути два інтерфейси Bulk-In — у такому разі встановіть "WinUSB" для обох). Після завершення роботи Zadig від’єднайте і знову під’єднайте RTL-SDR. + +Далі потрібно пробросити USB-пристрій RTL-SDR у WSL. Спершу встановіть останню `msi-версію утиліти usbipd `_ (у цьому посібнику передбачається, що у вас usbipd-win 4.0.0 або новіша), потім відкрийте PowerShell з правами адміністратора та виконайте: + +.. code-block:: bash + + # (від’єднайте RTL-SDR) + usbipd list + # (під’єднайте RTL-SDR) + usbipd list + # (знайдіть новий пристрій і підставте його індекс у команді нижче) + usbipd bind --busid 1-5 + usbipd attach --wsl --busid 1-5 + +У WSL ви повинні мати змогу запустити :code:`lsusb` і побачити новий запис під назвою RTL2838 DVB-T або щось подібне. + +Якщо виникають проблеми з дозволами (наприклад, тест нижче працює лише з :code:`sudo`), потрібно налаштувати правила udev. Спочатку виконайте :code:`lsusb`, щоб знайти ID вашого RTL-SDR, після чого створіть файл :code:`/etc/udev/rules.d/10-rtl-sdr.rules` з таким вмістом, підставивши idVendor та idProduct вашого пристрою, якщо вони відрізняються: + +.. code-block:: + + SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666" + +Щоб перезапустити udev, виконайте: + +.. code-block:: bash + + sudo udevadm control --reload-rules + sudo udevadm trigger + +Якщо ви користуєтесь WSL і отримуєте повідомлення :code:`Failed to send reload request: No such file or directory`, це означає, що служба udev не запущена. Додайте в :code:`sudo nano /etc/wsl.conf` такі рядки: + +.. code-block:: bash + + [boot] + command="service udev start" + +Після цього перезапустіть WSL у PowerShell з правами адміністратора командою :code:`wsl.exe --shutdown`. + +Може також знадобитися знову від’єднати й під’єднати RTL-SDR (у WSL його доведеться знову підключити за допомогою :code:`usbipd attach`). + +Windows +################### + +Користувачам Windows варто звернутися до https://www.rtl-sdr.com/rtl-sdr-quick-start-guide/. + +******************************** +Тестування RTL-SDR +******************************** + +Якщо встановлення пройшло успішно, ви зможете виконати такий тест, що налаштує RTL-SDR на FM-діапазон і збереже 1 мільйон відліків у файл :code:`recording.iq` у каталозі :code:`/tmp`. + +.. code-block:: bash + + rtl_sdr /tmp/recording.iq -s 2e6 -f 100e6 -n 1e6 + +Якщо ви отримуєте :code:`No supported devices found`, навіть із :code:`sudo`, це означає, що Linux взагалі не бачить RTL-SDR. Якщо команда працює з :code:`sudo`, значить проблема у правилах udev; спробуйте перезавантажити комп’ютер після виконання вказівок з налаштування udev вище. Або ж можете використовувати :code:`sudo` всюди, включно із запуском Python. + +Перевірити, чи Python бачить RTL-SDR, можна таким скриптом: + +.. code-block:: python + + from rtlsdr import RtlSdr + + sdr = RtlSdr() + sdr.sample_rate = 2.048e6 # Hz + sdr.center_freq = 100e6 # Hz + sdr.freq_correction = 60 # PPM + sdr.gain = 'auto' + + print(len(sdr.read_samples(1024))) + sdr.close() + +який має вивести: + +.. code-block:: bash + + Found Rafael Micro R820T tuner + [R82XX] PLL not locked! + 1024 + +******************************** +Код RTL-SDR на Python +******************************** + +Код вище можна вважати базовим прикладом використання RTL-SDR у Python. Далі ми детальніше розглянемо різні параметри та прийоми роботи. + +Запобігання збоїв RTL-SDR +############################### + +Наприкінці скрипта або щоразу, коли завершили зчитування відліків із RTL-SDR, викликайте :code:`sdr.close()`, щоб уникнути зависань пристрою, коли його доводиться фізично від’єднувати й під’єднувати. Навіть із викликом close() це може статися — ви це зрозумієте, якщо RTL-SDR зависне під час :code:`read_samples()`. Якщо так сталося, потрібно від’єднати і знову під’єднати RTL-SDR і, можливо, перезавантажити комп’ютер. Якщо ви використовуєте WSL, доведеться повторно підключити RTL-SDR за допомогою usbipd. + +Налаштування підсилення +############# + +Параметр :code:`sdr.gain = 'auto'` вмикає автоматичне керування підсиленням (AGC), через що RTL-SDR регулює підсилення, намагаючись заповнити 8-бітний ADC без його насичення. У багатьох випадках, наприклад для побудови аналізатора спектра, корисно тримати підсилення сталим, тобто доводиться задавати його вручну. RTL-SDR не підтримує довільні значення підсилення; список допустимих значень можна отримати командою :code:`print(sdr.valid_gains_db)`. Якщо задати інше значення, пристрій автоматично вибере найближче допустиме. Поточне підсилення можна перевірити командою :code:`print(sdr.gain)`. У прикладі нижче ми встановлюємо підсилення 49,6 дБ, зчитуємо 4096 відліків і будуємо їх у часовій області: + +.. code-block:: python + + from rtlsdr import RtlSdr + import numpy as np + import matplotlib.pyplot as plt + + sdr = RtlSdr() + sdr.sample_rate = 2.048e6 # Hz + sdr.center_freq = 100e6 # Hz + sdr.freq_correction = 60 # PPM + print(sdr.valid_gains_db) + sdr.gain = 49.6 + print(sdr.gain) + + x = sdr.read_samples(4096) + sdr.close() + + plt.plot(x.real) + plt.plot(x.imag) + plt.legend(["I", "Q"]) + plt.savefig("../_images/rtlsdr-gain.svg", bbox_inches='tight') + plt.show() + +.. image:: ../_images/rtlsdr-gain.svg + :align: center + :target: ../_images/rtlsdr-gain.svg + :alt: Приклад ручного підсилення RTL-SDR + +Тут варто звернути увагу на кілька моментів. Перші ~2 тисячі відліків майже не містять енергії сигналу, бо це перехідні процеси. Рекомендується щоразу відкидати перші 2 тисячі відліків, наприклад, виконавши :code:`sdr.read_samples(2048)` і не використовуючи результат. Інше спостереження — pyrtlsdr повертає нам відліки у вигляді чисел з рухомою комою в діапазоні від -1 до +1. Хоча пристрій використовує 8-бітний ADC і видає цілі числа, pyrtlsdr ділить їх на 127.0 для нашої зручності. + +Допустимі частоти дискретизації +##################### + +Більшість RTL-SDR вимагають, щоб частота дискретизації лежала або в діапазоні 230–300 кГц, або 900–3,2 МГц. Зверніть увагу, що на високих частотах, особливо понад 2,4 МГц, через USB можуть не проходити всі відліки. Якщо задати непідтримувану частоту, ви отримаєте помилку :code:`rtlsdr.rtlsdr.LibUSBError: Error code -22: Could not set sample rate to 899000 Hz`. Після встановлення допустимого значення в консолі з’явиться точна частота дискретизації; цю ж величину можна отримати викликом :code:`sdr.sample_rate`. У деяких застосунках може бути важливо враховувати саме точне значення в обчисленнях. + +Як вправу встановімо частоту дискретизації 2,4 МГц і побудуємо спектрограму FM-діапазону: + +.. code-block:: python + + # ... + sdr.sample_rate = 2.4e6 # Hz + # ... + + fft_size = 512 + num_rows = 500 + x = sdr.read_samples(2048) # позбавляємося початкових порожніх відліків + x = sdr.read_samples(fft_size*num_rows) # зчитуємо всі відліки для спектрограми + spectrogram = np.zeros((num_rows, fft_size)) + for i in range(num_rows): + spectrogram[i,:] = 10*np.log10(np.abs(np.fft.fftshift(np.fft.fft(x[i*fft_size:(i+1)*fft_size])))**2) + extent = [(sdr.center_freq + sdr.sample_rate/-2)/1e6, + (sdr.center_freq + sdr.sample_rate/2)/1e6, + len(x)/sdr.sample_rate, 0] + plt.imshow(spectrogram, aspect='auto', extent=extent) + plt.xlabel("Частота [МГц]") + plt.ylabel("Час [с]") + plt.show() + +.. image:: ../_images/rtlsdr-waterfall.svg + :align: center + :target: ../_images/rtlsdr-waterfall.svg + :alt: Приклад водоспаду (спектрограми) RTL-SDR + +Параметр PPM +############ + +Щодо параметра ppm: кожен RTL-SDR має невелику частотну похибку через низьку вартість тюнерів та відсутність калібрування. Це зміщення частоти здебільшого лінійне (а не сталий зсув), тож ми можемо компенсувати його, вказавши значення PPM у частинах на мільйон. Наприклад, якщо налаштуватися на 100 МГц і задати PPM = 25, сигнал зміститься вгору на 100e6/1e6*25 = 2500 Гц. Вужчі сигнали чутливіші до помилок частоти. Водночас багато сучасних сигналів мають етап синхронізації частоти, який компенсує похибки на передавачі, приймачі або через доплерівський зсув. + +******************************** +Додаткові матеріали +******************************** + +#. `Сторінка "About" на RTL-SDR.com `_ +#. https://hackaday.com/2019/07/31/rtl-sdr-seven-years-later/ +#. https://osmocom.org/projects/rtl-sdr/wiki/Rtl-sdr From ce16460669c561152ab674dfd89f2d0b437ceda3 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Wed, 8 Oct 2025 13:37:05 +0300 Subject: [PATCH 18/42] !!!! ITS JUST TEST THE READDOCS !!!! --- .readthedocs.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..7a30f293 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,26 @@ + + +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version, and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally, but recommended, +# declare the Python requirements required to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt + + From 19998cd52697f36ff1e445408bf2128c9fddbdd3 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Wed, 8 Oct 2025 13:43:43 +0300 Subject: [PATCH 19/42] !!!! ITS JUST TEST THE READDOCS 2 !!!! --- .readthedocs.yaml | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 7a30f293..39e23f10 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,26 +1,31 @@ - - # Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# See https://docs.readthedocs.io/en/stable/config-file/v2.html -# Required version: 2 -# Set the OS, Python version, and other tools you might need build: os: ubuntu-24.04 tools: python: "3.13" -# Build documentation in the "docs/" directory with Sphinx +# Sphinx configuration sphinx: - configuration: docs/conf.py + configuration: docs/conf.py + builder: html + # Extra Sphinx build options to match your CLI command + fail_on_warning: false -# Optionally, but recommended, -# declare the Python requirements required to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -# python: -# install: -# - requirements: docs/requirements.txt - +# Override default sphinx-build arguments +# Equivalent to: sphinx-build -b html -D exclude_patterns=... -D master_doc=... +# and change output dir to _build/ukraine/ +build: + os: ubuntu-24.04 + tools: + python: "3.13" + commands: + - sphinx-build -b html -D exclude_patterns="_build,index.rst,content/*" -D master_doc="index-ukraine" docs _build/ukraine +# Optional (if you have dependencies) +# python: +# install: +# - requirements: docs/requirements.txt From f2b27fc7a5c38924e71b3da2ef259f6837297b07 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Wed, 8 Oct 2025 13:48:08 +0300 Subject: [PATCH 20/42] !!!! ITS JUST TEST THE READDOCS 3 !!!! --- .readthedocs.yaml | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 39e23f10..cbf6e688 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,31 +1,18 @@ -# Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html - +# .readthedocs.yaml version: 2 -build: - os: ubuntu-24.04 - tools: - python: "3.13" - -# Sphinx configuration -sphinx: - configuration: docs/conf.py - builder: html - # Extra Sphinx build options to match your CLI command - fail_on_warning: false - -# Override default sphinx-build arguments -# Equivalent to: sphinx-build -b html -D exclude_patterns=... -D master_doc=... -# and change output dir to _build/ukraine/ build: os: ubuntu-24.04 tools: python: "3.13" commands: - - sphinx-build -b html -D exclude_patterns="_build,index.rst,content/*" -D master_doc="index-ukraine" docs _build/ukraine + # Install your documentation dependencies + - python -m pip install -U pip + - pip install sphinx + - if [ -f docs/requirements.txt ]; then pip install -r docs/requirements.txt; fi -# Optional (if you have dependencies) -# python: -# install: -# - requirements: docs/requirements.txt + # Build docs manually, as you need custom flags + - sphinx-build -b html \ + -D exclude_patterns="_build,index.rst,content/*" \ + -D master_doc="index-ukraine" \ + docs "$READTHEDOCS_OUTPUT/html" From 0cd3e26cba502fb039fc1c0b47f93e9c25fdcfbc Mon Sep 17 00:00:00 2001 From: mrbloom Date: Wed, 8 Oct 2025 14:26:58 +0300 Subject: [PATCH 21/42] !!!! REMOVE READDOCS !!!! --- .readthedocs.yaml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml deleted file mode 100644 index cbf6e688..00000000 --- a/.readthedocs.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# .readthedocs.yaml -version: 2 - -build: - os: ubuntu-24.04 - tools: - python: "3.13" - commands: - # Install your documentation dependencies - - python -m pip install -U pip - - pip install sphinx - - if [ -f docs/requirements.txt ]; then pip install -r docs/requirements.txt; fi - - # Build docs manually, as you need custom flags - - sphinx-build -b html \ - -D exclude_patterns="_build,index.rst,content/*" \ - -D master_doc="index-ukraine" \ - docs "$READTHEDOCS_OUTPUT/html" From 21efe1a14f8eb521b30eec3faf385707b52986fb Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 14:36:40 +0300 Subject: [PATCH 22/42] Update Ukrainian beamforming taxonomy caption --- content-ukraine/doa.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content-ukraine/doa.rst b/content-ukraine/doa.rst index 469a2ca5..83709f79 100644 --- a/content-ukraine/doa.rst +++ b/content-ukraine/doa.rst @@ -23,7 +23,7 @@ У наведенему нижче рисунку представлена класифікація видів формування променя, а також наведено приклади методів для їх створення: -The following taxonomy attempts to categorize the many areas of beamforming while providing example techniques: +Наведена нижче таксономія намагається класифікувати основні напрями формування променя та подає приклади відповідних методів: .. image:: ../_images/beamforming_taxonomy.svg :align: center From 440f1ae299e3fd68be8b871fc092680ed3de15fc Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 14:46:48 +0300 Subject: [PATCH 23/42] Update Ukrainian IQ files translation --- content-ukraine/iq_files.rst | 159 +++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 18 deletions(-) diff --git a/content-ukraine/iq_files.rst b/content-ukraine/iq_files.rst index 4d97a217..ee044422 100644 --- a/content-ukraine/iq_files.rst +++ b/content-ukraine/iq_files.rst @@ -16,21 +16,21 @@ IQ файли та SigMF Ці числа відповідають [I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, ...]. -Коли ми хочемо зберегти комплексні числа у файл, ми зберігаємо їх у форматі IQIQIQIQIQIQIQIQIQIQIQ. Тобто, ми зберігаємо купу чисел з плаваючою комою підряд, а коли ми їх зчитуємо, ми повинні розділити їх назад на [I+jQ, I+jQ, ...]. +Коли ми хочемо зберегти комплексні числа у файл, ми зберігаємо їх у форматі IQIQIQIQIQIQIQIQIQIQIQ. Тобто, ми зберігаємо купу цілих чисел або чисел з плаваючою комою підряд, а коли ми їх зчитуємо, ми повинні розділити їх назад на [I+jQ, I+jQ, ...]. -Хоча комплексні числа можна зберігати в текстовому файлі або csv-файлі, ми вважаємо за краще зберігати їх у так званому "двійковому файлі", щоб заощадити місце. При високій частоті дискретизації ваші записи сигналів можуть легко займати кілька гігабайт, і ми хочемо бути максимально ефективними в плані використання пам'яті. Якщо ви коли-небудь відкривали файл у текстовому редакторі і він виглядав незрозуміло, як на скріншоті нижче, ймовірно, він був бінарним. Бінарні файли містять серію байтів, і вам доведеться самостійно відстежувати формат. Двійкові файли є найефективнішим способом зберігання даних, якщо припустити, що було виконано все можливе стиснення. Оскільки наші сигнали зазвичай виглядають як випадкова послідовність чисел з плаваючою комою, ми зазвичай не намагаємося стискати дані. Двійкові файли використовуються для багатьох інших речей, наприклад, для компіляції програм (так званих "бінарників"). Коли вони використовуються для збереження сигналів, ми називаємо їх двійковими "IQ-файлами", використовуючи розширення .iq. +Хоча комплексні числа можна зберігати в текстовому файлі або csv-файлі, ми вважаємо за краще зберігати їх у так званому "двійковому файлі", щоб заощадити місце. При високій частоті дискретизації ваші записи сигналів можуть легко займати кілька гігабайт, і ми хочемо бути максимально ефективними в плані використання пам'яті. Якщо ви коли-небудь відкривали файл у текстовому редакторі і він виглядав незрозуміло, як на скріншоті нижче, ймовірно, він був бінарним. Бінарні файли містять серію байтів, і вам доведеться самостійно відстежувати формат. Двійкові файли є найефективнішим способом зберігання даних, якщо припустити, що було виконано все можливе стиснення. Оскільки наші сигнали зазвичай виглядають як випадкова послідовність цілих чисел або чисел з плаваючою комою, ми зазвичай не намагаємося стискати дані. Двійкові файли використовуються для багатьох інших речей, наприклад, для компіляції програм (так званих "бінарників"). Коли вони використовуються для збереження сигналів, ми називаємо їх двійковими "IQ-файлами", використовуючи розширення .iq. .. image:: ../_images/binary_file.png - :scale: 70 + :scale: 70 % :align: center -У Python за замовчуванням комплексним типом є np.complex128, який використовує два 64-бітних плаваючих числа на семпл. Але в DSP/SDR ми, як правило, використовуємо 32-розрядні плаваючі числа, тому що АЦП на наших SDR не мають **такої** точності, щоб гарантувати 64-розрядні плаваючі числа. У Python ми будемо використовувати **np.complex64**, який використовує два 32-бітних плаваючих числа. Коли ви просто обробляєте сигнал у Python, це не має значення, але коли ви збираєтеся зберегти масив 1d у файл, ви хочете спочатку переконатися, що це масив np.complex64. +У Python за замовчуванням комплексним типом є np.complex128, який використовує два 64-бітних числа з плаваючою комою на семпл. Але в DSP/SDR ми, як правило, використовуємо 16-бітні цілі числа або 32-бітні числа з плаваючою комою, тому що АЦП на наших SDR не мають **такої** точності, щоб гарантувати 64-бітні числа з плаваючою комою. Насправді більшість SDR мають 12-бітні АЦП, тому ми можемо мінімізувати використання сховища, зберігаючи як 16-бітні цілі числа (np.int16 у Python), що означає, що кожен IQ-семпл займатиме 4 байти, і наш RF-запис створюватиме файл розміром у байтах, що дорівнює частоті дискретизації, помноженій на 4, відомий як "правило 4x від Сіна". У наведених нижче прикладах Python ми будемо використовувати **np.complex64**, який використовує два 32-бітних числа з плаваючою комою, оскільки Python не має власного комплексного цілочисельного типу (це не заважає нам зберігати IQ як цілі числа у файлі, як ви побачите). Коли ви просто обробляєте сигнал у Python, це не має значення, але коли ви збираєтеся зберегти 1d-масив у файл, ви хочете спочатку переконатися, що це масив np.complex64 (або np.int16 з інтерлівом IQ). ************************* Приклади на Python ************************* -У Python, зокрема у numpy, ми використовуємо функцію :code:`tofile()` для збереження масиву numpy у файл. Ось короткий приклад створення простого BPSK-сигналу з шумом і збереження його у файлі в тому ж каталозі, звідки ми запускали наш скрипт: +У Python, зокрема у numpy, ми використовуємо функцію :code:`tofile()` для збереження масиву numpy у файл. Ось короткий приклад створення простого QPSK-сигналу з шумом і збереження його у файлі в тому ж каталозі, звідки ми запускали наш скрипт: .. code-block:: python @@ -39,8 +39,13 @@ IQ файли та SigMF num_symbols = 10000 - x_symbols = np.random.randint(0, 2, num_symbols)*2-1 # -1 та 1 - n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # AWGN з одиничним степенем + # Масив x_symbols міститиме комплексні числа, що представляють символи QPSK. Кожен символ буде комплексним числом + # з модулем 1 і фазовим кутом, що відповідає одній з чотирьох точок сузір'я QPSK (45, 135, 225 або 315 градусів) + x_int = np.random.randint(0, 4, num_symbols) # від 0 до 3 + x_degrees = x_int*360/4.0 + 45 # 45, 135, 225, 315 градусів + x_radians = x_degrees*np.pi/180.0 # sin() та cos() приймають радіани + x_symbols = np.cos(x_radians) + 1j*np.sin(x_radians) # це створює наші комплексні символи QPSK + n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # AWGN з одиничною потужністю r = x_symbols + n * np.sqrt(0.01) # потужність шуму 0.01 print(r) plt.plot(np.real(r), np.imag(r), '.') @@ -51,9 +56,9 @@ IQ файли та SigMF print(type(r[0])) # Перевіряємо тип даних. Упс, 128, а не 64! r = r.astype(np.complex64) # Переводимо в 64 print(type(r[0])) # Переконатись, що це 64 - r.tofile('bpsk_in_noise.iq') # Зберегти у файл + r.tofile('qpsk_in_noise.iq') # Зберегти у файл -Тепер подивіться на деталі створеного файлу і перевірте, скільки у ньому байт. Він має бути num_symbols * 8, тому що ми використовували np.complex64, який має 8 байт на семпл, 4 байти на плаваючу комірку (2 плаваючі комірки на семпл). +Тепер подивіться на деталі створеного файлу і перевірте, скільки у ньому байт. Він має бути num_symbols * 8, тому що ми використовували np.complex64, який має 8 байт на семпл, 4 байти на число з плаваючою комою (2 числа з плаваючою комою на семпл). Використовуючи новий скрипт Python, ми можемо прочитати цей файл за допомогою :code:`np.fromfile()`, наприклад, так: @@ -62,7 +67,7 @@ IQ файли та SigMF import numpy as np import matplotlib.pyplot as plt - samples = np.fromfile('bpsk_in_noise.iq', np.complex64) # Читаємо у файл. Треба вказати, у якому він форматі + samples = np.fromfile('qpsk_in_noise.iq', np.complex64) # Читаємо у файл. Треба вказати, у якому він форматі print(samples) # Побудуємо сузір'я, щоб переконатися, що воно виглядає правильно @@ -88,6 +93,31 @@ IQ файли та SigMF samples /= 32768 # конвертуємо в -1 до +1 (необов'язково) samples = samples[::2] + 1j*samples[1::2] # конвертувати в IQIQIQ... +***************************** +Перехід з MATLAB +***************************** + +Якщо ви намагаєтеся перейти з MATLAB на Python, ви можете поцікавитися, як зберегти змінні MATLAB і файли .mat як двійкові IQ-файли. Спочатку нам потрібно обрати тип формату. Наприклад, якщо наші семпли є цілими числами між -127 і +127, ми можемо використати 8-бітні цілі числа. У такому випадку ми можемо скористатися наступним кодом MATLAB, щоб зберегти семпли у двійковий IQ-файл: + +.. code-block:: MATLAB + + % припустимо, що наші IQ-семпли містяться у змінній samples + disp(samples(1:20)) + filename = 'samples.iq' + fwrite(fopen(filename,'w'), reshape([real(samples);imag(samples)],[],1), 'int8') + +Ви можете переглянути всі допустимі типи форматів для fwrite() в `документації MATLAB `_. Проте найкраще дотримуватися форматів :code:`'int8'`, :code:`'int16'` або :code:`'float32'`. + +З боку Python ви можете завантажити цей файл за допомогою: + +.. code-block:: python + + samples = np.fromfile('samples.iq', np.int8) + samples = samples[::2] + 1j*samples[1::2] + print(samples[0:20]) # переконайтеся, що перші 20 семплів збігаються з MATLAB + +Для :code:`'float32'`, збереженого в MATLAB, ви можете використати :code:`np.complex64` у Python, що відповідає інтерлівним float32, і тоді можна пропустити частину :code:`samples[::2] + 1j*samples[1::2]`, тому що numpy автоматично інтерпретує інтерлівні числа з плаваючою комою як комплексні. + ******************************************* Візуальний аналіз радіочастотного файлу ******************************************* @@ -167,7 +197,7 @@ SigMF та анотування IQ файлів pip install sigmf -Нижче наведено код Python для написання файлу .sigmf-meta для прикладу на початку цієї глави, куди ми зберегли bpsk_in_noise.iq: +Нижче наведено код Python для написання файлу .sigmf-meta для прикладу на початку цієї глави, куди ми зберегли qpsk_in_noise.iq: .. code-block:: python @@ -179,17 +209,17 @@ SigMF та анотування IQ файлів # <код з прикладу - # r.tofile('bpsk_in_noise.iq') - r.tofile('bpsk_in_noise.sigmf-data') # замінити рядок вище на цей + # r.tofile('qpsk_in_noise.iq') + r.tofile('qpsk_in_noise.sigmf-data') # замінити рядок вище на цей # створюємо метадані meta = SigMFFile( - data_file='bpsk_in_noise.sigmf-data', # extension is optional + data_file='qpsk_in_noise.sigmf-data', # extension is optional global_info = { SigMFFile.DATATYPE_KEY: 'cf32_le', SigMFFile.SAMPLE_RATE_KEY: 8000000, SigMFFile.AUTHOR_KEY: 'Your name and/or email', - SigMFFile.DESCRIPTION_KEY: 'Simulation of BPSK with noise', + SigMFFile.DESCRIPTION_KEY: 'Simulation of qpsk with noise', SigMFFile.VERSION_KEY: sigmf.__version__, } ) @@ -202,18 +232,18 @@ SigMF та анотування IQ файлів # перевірка на помилки та запис на диск meta.validate() - meta.tofile('bpsk_in_noise.sigmf-meta') # розширення не обов'язкове + meta.tofile('qpsk_in_noise.sigmf-meta') # розширення не обов'язкове Просто замініть :code:`8000000` та :code:`915000000` на змінні, які ви використовували для зберігання частоти дискретизації та центральної частоти відповідно. -Щоб прочитати запис у форматі SigMF у Python, скористайтеся наступним кодом. У цьому прикладі два SigMF-файли слід назвати :code:`bpsk_in_noise.sigmf-meta` і :code:`bpsk_in_noise.sigmf-data`. +Щоб прочитати запис у форматі SigMF у Python, скористайтеся наступним кодом. У цьому прикладі два SigMF-файли слід назвати :code:`qpsk_in_noise.sigmf-meta` і :code:`qpsk_in_noise.sigmf-data`. .. code-block:: python from sigmf import SigMFFile, sigmffile # Завантажити набір даних - filename = 'bpsk_in_noise' + filename = 'qpsk_in_noise' signal = sigmffile.fromfile(filename) samples = signal.read_samples().view(np.complex64).flatten() print(samples[0:10]) # виводимо перші 10 зразків @@ -279,4 +309,97 @@ SigMF та анотування IQ файлів iio.imwrite('sigmf_logo.gif', images, fps=20) +************************************** +Колекція SigMF для масивних записів +************************************** + +Якщо у вас є фазована антена, цифрова решітка MIMO, датчики TDOA або будь-яка інша ситуація, коли ви записуєте кілька каналів синхронізованих радіоданих, ви, мабуть, замислюєтеся, як зберігати сирі IQ кількох потоків у файлі за допомогою SigMF. Система **Колекцій** SigMF була розроблена саме для таких випадків; колекція - це просто група записів SigMF (кожен складається з одного метафайлу та одного файлу даних), об'єднаних разом за допомогою верхнього рівня JSON-файлу з розширенням :code:`.sigmf-collection`. Цей JSON-файл досить простий; він повинен містити версію SigMF, необов'язковий опис, а також список "потоків", що насправді є базовими назвами кожного запису SigMF у колекції. Ось приклад файлу :code:`.sigmf-collection`: + +.. code-block:: json + + { + "collection": { + "core:version": "1.2.0", + "core:description": "a 4-element phased array recording", + "core:streams": [ + { + "name": "channel-0" + }, + { + "name": "channel-1" + }, + { + "name": "channel-2" + }, + { + "name": "channel-3" + } + ] + } + } + +Назви записів необов'язково мають бути :code:`channel-0`, :code:`channel-1`, ..., вони можуть бути будь-якими, лише б були унікальними і щоб кожна назва відповідала одному файлу даних і одному метафайлу. У наведеному вище прикладі цей файл .sigmf-collection, який ми могли б назвати, наприклад, :code:`4_element_recording.sigmf-collection`, повинен бути в тому самому каталозі, що й файли метаданих і даних, тобто в тому ж каталозі ми матимемо: + +* :code:`4_element_recording.sigmf-collection` +* :code:`channel-0.sigmf-meta` +* :code:`channel-0.sigmf-data` +* :code:`channel-1.sigmf-meta` +* :code:`channel-1.sigmf-data` +* :code:`channel-2.sigmf-meta` +* :code:`channel-2.sigmf-data` +* :code:`channel-3.sigmf-meta` +* :code:`channel-3.sigmf-data` + +Можливо, ви подумаєте, що це призведе до величезної кількості файлів, наприклад, масив із 16 елементів створить 33 файли! Саме з цієї причини SigMF запровадив систему **Архівів**, яка насправді є терміном SigMF для упаковування набору файлів у tar-архів. Файл архіву SigMF використовує розширення :code:`.sigmf`, а не :code:`.tar`! Багато людей вважають, що файли .tar стиснені, але це не так; це просто спосіб об'єднати файли разом (це фактично конкатенація файлів без стиснення). Можливо, ви бачили файл :code:`.tar.gz`; це tar-архів, який було стиснено за допомогою gzip. Для наших архівів SigMF ми не будемо їх стискати, оскільки файли даних уже є двійковими і не сильно стискаються, особливо якщо використовувалося автоматичне керування підсиленням. Якщо ви хочете створити архів SigMF у Python, ви можете запакувати всі файли в каталозі разом таким чином: + +.. code-block:: python + + import tarfile + import os + + target_dir = '/mnt/c/Users/marclichtman/Downloads/exampletar/' # SigMF файли тут + with tarfile.open(os.path.join(target_dir, '4_element_recording.sigmf'), 'x') as tar: # x означає створити, але помилитись, якщо вже існує + for file in os.listdir(target_dir): + tar.add(os.path.join(target_dir, file), arcname=file) # arcname не дозволяє включати повний шлях у tar + +І все! Спробуйте (тимчасово) перейменувати .sigmf на .tar і перегляньте файли у файловому менеджері. Щоб відкривати будь-які файли безпосередньо (без ручного розпакування tar) у Python, ви можете використати: + +.. code-block:: python + + import tarfile + import json + + collection_file = '/mnt/c/Users/marclichtman/Downloads/exampletar/4_element_recording.sigmf' + tar_obj = tarfile.open(collection_file) + print(tar_obj.getnames()) # список рядків із назвами всіх файлів у tar + channel_0_meta = tar_obj.extractfile('channel-0.sigmf-meta').read() # читаємо один з метафайлів як приклад + channel_0_dict = json.loads(channel_0_meta) # перетворюємо на словник Python + print(channel_0_dict) + +Для зчитування IQ-семплів безпосередньо з tar замість :code:`np.fromfile()` ми використаємо :code:`np.frombuffer()`: + +.. code-block:: python + + import tarfile + import numpy as np + + collection_file = '/mnt/c/Users/marclichtman/Downloads/exampletar/4_element_recording.sigmf' + tar_obj = tarfile.open(collection_file) + channel_0_data_f = tar_obj.extractfile('channel-0.sigmf-data').read() # тип bytes + samples = np.frombuffer(channel_0_data_f, dtype=np.int16) + samples = samples[::2] + 1j*samples[1::2] # конвертувати в IQIQIQ... + samples /= 32768 # конвертувати в -1 до +1 + print(samples[0:10]) + +Якщо ви хочете перейти до іншої частини файлу, використовуйте :code:`tar_obj.extractfile('channel-0.sigmf-data').seek(offset)`. +Потім, щоб прочитати конкретну кількість байтів, скористайтеся :code:`.read(num_bytes)`. Переконайтеся, що кількість байтів є кратною вашому типу даних! + +Підсумуємо: при створенні нового архіву колекції SigMF слід виконати такі кроки: + +1. Створити файл .sigmf-meta та .sigmf-data для кожного каналу +2. Створити файл .sigmf-collection +3. Упакувати всі файли разом у файл .sigmf +4. (За бажанням) Поділитися файлом .sigmf з іншими! + + From 170508b88a671fccbff36c1866e5d5c5652c9087 Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 15:55:56 +0300 Subject: [PATCH 24/42] Make translation links configurable via base URL --- .github/workflows/build-and-deploy.yml | 2 ++ .github/workflows/build-and-spell-check.yml | 2 ++ _templates/layout.html | 14 +++++++------- conf.py | 15 ++++++++++++++- scrape_patreon.py | 10 +++++++--- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 3905aed5..51d9fa84 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -29,6 +29,8 @@ concurrency: jobs: # Single deploy job since we're just deploying deploy: + env: + PYSDR_BASEURL: https://distribtech.github.io/PySDR/ environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/build-and-spell-check.yml b/.github/workflows/build-and-spell-check.yml index 7cecf2d5..b1daf248 100644 --- a/.github/workflows/build-and-spell-check.yml +++ b/.github/workflows/build-and-spell-check.yml @@ -4,6 +4,8 @@ on: [pull_request] jobs: job1: + env: + PYSDR_BASEURL: https://distribtech.github.io/PySDR/ runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/_templates/layout.html b/_templates/layout.html index da78e0bd..41305c23 100644 --- a/_templates/layout.html +++ b/_templates/layout.html @@ -75,13 +75,13 @@  |  - EnglishEnglish   - DutchDutch   - FrenchFrench   - UkrainianUkrainian   - ChineseChinese   - SpanishSpanish   - JapaneseJapanese + EnglishEnglish   + DutchDutch   + FrenchFrench   + UkrainianUkrainian   + ChineseChinese   + SpanishSpanish   + JapaneseJapanese {% endblock %} diff --git a/conf.py b/conf.py index 0df64d0f..86b1ebbf 100644 --- a/conf.py +++ b/conf.py @@ -48,6 +48,19 @@ copyright = year + u', Marc Lichtman' author = u'Marc Lichtman' +# Base URL used when generating fully-qualified links throughout the +# documentation. It defaults to the historical pysdr.org domain but can be +# overridden (for example in GitHub Actions) by setting the PYSDR_BASEURL +# environment variable. The trailing slash simplifies concatenation when the +# value is used inside the Jinja templates. +pysdr_baseurl = os.environ.get('PYSDR_BASEURL', 'https://pysdr.org/') +if not pysdr_baseurl.endswith('/'): + pysdr_baseurl += '/' + +html_context = { + 'pysdr_baseurl': pysdr_baseurl, +} + # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. @@ -124,7 +137,7 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = {'description':'By Dr. Marc Lichtman - marc@pysdr.org', +html_theme_options = {'description':f'By Dr. Marc Lichtman - marc@pysdr.org', 'logo': 'logo.svg', 'logo_name': True, # used if the logo doesn't contain the project name itself 'fixed_sidebar': True, # on smaller screens you can't see the whole sidebar, and it won't scroll diff --git a/scrape_patreon.py b/scrape_patreon.py index 179e00ff..54cd4867 100644 --- a/scrape_patreon.py +++ b/scrape_patreon.py @@ -1,6 +1,10 @@ import patreon import os +BASE_URL = os.environ.get('PYSDR_BASEURL', 'https://pysdr.org/') +if not BASE_URL.endswith('/'): + BASE_URL += '/' + # needed by sphinx def setup(app): return @@ -28,10 +32,10 @@ def scrape_patreon(): if full_name == "Дмитрий Ступаков": continue if full_name == "Al Grant": - names.append('Al Grant ') + names.append(f'Al Grant ') continue if full_name == "Hash" or full_name == "RECESSIM": - names.append(f'{full_name} ') + names.append(f'{full_name} ') continue names.append(full_name) # there's also 'first_name' which might be better for a public display name # Patreon Supporters @@ -42,7 +46,7 @@ def scrape_patreon(): html_string += '⚬ ' + name + "
" # Organizations that are sponsoring (Manually added to get logo included) html_string += '
and organization-level supporters:
' - html_string += '' + ' Analog Devices, Inc.' + "
" + html_string += f'' + ' Analog Devices, Inc.' + "
" html_string += "" with open("_templates/patrons.html", "w", encoding="utf-8") as patron_file: patron_file.write(html_string) From 506344d6b9d807255004a92a3cfa9c5da710afbe Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 14:36:40 +0300 Subject: [PATCH 25/42] Update Ukrainian beamforming taxonomy caption --- content-ukraine/doa.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content-ukraine/doa.rst b/content-ukraine/doa.rst index 469a2ca5..83709f79 100644 --- a/content-ukraine/doa.rst +++ b/content-ukraine/doa.rst @@ -23,7 +23,7 @@ У наведенему нижче рисунку представлена класифікація видів формування променя, а також наведено приклади методів для їх створення: -The following taxonomy attempts to categorize the many areas of beamforming while providing example techniques: +Наведена нижче таксономія намагається класифікувати основні напрями формування променя та подає приклади відповідних методів: .. image:: ../_images/beamforming_taxonomy.svg :align: center From a71c6c0b7ecfd6a3df5c165008566ef7911d38a4 Mon Sep 17 00:00:00 2001 From: distribtech Date: Wed, 8 Oct 2025 14:46:48 +0300 Subject: [PATCH 26/42] Update Ukrainian IQ files translation --- content-ukraine/iq_files.rst | 159 +++++++++++++++++++++++++++++++---- 1 file changed, 141 insertions(+), 18 deletions(-) diff --git a/content-ukraine/iq_files.rst b/content-ukraine/iq_files.rst index 4d97a217..ee044422 100644 --- a/content-ukraine/iq_files.rst +++ b/content-ukraine/iq_files.rst @@ -16,21 +16,21 @@ IQ файли та SigMF Ці числа відповідають [I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, ...]. -Коли ми хочемо зберегти комплексні числа у файл, ми зберігаємо їх у форматі IQIQIQIQIQIQIQIQIQIQIQ. Тобто, ми зберігаємо купу чисел з плаваючою комою підряд, а коли ми їх зчитуємо, ми повинні розділити їх назад на [I+jQ, I+jQ, ...]. +Коли ми хочемо зберегти комплексні числа у файл, ми зберігаємо їх у форматі IQIQIQIQIQIQIQIQIQIQIQ. Тобто, ми зберігаємо купу цілих чисел або чисел з плаваючою комою підряд, а коли ми їх зчитуємо, ми повинні розділити їх назад на [I+jQ, I+jQ, ...]. -Хоча комплексні числа можна зберігати в текстовому файлі або csv-файлі, ми вважаємо за краще зберігати їх у так званому "двійковому файлі", щоб заощадити місце. При високій частоті дискретизації ваші записи сигналів можуть легко займати кілька гігабайт, і ми хочемо бути максимально ефективними в плані використання пам'яті. Якщо ви коли-небудь відкривали файл у текстовому редакторі і він виглядав незрозуміло, як на скріншоті нижче, ймовірно, він був бінарним. Бінарні файли містять серію байтів, і вам доведеться самостійно відстежувати формат. Двійкові файли є найефективнішим способом зберігання даних, якщо припустити, що було виконано все можливе стиснення. Оскільки наші сигнали зазвичай виглядають як випадкова послідовність чисел з плаваючою комою, ми зазвичай не намагаємося стискати дані. Двійкові файли використовуються для багатьох інших речей, наприклад, для компіляції програм (так званих "бінарників"). Коли вони використовуються для збереження сигналів, ми називаємо їх двійковими "IQ-файлами", використовуючи розширення .iq. +Хоча комплексні числа можна зберігати в текстовому файлі або csv-файлі, ми вважаємо за краще зберігати їх у так званому "двійковому файлі", щоб заощадити місце. При високій частоті дискретизації ваші записи сигналів можуть легко займати кілька гігабайт, і ми хочемо бути максимально ефективними в плані використання пам'яті. Якщо ви коли-небудь відкривали файл у текстовому редакторі і він виглядав незрозуміло, як на скріншоті нижче, ймовірно, він був бінарним. Бінарні файли містять серію байтів, і вам доведеться самостійно відстежувати формат. Двійкові файли є найефективнішим способом зберігання даних, якщо припустити, що було виконано все можливе стиснення. Оскільки наші сигнали зазвичай виглядають як випадкова послідовність цілих чисел або чисел з плаваючою комою, ми зазвичай не намагаємося стискати дані. Двійкові файли використовуються для багатьох інших речей, наприклад, для компіляції програм (так званих "бінарників"). Коли вони використовуються для збереження сигналів, ми називаємо їх двійковими "IQ-файлами", використовуючи розширення .iq. .. image:: ../_images/binary_file.png - :scale: 70 + :scale: 70 % :align: center -У Python за замовчуванням комплексним типом є np.complex128, який використовує два 64-бітних плаваючих числа на семпл. Але в DSP/SDR ми, як правило, використовуємо 32-розрядні плаваючі числа, тому що АЦП на наших SDR не мають **такої** точності, щоб гарантувати 64-розрядні плаваючі числа. У Python ми будемо використовувати **np.complex64**, який використовує два 32-бітних плаваючих числа. Коли ви просто обробляєте сигнал у Python, це не має значення, але коли ви збираєтеся зберегти масив 1d у файл, ви хочете спочатку переконатися, що це масив np.complex64. +У Python за замовчуванням комплексним типом є np.complex128, який використовує два 64-бітних числа з плаваючою комою на семпл. Але в DSP/SDR ми, як правило, використовуємо 16-бітні цілі числа або 32-бітні числа з плаваючою комою, тому що АЦП на наших SDR не мають **такої** точності, щоб гарантувати 64-бітні числа з плаваючою комою. Насправді більшість SDR мають 12-бітні АЦП, тому ми можемо мінімізувати використання сховища, зберігаючи як 16-бітні цілі числа (np.int16 у Python), що означає, що кожен IQ-семпл займатиме 4 байти, і наш RF-запис створюватиме файл розміром у байтах, що дорівнює частоті дискретизації, помноженій на 4, відомий як "правило 4x від Сіна". У наведених нижче прикладах Python ми будемо використовувати **np.complex64**, який використовує два 32-бітних числа з плаваючою комою, оскільки Python не має власного комплексного цілочисельного типу (це не заважає нам зберігати IQ як цілі числа у файлі, як ви побачите). Коли ви просто обробляєте сигнал у Python, це не має значення, але коли ви збираєтеся зберегти 1d-масив у файл, ви хочете спочатку переконатися, що це масив np.complex64 (або np.int16 з інтерлівом IQ). ************************* Приклади на Python ************************* -У Python, зокрема у numpy, ми використовуємо функцію :code:`tofile()` для збереження масиву numpy у файл. Ось короткий приклад створення простого BPSK-сигналу з шумом і збереження його у файлі в тому ж каталозі, звідки ми запускали наш скрипт: +У Python, зокрема у numpy, ми використовуємо функцію :code:`tofile()` для збереження масиву numpy у файл. Ось короткий приклад створення простого QPSK-сигналу з шумом і збереження його у файлі в тому ж каталозі, звідки ми запускали наш скрипт: .. code-block:: python @@ -39,8 +39,13 @@ IQ файли та SigMF num_symbols = 10000 - x_symbols = np.random.randint(0, 2, num_symbols)*2-1 # -1 та 1 - n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # AWGN з одиничним степенем + # Масив x_symbols міститиме комплексні числа, що представляють символи QPSK. Кожен символ буде комплексним числом + # з модулем 1 і фазовим кутом, що відповідає одній з чотирьох точок сузір'я QPSK (45, 135, 225 або 315 градусів) + x_int = np.random.randint(0, 4, num_symbols) # від 0 до 3 + x_degrees = x_int*360/4.0 + 45 # 45, 135, 225, 315 градусів + x_radians = x_degrees*np.pi/180.0 # sin() та cos() приймають радіани + x_symbols = np.cos(x_radians) + 1j*np.sin(x_radians) # це створює наші комплексні символи QPSK + n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # AWGN з одиничною потужністю r = x_symbols + n * np.sqrt(0.01) # потужність шуму 0.01 print(r) plt.plot(np.real(r), np.imag(r), '.') @@ -51,9 +56,9 @@ IQ файли та SigMF print(type(r[0])) # Перевіряємо тип даних. Упс, 128, а не 64! r = r.astype(np.complex64) # Переводимо в 64 print(type(r[0])) # Переконатись, що це 64 - r.tofile('bpsk_in_noise.iq') # Зберегти у файл + r.tofile('qpsk_in_noise.iq') # Зберегти у файл -Тепер подивіться на деталі створеного файлу і перевірте, скільки у ньому байт. Він має бути num_symbols * 8, тому що ми використовували np.complex64, який має 8 байт на семпл, 4 байти на плаваючу комірку (2 плаваючі комірки на семпл). +Тепер подивіться на деталі створеного файлу і перевірте, скільки у ньому байт. Він має бути num_symbols * 8, тому що ми використовували np.complex64, який має 8 байт на семпл, 4 байти на число з плаваючою комою (2 числа з плаваючою комою на семпл). Використовуючи новий скрипт Python, ми можемо прочитати цей файл за допомогою :code:`np.fromfile()`, наприклад, так: @@ -62,7 +67,7 @@ IQ файли та SigMF import numpy as np import matplotlib.pyplot as plt - samples = np.fromfile('bpsk_in_noise.iq', np.complex64) # Читаємо у файл. Треба вказати, у якому він форматі + samples = np.fromfile('qpsk_in_noise.iq', np.complex64) # Читаємо у файл. Треба вказати, у якому він форматі print(samples) # Побудуємо сузір'я, щоб переконатися, що воно виглядає правильно @@ -88,6 +93,31 @@ IQ файли та SigMF samples /= 32768 # конвертуємо в -1 до +1 (необов'язково) samples = samples[::2] + 1j*samples[1::2] # конвертувати в IQIQIQ... +***************************** +Перехід з MATLAB +***************************** + +Якщо ви намагаєтеся перейти з MATLAB на Python, ви можете поцікавитися, як зберегти змінні MATLAB і файли .mat як двійкові IQ-файли. Спочатку нам потрібно обрати тип формату. Наприклад, якщо наші семпли є цілими числами між -127 і +127, ми можемо використати 8-бітні цілі числа. У такому випадку ми можемо скористатися наступним кодом MATLAB, щоб зберегти семпли у двійковий IQ-файл: + +.. code-block:: MATLAB + + % припустимо, що наші IQ-семпли містяться у змінній samples + disp(samples(1:20)) + filename = 'samples.iq' + fwrite(fopen(filename,'w'), reshape([real(samples);imag(samples)],[],1), 'int8') + +Ви можете переглянути всі допустимі типи форматів для fwrite() в `документації MATLAB `_. Проте найкраще дотримуватися форматів :code:`'int8'`, :code:`'int16'` або :code:`'float32'`. + +З боку Python ви можете завантажити цей файл за допомогою: + +.. code-block:: python + + samples = np.fromfile('samples.iq', np.int8) + samples = samples[::2] + 1j*samples[1::2] + print(samples[0:20]) # переконайтеся, що перші 20 семплів збігаються з MATLAB + +Для :code:`'float32'`, збереженого в MATLAB, ви можете використати :code:`np.complex64` у Python, що відповідає інтерлівним float32, і тоді можна пропустити частину :code:`samples[::2] + 1j*samples[1::2]`, тому що numpy автоматично інтерпретує інтерлівні числа з плаваючою комою як комплексні. + ******************************************* Візуальний аналіз радіочастотного файлу ******************************************* @@ -167,7 +197,7 @@ SigMF та анотування IQ файлів pip install sigmf -Нижче наведено код Python для написання файлу .sigmf-meta для прикладу на початку цієї глави, куди ми зберегли bpsk_in_noise.iq: +Нижче наведено код Python для написання файлу .sigmf-meta для прикладу на початку цієї глави, куди ми зберегли qpsk_in_noise.iq: .. code-block:: python @@ -179,17 +209,17 @@ SigMF та анотування IQ файлів # <код з прикладу - # r.tofile('bpsk_in_noise.iq') - r.tofile('bpsk_in_noise.sigmf-data') # замінити рядок вище на цей + # r.tofile('qpsk_in_noise.iq') + r.tofile('qpsk_in_noise.sigmf-data') # замінити рядок вище на цей # створюємо метадані meta = SigMFFile( - data_file='bpsk_in_noise.sigmf-data', # extension is optional + data_file='qpsk_in_noise.sigmf-data', # extension is optional global_info = { SigMFFile.DATATYPE_KEY: 'cf32_le', SigMFFile.SAMPLE_RATE_KEY: 8000000, SigMFFile.AUTHOR_KEY: 'Your name and/or email', - SigMFFile.DESCRIPTION_KEY: 'Simulation of BPSK with noise', + SigMFFile.DESCRIPTION_KEY: 'Simulation of qpsk with noise', SigMFFile.VERSION_KEY: sigmf.__version__, } ) @@ -202,18 +232,18 @@ SigMF та анотування IQ файлів # перевірка на помилки та запис на диск meta.validate() - meta.tofile('bpsk_in_noise.sigmf-meta') # розширення не обов'язкове + meta.tofile('qpsk_in_noise.sigmf-meta') # розширення не обов'язкове Просто замініть :code:`8000000` та :code:`915000000` на змінні, які ви використовували для зберігання частоти дискретизації та центральної частоти відповідно. -Щоб прочитати запис у форматі SigMF у Python, скористайтеся наступним кодом. У цьому прикладі два SigMF-файли слід назвати :code:`bpsk_in_noise.sigmf-meta` і :code:`bpsk_in_noise.sigmf-data`. +Щоб прочитати запис у форматі SigMF у Python, скористайтеся наступним кодом. У цьому прикладі два SigMF-файли слід назвати :code:`qpsk_in_noise.sigmf-meta` і :code:`qpsk_in_noise.sigmf-data`. .. code-block:: python from sigmf import SigMFFile, sigmffile # Завантажити набір даних - filename = 'bpsk_in_noise' + filename = 'qpsk_in_noise' signal = sigmffile.fromfile(filename) samples = signal.read_samples().view(np.complex64).flatten() print(samples[0:10]) # виводимо перші 10 зразків @@ -279,4 +309,97 @@ SigMF та анотування IQ файлів iio.imwrite('sigmf_logo.gif', images, fps=20) +************************************** +Колекція SigMF для масивних записів +************************************** + +Якщо у вас є фазована антена, цифрова решітка MIMO, датчики TDOA або будь-яка інша ситуація, коли ви записуєте кілька каналів синхронізованих радіоданих, ви, мабуть, замислюєтеся, як зберігати сирі IQ кількох потоків у файлі за допомогою SigMF. Система **Колекцій** SigMF була розроблена саме для таких випадків; колекція - це просто група записів SigMF (кожен складається з одного метафайлу та одного файлу даних), об'єднаних разом за допомогою верхнього рівня JSON-файлу з розширенням :code:`.sigmf-collection`. Цей JSON-файл досить простий; він повинен містити версію SigMF, необов'язковий опис, а також список "потоків", що насправді є базовими назвами кожного запису SigMF у колекції. Ось приклад файлу :code:`.sigmf-collection`: + +.. code-block:: json + + { + "collection": { + "core:version": "1.2.0", + "core:description": "a 4-element phased array recording", + "core:streams": [ + { + "name": "channel-0" + }, + { + "name": "channel-1" + }, + { + "name": "channel-2" + }, + { + "name": "channel-3" + } + ] + } + } + +Назви записів необов'язково мають бути :code:`channel-0`, :code:`channel-1`, ..., вони можуть бути будь-якими, лише б були унікальними і щоб кожна назва відповідала одному файлу даних і одному метафайлу. У наведеному вище прикладі цей файл .sigmf-collection, який ми могли б назвати, наприклад, :code:`4_element_recording.sigmf-collection`, повинен бути в тому самому каталозі, що й файли метаданих і даних, тобто в тому ж каталозі ми матимемо: + +* :code:`4_element_recording.sigmf-collection` +* :code:`channel-0.sigmf-meta` +* :code:`channel-0.sigmf-data` +* :code:`channel-1.sigmf-meta` +* :code:`channel-1.sigmf-data` +* :code:`channel-2.sigmf-meta` +* :code:`channel-2.sigmf-data` +* :code:`channel-3.sigmf-meta` +* :code:`channel-3.sigmf-data` + +Можливо, ви подумаєте, що це призведе до величезної кількості файлів, наприклад, масив із 16 елементів створить 33 файли! Саме з цієї причини SigMF запровадив систему **Архівів**, яка насправді є терміном SigMF для упаковування набору файлів у tar-архів. Файл архіву SigMF використовує розширення :code:`.sigmf`, а не :code:`.tar`! Багато людей вважають, що файли .tar стиснені, але це не так; це просто спосіб об'єднати файли разом (це фактично конкатенація файлів без стиснення). Можливо, ви бачили файл :code:`.tar.gz`; це tar-архів, який було стиснено за допомогою gzip. Для наших архівів SigMF ми не будемо їх стискати, оскільки файли даних уже є двійковими і не сильно стискаються, особливо якщо використовувалося автоматичне керування підсиленням. Якщо ви хочете створити архів SigMF у Python, ви можете запакувати всі файли в каталозі разом таким чином: + +.. code-block:: python + + import tarfile + import os + + target_dir = '/mnt/c/Users/marclichtman/Downloads/exampletar/' # SigMF файли тут + with tarfile.open(os.path.join(target_dir, '4_element_recording.sigmf'), 'x') as tar: # x означає створити, але помилитись, якщо вже існує + for file in os.listdir(target_dir): + tar.add(os.path.join(target_dir, file), arcname=file) # arcname не дозволяє включати повний шлях у tar + +І все! Спробуйте (тимчасово) перейменувати .sigmf на .tar і перегляньте файли у файловому менеджері. Щоб відкривати будь-які файли безпосередньо (без ручного розпакування tar) у Python, ви можете використати: + +.. code-block:: python + + import tarfile + import json + + collection_file = '/mnt/c/Users/marclichtman/Downloads/exampletar/4_element_recording.sigmf' + tar_obj = tarfile.open(collection_file) + print(tar_obj.getnames()) # список рядків із назвами всіх файлів у tar + channel_0_meta = tar_obj.extractfile('channel-0.sigmf-meta').read() # читаємо один з метафайлів як приклад + channel_0_dict = json.loads(channel_0_meta) # перетворюємо на словник Python + print(channel_0_dict) + +Для зчитування IQ-семплів безпосередньо з tar замість :code:`np.fromfile()` ми використаємо :code:`np.frombuffer()`: + +.. code-block:: python + + import tarfile + import numpy as np + + collection_file = '/mnt/c/Users/marclichtman/Downloads/exampletar/4_element_recording.sigmf' + tar_obj = tarfile.open(collection_file) + channel_0_data_f = tar_obj.extractfile('channel-0.sigmf-data').read() # тип bytes + samples = np.frombuffer(channel_0_data_f, dtype=np.int16) + samples = samples[::2] + 1j*samples[1::2] # конвертувати в IQIQIQ... + samples /= 32768 # конвертувати в -1 до +1 + print(samples[0:10]) + +Якщо ви хочете перейти до іншої частини файлу, використовуйте :code:`tar_obj.extractfile('channel-0.sigmf-data').seek(offset)`. +Потім, щоб прочитати конкретну кількість байтів, скористайтеся :code:`.read(num_bytes)`. Переконайтеся, що кількість байтів є кратною вашому типу даних! + +Підсумуємо: при створенні нового архіву колекції SigMF слід виконати такі кроки: + +1. Створити файл .sigmf-meta та .sigmf-data для кожного каналу +2. Створити файл .sigmf-collection +3. Упакувати всі файли разом у файл .sigmf +4. (За бажанням) Поділитися файлом .sigmf з іншими! + + From 222aa8b3144881233da75f5210af878008894e98 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Fri, 10 Oct 2025 14:00:11 +0300 Subject: [PATCH 27/42] Update frequency_domain.rst Just rename of title in the ukrainian --- content-ukraine/frequency_domain.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content-ukraine/frequency_domain.rst b/content-ukraine/frequency_domain.rst index 5015a052..28232311 100644 --- a/content-ukraine/frequency_domain.rst +++ b/content-ukraine/frequency_domain.rst @@ -1,8 +1,8 @@ .. _freq-domain-chapter: -##################### -Частотний домен -##################### +################ +Частотна область +################ Ця глава знайомить з частотною областю і охоплює ряди Фур'є, перетворення Фур'є, властивості Фур'є, ШПФ, вікна і спектрограми, використовуючи приклади з Python. @@ -549,4 +549,4 @@ :target: ../_images/fft_in_python.svg :alt: приклад реалізації fft на python -Для тих, хто цікавиться реалізаціями на JavaScript та/або WebAssembly, зверніть увагу на бібліотеку `WebFFT `_ для виконання ШПФ у веб- або NodeJS-додатках, вона містить кілька реалізацій, а також інструмент `benchmarking tool `_ для порівняння продуктивності кожної реалізації. \ No newline at end of file +Для тих, хто цікавиться реалізаціями на JavaScript та/або WebAssembly, зверніть увагу на бібліотеку `WebFFT `_ для виконання ШПФ у веб- або NodeJS-додатках, вона містить кілька реалізацій, а також інструмент `benchmarking tool `_ для порівняння продуктивності кожної реалізації. From f3d862928791fc53bb8027a4dbea00cc96fdbc0b Mon Sep 17 00:00:00 2001 From: distribtech Date: Fri, 10 Oct 2025 14:06:08 +0300 Subject: [PATCH 28/42] Update Ukrainian intro translation --- content-ukraine/intro.rst | 73 +++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/content-ukraine/intro.rst b/content-ukraine/intro.rst index 8091a17d..42cb67b7 100644 --- a/content-ukraine/intro.rst +++ b/content-ukraine/intro.rst @@ -10,13 +10,21 @@ Перш за все, кілька важливих термінів: -**Програмно-кероване радіо (SDR):**. - Радіо, яке використовує програмне забезпечення для виконання завдань обробки сигналів, які традиційно виконувалися апаратним забезпеченням. - -**Цифрова обробка сигналів (DSP):**. - Цифрова обробка сигналів, у нашому випадку радіосигналів +**Програмно-визначене радіо (SDR):** + Як *концепція*, це використання програмного забезпечення для виконання завдань обробки сигналів, які традиційно виконувалися + апаратними засобами та пов'язані з радіо/RF застосунками. Це програмне забезпечення може працювати на комп'ютері загального + призначення (CPU), FPGA або навіть GPU і може застосовуватися як у режимі реального часу, так і для офлайн-обробки записаних + сигналів. Аналогічні терміни: «програмне радіо» та «радіочастотна цифрова обробка сигналів». -Цей підручник є практичним вступом до DSP, SDR та бездротового зв'язку. Він призначений для тих, хто: + Як *пристрій* (наприклад, «SDR») це зазвичай пристрій, до якого можна під'єднати антену й приймати радіочастотні сигнали, а + оцифровані RF-вибірки надсилаються на комп'ютер для обробки чи запису (наприклад, через USB, Ethernet, PCI). Багато SDR також + мають можливості передавання, що дозволяють комп'ютеру надсилати вибірки до SDR, який потім випромінює сигнал на заданій + радіочастоті. Деякі вбудовані SDR мають інтегрований комп'ютер. + +**Цифрова обробка сигналів (DSP):** + Цифрова обробка сигналів; у нашому випадку — радіосигналів. + +Цей підручник є практичним вступом до галузей DSP, SDR та бездротового зв'язку. Він призначений для тих, хто: #. Зацікавлений у *використанні* SDR для створення крутих речей #. Добре знається на Python @@ -25,39 +33,60 @@ #. Краще розуміє рівняння *після* вивчення концепцій #. Шукає стислі пояснення, а не 1000-сторінковий підручник -Прикладом може бути студент факультету комп'ютерних наук, зацікавлений у роботі, пов'язаній з бездротовим зв'язком, після закінчення навчання. Хоча цей посібник може бути використаний будь-ким, хто хоче дізнатися про SDR і має досвід програмування. Таким чином, він охоплює необхідну теорію для розуміння методів ЦОС (DSP) без інтенсивної математики, яка зазвичай включається в курси з ЦОС (DSP). Замість того, щоб занурюватися в рівняння, автор використовує велику кількість рисунків та анімацій, які допомагають передати концепції. Таких як, наприклад, анімація побудови кривої на комплексній площині за допомогою ряду Фур'є, що наведена нижче. Я вважаю, що рівняння найкраще розуміються *після* вивчення концепцій за допомогою візуальних образів і практичних вправ. Інтенсивне використання анімації є причиною того, що PySDR ніколи не буде продаватися в друкованому вигляді на Amazon. +Прикладом може бути студент факультету комп'ютерних наук, зацікавлений у роботі, пов'язаній з бездротовим зв'язком, після +закінчення навчання, хоча ним може користуватися будь-хто, хто прагне вивчити SDR і має досвід програмування. Таким чином, він +охоплює необхідну теорію для розуміння методів DSP без складної математики, яка зазвичай присутня в курсах DSP. Замість того, +щоб занурюватися в рівняння, автор використовує велику кількість зображень і анімацій, які допомагають передати концепції, +наприклад анімацію комплексної площини ряду Фур'є, наведену нижче. Я вважаю, що рівняння найкраще розуміються *після* +опанування концепцій за допомогою візуалізацій і практичних вправ. Інтенсивне використання анімацій — причина, чому PySDR +ніколи не матиме друкованої версії, що продається на Amazon. .. image:: ../_images/fft_logo_wide.gif :scale: 70 % :align: center :alt: Логотип PySDR, створений за допомогою перетворення Фур'є -Цей підручник призначений для швидкого і плавного введення понять, що дозволить читачеві виконувати ЦОС (DSP) і розумно використовувати SDR. Він не є довідником з усіх тем ЦОС (DSP)/SDR; вже існує безліч чудових підручників, таких як `Analog Device's SDR textbook -`_ та `dspguide.com `_. Ви завжди можете скористатися Google, щоб згадати тригонометричні тотожності або межу Шеннона. Вважайте цей підручник "воротами" у світ ЦОС (DSP) і SDR: він легший і вимагає менших витрати часу і грошей, якщо порівнювати з більш традиційними курсами і підручниками. +Цей підручник покликаний швидко і плавно ознайомити з поняттями, що дозволить читачеві виконувати DSP і розумно використовувати +SDR. Він не задуманий як довідник з усіх тем DSP/SDR; уже існує безліч чудових підручників, таких як `Analog Device's SDR +textbook `_ та `dspguide.com +`_. Ви завжди можете скористатися Google, щоб пригадати тригонометричні тотожності або межу Шеннона. +Сприймайте цей підручник як «ворота» у світ DSP та SDR: він легший і потребує менше часу та коштів у порівнянні з більш +традиційними курсами і підручниками. -Уся фундаментальна теорію ЦОС, щоб охоплює цілий семестр "Сигналів і систем", типового курсу в електротехніці, тут стиснута до кількох розділів. Після вивчення основ ЦОС ми переходимо до SDR, хоча концепції ЦОС і бездротового зв'язку продовжують з'являтися протягом усього підручника. +Щоб охопити фундаментальну теорію DSP, цілий семестр курсу «Сигнали і системи», типового для електротехнічних спеціальностей, +стиснуто до кількох розділів. Після вивчення основ DSP ми переходимо до SDR, хоча поняття DSP і бездротового зв'язку продовжують +з'являтися протягом усього підручника. -Приклади коду подано мовою Python. Використовується NumPy, яка є стандартною бібліотекою Python для масивів і високорівневої математики. Приклади також спираються на Matplotlib - бібліотеку побудови графіків Python, яка забезпечує простий спосіб візуалізації сигналів, масивів і комплексних чисел. Зауважте, що хоча Python загалом "повільніша" за C++, більшість математичних функцій у Python/NumPy реалізовано на C/C++ і добре оптимізовано. Аналогічно, SDR API, який ми використовуємо, є просто Python-обгорткою до функцій/класів C/C++. Ті, хто має невеликий досвід роботи з Python, але натомість мають міцну базу знаннь в MATLAB, Ruby або Perl, швидше за все, після ознайомлення з синтаксисом Python не будуть мати проблем з ним. +Приклади коду наведено мовою Python. Використовується NumPy — стандартна бібліотека Python для масивів і високорівневої +математики. Приклади також покладаються на Matplotlib — бібліотеку побудови графіків Python, що надає простий спосіб +візуалізації сигналів, масивів і комплексних чисел. Зауважте, що хоча Python загалом «повільніша», ніж C++, більшість +математичних функцій у Python/NumPy реалізовано на C/C++ і добре оптимізовано. Так само API SDR, який ми використовуємо, — це +набір Python-обгорток для функцій/класів C/C++. Ті, хто має небагато досвіду з Python, але міцну базу в MATLAB, Ruby або Perl, +ймовірно, почуватимуться впевнено після ознайомлення із синтаксисом Python. *************** Долучитися *************** +Якщо ви отримали користь від PySDR, поділіться ним із колегами, студентами та іншими допитливими людьми, яких може зацікавити цей матеріал. Ви також можете зробити пожертву через `PySDR Patreon `_ як спосіб подяки та отримати згадку зліва на кожній сторінці під списком розділів. + Якщо ви прочитаєте будь-яку частину цього підручника і напишете мені на marc@pysdr.org з питаннями/коментарями/пропозиціями, то вітаємо, ви зробили свій внесок у створення цього підручника! Ви також можете редагувати вихідний матеріал безпосередньо на сторінці `підручника на GitHub `_ (ваша зміна покладе початок новому запиту на заміну). Не соромтеся надсилати проблему або навіть запит на вилучення (PR) з виправленнями або покращеннями. Ті, хто надсилає цінні відгуки/виправлення, будуть постійно додаватися до розділу подяк нижче. Не дуже добре володієте Git'ом, але хочете запропонувати зміни? Не соромтеся писати мені на marc@pysdr.org. ***************** Подяки ***************** -Дякуємо всім, хто прочитав будь-яку частину цього підручника і залишив відгук, і особливо - -- Баррі Даґґану (Barry Duggan ) -- Метью Хеннону -- Джеймсу Хайєку -- Дейдрі Стаффер -- Таріку Бенадді за переклад PySDR французькою мовою `_. -- Даніелю Верслуїсу `_ за переклад PySDR голландською мовою `_. -- `mrbloom `_ за `переклад PySDR українською `_ -- `Yimin Zhao `_ за `переклад PySDR спрощеною китайською `_ -- `Eduardo Chancay `_ за `переклад PySDR іспанською `_ +Дякуємо всім, хто прочитав будь-яку частину цього підручника і надіслав відгук, а особливо: + +- `Barry Duggan `_ +- Matthew Hannon +- James Hayek +- Deidre Stuffer +- Tarik Benaddi за `переклад PySDR французькою `_ +- `Daniel Versluis `_ за `переклад PySDR нідерландською `_ +- `mrbloom `_ за `переклад PySDR українською `_ +- `Yimin Zhao `_ за `переклад PySDR спрощеною китайською `_ +- `Eduardo Chancay `_ за `переклад PySDR іспанською `_ + +А також усім прихильникам `PySDR Patreon `_! From 2654d6a651fc304f5b92ef0146e5faefbe057046 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Mon, 1 Dec 2025 15:37:25 +0200 Subject: [PATCH 29/42] merged with eng version of book --- _images/doa_time_domain.svg | 791 ++++++++++++++++++------------------ _templates/layout.html | 14 +- conf.py | 15 +- content/doa.rst | 6 +- scrape_patreon.py | 10 +- 5 files changed, 410 insertions(+), 426 deletions(-) diff --git a/_images/doa_time_domain.svg b/_images/doa_time_domain.svg index faf65553..3968bb62 100644 --- a/_images/doa_time_domain.svg +++ b/_images/doa_time_domain.svg @@ -6,11 +6,11 @@ - 2023-04-03T01:00:33.458967 + 2025-11-13T15:39:57.089539 image/svg+xml - Matplotlib v3.5.1, https://matplotlib.org/ + Matplotlib v3.10.3, https://matplotlib.org/ @@ -42,21 +42,21 @@ z +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - - + - + +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - + @@ -158,18 +158,18 @@ z +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - + @@ -177,16 +177,16 @@ L 159.134304 7.2 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - + @@ -208,16 +208,16 @@ z +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + @@ -244,19 +244,19 @@ z +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + @@ -264,19 +264,19 @@ L 292.962536 7.2 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + @@ -284,19 +284,19 @@ L 337.571947 7.2 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + @@ -304,25 +304,25 @@ L 382.181358 7.2 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + - + - - - + + + @@ -416,21 +416,21 @@ z +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - - + - + - - - + + + @@ -458,20 +458,20 @@ z +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - - + + + @@ -479,19 +479,19 @@ L 442.760938 128.16 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + @@ -499,19 +499,19 @@ L 442.760938 90.36 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + @@ -519,25 +519,25 @@ L 442.760938 52.56 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #b0b0b0; stroke-width: 0.8; stroke-linecap: square"/> - + - + - - + + - + - - - - - - + + + + + + @@ -840,335 +840,336 @@ L 421.437639 20.068898 L 423.222016 17.135113 L 425.006392 15.356129 L 425.006392 15.356129 -" clip-path="url(#p5f98b12812)" style="fill: none; stroke: #1f77b4; stroke-width: 1.5; stroke-linecap: square"/> +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #1f77b4; stroke-width: 1.5; stroke-linecap: square"/> +L 385.750111 108.695861 +L 394.671993 62.084247 +L 396.456369 53.519713 +L 398.240746 45.536172 +L 400.025122 38.259529 +L 401.809498 31.804542 +L 403.593875 26.27301 +L 405.378251 21.752168 +L 407.162628 18.313312 +L 408.947004 16.010676 +L 410.731381 14.880573 +L 412.515757 14.940825 +L 414.300133 16.190483 +L 416.08451 18.609839 +L 417.868886 22.160737 +L 419.653263 26.787178 +L 421.437639 32.416201 +L 423.222016 38.959033 +L 425.006392 46.312488 +L 425.006392 46.312488 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #ff7f0e; stroke-width: 1.5; stroke-linecap: square"/> +L 383.965734 41.435274 +L 385.750111 34.597547 +L 387.534487 28.639227 +L 389.318863 23.65428 +L 391.10324 19.721322 +L 392.887616 16.902379 +L 394.671993 15.241906 +L 396.456369 14.76609 +L 398.240746 15.482435 +L 400.025122 17.379644 +L 401.809498 20.427798 +L 403.593875 24.578823 +L 405.378251 29.767257 +L 407.162628 35.911275 +L 408.947004 42.913982 +L 410.731381 50.664941 +L 414.300133 67.912793 +L 419.653263 96.064248 +L 423.222016 114.632399 +L 425.006392 123.414557 +L 425.006392 123.414557 +" clip-path="url(#peb34b321ef)" style="fill: none; stroke: #2ca02c; stroke-width: 1.5; stroke-linecap: square"/> - + @@ -1224,7 +1225,7 @@ L 419.398438 34.976563 - + @@ -1236,7 +1237,7 @@ L 419.398438 49.654688 - + @@ -1244,7 +1245,7 @@ L 419.398438 49.654688 - + diff --git a/_templates/layout.html b/_templates/layout.html index 41305c23..da78e0bd 100644 --- a/_templates/layout.html +++ b/_templates/layout.html @@ -75,13 +75,13 @@  |  - EnglishEnglish   - DutchDutch   - FrenchFrench   - UkrainianUkrainian   - ChineseChinese   - SpanishSpanish   - JapaneseJapanese + EnglishEnglish   + DutchDutch   + FrenchFrench   + UkrainianUkrainian   + ChineseChinese   + SpanishSpanish   + JapaneseJapanese {% endblock %} diff --git a/conf.py b/conf.py index 86b1ebbf..0df64d0f 100644 --- a/conf.py +++ b/conf.py @@ -48,19 +48,6 @@ copyright = year + u', Marc Lichtman' author = u'Marc Lichtman' -# Base URL used when generating fully-qualified links throughout the -# documentation. It defaults to the historical pysdr.org domain but can be -# overridden (for example in GitHub Actions) by setting the PYSDR_BASEURL -# environment variable. The trailing slash simplifies concatenation when the -# value is used inside the Jinja templates. -pysdr_baseurl = os.environ.get('PYSDR_BASEURL', 'https://pysdr.org/') -if not pysdr_baseurl.endswith('/'): - pysdr_baseurl += '/' - -html_context = { - 'pysdr_baseurl': pysdr_baseurl, -} - # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. @@ -137,7 +124,7 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = {'description':f'By Dr. Marc Lichtman - marc@pysdr.org', +html_theme_options = {'description':'By Dr. Marc Lichtman - marc@pysdr.org', 'logo': 'logo.svg', 'logo_name': True, # used if the logo doesn't contain the project name itself 'fixed_sidebar': True, # on smaller screens you can't see the whole sidebar, and it won't scroll diff --git a/content/doa.rst b/content/doa.rst index 5c3fe73e..4d870c5d 100644 --- a/content/doa.rst +++ b/content/doa.rst @@ -708,7 +708,7 @@ In Python we can implement the MVDR/Capon beamformer as follows, which will be d .. code-block:: python # theta is the direction of interest, in radians, and X is our received signal - def w_mvdr(theta, r): + def w_mvdr(theta, X): s = np.exp(2j * np.pi * d * np.arange(Nr) * np.sin(theta)) # steering vector in the desired direction theta s = s.reshape(-1,1) # make into a column vector (size 3x1) R = (X @ X.conj().T)/X.shape[1] # Calc covariance matrix. gives a Nr x Nr covariance matrix of the samples @@ -723,7 +723,7 @@ Using this MVDR beamformer in the context of DOA, we get the following Python ex theta_scan = np.linspace(-1*np.pi, np.pi, 1000) # 1000 different thetas between -180 and +180 degrees results = [] for theta_i in theta_scan: - w = w_mvdr(theta_i, r) # 3x1 + w = w_mvdr(theta_i, X) # 3x1 X_weighted = w.conj().T @ X # apply weights power_dB = 10*np.log10(np.var(X_weighted)) # power in signal, in dB so its easier to see small and large lobes at the same time results.append(power_dB) @@ -794,7 +794,7 @@ Meaning we don't have to apply the weights at all, this final equation above for .. code-block:: python - def power_mvdr(theta, r): + def power_mvdr(theta, X): s = np.exp(2j * np.pi * d * np.arange(r.shape[0]) * np.sin(theta)) # steering vector in the desired direction theta_i s = s.reshape(-1,1) # make into a column vector (size 3x1) R = (X @ X.conj().T)/X.shape[1] # Calc covariance matrix. gives a Nr x Nr covariance matrix of the samples diff --git a/scrape_patreon.py b/scrape_patreon.py index 54cd4867..179e00ff 100644 --- a/scrape_patreon.py +++ b/scrape_patreon.py @@ -1,10 +1,6 @@ import patreon import os -BASE_URL = os.environ.get('PYSDR_BASEURL', 'https://pysdr.org/') -if not BASE_URL.endswith('/'): - BASE_URL += '/' - # needed by sphinx def setup(app): return @@ -32,10 +28,10 @@ def scrape_patreon(): if full_name == "Дмитрий Ступаков": continue if full_name == "Al Grant": - names.append(f'Al Grant ') + names.append('Al Grant ') continue if full_name == "Hash" or full_name == "RECESSIM": - names.append(f'{full_name} ') + names.append(f'{full_name} ') continue names.append(full_name) # there's also 'first_name' which might be better for a public display name # Patreon Supporters @@ -46,7 +42,7 @@ def scrape_patreon(): html_string += '⚬ ' + name + "
" # Organizations that are sponsoring (Manually added to get logo included) html_string += '
and organization-level supporters:
' - html_string += f'' + ' Analog Devices, Inc.' + "
" + html_string += '' + ' Analog Devices, Inc.' + "
" html_string += "" with open("_templates/patrons.html", "w", encoding="utf-8") as patron_file: patron_file.write(html_string) From b8e3995fc5dc94bf6531d8c43fd29b983c2cb57e Mon Sep 17 00:00:00 2001 From: mrbloom Date: Tue, 9 Dec 2025 11:30:28 +0200 Subject: [PATCH 30/42] translation of term cyclostaionary --- content-ukraine/cyclostationary.rst | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content-ukraine/cyclostationary.rst b/content-ukraine/cyclostationary.rst index 258355c0..72d013d8 100644 --- a/content-ukraine/cyclostationary.rst +++ b/content-ukraine/cyclostationary.rst @@ -1,20 +1,20 @@ .. _freq-domain-chapter: ########################## -Циклостаціонарна обробка +Циклічно-стаціонарна обробка ########################## .. raw:: html У співавторстві з Sam Brown -У цьому розділі ми розкриваємо суть обробки циклостаціонарних сигналів (cyclostationary signal processing, або CSP) — відносно нішової галузі радіочастотної (RF) обробки сигналів, яка використовується для аналізу або виявлення (часто за дуже низького SNR!) сигналів із циклостаціонарними властивостями, зокрема більшості сучасних схем цифрової модуляції. Ми розглянемо циклічну автокореляційну функцію (CAF), спектральну кореляційну функцію (SCF), функцію спектральної когерентності (COH), їхні спряжені варіанти та способи застосування. Розділ містить кілька повних реалізацій на Python з прикладами, що охоплюють BPSK, QPSK, OFDM та кілька одночасних сигналів. +У цьому розділі ми розкриваємо суть обробки циклічно-стаціонарних сигналів (cyclostationary signal processing, або CSP) — відносно нішової галузі радіочастотної (RF) обробки сигналів, яка використовується для аналізу або виявлення (часто за дуже низького SNR!) сигналів із циклічно-стаціонарними властивостями, зокрема більшості сучасних схем цифрової модуляції. Ми розглянемо циклічну автокореляційну функцію (CAF), спектральну кореляційну функцію (SCF), функцію спектральної когерентності (COH), їхні спряжені варіанти та способи застосування. Розділ містить кілька повних реалізацій на Python з прикладами, що охоплюють BPSK, QPSK, OFDM та кілька одночасних сигналів. **************** Вступ **************** -Циклостаціонарна обробка сигналів (CSP або просто циклостаціонарна обробка) — це набір технік, що дозволяє використовувати циклостаціонарну властивість, притаманну багатьом реальним комунікаційним сигналам. Це можуть бути модульовані сигнали, як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистика яких має періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклостаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклостаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. +Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним комунікаційним сигналам. Це можуть бути модульовані сигнали, як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистика яких має періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. Якщо після читання цього розділу та експериментів у Python ви захочете глибше зануритися в CSP, перегляньте підручник Вільяма Гарднера 1994 року `Cyclostationarity in Communications and Signal Processing `_, його підручник 1987 року `Statistical Spectral Analysis `_, або `збірку публікацій у блозі Чада Спунера `_. @@ -57,7 +57,7 @@ .. 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} -Це дозволяє перевірити, наскільки сильною є частота :math:`\alpha`. Наведений вище вираз називається циклічною автокореляційною функцією (CAF). Інший спосіб інтерпретації CAF — це набір коефіцієнтів ряду Фур’є, які описують згадану періодичність. Іншими словами, CAF — це амплітуда та фаза гармонік, присутніх в автокореляції сигналу. Ми використовуємо термін «циклостаціонарний», щоб описати сигнали, автокореляція яких є періодичною або майже періодичною. CAF є розширенням традиційної автокореляційної функції для циклостаціонарних сигналів. +Це дозволяє перевірити, наскільки сильною є частота :math:`\alpha`. Наведений вище вираз називається циклічною автокореляційною функцією (CAF). Інший спосіб інтерпретації CAF — це набір коефіцієнтів ряду Фур’є, які описують згадану періодичність. Іншими словами, CAF — це амплітуда та фаза гармонік, присутніх в автокореляції сигналу. Ми використовуємо термін «циклічно-стаціонарний», щоб описати сигнали, автокореляція яких є періодичною або майже періодичною. CAF є розширенням традиційної автокореляційної функції для циклічно-стаціонарних сигналів. Видно, що CAF залежить від двох змінних: затримки :math:`\tau` (tau) та циклічної частоти :math:`\alpha`. Циклічні частоти в CSP відображають швидкість зміни статистики сигналу, що у випадку CAF означає момент другого порядку або дисперсію. Тож циклічні частоти часто відповідають виразним періодичним явищам, таким як символи, що модулюються в комунікаційних сигналах. Побачимо, як символова швидкість сигналу BPSK та її цілі кратні (гармоніки) проявляються як циклічні частоти у CAF. @@ -71,7 +71,7 @@ Ми використовуємо :code:`np.roll()` для зсуву одного набору відліків на :code:`tau`, адже потрібно зміщувати на ціле число відліків. Якби ми зсували обидва набори у протилежних напрямках, ми пропускали б кожне друге зміщення. Також необхідно додати частотний зсув, щоб компенсувати те, що ми зміщуємо на 1 відлік за раз і лише з одного боку (замість половини відліку в обидва боки, як у базовому рівнянні CAF). Частота цього зсуву дорівнює :code:`alpha/2`. -Щоб погратися з CAF у Python, спершу змоделюємо приклад сигналу. Поки що використаємо прямокутний сигнал BPSK (тобто BPSK без формування імпульсу) з 20 відліками на символ та додамо білий гаусів шум (AWGN). Ми навмисне внесемо частотний зсув у сигнал BPSK, аби пізніше продемонструвати, як циклостаціонарна обробка допомагає оцінювати і частотний зсув, і циклічну частоту. Цей зсув відповідає ситуації, коли приймач не ідеально налаштований на частоту сигналу: або трохи хибить, або суттєво, але не настільки, щоб сигнал виходив за межі смуги дискретизації. +Щоб погратися з CAF у Python, спершу змоделюємо приклад сигналу. Поки що використаємо прямокутний сигнал BPSK (тобто BPSK без формування імпульсу) з 20 відліками на символ та додамо білий гаусів шум (AWGN). Ми навмисне внесемо частотний зсув у сигнал BPSK, аби пізніше продемонструвати, як циклічно-стаціонарна обробка допомагає оцінювати і частотний зсув, і циклічну частоту. Цей зсув відповідає ситуації, коли приймач не ідеально налаштований на частоту сигналу: або трохи хибить, або суттєво, але не настільки, щоб сигнал виходив за межі смуги дискретизації. Наведений нижче код генерує IQ-відліки, які ми використовуватимемо впродовж двох наступних розділів: @@ -448,7 +448,7 @@ QPSK та модуляції вищих порядків Отже, маємо два сигнали з однаковою циклічною частотою та два — з однаковою RF-частотою. Це дозволить дослідити різні ступені перекриття параметрів. -До кожного сигналу додається фільтр дробової затримки з довільною (нецілою) затримкою, щоб уникнути артефактів, пов’язаних із синхронним розташуванням відліків (докладніше про це в розділі :ref:`sync-chapter`). Потужність прямокутного BPSK зменшено порівняно з двома іншими, оскільки сигнали з прямокутними імпульсами мають дуже виражені циклостаціонарні властивості й схильні домінувати в SCF. +До кожного сигналу додається фільтр дробової затримки з довільною (нецілою) затримкою, щоб уникнути артефактів, пов’язаних із синхронним розташуванням відліків (докладніше про це в розділі :ref:`sync-chapter`). Потужність прямокутного BPSK зменшено порівняно з двома іншими, оскільки сигнали з прямокутними імпульсами мають дуже виражені циклічно-стаціонарні властивості й схильні домінувати в SCF. .. raw:: html @@ -526,13 +526,13 @@ QPSK та модуляції вищих порядків :target: ../_images/scf_freq_smoothing_pulse_multiple_signals.svg :alt: SCF трьох сигналів, обчислена методом згладжування за частотою (FSM) -Зауважте, що сигнал 1, хоч і з прямокутними імпульсами, переважно маскується «конусом» над сигналом 3. На PSD сигнал 1 «ховався» за сигналом 3. Завдяки CSP ми можемо виявити присутність сигналу 1 та приблизно визначити його циклічну частоту, яку потім можна використати для синхронізації. Ось у чому сила циклостаціонарної обробки! +Зауважте, що сигнал 1, хоч і з прямокутними імпульсами, переважно маскується «конусом» над сигналом 3. На PSD сигнал 1 «ховався» за сигналом 3. Завдяки CSP ми можемо виявити присутність сигналу 1 та приблизно визначити його циклічну частоту, яку потім можна використати для синхронізації. Ось у чому сила циклічно-стаціонарної обробки! ************************ Альтернативні ознаки CSP ************************ -SCF — не єдиний спосіб виявляти циклостаціонарність сигналу, особливо якщо вам не потрібно розглядати циклічну частоту як функцію RF-частоти. Проста (і концептуально, і обчислювально) техніка передбачає взяття **FFT від модуля** сигналу й пошук піків. У Python це виглядає так: +SCF — не єдиний спосіб виявляти циклічно-стаціонарність сигналу, особливо якщо вам не потрібно розглядати циклічну частоту як функцію RF-частоти. Проста (і концептуально, і обчислювально) техніка передбачає взяття **FFT від модуля** сигналу й пошук піків. У Python це виглядає так: .. code-block:: python @@ -556,11 +556,11 @@ SCF — не єдиний спосіб виявляти циклостаціон .. image:: ../_images/non_csp_metric.svg :align: center :target: ../_images/non_csp_metric.svg - :alt: Метрика для виявлення циклостаціонарності без використання CAF чи SCF + :alt: Метрика для виявлення циклічно-стаціонарності без використання CAF чи SCF Гармоніки прямокутного BPSK, на жаль, перекриваються з циклічними частотами інших сигналів — це демонструє недолік цього альтернативного підходу: він не дозволяє розглядати циклічну частоту як функцію RF-частоти, як це робить SCF. -Хоч цей метод і використовує циклостаціонарність сигналів, його зазвичай не відносять до «технік CSP», можливо, через простоту... +Хоч цей метод і використовує циклічно-стаціонарність сигналів, його зазвичай не відносять до «технік CSP», можливо, через простоту... Для пошуку RF-частоти сигналу, тобто зсуву несучої, існує схожий прийом. Для сигналів BPSK достатньо взяти FFT від сигналу у квадраті (вхід FFT буде комплексним). Це дасть пік на частоті, що дорівнює подвоєному зсуву несучої. Для QPSK можна взяти FFT від сигналу в четвертій степені й отримати пік на частоті, що дорівнює зсуву несучої, помноженому на 4. @@ -582,7 +582,7 @@ SCF — не єдиний спосіб виявляти циклостаціон *Коротко: функція спектральної когерентності — це нормалізована версія SCF, яка в деяких випадках є кориснішою за звичайну SCF.* -Ще одна міра циклостаціонарності, що в багатьох випадках може дати більше інформації, ніж «сире» SCF, — це функція спектральної когерентності (COH). COH нормалізує SCF так, що результат лежить у діапазоні від -1 до 1 (ми розглядатимемо модуль, тобто 0–1). Це корисно, адже із результату вилучається інформація про спектр потужності сигналу, яку містить «сире» SCF. Нормалізація залишає лише впливи циклічної кореляції. +Ще одна міра циклічно-стаціонарності, що в багатьох випадках може дати більше інформації, ніж «сире» SCF, — це функція спектральної когерентності (COH). COH нормалізує SCF так, що результат лежить у діапазоні від -1 до 1 (ми розглядатимемо модуль, тобто 0–1). Це корисно, адже із результату вилучається інформація про спектр потужності сигналу, яку містить «сире» SCF. Нормалізація залишає лише впливи циклічної кореляції. Щоб краще зрозуміти COH, згадаємо `коефіцієнт кореляції `_ зі статистики. Коефіцієнт кореляції :math:`\rho_{X,Y}` вимірює зв’язок між двома випадковими величинами :math:`X` і :math:`Y` у діапазоні -1…1. Він визначається як ковариація, поділена на добуток стандартних відхилень: @@ -902,7 +902,7 @@ COH розширює цю концепцію на спектральну кор OFDM ******************************** -Циклостаціонарність особливо виражена в OFDM-сигналах через використання циклічного префікса (CP), коли кілька останніх відліків кожного OFDM-символу копіюються на його початок. Це створює сильну циклічну частоту, що відповідає довжині OFDM-символу (яка дорівнює оберненій величині міжсубдіапазонного інтервалу плюс тривалість CP). +Циклічно-стаціонарність особливо виражена в OFDM-сигналах через використання циклічного префікса (CP), коли кілька останніх відліків кожного OFDM-символу копіюються на його початок. Це створює сильну циклічну частоту, що відповідає довжині OFDM-символу (яка дорівнює оберненій величині міжсубдіапазонного інтервалу плюс тривалість CP). Змоделюймо OFDM-сигнал. Нижче наведено код, який генерує OFDM із CP, використовуючи 64 піднесучі, 25% CP і QPSK на кожній піднесучій. Ми інтерполюємо сигнал удвічі, щоб змоделювати приймання з достатньо високою швидкістю дискретизації, тому довжина OFDM-символу в відліках становитиме (64 + (64*0.25)) * 2 = 160. Це означає, що очікуємо піки на :math:`\alpha`, кратних 1/160 (0.00625, 0.0125, 0.01875 тощо). Симулюємо 100 тис. відліків, що відповідає 625 OFDM-символам (кожен символ доволі довгий). From d67f41975ae5a07a755bc235bd5734dacd14e997 Mon Sep 17 00:00:00 2001 From: distribtech Date: Tue, 9 Dec 2025 11:40:02 +0200 Subject: [PATCH 31/42] Refine description of cyclostationary signal processing Intruduction paragraph --- content-ukraine/cyclostationary.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content-ukraine/cyclostationary.rst b/content-ukraine/cyclostationary.rst index 72d013d8..45a6fb48 100644 --- a/content-ukraine/cyclostationary.rst +++ b/content-ukraine/cyclostationary.rst @@ -8,13 +8,13 @@ У співавторстві з Sam Brown -У цьому розділі ми розкриваємо суть обробки циклічно-стаціонарних сигналів (cyclostationary signal processing, або CSP) — відносно нішової галузі радіочастотної (RF) обробки сигналів, яка використовується для аналізу або виявлення (часто за дуже низького SNR!) сигналів із циклічно-стаціонарними властивостями, зокрема більшості сучасних схем цифрової модуляції. Ми розглянемо циклічну автокореляційну функцію (CAF), спектральну кореляційну функцію (SCF), функцію спектральної когерентності (COH), їхні спряжені варіанти та способи застосування. Розділ містить кілька повних реалізацій на Python з прикладами, що охоплюють BPSK, QPSK, OFDM та кілька одночасних сигналів. +У цьому розділі ми розкриваємо суть обробки циклічно-стаціонарних сигналів (cyclostationary signal processing, або CSP) це відносно нішовий розділ радіочастотної (RF) обробки сигналів, який використовується для аналізу або виявлення (часто за дуже низького SNR!) сигналів із циклічно-стаціонарними властивостями, а це зокрема цигнали більшості сучасних схем цифрової модуляції. Ми розглянемо циклічну автокореляційну функцію (CAF), спектральну кореляційну функцію (SCF), функцію спектральної когерентності (COH), їхні спряжені варіанти та способи застосування. Розділ містить кілька повних реалізацій на Python з прикладами, що охоплюють BPSK, QPSK, OFDM та комбінацію одночасних сигналів. **************** Вступ **************** -Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним комунікаційним сигналам. Це можуть бути модульовані сигнали, як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистика яких має періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. +Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним сигналам, що використовуються в зв'язку. Це можуть бути модульовані сигнали, такі як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистичні характеристики, котрих мають періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. Якщо після читання цього розділу та експериментів у Python ви захочете глибше зануритися в CSP, перегляньте підручник Вільяма Гарднера 1994 року `Cyclostationarity in Communications and Signal Processing `_, його підручник 1987 року `Statistical Spectral Analysis `_, або `збірку публікацій у блозі Чада Спунера `_. From 67eaef57268dd13c9665bb550a7ee15f7ea5e4d9 Mon Sep 17 00:00:00 2001 From: mrbloom Date: Tue, 9 Dec 2025 11:43:45 +0200 Subject: [PATCH 32/42] cyclostationary statistic translation --- content-ukraine/cyclostationary.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content-ukraine/cyclostationary.rst b/content-ukraine/cyclostationary.rst index 45a6fb48..b57eee14 100644 --- a/content-ukraine/cyclostationary.rst +++ b/content-ukraine/cyclostationary.rst @@ -14,7 +14,7 @@ Вступ **************** -Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним сигналам, що використовуються в зв'язку. Це можуть бути модульовані сигнали, такі як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистичні характеристики, котрих мають періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. +Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним сигналам, що використовуються в зв'язку. Це можуть бути модульовані сигнали, такі як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистичні характеристики, котрих мають періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистичні характеристики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. Якщо після читання цього розділу та експериментів у Python ви захочете глибше зануритися в CSP, перегляньте підручник Вільяма Гарднера 1994 року `Cyclostationarity in Communications and Signal Processing `_, його підручник 1987 року `Statistical Spectral Analysis `_, або `збірку публікацій у блозі Чада Спунера `_. @@ -59,7 +59,7 @@ Це дозволяє перевірити, наскільки сильною є частота :math:`\alpha`. Наведений вище вираз називається циклічною автокореляційною функцією (CAF). Інший спосіб інтерпретації CAF — це набір коефіцієнтів ряду Фур’є, які описують згадану періодичність. Іншими словами, CAF — це амплітуда та фаза гармонік, присутніх в автокореляції сигналу. Ми використовуємо термін «циклічно-стаціонарний», щоб описати сигнали, автокореляція яких є періодичною або майже періодичною. CAF є розширенням традиційної автокореляційної функції для циклічно-стаціонарних сигналів. -Видно, що CAF залежить від двох змінних: затримки :math:`\tau` (tau) та циклічної частоти :math:`\alpha`. Циклічні частоти в CSP відображають швидкість зміни статистики сигналу, що у випадку CAF означає момент другого порядку або дисперсію. Тож циклічні частоти часто відповідають виразним періодичним явищам, таким як символи, що модулюються в комунікаційних сигналах. Побачимо, як символова швидкість сигналу BPSK та її цілі кратні (гармоніки) проявляються як циклічні частоти у CAF. +Видно, що CAF залежить від двох змінних: затримки :math:`\tau` (tau) та циклічної частоти :math:`\alpha`. Циклічні частоти в CSP відображають швидкість зміни статистистичних характеристик сигналу, що у випадку CAF означає момент другого порядку або дисперсію. Тож циклічні частоти часто відповідають виразним періодичним явищам, таким як символи, що модулюються в комунікаційних сигналах. Побачимо, як символова швидкість сигналу BPSK та її цілі кратні (гармоніки) проявляються як циклічні частоти у CAF. У Python CAF базового сигналу :code:`samples` для заданих :code:`alpha` та :code:`tau` можна обчислити за допомогою такого фрагмента (ми незабаром додамо обрамлювальний код): From e00ec635b799eef3d6dea5f6ecce8a0b5e4589ee Mon Sep 17 00:00:00 2001 From: distribtech Date: Tue, 9 Dec 2025 13:39:20 +0200 Subject: [PATCH 33/42] Grammatical and stylistic errors Grammatical and stylistic errors --- content-ukraine/cyclostationary.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/content-ukraine/cyclostationary.rst b/content-ukraine/cyclostationary.rst index b57eee14..08fc08ce 100644 --- a/content-ukraine/cyclostationary.rst +++ b/content-ukraine/cyclostationary.rst @@ -14,11 +14,11 @@ Вступ **************** -Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним сигналам, що використовуються в зв'язку. Це можуть бути модульовані сигнали, такі як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистичні характеристики, котрих мають періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистичні характеристики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхня статистика змінюється *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, що перекриваються як у часі, так і в частоті. +Циклічно-стаціонарна обробка сигналів (CSP або просто циклічно-стаціонарна обробка) — це набір технік, що дозволяє використовувати циклічно-стаціонарну властивість, притаманну багатьом реальним сигналам, що використовуються в зв'язку. Це можуть бути модульовані сигнали, такі як-от трансляції AM/FM/ТБ, стільниковий та WiFi зв’язок, а також радари й інші сигнали, статистичні характеристики, котрих мають періодичність. Значна частина класичних методів обробки сигналів ґрунтується на припущенні, що сигнал стаціонарний, тобто його статистичні характеристики, такі як середнє значення, дисперсія та моменти вищих порядків, не змінюються з часом. Однак більшість реальних RF-сигналів є циклічно-стаціонарними, тобто їхні статистичні характеристики змінюються *періодично* з часом. Техніки CSP використовують цю циклічно-стаціонарну властивість і можуть застосовуватися для виявлення сигналів у шумі, розпізнавання модуляції та розділення сигналів, які перетинаються як у часі, так і по частоті. -Якщо після читання цього розділу та експериментів у Python ви захочете глибше зануритися в CSP, перегляньте підручник Вільяма Гарднера 1994 року `Cyclostationarity in Communications and Signal Processing `_, його підручник 1987 року `Statistical Spectral Analysis `_, або `збірку публікацій у блозі Чада Спунера `_. +Якщо після прочитання цього розділу та експериментів з Python ви захочете глибше зануритися в CSP, перегляньте підручник Вільяма Гарднера 1994 року `Cyclostationarity in Communications and Signal Processing `_, його підручник 1987 року `Statistical Spectral Analysis `_, або `збірку публікацій у блозі Чада Спунера `_. -Ресурс, який ви знайдете лише тут і ні в жодному підручнику: наприкінці розділу про SCF на вас чекає інтерактивний JavaScript-додаток, що дає змогу експериментувати зі SCF прикладного сигналу та спостерігати, як SCF змінюється за різних параметрів сигналу та самої SCF — усе це просто у вашому браузері! Хоча ці інтерактивні демонстрації безкоштовні для всіх, здебільшого вони стають можливими завдяки підтримці учасників `Patreon PySDR `_. +А один ресурс, який ви знайдете лише тут і ні в жодному іншому підручнику це наприкінці розділу про SCF вас чекає інтерактивний JavaScript-додаток, що дає змогу експериментувати з SCF для сигналу-приклада та спостерігати, як SCF змінюється за різних параметрів сигналу та самої SCF. І усе це просто у вашому браузері! Хоча ці інтерактивні демонстрації безкоштовні для всіх, здебільшого вони стають можливими завдяки підтримці учасників `Patreon PySDR `_. ************************* Огляд автокореляції @@ -59,9 +59,9 @@ Це дозволяє перевірити, наскільки сильною є частота :math:`\alpha`. Наведений вище вираз називається циклічною автокореляційною функцією (CAF). Інший спосіб інтерпретації CAF — це набір коефіцієнтів ряду Фур’є, які описують згадану періодичність. Іншими словами, CAF — це амплітуда та фаза гармонік, присутніх в автокореляції сигналу. Ми використовуємо термін «циклічно-стаціонарний», щоб описати сигнали, автокореляція яких є періодичною або майже періодичною. CAF є розширенням традиційної автокореляційної функції для циклічно-стаціонарних сигналів. -Видно, що CAF залежить від двох змінних: затримки :math:`\tau` (tau) та циклічної частоти :math:`\alpha`. Циклічні частоти в CSP відображають швидкість зміни статистистичних характеристик сигналу, що у випадку CAF означає момент другого порядку або дисперсію. Тож циклічні частоти часто відповідають виразним періодичним явищам, таким як символи, що модулюються в комунікаційних сигналах. Побачимо, як символова швидкість сигналу BPSK та її цілі кратні (гармоніки) проявляються як циклічні частоти у CAF. +Видно, що CAF залежить від двох змінних: затримки :math:`\tau` (tau) та циклічної частоти :math:`\alpha`. Циклічні частоти в CSP відображають швидкість зміни статистистичних характеристик сигналу, що у випадку CAF означає момент другого порядку або дисперсію. Тож циклічні частоти часто відповідають виразним періодичним явищам, такими як символи, що модулюються в сигналах зв'язку. Ми побачимо, як символьна швидкість сигналу BPSK та її цілі кратні (гармоніки) проявляються як циклічні частоти CAF. -У Python CAF базового сигналу :code:`samples` для заданих :code:`alpha` та :code:`tau` можна обчислити за допомогою такого фрагмента (ми незабаром додамо обрамлювальний код): +У Python CAF базового сигналу :code:`samples` для заданих :code:`alpha` та :code:`tau` можна обчислити за допомогою такого фрагмента (ми незабаром додамо і інший обрамлюваний код): .. code-block:: python @@ -73,7 +73,7 @@ Щоб погратися з CAF у Python, спершу змоделюємо приклад сигналу. Поки що використаємо прямокутний сигнал BPSK (тобто BPSK без формування імпульсу) з 20 відліками на символ та додамо білий гаусів шум (AWGN). Ми навмисне внесемо частотний зсув у сигнал BPSK, аби пізніше продемонструвати, як циклічно-стаціонарна обробка допомагає оцінювати і частотний зсув, і циклічну частоту. Цей зсув відповідає ситуації, коли приймач не ідеально налаштований на частоту сигналу: або трохи хибить, або суттєво, але не настільки, щоб сигнал виходив за межі смуги дискретизації. -Наведений нижче код генерує IQ-відліки, які ми використовуватимемо впродовж двох наступних розділів: +Наведений нижче код генерує IQ-відліки, які ми будемо використовувати впродовж двох наступних розділів: .. code-block:: python @@ -88,7 +88,7 @@ noise = np.random.randn(N) + 1j*np.random.randn(N) # complex white Gaussian noise samples = bpsk + 0.1*noise # add noise to the signal -Оскільки абсолютні швидкість дискретизації та швидкість символів у цьому розділі не відіграють ролі, ми використовуємо нормалізовані частоти, що еквівалентно припущенню, що частота дискретизації = 1 Гц. Це означає, що сигнал мусить лежати в діапазоні від -0.5 до +0.5 Гц. Тому ви *не* побачите змінної :code:`sample_rate` у коді: ми працюємо через кількість відліків на символ (:code:`sps`). +Оскільки абсолютні швидкість дискретизації та швидкість символів у цьому розділі не відіграють важливої ролі, ми використовуємо нормалізовані частоти, що еквівалентно припущенню, що частота дискретизації = 1 Гц. Це означає, що сигнал мусить лежати в діапазоні від -0.5 до +0.5 Гц. Тому ви *не* побачите змінної :code:`sample_rate` у коді: ми працюємо з кількістю відліків на символ (:code:`sps`). Для розігріву погляньмо на щільність спектральної потужності (PSD, тобто FFT) сигналу до будь-якої обробки CSP: @@ -151,7 +151,7 @@ Бачимо очікуваний пік на 0.05 Гц, а також на цілих кратних 0.05 Гц. Це тому, що CAF — це ряд Фур’є, і гармоніки основної частоти присутні в CAF, особливо для PSK/QAM без формування імпульсу. Енергія на :math:`\alpha = 0` відповідає загальній потужності у PSD сигналу, хоча зазвичай ми її занулюємо, адже 1) PSD часто будують окремо і 2) вона псує динамічний діапазон колірної карти, коли ми починаємо відображати 2D-дані. -Хоч CAF цікавий, зазвичай нам хочеться побачити циклічну частоту *як функцію RF-частоти*, а не лише циклічну частоту, як у графіку вище. Це приводить нас до спектральної кореляційної функції (SCF), яку розглянемо далі. +Хоч CAF цікавий, ми зазвичай хочемо побачити циклічну частоту *як функцію RF-частоти*, а не лише циклічну частоту, як у графіку вище. Це приводить нас до спектральної кореляційної функції (SCF), яку розглянемо далі. ************************************************ Спектральна кореляційна функція (SCF) @@ -235,7 +235,7 @@ SCF можна отримати простим перетворенням Фур Метод згладжування за частотою (FSM) ******************************** -Тепер, коли ми маємо гарне інтуїтивне уявлення про SCF, розгляньмо, як обчислити її ефективно. Спершу пригадаємо періодограму — квадрат модуля перетворення Фур’є сигналу: +Тепер, коли ми маємо гарне інтуїтивне уявлення про SCF, розгляньмо, як обчислити її ефективно. Спершу пригадаємо періодограму — це просто квадрат модуля амплітуд перетворення Фур’є сигналу: .. math:: @@ -292,7 +292,7 @@ SCF можна отримати простим перетворенням Фур :target: ../_images/scf_freq_smoothing.svg :alt: SCF, обчислена методом згладжування за частотою (FSM) -Цей метод вимагає лише одного великого FFT, але потребує численних операцій згортки для згладжування. Зверніть увагу на проріджування після згортки :code:`[::Nw]`; воно не обов’язкове, але дуже бажане, щоб зменшити кількість пікселів для відображення, і завдяки способу обчислення SCF ми не «викидаємо» інформацію, проріджуючи на :code:`Nw`. +Цей метод вимагає лише одного великого FFT, але потребує численних операцій згортки для згладжування. Зверніть увагу на проріджування після згортки :code:`[::Nw]`; воно не обов’язкове, але дуже бажане, щоб зменшити кількість пікселів для відображення, і завдяки способу обчислення SCF ми не «викидаємо» інформацію, проріджуючи по :code:`Nw`. *************************** Метод згладжування за часом (TSM) From fdecef020a1027f1ff5b7570cc8c5c8a7984b4cd Mon Sep 17 00:00:00 2001 From: distribtech Date: Tue, 9 Dec 2025 14:56:42 +0200 Subject: [PATCH 34/42] Phaser translation Untill Callibration --- content-ukraine/phaser.rst | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/content-ukraine/phaser.rst b/content-ukraine/phaser.rst index 9ca03251..dda0ac07 100644 --- a/content-ukraine/phaser.rst +++ b/content-ukraine/phaser.rst @@ -23,9 +23,9 @@ Phaser - це одна плата, що містить фазовану антенну решітку та низку інших компонентів, до якої з одного боку підключено Raspberry Pi, а з іншого боку - Pluto. Високорівнева блок-схема показана нижче. Деякі моменти, на які слід звернути увагу: 1. Хоча це виглядає як 32-елементний двовимірний масив, насправді це 8-елементний одновимірний масив -2. Використовуються обидва канали прийому на Плутоні (другий канал використовує роз'єм u.FL) -3. LO на борту використовується для перетворення прийнятого сигналу з частоти близько 10,25 ГГц до частоти близько 2 ГГц, щоб Плутон міг його прийняти -4. Кожен ADAR1000 має чотири фазообертачі з регульованим коефіцієнтом підсилення, і всі чотири канали підсумовуються перед відправкою на Плутон +2. Використовуються обидва канали прийому на Pluto (другий канал використовує роз'єм u.FL) +3. LO на борту використовується для перетворення прийнятого сигналу з частоти близько 10,25 ГГц до частоти близько 2 ГГц, щоб Pluto міг його прийняти +4. Кожен ADAR1000 має чотири фазообертачі з регульованим коефіцієнтом підсилення, і всі чотири канали підсумовуються перед відправкою на Pluto 5. Фазообертач по суті містить два "підмасиви", кожен з яких містить чотири канали 6. Нижче не показані GPIO і послідовні сигнали від Raspberry Pi, які використовуються для керування різними компонентами фазообертача @@ -35,7 +35,7 @@ Phaser - це одна плата, що містить фазовану анте :align: center :alt: Компоненти фазера (CN0566), включаючи ADF4159, LTC5548, ADAR1000 -Наразі проігноруємо передавальну частину фазоінвертора, оскільки в цій главі ми використовуватимемо пристрій HB100 лише як тестовий передавач. ADF4159 - це синтезатор частоти, який виробляє тон з частотою до 13 ГГц, який ми називаємо локальним генератором або LO. Цей ЛО подається на мікшер LTC5548, який може здійснювати як висхідне, так і низхідне перетворення, хоча ми використовуватимемо його для низхідного перетворення. Для низхідного перетворення він приймає сигнал LO, а також сигнал в діапазоні від 2 до 14 ГГц, і перемножує їх разом, що призводить до зсуву частоти. Результуючий сигнал може бути в діапазоні від постійного струму до 6 ГГц, хоча ми націлені на частоту близько 2 ГГц. ADAR1000 - це 4-канальний аналоговий формувач променя, тому Фазер використовує два з них. Аналоговий формувач променя має незалежно регульовані фазові перемикачі і коефіцієнт підсилення для кожного каналу, що дозволяє затримувати в часі і послаблювати кожен канал перед підсумовуванням в аналоговому діапазоні (в результаті чого виходить один канал). На фазообертачі кожен ADAR1000 виводить сигнал, який перетворюється вниз, а потім приймається Плутоном. Використовуючи Raspberry Pi, ми можемо контролювати фазу і посилення всіх восьми каналів в реальному часі, щоб виконувати формування променя. У нас також є можливість виконувати двоканальне цифрове формування променя/обробку масивів, що обговорюється в наступному розділі. +Наразі проігноруємо передавальну частину фазоінвертора, оскільки в цій главі ми використовуватимемо пристрій HB100 лише як тестовий передавач. ADF4159 - це синтезатор частоти, який виробляє тон з частотою до 13 ГГц, який ми називаємо локальним генератором або LO. Цей ЛО подається на мікшер LTC5548, який може здійснювати як висхідне, так і низхідне перетворення, хоча ми використовуватимемо його для низхідного перетворення. Для низхідного перетворення він приймає сигнал LO, а також сигнал в діапазоні від 2 до 14 ГГц, і перемножує їх разом, що призводить до зсуву частоти. Результуючий сигнал може бути в діапазоні від постійного струму до 6 ГГц, хоча ми націлені на частоту близько 2 ГГц. ADAR1000 - це 4-канальний аналоговий формувач променя, тому Фазер використовує два з них. Аналоговий формувач променя має незалежно регульовані фазові перемикачі і коефіцієнт підсилення для кожного каналу, що дозволяє затримувати в часі і послаблювати кожен канал перед підсумовуванням в аналоговому діапазоні (в результаті чого виходить один канал). На фазообертачі кожен ADAR1000 виводить сигнал, який перетворюється вниз, а потім приймається Pluto. Використовуючи Raspberry Pi, ми можемо контролювати фазу і посилення всіх восьми каналів в реальному часі, щоб виконувати формування променя. У нас також є можливість виконувати двоканальне цифрове формування променя/обробку масивів, що описано в наступному розділі. Для тих, хто цікавиться, нижче наведено дещо детальнішу блок-схему. @@ -62,7 +62,7 @@ Phaser - це одна плата, що містить фазовану анте Встановлення програмного забезпечення *************************************** -Після завантаження в Raspberry Pi за допомогою образу попередньої збірки, використовуючи стандартний користувач/пароль аналог/аналог, рекомендується виконати наступні кроки: +Після завантаження в Raspberry Pi за допомогою образу попередньої збірки, використовуючи стандартний користувач/пароль analog/analog, рекомендується виконати наступні кроки: .. code-block:: bash @@ -86,14 +86,14 @@ Phaser - це одна плата, що містить фазовану анте HB100, що постачається з Phaser, - це недорогий доплерівський радарний модуль, який ми будемо використовувати як тестовий передавач, оскільки він передає безперервний тон на частоті близько 10 ГГц. Він працює від 2 батарейок типу АА або від настільного джерела живлення 3 В, і коли він увімкнений, на ньому світиться яскравий червоний світлодіод. -Оскільки HB100 є недорогим і використовує дешеві радіочастотні компоненти, його частота передачі варіюється від одиниці до одиниці, понад сотні МГц, що є діапазоном, який перевищує найвищу пропускну здатність, яку ми можемо отримати, використовуючи Плутон (56 МГц). Тому, щоб переконатися, що ми налаштували наш Pluto і понижуючий перетворювач таким чином, щоб завжди отримувати сигнал HB100, ми повинні визначити частоту передачі HB100. Це робиться за допомогою прикладної програми від Analog Devices, яка виконує розгортку частоти і обчислює ШПФ, шукаючи пік. Переконайтеся, що ваш HB100 увімкнений і знаходиться в безпосередній близькості від Phaser, а потім запустіть утиліту з..: +Оскільки HB100 є недорогим і використовує дешеві радіочастотні компоненти, його частота передачі варіюється від зразка до зразка, на понад сотні МГц, такий розкид по частоті перевищує діапазон найвищої пропускну здатність, яку можно отримати від Pluto (56 МГц). Тому, щоб переконатися, що ми налаштували наш Pluto і понижуючий перетворювач таким чином, щоб завжди отримувати сигнал HB100, ми повинні визначити частоту передачі HB100. Це робиться за допомогою прикладної програми від Analog Devices, яка виконує розгортку частоти і обчислює ШПФ, шукаючи пік. Переконайтеся, що ваш HB100 увімкнений і знаходиться в безпосередній близькості від Phaser, а потім запустіть утиліту з..: .. code-block:: bash cd ~/pyadi-iio/examples/phaser python phaser_find_hb100.py -Він повинен створити файл з назвою hb100_freq_val.pkl у тій самій директорії. Цей файл містить частоту передачі HB100 в Гц (мариновану, тому її не можна переглянути у відкритому вигляді), яку ми будемо використовувати на наступному кроці. +Він повинен створити файл з назвою hb100_freq_val.pkl у тій самій директорії. Цей файл містить частоту передачі HB100 в Гц (у двійковому формфті, тому її не можна переглянути у відкритому вигляді). Цю частоту ми будемо використовувати на наступному кроці. ************************ Калібрування @@ -190,16 +190,16 @@ Phaser на Python sdr.rx_hardwaregain_chan0 = 10 # дБ, 0 - найнижчий коефіцієнт підсилення. HB100 досить гучний sdr.rx_hardwaregain_chan1 = 10 # dB - sdr.rx_lo = int(2.2e9) # Плутон налаштується на цю частоту + sdr.rx_lo = int(2.2e9) # Pluto налаштується на цю частоту # Налаштуйте PLL фазоінвертора (ADF4159 на борту) на пониження частоти HB100 до 2.2 ГГц плюс невеликий зсув offset = 1000000 # додаємо невелике довільне зміщення, щоб ми не були прямо на 0 Гц, де є стрибок постійного струму phaser.lo = int(signal_freq + sdr.rx_lo - offset) -Отримання семплів з Плутона +Отримання семплів з Plutoа ################################ -На цьому етапі фазер і Плутон налаштовані і готові до роботи. Тепер ми можемо почати отримувати дані з Плутона. Давайте візьмемо один пакет з 1024 відліків, а потім зробимо ШПФ кожного з двох каналів. +На цьому етапі фазер і Pluto налаштовані і готові до роботи. Тепер ми можемо почати отримувати дані з Plutoа. Давайте візьмемо один пакет з 1024 відліків, а потім зробимо ШПФ кожного з двох каналів. .. code-block:: python From f450148a00a64c8e91cdefdd3fe934504dc743cc Mon Sep 17 00:00:00 2001 From: distribtech Date: Thu, 11 Dec 2025 12:27:33 +0200 Subject: [PATCH 35/42] new person end intro --- content-ukraine/intro.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/content-ukraine/intro.rst b/content-ukraine/intro.rst index 42cb67b7..b5a2a593 100644 --- a/content-ukraine/intro.rst +++ b/content-ukraine/intro.rst @@ -88,5 +88,6 @@ textbook `_ за `переклад PySDR українською `_ - `Yimin Zhao `_ за `переклад PySDR спрощеною китайською `_ - `Eduardo Chancay `_ за `переклад PySDR іспанською `_ +- John Marcovici А також усім прихильникам `PySDR Patreon `_! From 482a122e16c51c124be2ced835f18ac962bd6cb8 Mon Sep 17 00:00:00 2001 From: distribtech Date: Thu, 11 Dec 2025 14:14:52 +0200 Subject: [PATCH 36/42] freq domain Till negative frequencies --- content-ukraine/frequency_domain.rst | 62 ++++++++++++++-------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/content-ukraine/frequency_domain.rst b/content-ukraine/frequency_domain.rst index 28232311..e3496755 100644 --- a/content-ukraine/frequency_domain.rst +++ b/content-ukraine/frequency_domain.rst @@ -4,29 +4,29 @@ Частотна область ################ -Ця глава знайомить з частотною областю і охоплює ряди Фур'є, перетворення Фур'є, властивості Фур'є, ШПФ, вікна і спектрограми, використовуючи приклади з Python. +Ця глава знайомить з частотною областю і охоплює ряди Фур'є, перетворення Фур'є, властивості Фур'є, ШПФ, вікна і спектрограми, для цього використовуються приклади на Python. -Одним з найцікавіших побічних ефектів вивчення DSP і бездротового зв'язку є те, що ви також навчитеся мислити в частотній області. Досвід більшості людей з "роботою" в частотній області обмежується регулюванням ручок низьких/середніх/високих частот в автомобільній аудіосистемі. Досвід більшості людей з "переглядом" чогось в частотній області обмежується переглядом звукового еквалайзера, як у цьому кліпі: +Одним з найцікавіших побічних ефектів вивчення цифрової обробки сигналів (DSP) і бездротового зв'язку є те, що ви також навчитеся мислити в частотній області. Досвід більшості людей "роботи" в частотній області обмежується регулюванням баса/середніх/високих частот в автомобільній аудіосистемі. А "перегляд" чогось в частотній області обмежується для більшості людей переглядом звукового звукового спектра, як у анімації нижче: .. image:: ../_images/audio_equalizer.webp :align: center -Наприкінці цього розділу ви зрозумієте, що насправді означає частотна область, як перетворювати час і частоту (а також що при цьому відбувається), і деякі цікаві принципи, які ми будемо використовувати під час вивчення DSP і SDR. До кінця цього підручника ви станете майстром у роботі з частотною областю, гарантовано! +Наприкінці цього розділу ви зрозумієте, що насправді означає термін частотна область, як перетворювати з часової області в частотну (а також що при цьому відбувається), і дізнаєтесь про деякі цікаві принципи, які ми будемо використовувати під час вивчення DSP і програмно-керованого радіо (SDR). До кінця цього підручника ви гарантовано станете майстром у роботі з частотною областю! -По-перше, чому нам подобається розглядати сигнали в частотній області? Ось два приклади сигналів, показаних як в часовій, так і в частотній області. +Перше питання, чому нам подобається розглядати сигнали в частотній області? Ось два приклади сигналів, показаних як в часовій, так і в частотній області. .. image:: ../_images/time_and_freq_domain_example_signals.png :scale: 40 % :align: center :alt: Два сигнали в часовій області можуть виглядати як шум, але в частотній області ми бачимо додаткові особливості -Як ви можете бачити, в часовій області обидва сигнали виглядають як шум, але в частотній області ми бачимо різні особливості. Все знаходиться в часовій області в своїй природній формі; коли ми робимо вибірки сигналів, ми будемо робити їх в часовій області, тому що ви не можете *безпосередньо* зробити вибірку сигналу в частотній області. Але найцікавіші речі зазвичай відбуваються саме в частотній області. +Як ви можете бачити, в часовій області обидва сигнали виглядають як шум, але в частотній області ми бачимо різні особливості. В природі все знаходиться в часовій області; коли ми робимо оцифровку сигналів, ми робимо їх в часовій області, тому що ми не можемо *безпосередньо* зробити оцифровку сигналу в частотній області. Але найцікавіші речі зазвичай відбуваються саме в частотній області. *************** Ряди Фур'є *************** -Основи частотної області починаються з розуміння того, що будь-який сигнал може бути представлений синусоїдальними хвилями, складеними разом. Коли ми розкладаємо сигнал на складові синусоїди, ми називаємо це рядом Фур'є. Ось приклад сигналу, який складається лише з двох синусоїд: +Основи для розуміння частотної області починається з того, що будь-який сигнал може бути представлений, як сума синусоїдальних хвиль. Коли ми розкладаємо сигнал на складові синусоїди, ми називаємо це рядом Фур'є. Ось приклад сигналу, який складається лише з двох синусоїд: .. image:: ../_images/summing_sinusoids.svg :align: center @@ -53,7 +53,7 @@ #. Частота #. Фаза -**Амплітуда** вказує на "силу" хвилі, тоді як **частота** - це кількість хвиль в секунду. **Фаза** використовується для представлення того, як синусоїда зсунута у часі, в межах від 0 до 360 градусів (або від 0 до :math:`2\pi`), але вона повинна бути відносно чогось, щоб мати якесь значення, наприклад, два сигнали з однаковою частотою можуть бути на 30 градусів у протифазі один з одним. +**Амплітуда** вказує на "силу" хвилі, тоді як **частота** - це кількість хвиль в секунду. **Фаза** використовується для представлення того, як синусоїда зсунута у часі, в межах від 0 до 360 градусів (або від 0 до :math:`2\pi`), але вона повинна бути виміряна відносно чогось, щоб мати якесь значення, наприклад, два сигнали з однаковою частотою можуть бути на 30 градусів зсунуті одна відносно іншої. .. image:: ../_images/amplitude_phase_period.svg :align: center @@ -62,15 +62,15 @@ На цьому етапі ви, можливо, зрозуміли, що "сигнал" - це, по суті, просто функція, зазвичай представлена "в часі" (тобто на осі х). Іншим атрибутом, який легко запам'ятати, є **період**, який є оберненою величиною до **частоти**. Період синусоїди - це кількість часу в секундах, за який хвиля завершує один цикл. Таким чином, одиницею частоти є 1/секунда, або Гц. -Коли ми розкладаємо сигнал на суму синусоїд, кожна з них матиме певну **амплітуду**, **фазу** і **частоту**. Амплітуда кожної синусоїди покаже нам, наскільки сильна **частота** існувала у вихідному сигналі. Не хвилюйтеся надто про **фазу** поки що, окрім усвідомлення того, що єдина різниця між sin() і cos() - це фазовий зсув (часовий зсув). +Коли ми розкладаємо сигнал на суму синусоїд, кожна з них матиме певну **амплітуду**, **фазу** і **частоту**. Амплітуда кожної синусоїди покаже нам, наскільки сильна **частота** існувала у вихідному сигналі. Не хвилюйтеся надто про **фазу** поки що, окрім усвідомлення того, що єдина різниця між sin() і cos() - це фазовий зсув (зсув у часі). -Важливіше зрозуміти концепцію, що лежить в основі, ніж самі рівняння, які потрібно розв'язати для ряду Фур'є, але для тих, хто цікавиться рівняннями, я відсилаю вас до стислого пояснення Вольфрама: https://mathworld.wolfram.com/FourierSeries.html. +Важливіше зрозуміти концепцію, що лежить в основі, ніж самі рівняння, які потрібно розв'язати для отримання ряду Фур'є, але для тих, хто цікавиться рівняннями, я відсилаю вас до стислого пояснення Вольфрама: https://mathworld.wolfram.com/FourierSeries.html. ******************** Часово-частотні пари ******************** -Ми з'ясували, що сигнали можуть бути представлені у вигляді синусоїд, які мають декілька атрибутів. Тепер давайте навчимося будувати графіки сигналів у частотній області. У той час як часова область демонструє, як сигнал змінюється з часом, частотна область показує, яка частина сигналу лежить на яких частотах. Замість осі абсцис - час, ми будемо відкладати частоту. Ми можемо побудувати графік заданого сигналу як в часі, так і в частоті. Для початку розглянемо кілька простих прикладів. +Ми з'ясували, що сигнали можуть бути представлені у вигляді синусоїд, які мають декілька атрибутів. Тепер давайте навчимося будувати графіки сигналів у частотній області. У той час як часова область демонструє, як сигнал змінюється з часом, частотна область показує, які частини сигналу припадають на які частоти. Замість осі абсцис по який ми відкаладали час, ми будемо відкладати частоту. Ми можемо побудувати графік заданого сигналу як від часу, так і від частоти. Для початку розглянемо кілька простих прикладів. Ось як виглядає синусоїда з частотою f в часовій і частотній області: @@ -79,9 +79,9 @@ :align: center :alt: Часо-частотна пара Фур'є синусоїди, яка є імпульсом у частотній області -Часова область має виглядати дуже знайомо. Це коливальна функція. Не турбуйтеся про те, в якій точці циклу вона починається або як довго триває. Суть в тому, що сигнал має **єдину частоту**, тому ми бачимо один пік в частотній області. На якій би частоті не коливалася ця синусоїда, ми побачимо пік у частотній області. Математична назва такого піку називається "імпульс". +Часова область має виглядати дуже знайомо. Це коливальна функція. Не турбуйтеся про те, в якій точці циклу вона починається або як довго триває. Суть в тому, що сигнал має **єдину частоту**, тому ми бачимо один пік в частотній області. На якій би частоті не коливалася ця синусоїда, ми побачимо пік у частотній області. Математична назва такого піку - "імпульс". -А що, якби ми мали імпульс у часовій області? Уявіть собі звукозапис того, як хтось плескає в долоні або б'є молотком по цвяху. Ця пара час-частота трохи менш інтуїтивно зрозуміла. +А що, якби ми мали імпульс у часовій області? Уявіть собі звукозапис того, як хтось плескає в долоні або б'є молотком по цвяху. Ця часова-частотна пара трохи менш інтуїтивно зрозуміла. .. image:: ../_images/impulse.png :scale: 70 % @@ -97,7 +97,7 @@ :target: ../_images/square-wave.svg :alt: Часово-частотна пара Фур'є квадратної хвилі, яка є синусоїдою (функцією sin(x)/x) у частотній області -Цей графік також менш інтуїтивно зрозумілий, але ми бачимо, що в частотній області є сильний пік, який знаходиться на частоті прямокутної хвилі, але з підвищенням частоти піків стає більше. Це пов'язано зі швидкою зміною часової області, як і в попередньому прикладі. Але частота не рівномірна. Вона має піки через певні проміжки часу, і рівень повільно спадає (хоча це буде тривати вічно). Прямокутна хвиля в часовій області має вигляд sin(x)/x в частотній області (так звана синусоїдальна функція). +Цей графік також менш інтуїтивно зрозумілий, але ми бачимо, що в частотній області є сильний пік, який знаходиться на частоті прямокутної хвилі, але з підвищенням частоти прямокутного сигналу піків стає більше. Це пов'язано зі швидкою зміною у часової області, як і в попередньому прикладі. Але амплітуда частот не рівномірна. Вона має піки через певні проміжки часу, і їх рівень повільно спадає (хоча і до нескінченості). Прямокутна хвиля в часовій області в частотній області має вигляд sin(x)/x (так звана sinc функція). А що, якщо у нас є постійний сигнал у часовій області? Постійний сигнал не має "частоти". Давайте подивимося: @@ -106,7 +106,7 @@ :align: center :alt: Часово-частотна пара Фур'є сигналу постійного струму, який є імпульсом з частотою 0 Гц у частотній області -Оскільки частота відсутня, у частотній області ми маємо стрибок на частоті 0 Гц. Це має сенс, якщо ви подумаєте про це. Частотна область не буде "порожньою", тому що це трапляється лише тоді, коли немає сигналу (тобто, часової області 0). Ми називаємо 0 Гц у частотній області "постійним струмом", тому що він викликаний сигналом постійного струму в часі (постійним сигналом, який не змінюється). Зауважте, що якщо ми збільшимо амплітуду нашого постійного сигналу в часовій області, стрибок на 0 Гц в частотній області також збільшиться. +Оскільки частота відсутня, у частотній області ми маємо стрибок на частоті 0 Гц. Якщо ви подумаєте це має сенс. Частотна область не буде "порожньою", тому що це може бути лише тоді, коли немає сигналу (тобто, в часової області 0). 0 Гц у частотній області називається "постійним струмом", тому що він викликаний сигналом постійного струму в часі (постійним сигналом, який не змінюється). Зауважте, що якщо ми збільшимо амплітуду нашого постійного сигналу в часовій області, стрибок на 0 Гц в частотній області також збільшиться. Пізніше ми дізнаємося, що саме означає вісь y на графіку в частотній області, але поки що ви можете думати про неї як про своєрідну амплітуду, яка показує, яка частина цієї частоти була присутня в сигналі в часовій області. @@ -121,7 +121,7 @@ Для сигналу x(t) ми можемо отримати частотну версію X(f), використовуючи цю формулу. Ми будемо позначати часову версію функції через x(t) або y(t), а відповідну частотну версію через X(f) та Y(f). Зверніть увагу, що "t" означає час, а "f" - частоту. "j" - це просто уявна одиниця. Ви могли бачити її як "i" на уроках математики в середній школі. Ми використовуємо "j" в інженерії та комп'ютерних науках, тому що "i" часто позначає струм, а в програмуванні часто використовується як ітератор. -Повернення до часової області з частоти відбувається майже так само, за винятком масштабного коефіцієнта та від'ємного знаку: +Зворотнє перетворенння з частотної в часову область відбувається майже так само, за винятком масштабного коефіцієнта та зміни знаку степені: .. math:: x(t) = \frac{1}{2 \pi} \int X(f) e^{j2\pi ft} df @@ -131,9 +131,9 @@ .. math:: \omega = 2 \pi f -Хоча це додає член :math:`2 \pi` до багатьох рівнянь, простіше дотримуватися частоти у Гц. Зрештою, ви будете працювати з Гц у вашій SDR програмі. +Хоча це додає член :math:`2 \pi` до багатьох рівнянь, простіше дотримуватися частоти у Гц. Зрештою, ви будете працювати з Гц у вашіх SDR та RF програмних застосунках. -Наведене вище рівняння для перетворення Фур'є є неперервною формою, яку ви побачите лише у математичних задачах. Дискретна форма набагато ближча до того, що реалізовано у коді: +Наведене вище рівняння для перетворення Фур'є є у неперервній формі, яку ви побачите лише у математичних задачах. Дискретна форма цієї формули набагато ближча до того, що реалізується у коді: .. math:: X_k = \sum_{n=0}^{N-1} x_n e^{-\frac{j2\pi}{N}kn} @@ -146,7 +146,7 @@ Часо-частотні властивості ************************* -Раніше ми розглянули приклади того, як сигнали з'являються в часовій і частотній областях. Зараз ми розглянемо п'ять важливих "властивостей Фур'є". Це властивості, які говорять нам, що якщо ми зробимо ____ з нашим сигналом у часовій області, то ____ станеться з нашим сигналом у частотній області. Це дасть нам важливе розуміння типу цифрової обробки сигналів (ЦОС), яку ми будемо виконувати з часовими сигналами на практиці. +Вище ми розглянули приклади того, як сигнали представляються в часовій і частотній областях. Зараз ми розглянемо п'ять важливих "властивостей Фур'є". Це властивості вказують нам, що якщо ми зробимо ____ з нашим сигналом у часовій області, то ____ станеться з цим сигналом у частотній області. Це дасть нам важливе розуміння типів цифрової обробки сигналів (ЦОС), які ми будемо виконувати з сигналами у часовій області на практиці. 1. Властивість лінійності: @@ -190,12 +190,12 @@ Ті, хто вже знайомий з цією властивістю, можуть помітити відсутність масштабного коефіцієнта; він не враховується заради простоти. Для практичних цілей це не має значення. -4. Згортання у властивості часу: +4. Згортка по часу: .. math:: \int x(\tau) y(t-\tau) d\tau \leftrightarrow X(f)Y(f) -Вона називається властивістю згортки, тому що у часовій області ми згортуємо x(t) та y(t). Можливо, ви ще не знаєте про операцію згортки, тому поки що уявіть її як крос-кореляцію, хоча ми зануримося у згортки глибше у :ref:`цьому розділі `. Коли ми згортуємо часові сигнали, це еквівалентно перемноженню частотних версій цих двох сигналів. Це дуже відрізняється від додавання двох сигналів. Коли ви додаєте два сигнали, як ми бачили, нічого насправді не відбувається, ви просто додаєте частотні версії. Але коли ви згортаєте два сигнали, ви ніби створюєте з них новий третій сигнал. Згортання - це найважливіша техніка в DSP, хоча для того, щоб повністю її зрозуміти, ми повинні спочатку зрозуміти, як працюють фільтри. +Вона називається згорткою по часу, тому що у часовій області ми згортаємо x(t) та y(t). Можливо, ви ще не знаєте про операцію згортки, тому поки уявіть її як крос-кореляцію, адже ми зануримося у згортки глибше у :ref:`цьому розділі `. Коли ми згортаємо часові сигнали, це еквівалентно перемноженню частотних версій цих двох сигналів. Це дуже відрізняється від додавання двох сигналів. Коли ви додаєте два сигнали, як ми бачили, нічого насправді не відбувається, ви просто додаєте частотні версії. Але коли ви згортаєте два сигнали, ви ніби створюєте з них новий третій сигнал. Згортка - це найважливіша техніка в ЦОС, але для того, щоб повністю її зрозуміти, ми повинні спочатку зрозуміти, як працюють фільтри. Перш ніж ми продовжимо, щоб коротко пояснити, чому ця властивість настільки важлива, розглянемо таку ситуацію: у вас є один сигнал, який ви хочете отримати, і поруч з ним є сигнал, що заважає. @@ -203,13 +203,13 @@ :align: center :target: ../_images/two-signals.svg -Концепція маскування широко використовується у програмуванні, тому давайте використаємо її тут. Що, якби ми могли створити маску нижче і помножити її на сигнал вище, щоб замаскувати той, який нам не потрібен? +Концепція маскування широко використовується у програмуванні, тому давайте використаємо її тут. Що, якби ми могли створити маску наведену на рисунку нижче і помножити її на сигнал наведений вище, щоб замаскувати той, який нам не потрібен? .. image:: ../_images/masking.svg :align: center :target: ../_images/masking.svg -Зазвичай ми виконуємо операції DSP у часовій області, тому давайте скористаємося властивістю згортки, щоб побачити, як ми можемо зробити це маскування у часовій області. Скажімо, що x(t) - це отриманий сигнал. Нехай Y(f) - це маска, яку ми хочемо застосувати у частотній області. Це означає, що y(t) є часовим представленням нашої маски, і якщо ми згорнемо її з x(t), ми зможемо "відфільтрувати" небажаний сигнал. +Зазвичай ми виконуємо операції ЦОС у часовій області, тому давайте скористаємося властивістю згортки, щоб побачити, як ми можемо зробити це маскування у часовій області. Скажімо, що x(t) - це отриманий сигнал. Нехай Y(f) - це маска, яку ми хочемо застосувати у частотній області. Це означає, що y(t) є часовим представленням нашої маски, і якщо ми згорнемо її з x(t), ми зможемо "відфільтрувати" небажаний сигнал. .. tikz:: [font=\Large\bfseries\sffamily] \definecolor{babyblueeyes}{rgb}{0.36, 0.61, 0.83} @@ -222,22 +222,22 @@ \draw[->,babyblueeyes,thick] (3,-4) -- (5.2,-2.8); :xscale: 70 -Коли ми будемо обговорювати фільтрацію, властивість згортки матиме більше сенсу. +Коли ми будемо обговорювати фільтрацію, згортки матимуть більше сенсу. -5. Властивість згортки за частотою: +5. Згортка по частоті: -Насамкінець, я хочу зазначити, що властивість згортки працює у зворотному напрямку, хоча ми не будемо використовувати її так часто, як властивість згортки у часовій області: +Насамкінець, я хочу зазначити, що властивість згортки працює і у зворотному напрямку, хоча ми не будемо використовувати її так часто, як властивість згортки у часовій області: .. math:: x(t)y(t) \leftrightarrow \int X(g) Y(f-g) dg -Існують і інші властивості, але наведені вище п'ять, на мою думку, є найбільш важливими для розуміння. Навіть якщо ми не довели кожну з них, суть в тому, що ми використовуємо математичні властивості, щоб зрозуміти, що відбувається з реальними сигналами при аналізі та обробці. Не зациклюйтеся на рівняннях. Переконайтеся, що ви розумієте опис кожної властивості. +Існують і інші властивості, але наведені вище п'ять, на мою думку, є найбільш важливими для розуміння. Навіть якщо ми не довели кожну з них, суть в тому, що ми використовуємо математичні властивості, щоб розуміти, що відбувається з реальними сигналами при аналізі та обробці. Не зациклюйтеся на рівняннях. Переконайтеся, що ви розумієте опис кожної властивості. ********************************* Швидке перетворення Фур'є (ШПФ) ********************************* -Тепер повернемося до перетворення Фур'є. Я показав вам рівняння для дискретного перетворення Фур'є, але 99.9% часу ви будете використовувати під час кодування функцію ШПФ, fft(). Швидке перетворення Фур'є (ШПФ) - це просто алгоритм для обчислення дискретного перетворення Фур'є. Його було розроблено десятки років тому, і хоча існують різні варіанти реалізації, він все ще залишається лідером з обчислення дискретного перетворення Фур'є. Пощастило, враховуючи, що в його назві використано слово "Fast". +Тепер повернемося до перетворення Фур'є. Я показав вам рівняння для дискретного перетворення Фур'є, але 99.9% часу ви будете використовувати під час кодування функцію ШПФ, fft(). Швидке перетворення Фур'є (ШПФ) - це просто алгоритм для обчислення дискретного перетворення Фур'є. Його було розроблено десятки років тому, і хоча існують різні варіанти реалізації, він все ще залишається лідером з обчислення дискретного перетворення Фур'є. Нам пощастило, що вони назвали його “швидким”. ШПФ - це функція з одним входом і одним виходом. Вона перетворює сигнал з часу в частоту: @@ -253,18 +253,18 @@ :target: ../_images/fft-io.svg :alt: Еталонна діаграма для вхідного (секунди) та вихідного (смуга пропускання) формату функції ШПФ, що показує частотні біни та дельта-t і дельта-f -Оскільки вихідні дані знаходяться в частотній області, діапазон осі х базується на частоті дискретизації, яку ми розглянемо в наступній главі. Коли ми використовуємо більше відліків для вхідного вектора, ми отримуємо кращу роздільну здатність у частотній області (на додаток до обробки більшої кількості відліків за один раз). Насправді ми не "бачимо" більше частот, маючи більший вхідний сигнал. Єдиний спосіб - збільшити частоту дискретизації (зменшити період дискретизації :math:`\Delta t`). +Оскільки вихідні дані знаходяться в частотній області, значення діапазону по осі х залежить від частоти дискретизації, яку ми розглянемо в наступній главі. Коли ми використовуємо більше відліків для вхідного вектора, ми отримуємо кращу роздільну здатність у частотній області (як додаток до обробки більшої кількості відліків за один раз). Насправді ми не "бачимо" більше частот, маючи довший вхідний сигнал. Єдиний спосіб збільшити кількість частот - збільшити частоту дискретизації (зменшити період дискретизації :math:`\Delta t`). -Як нам насправді побудувати цей вихід? Для прикладу припустимо, що наша частота дискретизації становить 1 мільйон відліків за секунду (1 МГц). Як ми дізнаємося з наступного розділу, це означає, що ми можемо бачити тільки сигнали з частотою до 0,5 МГц, незалежно від того, скільки відліків ми подаємо на ШПФ. Вихідні дані ШПФ можна представити наступним чином: +Як нам насправді відобразити цей вихідний результат ШПФ? Для прикладу припустимо, що наша частота дискретизації становить 1 мільйон відліків за секунду (1 МГц). Як ми дізнаємося з наступного розділу, це означає, що ми можемо бачити тільки сигнали з частотою до 0,5 МГц, незалежно від того, скільки відліків ми подаємо на ШПФ. Вихідні дані ШПФ можна представити наступним чином: .. image:: ../_images/negative-frequencies.svg :align: center :target: ../_images/negative-frequencies.svg :alt: Введення від'ємних частот -Це завжди так; на виході ШПФ завжди буде показано :math:`\text{-} f_s/2` до :math:`f_s/2`, де :math:`f_s` - частота дискретизації. Тобто на виході завжди буде від'ємна частина і додатна частина. Якщо вхідний сигнал комплексний, то від'ємна і додатна частини будуть відрізнятися, але якщо він дійсний, то вони будуть ідентичні. +Це завжди так; на виході ШПФ завжди будуть значення від :math:`\text{-} f_s/2` до :math:`f_s/2`, де :math:`f_s` - частота дискретизації. Тобто на виході завжди буде від'ємна частина і додатна частина. Якщо вхідний сигнал комплексний, то від'ємна і додатна частини будуть відрізнятися, але якщо він дійсний, то вони будуть ідентичні. -Щодо частотного інтервалу, то кожен бін відповідає :math:`f_s/N` Гц, тобто подача більшої кількості відліків на кожне ШПФ призведе до більш деталізованої роздільної здатності на виході. Дуже незначна деталь, яку можна проігнорувати, якщо ви новачок: математично останній індекс не відповідає *точно* :math:`f_s/2`, скоріше це :math:`f_s/2 - f_s/N`, що для великого :math:`N` буде приблизно дорівнювати :math:`f_s/2`. +Щодо частотного інтервалу, то кожен відлік відповідає :math:`f_s/N` Гц, тобто подача більшої кількості відліків на кожне ШПФ призведе до більш деталізованої роздільної здатності на виході. Дуже незначна деталь, яку можна проігнорувати, якщо ви новачок: математично останній індекс не відповідає *точно* :math:`f_s/2`, скоріше це :math:`f_s/2 - f_s/N`, що для великого :math:`N` буде приблизно дорівнювати :math:`f_s/2`. ******************** Від'ємні частоти From ec31b8681336b833d7e6671a0eba382344e4f085 Mon Sep 17 00:00:00 2001 From: distribtech Date: Thu, 11 Dec 2025 15:31:37 +0200 Subject: [PATCH 37/42] frequency domain end of negative frequencies --- content-ukraine/frequency_domain.rst | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/content-ukraine/frequency_domain.rst b/content-ukraine/frequency_domain.rst index e3496755..16100397 100644 --- a/content-ukraine/frequency_domain.rst +++ b/content-ukraine/frequency_domain.rst @@ -270,20 +270,44 @@ Від'ємні частоти ******************** -Що таке від'ємна частота? Наразі просто знайте, що це пов'язано з використанням комплексних чисел (уявних чисел) - насправді не існує такого поняття, як "від'ємна частота", коли мова йде про передачу/прийом радіосигналів, це просто уявлення, яке ми використовуємо. Ось інтуїтивний спосіб подумати про це. Уявімо, що ми говоримо нашому SDR налаштуватися на 100 МГц (FM-діапазон) і робити вибірку на частоті 10 МГц. Іншими словами, ми будемо переглядати спектр від 95 МГц до 105 МГц. Можливо, там присутні три сигнали: +Що таке від'ємна частота? Наразі просто вважайте, що це пов'язано з використанням комплексних чисел (уявних чисел) - насправді не існує такого поняття, як "від'ємна частота", коли мова йде про передачу/прийом радіосигналів, це просто представлення, яке ми використовуємо. Можно думати про це наступним чином. Уявімо, що ми говоримо нашому SDR налаштуватися на частоту 100 МГц (FM-діапазон) і робити оцифровку з частотою дискритизації 10 МГц. Іншими словами, ми будемо бачити спектр від 95 МГц до 105 МГц. Незхай в цьому діапазоні присутні три сигнали: .. image:: ../_images/negative-frequencies2.svg :align: center :target: ../_images/negative-frequencies2.svg -Тепер, коли SDR видає нам зразки, це буде виглядати так: +І, коли SDR зробить ШПФ ми отримаємо на виході: .. image:: ../_images/negative-frequencies3.svg :align: center :target: ../_images/negative-frequencies3.svg :alt: Негативні частоти - це просто частоти нижче центральної (так званої несучої) частоти, на яку налаштовано радіоприймач -Пам'ятайте, що ми налаштували SDR на 100 МГц. Отже, сигнал, який був на частоті близько 97,5 МГц, у цифровому вираженні виглядає як -2,5 МГц, що технічно є від'ємною частотою. Насправді це просто частота, нижча за центральну частоту. Це матиме більше сенсу, коли ми дізнаємося більше про дискретизацію і отримаємо досвід використання наших СПЗ. +Пам'ятайте, що ми налаштували SDR на 100 МГц. Отже, сигнал, який був на частоті близько 97,5 МГц, у цифровому вираженні виглядає як -2,5 МГц, що технічно є від'ємною частотою. Насправді це просто частота, нижча за центральну частоту. Це матиме більше сенсу, коли ми дізнаємося більше про дискретизацію і отримаємо досвід використання наших SDR. + +З математичної точки зору, негативні частоти можна уявити наступним чином, розглянемо комплексну експоненціальну функцію :math:`e^{2j \pi f t}`. Якщо у нас від'ємна частота, то це еквівалентно тому, що в полярних координатах ця комплексна синусоїда обертається у протилежному напрямку. + +.. 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 + +Ми використали комплексну експоненту вище тому, що :math:`cos()` або :math:`sin()` містить як додатні, так і від'ємні частоти, як видно з формули Ейлера, застосованої до синусоїди на частоті :math:`f` з часом :math:`t`: + +.. math:: + \cos(2 \pi f t) = \underbrace{\frac{1}{2} e^{2j \pi f t}}_\text{positive} + \underbrace{\frac{1}{2} e^{-2j \pi f t}}_\text{negative} + +.. math:: + \sin(2 \pi f t) = \underbrace{\frac{1}{2j} e^{2j \pi f t}}_\text{positive} - \underbrace{\frac{1}{2j} e^{-2j \pi f t}}_\text{negative} + +Отже, в обробці радіочастотних сигналів ми зазвичай використовуємо комплексні експоненти замість косинусів і синусів. ******************************* Порядок в часі не має значення From 202dba2f694f63e3b4e0ecdb21412d99e6d0b31e Mon Sep 17 00:00:00 2001 From: distribtech Date: Thu, 11 Dec 2025 15:43:39 +0200 Subject: [PATCH 38/42] frequency domain order in time --- content-ukraine/frequency_domain.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content-ukraine/frequency_domain.rst b/content-ukraine/frequency_domain.rst index 16100397..415a4dbd 100644 --- a/content-ukraine/frequency_domain.rst +++ b/content-ukraine/frequency_domain.rst @@ -313,14 +313,14 @@ Порядок в часі не має значення ******************************* -Остання властивість перед тим, як ми перейдемо до ШПФ. Функція ШПФ ніби "перемішує" вхідний сигнал, щоб сформувати вихідний, який має інший масштаб і одиниці виміру. Зрештою, ми більше не перебуваємо в часовій області. Хороший спосіб зрозуміти цю різницю між областями - усвідомити, що зміна порядку, в якому все відбувається в часовій області, не змінює частотні компоненти в сигналі. Тобто, ШПФ наступних двох сигналів матиме ті самі два піки, тому що сигнал - це просто дві синусоїди на різних частотах. Зміна порядку появи синусоїд не змінює того факту, що це дві синусоїди на різних частотах. +Остання властивість перед тим, як ми перейдемо до ШПФ. Функція ШПФ ніби "перемішує" вхідний сигнал так, щоб сформувати вихідний сигнал, який має інший масштаб і одиниці виміру. Після чого, ми більше не перебуваємо в часовій області. Гарний спосіб усвідомити цю різницю між областями - це усвідомити, що зміна порядку подій у часовій області не змінює частотні компоненти сигналу. Тобто, виконання одного ШПФ для приведених нижче на рисунку двох сигналів матиме однакові два піки, оскільки сигнал - це просто дві синусоїди на різних частотах. Зміна порядку виникнення синусоїд не змінює того факту, що це дві синусоїди на різних частотах. Це передбачає, що обидві синусоїди виникають в один і той самий проміжок часу, що подається на ШПФ; якщо скоротити розмір ШПФ та виконати кілька ШПФ (як ми зробимо в розділі "Спектрограма"), то можна розрізнити ці дві синусоїди. .. image:: ../_images/fft_signal_order.png :scale: 50 % :align: center :alt: При виконанні ШПФ на наборі відліків порядок у часі, у якому різні частоти зустрічаються у цих відліках, не змінює результуючий результат ШПФ -Технічно, фаза значень ШПФ зміниться через часовий зсув синусоїд. Однак у перших кількох розділах цього підручника нас цікавитиме здебільшого величина ШПФ. +Технічно, фаза значень ШПФ зміниться через часовий зсув синусоїд. Однак у перших кількох розділах цього підручника нас цікавитиме здебільшого амплітудти ШПФ. ******************* ШПФ у Python From c3923944a26290526fb78eb1c07bad1eef949f0a Mon Sep 17 00:00:00 2001 From: distribtech Date: Thu, 11 Dec 2025 15:54:44 +0200 Subject: [PATCH 39/42] frequency domain begin fft --- content-ukraine/frequency_domain.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content-ukraine/frequency_domain.rst b/content-ukraine/frequency_domain.rst index 415a4dbd..884abc36 100644 --- a/content-ukraine/frequency_domain.rst +++ b/content-ukraine/frequency_domain.rst @@ -328,7 +328,7 @@ Тепер, коли ми дізналися про те, що таке ШПФ і як представляється результат, давайте розглянемо код на Python і скористаємося функцією ШПФ Numpy, np.fft.fft(). Рекомендується використовувати повноцінну консоль/IDE Python на вашому комп'ютері, але в крайньому випадку ви можете скористатися веб-консоллю Python, посилання на яку знаходиться внизу навігаційної панелі зліва. -Спочатку нам потрібно створити сигнал у часовій області. Не соромтеся скористатися власною консоллю Python. Для спрощення ми створимо просту синусоїду з частотою 0,15 Гц. Ми також будемо використовувати частоту дискретизації 1 Гц, тобто в часі ми будемо робити відліки через 0, 1, 2, 3 секунди і т.д. +Спочатку нам потрібно створити сигнал у часовій області. Ви можете скористатися власною консоллю Python для відтворення прикладів. Для спрощення ми створимо просту синусоїду з частотою 0,15 Гц. Ми також будемо використовувати частоту дискретизації 1 Гц, тобто в часі ми будемо робити відліки через 0, 1, 2, 3 секунди і т.д. .. code-block:: python From 1389aebbc0e615a43a746b2d3f8d9f49836aacd2 Mon Sep 17 00:00:00 2001 From: distribtech Date: Thu, 11 Dec 2025 16:21:06 +0200 Subject: [PATCH 40/42] freqquency domain check code in fft in python --- content-ukraine/frequency_domain.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content-ukraine/frequency_domain.rst b/content-ukraine/frequency_domain.rst index 884abc36..8899325f 100644 --- a/content-ukraine/frequency_domain.rst +++ b/content-ukraine/frequency_domain.rst @@ -354,7 +354,7 @@ 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,. -Порада: незалежно від того, що ви робите, якщо ви коли-небудь зіткнетеся з комплексними числами, спробуйте обчислити амплітуду і фазу і подивіться, чи вони мають більше сенсу. Давайте так і зробимо, і побудуємо графік амплітуди і фази. У більшості мов для знаходження амплітуди комплексного числа є функція abs(). Функція для фази може бути різною, але у Python це :code:`np.angle()`. +Порада: незалежно від того, що ви робите, якщо ви зіткнулись з комплексними числами, спробуйте обчислити амплітуду і фазу і подивіться, може це додасть більше розуміння. Давайте так і зробимо, і побудуємо графік амплітуди і фази. У більшості мов для знаходження амплітуди комплексного числа є функція abs(). Функція для фази може бути різною, але у Python це :code:`np.angle()`. .. code block:: python @@ -368,27 +368,27 @@ :scale: 80 % :align: center -Наразі ми не додаємо вісь x до графіків, а лише індекс масиву (рахуючи від 0). З математичних міркувань, вихідні дані ШПФ мають наступний формат: +Наразі ми не додаємо розмірності до вісі x для графіків, це лише індекс масиву (що рахується від 0). З математичних міркувань, вихідні дані після ШПФ мають наступний формат: .. image:: ../_images/fft-python3.svg :align: center :target: ../_images/fft-python3.svg :alt: Організація виводу ШПФ перед виконанням зсуву ШПФ -Але ми хочемо мати 0 Гц (постійний струм) в центрі і від'ємні частоти зліва (це просто те, як ми любимо візуалізувати речі). Отже, кожного разу, коли ми робимо ШПФ, нам потрібно виконати "зсув ШПФ", який є простою операцією перегрупування масиву, на кшталт кругового зсуву, але більше схожого на "покладіть це сюди, а це туди". На наведеній нижче схемі повністю описано, що робить операція зсуву ШПФ: +Але ми хотіли б мати 0 Гц (постійний струм) в центрі і від'ємні частоти зліва (це те як зазвичай ми представляємо графіки). Отже, кожного разу, коли ми робимо ШПФ, нам потрібно виконати "зсув ШПФ", який є простою операцією перегрупування масиву, на кшталт кільцевого зсуву, але більше схожого на "покладіть це сюди, а це туди". На наведеній нижче схемі повністю описано, що робить операція зсуву ШПФ: .. image:: ../_images/fft-python4.svg :align: center :target: ../_images/fft-python4.svg :alt: Еталонна діаграма функції зсуву ШПФ, що показує додатні та від'ємні частоти та постійний струм -Для нашої зручності у Numpy є функція зсуву ШПФ, :code:`np.fft.fftshift()`. Замініть рядок np.fft.fft() на: +Для зручності у Numpy є функція зсуву ШПФ, :code:`np.fft.fftshift()`. Замініть рядок np.fft.fft() на: .. code-block:: python S = np.fft.fftshift(np.fft.fft(s)) -Нам також потрібно розібратися зі значеннями/мітками по осі x. Пам'ятайте, що ми використовували частоту дискретизації 1 Гц для спрощення. Це означає, що лівий край графіка частотної області буде -0,5 Гц, а правий - 0,5 Гц. Якщо це незрозуміло, то стане зрозуміло після того, як ви прочитаєте розділ :ref:`sampling-chapter`. Давайте дотримуватися цього припущення, що наша частота дискретизації становить 1 Гц, і побудуємо графік амплітуди і фази вихідного сигналу ШПФ з відповідною міткою на осі абсцис. Ось остаточна версія цього прикладу на Python і результат: +Нам також потрібно розібратися зі значеннями/мітками по осі x. Пам'ятайте, що ми використовували частоту дискретизації 1 Гц для спрощення. Це означає, що лівий край графіка частотної області буде -0,5 Гц, а правий - 0,5 Гц. Якщо що це поки незрозуміло, то стане зрозуміло після того, як ви прочитаєте розділ :ref:`sampling-chapter`. Давайте дотримуватися цього припущення, що наша частота дискретизації становить 1 Гц, і побудуємо графік амплітуди і фази вихідного сигналу ШПФ з відповідною міткою на осі абсцис. Ось остаточна версія цього прикладу на Python і результат: .. code-block:: python From e3c8a977a8950c3bfb221cb711571941aeab7ece Mon Sep 17 00:00:00 2001 From: distribtech Date: Tue, 6 Jan 2026 15:54:47 +0200 Subject: [PATCH 41/42] Remove PYSDR_BASEURL from build workflow Removed PYSDR_BASEURL environment variable from job1. --- .github/workflows/build-and-spell-check.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-and-spell-check.yml b/.github/workflows/build-and-spell-check.yml index b1daf248..7cecf2d5 100644 --- a/.github/workflows/build-and-spell-check.yml +++ b/.github/workflows/build-and-spell-check.yml @@ -4,8 +4,6 @@ on: [pull_request] jobs: job1: - env: - PYSDR_BASEURL: https://distribtech.github.io/PySDR/ runs-on: ubuntu-latest steps: - name: Checkout Code From dd4ac46f20c2486ccedb5e9e16389f61c2c021ce Mon Sep 17 00:00:00 2001 From: distribtech Date: Tue, 6 Jan 2026 15:59:45 +0200 Subject: [PATCH 42/42] Remove PYSDR_BASEURL from deployment workflow Removed PYSDR_BASEURL environment variable from deploy job. --- .github/workflows/build-and-deploy.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 51d9fa84..3905aed5 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -29,8 +29,6 @@ concurrency: jobs: # Single deploy job since we're just deploying deploy: - env: - PYSDR_BASEURL: https://distribtech.github.io/PySDR/ environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }}