Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ jobs:
uses: actions/checkout@v4.1.7

- name: apt installs
run: sudo apt update && sudo apt install -y texlive-latex-extra texlive-lang-cyrillic ghostscript
run: sudo apt update && sudo apt install -y texlive-latex-extra texlive-lang-cyrillic ghostscript libenchant-2-dev

- uses: actions/setup-python@v5.1.0
- uses: actions/setup-python@v6.2.0
with:
python-version: '3.10'
python-version: '3.12'

- name: pip installs
run: pip install -r requirements.txt
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/build-and-spell-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ jobs:
uses: actions/checkout@v4.1.7

- name: apt installs
run: sudo apt update && sudo apt install -y texlive-latex-extra texlive-lang-cyrillic ghostscript
run: sudo apt update && sudo apt install -y texlive-latex-extra texlive-lang-cyrillic ghostscript libenchant-2-dev

- uses: actions/setup-python@v5.1.0
- uses: actions/setup-python@v6.2.0
with:
python-version: '3.10'
python-version: '3.12'

- name: pip installs
run: pip install -r requirements.txt
Expand Down
73 changes: 0 additions & 73 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,78 +1,5 @@
{
"esbonio.sphinx.confDir": "",
"cSpell.words": [
"arange",
"argmax",
"argsort",
"asarray",
"asmatrix",
"astype",
"AWGN",
"baseband",
"beamformer",
"beamformers",
"beamforming",
"boresight",
"bpsk",
"bytearray",
"CDMA",
"checkword",
"convolutional",
"Costas",
"datacast",
"dataword",
"demod",
"downconversion",
"dtype",
"endfire",
"Ettus",
"figsize",
"fillmein",
"firwin",
"fontsize",
"fromfile",
"Gbps",
"imag",
"lastseen",
"leftrightarrow",
"lfilter",
"linalg",
"linspace",
"mathrm",
"matplotlib",
"Mbps",
"mlen",
"multipath",
"MVDR",
"numpy",
"numtaps",
"Nyquist",
"OFDM",
"pinv",
"plen",
"postcostas",
"presync",
"pyplot",
"QPSK",
"radiotext",
"randint",
"randn",
"rgrids",
"Rinv",
"rlabel",
"savefig",
"scipy",
"thetamax",
"thetamin",
"Uplif",
"USRP",
"webp",
"wirelessly",
"xdata",
"xlabel",
"ydata",
"ylabel"
],
"githubPullRequests.ignoredPullRequestBranches": [
"master"
],
Expand Down
6 changes: 1 addition & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ Marc uses AI to help create the JavaScript mini-apps and solve issues like when

## How to build locally

- Activate the project virtual environment:

```bash
source /home/marc/venvs/pysdr/bin/activate
```
- Activate the project virtual environment which should be in the root of this repo under .venv

- Build the site using:

Expand Down
Binary file added _images/2d_array_ladder_pic.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build', 'index-fr.rst', 'content-fr/*', 'index-nl.rst', 'content-nl/*', 'index-ukraine.rst', 'content-ukraine/*', 'index-zh.rst', 'content-zh/*', 'index-es.rst', 'content-es/*', 'index-ja.rst', 'content-ja/*']
exclude_patterns = ['_build', '.venv', 'index-fr.rst', 'content-fr/*', 'index-nl.rst', 'content-nl/*', 'index-ukraine.rst', 'content-ukraine/*', 'index-zh.rst', 'content-zh/*', 'index-es.rst', 'content-es/*', 'index-ja.rst', 'content-ja/*']

# The reST default role (used for this markup: `text`) to use for all
# documents.
Expand Down
9 changes: 8 additions & 1 deletion content/2d_beamforming.rst
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,14 @@ The code for this section can be found `here <https://github.com/777arc/PySDR/bl
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 <https://www.analog.com/en/resources/evaluation-hardware-and-software/evaluation-boards-kits/quad-mxfe.html#eb-overview>`_ 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.
In this section we work with some actual data recorded by `Jon Kraft <https://www.youtube.com/@jonkraft>`_ using a 3x5 digital array made out of a `QUAD-MxFE <https://www.analog.com/en/resources/evaluation-hardware-and-software/evaluation-boards-kits/quad-mxfe.html#eb-overview>`_ platform from Analog Devices which supports up to 16 transmit and receive channels (we only used 15 and only in receive mode). Below are some pictures showing the setup, with transmitters labeled.

.. image:: ../_images/2d_array_ladder_pic.png
:align: center
:target: ../_images/2d_array_ladder_pic.png
:alt: Images showing the 3x5 array used to record the data, which was made using a QUAD-MxFE platform from Analog Devices

Two downloadable 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 <https://github.com/777arc/RADAR-2025-Beamforming-Labs/raw/refs/heads/main/Lab%207%20-%202D%20Rectangular%20Array/C_only_capture1.npy>`_ (used for calibration, as C is at boresight)
- `IQ recording of B and D <https://github.com/777arc/RADAR-2025-Beamforming-Labs/raw/refs/heads/main/Lab%207%20-%202D%20Rectangular%20Array/DandB_capture1.npy>`_ (used for beamforming/DOA testing)
Expand Down
77 changes: 72 additions & 5 deletions content/doa.rst
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ Types of Arrays

Phased arrays can be broken down into three types:

1. **Analog**, a.k.a. passive electronically scanned array (PESA) or traditional phased arrays, where analog phase shifters are used to steer the beam. On the receive side, all elements are summed after phase shifting (and optionally, adjustable gain) and turned into a signal channel which is downconverted and received. On the transmit side the inverse takes place; a single digital signal is outputted from the digital side, and on the analog side phase shifters and gain stages are used to produce the output going to each antenna. These digital phase shifters will have a limited number of bits of resolution, and control latency.
2. **Digital**, a.k.a. active electronically scanned array (AESA), where every single element has its own RF front end, and the beamforming is done entirely in the digital domain. This is the most expensive approach, as RF components are expensive, but it provides much more flexibility and speed than PESAs. Digital arrays are popular with SDRs, although the number of receive or transmit channels of the SDR limits the number of elements in your array.
3. **Hybrid**, where the array consists of many subarrays that individually resemble analog arrays, where each subarray has its own RF front-end just like with digital arrays. This is the most common approach for modern phased arrays, as it provides the best of both worlds.
1. **Analog**, a.k.a. passive electronically scanned array (PESA) or traditional phased arrays, where analog phase shifters are used to steer the beam. On the receive side, all elements are summed after phase shifting (and optionally, adjustable gain) and turned into a signal channel which is downconverted and received. On the transmit side the inverse takes place; a single digital signal is outputted from the digital side, and on the analog side phase shifters and gain stages are used to produce the output going to each antenna. These digital phase shifters will have a limited number of bits of resolution, and control latency. A huge advantage of analog beamforming is that strong interferers can be nulled out in the analog domain before the ADC, which can prevent saturating the receiver.
2. **Digital**, a.k.a. active electronically scanned array (AESA), where every single element has its own RF front end, and the beamforming is done entirely in the digital domain. This is the most expensive approach, as RF components are expensive, but it provides much more flexibility and speed than PESAs, and allows for using the adaptive beamforming techniques we will discuss later in this chapter. Digital arrays are popular with SDRs, although the number of receive or transmit channels of the SDR limits the number of elements in your array.
3. **Hybrid**, where the array consists of many subarrays that individually resemble analog arrays, where each subarray has its own RF front-end just like with digital arrays. This is the most common approach for modern phased arrays, as it provides the best of both worlds. A hybrid array allows for adaptive techniques, and can also null out strong interferers in the analog domain before the ADC, which is especially important for radar applications where the target is often much weaker than the interferers, or communications in hostile wireless environments.

Note that the terms PESA and AESA are mainly just used in the context of radar, and there is some ambiguity when it comes to exactly what constitutes a PESA or AESA. Therefore, using the terms analog/digital/hybrid array is clearer and can be applied to any type of application.

Expand Down Expand Up @@ -1072,9 +1072,9 @@ We get the following beam pattern. You may notice nulls in positions that you d
:target: ../_images/null_steering.svg
:alt: Example of null steering beamforming

*******************
*****
MUSIC
*******************
*****

We will now change gears and talk about a different kind of beamformer. All of the previous ones have fallen in the "delay-and-sum" category, but now we will dive into "sub-space" methods. These involve dividing the signal subspace and noise subspace, which means we must estimate how many signals are being received by the array, to get a good result. MUltiple SIgnal Classification (MUSIC) is a very popular sub-space method that involves calculating the eigenvectors of the covariance matrix (which is a computationally intensive operation by the way). We split the eigenvectors into two groups: signal sub-space and noise-subspace, then project steering vectors into the noise sub-space and steer for nulls. That might seem confusing at first, which is part of why MUSIC seems like black magic!

Expand Down Expand Up @@ -1136,6 +1136,73 @@ Another experiment worth trying with MUSIC is to see how close two signals can a
:scale: 100 %
:align: center

**********
Root MUSIC
**********

Every DOA technique we have covered so far, including conventional beamforming, MVDR, and MUSIC itself, works by sweeping through a grid of candidate angles and computing a metric at each one (often in parallel). Root MUSIC eliminates that scan entirely! Instead of searching for peaks in a spectrum, it finds the signal directions analytically by solving for the roots of a polynomial. This gives Root MUSIC potential to be both faster and more precise than spectral MUSIC, since the peak location is no longer limited by the angular resolution of your scan grid. One limitation of Root MUSIC is that it only works for a ULA; for 2D arrays or non-ULA 1D arrays there are variations/extensions of Root MUSIC that can be used, but they are far more complex. We also still need :code:`num_expected_signals` just like in MUSIC, which can be seen as a limitation.

Root MUSIC takes advantage of the fact that a ULA's steering vector has a clean Vandermonde structure, which is any vector (or matrix) where each row is built by taking successive powers of some base value, e.g. :code:`[1, x, x², x³, ..., x^(n-1)]`. With half-wavelength element spacing the steering vector elements are just consecutive powers of a single complex number :math:`z = e^{j\pi\sin\theta}`, as we saw at the beginning of this chapter.

To perform Root MUSIC, we form a polynomial from the noise-subspace projection matrix. We use the same MUSIC cost function as in the previous section, but now it takes the form:

.. math::
P(z) = z^{N_r-1} \, s^H(z) \, V_n V_n^H \, s(z)

where :math:`V_n` is the noise-subspace matrix from the eigendecomposition of the covariance matrix :math:`R`, exactly as in MUSIC. Expanding the product yields a polynomial of degree :math:`2(N_r-1)`. Wherever :math:`P(z)` has a root on the unit circle :math:`|z|=1`, the MUSIC cost would be infinite, meaning that point is a signal direction. In practice, with finite samples, the roots don't land exactly on the unit circle but cluster near it, so we look for the :math:`D` roots (where :math:`D` is the number of expected signals) that are closest to the unit circle.

The polynomial coefficients are built by summing the diagonals of the noise-subspace projection matrix :math:`D = V_n V_n^H`:

.. math::
p_k = \sum_{\substack{m,n=0 \\ n-m = k-(N_r-1)}}^{N_r-1} [D]_{m,n}, \quad k = 0, 1, \ldots, 2(N_r-1)

which is simply the sum along the :math:`(k-(N_r-1))`-th diagonal of :math:`D`. Once we have the polynomial :math:`P(z) = p_0 + p_1 z + \cdots + p_{2(N_r-1)} z^{2(N_r-1)}`, we extract its roots numerically and convert the signal roots back to angles:

.. math::
\hat{\theta} = \arcsin\!\left(\frac{\angle z}{2\pi d}\right)

The full Root MUSIC code, using the same received signal :code:`X` and parameters from the MUSIC example, is:

.. code-block:: python

num_expected_signals = 3

# Same eigendecomposition as MUSIC
R = np.cov(X)
w, v = np.linalg.eig(R)
eig_val_order = np.argsort(np.abs(w))
v = v[:, eig_val_order]
V = v[:, :Nr - num_expected_signals] # noise subspace eigenvectors

# Build the Root MUSIC polynomial from diagonals of noise-subspace projection
D = V @ V.conj().T
p = np.zeros(2*Nr - 1, dtype=np.complex128)
for k in range(2*Nr - 1):
p[k] = np.sum(np.diag(D, k - (Nr - 1)))

# Find roots, keep those inside the unit circle, pick the num_expected_signals roots closest to the unit circle
roots = np.roots(p[::-1]) # np.roots expects highest-degree coefficient first
roots = roots[np.abs(roots) <= 1.0] # remove the conjugate-reciprocal partners which correspond to the same DOA estimate anyway
roots = roots[np.argsort(-np.abs(roots))] # sort closest-to-unit-circle first
doa_roots = roots[:num_expected_signals]

# Convert roots to angles in degrees
doas_deg = np.sort(np.arcsin(np.angle(doa_roots) / (2 * np.pi * d)) * 180 / np.pi)
print("Estimated DOAs (degrees):", doas_deg)

The heavy lifting is done by NumPy's :code:`np.roots()` function, which uses the companion matrix method to find the roots of the polynomial.

Running this on the same three-signal scenario produces pretty accurate estimated angles, with no sweep, resolution, or peak-finding required:

.. code-block:: console

Estimated DOAs (degrees): [-39.98674197 19.99724883 25.00387589]
True DOAs (degrees): [-40. 20. 25.]

Compare that to spectral MUSIC, which required a thousand-point theta sweep to find those same three peaks. The accuracy you get from Root MUSIC is essentially limited only by the covariance matrix estimate, not by any grid spacing you chose. The computational savings are especially noticeable when :code:`Nr` is large, since building and solving a degree-:math:`2(N_r-1)` polynomial is far cheaper than iterating the MUSIC equation over thousands of steering angles.

One thing to keep in mind: Root MUSIC inherits the same requirements as MUSIC. You still need to know (or estimate) the number of signals, and you still need enough elements that :math:`N_r > D`. The eigenvalue plot trick described in the MUSIC section works just as well here for estimating the signal count before running Root MUSIC.

***
LMS
***
Expand Down
8 changes: 4 additions & 4 deletions content/sync.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ Let's examine Python code for simulating a non-integer delay and a frequency off

We will leave out the plotting-related code because by now you have probably learned how to plot any signal you want. Making the plots look pretty, as they often do in this textbook, requires a lot of extra code that is not necessary to understand.

Next, we must simulate the delay a signal experiences as it travels through the wireless channel. We can easily simulate a delay by shifting samples, but it only simulates a delay that is an integer multiple of our sample period. In the real world the delay will be some fraction of a sample period, so to simulate that we need to create a "fractional delay" filter.

Adding a Delay
##############
Fractional Delay Filters
########################

We can easily simulate a delay by shifting samples, but it only simulates a delay that is an integer multiple of our sample period. In the real world the delay will be some fraction of a sample period. We can simulate the delay of a fraction of a sample by making a "fractional delay" filter, which passes all frequencies but delays the samples by some amount that isn't limited to the sample interval. You can think of it as an all-pass filter that applies the same phase shift to all frequencies. (Recall that a time delay and phase shift are equivalent.) The Python code to create this filter is shown below:
A fractional delay filter is a type of all-pass filter which (ideally) passes all frequencies but delays the samples by some amount, typically between -0.5 and 0.5 of a sample period, because you can perform the integer portion of delay through simple indexing. It applies a constant time delay to the entire signal, which in the frequency domain corresponds to a linear phase shift (phase that increases proportionally with frequency). Every frequency component gets delayed by the same amount of time, so the signal's shape is preserved, it just arrives later. This is in contrast to doing a phase shift which shifts all frequencies by a constant phase; low frequencies get delayed more and high frequencies get delayed less. The Python code to create a fractional delay filter is shown below, using the windowed-sinc method:

.. code-block:: python

Expand All @@ -89,7 +90,6 @@ If we plot the "before" and "after" of filtering a signal, we can observe the fr
:target: ../_images/fractional-delay-filter.svg



Adding a Frequency Offset
##########################

Expand Down
Loading
Loading