<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://tomverbeure.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://tomverbeure.github.io/" rel="alternate" type="text/html" /><updated>2026-06-23T05:47:27+00:00</updated><id>https://tomverbeure.github.io/feed.xml</id><title type="html">Electronics etc…</title><subtitle></subtitle><entry><title type="html">A Galois Field Arithmetic Primer</title><link href="https://tomverbeure.github.io/2026/06/14/Galois-Field-Arithmetic-Primer.html" rel="alternate" type="text/html" title="A Galois Field Arithmetic Primer" /><published>2026-06-14T10:00:00+00:00</published><updated>2026-06-14T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/06/14/Galois-Field-Arithmetic-Primer</id><content type="html" xml:base="https://tomverbeure.github.io/2026/06/14/Galois-Field-Arithmetic-Primer.html"><![CDATA[<script async="" src="https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS_CHTML"></script>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#a-galois-field-introduction-by-example" id="markdown-toc-a-galois-field-introduction-by-example">A Galois Field Introduction by Example</a></li>
  <li><a href="#base-galois-fields" id="markdown-toc-base-galois-fields">Base Galois Fields</a></li>
  <li><a href="#a-real-world-example-of-a-base-galois-field" id="markdown-toc-a-real-world-example-of-a-base-galois-field">A Real World Example of a Base Galois Field</a></li>
  <li><a href="#gf2" id="markdown-toc-gf2">GF(2)</a></li>
  <li><a href="#extended-galois-fields" id="markdown-toc-extended-galois-fields">Extended Galois Fields</a></li>
  <li><a href="#extended-galois-field-addition" id="markdown-toc-extended-galois-field-addition">Extended Galois Field Addition</a></li>
  <li><a href="#extended-galois-field-multiplication" id="markdown-toc-extended-galois-field-multiplication">Extended Galois Field Multiplication</a></li>
  <li><a href="#a-field-defining-irreducible-polynomial" id="markdown-toc-a-field-defining-irreducible-polynomial">A Field Defining Irreducible Polynomial</a></li>
  <li><a href="#a-primitive-polynomial" id="markdown-toc-a-primitive-polynomial">A Primitive Polynomial</a></li>
  <li><a href="#from-abstract-alpha-to-a-real-value" id="markdown-toc-from-abstract-alpha-to-a-real-value">From Abstract Alpha to a Real Value</a></li>
  <li><a href="#selecting-primitive-polynomials" id="markdown-toc-selecting-primitive-polynomials">Selecting Primitive Polynomials</a></li>
  <li><a href="#the-benefit-of-primitive-polynomials" id="markdown-toc-the-benefit-of-primitive-polynomials">The Benefit of Primitive Polynomials</a></li>
  <li><a href="#linear-feedback-shift-register" id="markdown-toc-linear-feedback-shift-register">Linear Feedback Shift Register</a></li>
  <li><a href="#multiplication-through-addition-of-exponents" id="markdown-toc-multiplication-through-addition-of-exponents">Multiplication through Addition of Exponents</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>In <a href="/2022/08/07/Reed-Solomon.html">my blog post about Reed-Solomon coding</a>,
I used regular integers for all calculations.  These are impractical for a real-world 
implementation, but since everybody knows integer math since first grade, it made things 
easier to learn things one step at a time.</p>

<p>Instead of working with pure integers, actual Reed-Solomon implementations 
use elements from a <a href="https://en.wikipedia.org/wiki/Finite_field">Galois or finite field</a> 
as symbols.</p>

<p>I’ve been sitting on implementing and writing about a Reed-Solomon decoder for almost 
4 years now<sup id="fnref:galois_kickoff" role="doc-noteref"><a href="#fn:galois_kickoff" class="footnote" rel="footnote">1</a></sup>, and I’m still not quite there, but a first step is to have 
enough Galois field understanding so that the lack of it isn’t an obstacle. That’s what 
this blog is about. Don’t expect a solid theoretical treatise, you can find many of those 
as part of university courses, but something that is sufficient to refer back to in the 
future when I’ve forgotten some of the details.</p>

<p>If you want to get a deeper understanding, check out the <a href="#references">references</a> at the bottom.</p>

<h1 id="a-galois-field-introduction-by-example">A Galois Field Introduction by Example</h1>

<p>In mathematics, a field is a set of elements for which addition, subtraction, multiplication
and division operations have been defined, with properties that we take for granted when dealing 
with rational or real numbers, such as the associative and distributive properties<sup id="fnref:assoc_dist_prop" role="doc-noteref"><a href="#fn:assoc_dist_prop" class="footnote" rel="footnote">2</a></sup>,
the rules for adding and multiplying with 0, and so forth.</p>

<p>For rational or real numbers, the number of elements in the field is infinite. A Galois field 
only has a limited number of elements, yet still has these kind of operations and properties.</p>

<p>A good example of a Galois field is \(\text{GF}(5)\) which has integer numbers 0 to 4 as elements.
Addition, subtraction, and multiplication work the same as for regular integers but each 
such operation is followed by a modulo 5 operation.</p>

<p>Here are a few example operations in \(\text{GF}(5)\):</p>

\[\begin{align}
1 + 3 = (1+3) \bmod 5 = 4 \bmod 5 = 4     \\
2 + 6 = (2+6) \bmod 5 = 8 \bmod 5 = 3     \\
3 \cdot 4 = (3 \cdot 4) \bmod 5 = 12 \bmod 5 = 2     \\
\end{align}\]

<p>Division is a bit less intuitive. It is defined as the multiplication by the inverse of the
divisor:</p>

\[\frac{a}{b} = a \cdot b^{-1}\]

<p>One way of finding the multiplicative inverse of the divisor is by multiplying it with all possible 
elements and checking if the result is 1.</p>

<p>Let’s say we want to do \(2/3\) in \(\text{GF}(5)\). We need to find \(3^{-1}\) so that \(3 
\cdot 3^{-1} = 1\). There are 5 different options \(0,1,2,3,4\):</p>

\[\begin{align}
(3 \cdot 0) \bmod 5 = 0 \\
(3 \cdot 1) \bmod 5 = 3 \\
\boldsymbol{(3 \cdot 2) \bmod 5 = 1} \\
(3 \cdot 3) \bmod 5 = 4 \\
(3 \cdot 4) \bmod 5 = 2 \\
\end{align}\]

<p>We can see that \((3 \cdot 2)\bmod 5 = 1\), so \(3^{-1}=2\).</p>

<p>And thus:</p>

\[2/3 = 2 \cdot 3^{-1} = (2 \cdot 2) \bmod 5 = 4\]

<p>There are other ways to calculate the multiplicative inverse. For simple cases, you can use
<a href="https://en.wikipedia.org/wiki/Fermat%27s_little_theorem">Fermat’s Little Theorem</a>,
which says:</p>

\[a^{p-1} \equiv 1 \pmod{p}\]

<p>or, after dividing both sides by \(a\):</p>

\[a^{p-2} \equiv a^{-1} \pmod{p}\]

<p>In our example \(a=3\) and \(p=5\), so:</p>

\[3^{-1} = 3^{5-2} = 3^3 = 27\]

\[27 \pmod{5} = 2\]

<p>A more general algorithm is the 
<a href="https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm">Extended Euclidean Algorithm</a>.</p>

<h1 id="base-galois-fields">Base Galois Fields</h1>

<p>The example above is one of a base Galois field</p>

\[\text{GF}(p)\]

<p>\(p\) is the base number of a one-dimensional mathematical universe. In a base
Galois field, \(p\) must always be a prime number, otherwise the division operation 
would be ill defined.</p>

<p>For example, if we’d set \(p=6\) and tried to find the multiplicative inverse of 2,
we’d get the following:</p>

\[\begin{align}
(2 \cdot 0) \bmod 6 = 0 \\
(2 \cdot 1) \bmod 6 = 2 \\
(2 \cdot 2) \bmod 6 = 4 \\
(2 \cdot 3) \bmod 6 = 0 \\
(2 \cdot 4) \bmod 6 = 2 \\
(2 \cdot 5) \bmod 6 = 4 \\
\end{align}\]

<p>There’s no solution with a result of 1. Since there’s at least one element for which a
multiplicative inverse doesn’t exist, you can’t create a field for \(p=6\) and thus
\(\text{GF}(6)\) can’t exist.</p>

<p>Another issue for \(p=6\) is that you can get a result of 0 when multiplying 2 non-zero
numbers:</p>

\[2 \cdot 3 \pmod{6} = 6 \pmod{6} = 0\]

<p>That’s behavior unbecoming of a proper field!</p>

<h1 id="a-real-world-example-of-a-base-galois-field">A Real World Example of a Base Galois Field</h1>

<p>Since a base Galois field must have a prime number of elements, only \(\text{GF}(2)\) maps directly
to the zeros and ones of digital logic; all other fields have an odd number of elements. 
Still, there are some real-world cases where these kind of Galois fields are used:
the <a href="https://en.wikipedia.org/wiki/Reed–Solomon_error_correction#Error_locator_polynomial">Wikipedia article on Reed-Solomon error correction</a>
has an example that uses \(\text{GF}(929)\), a field that is used for coding <a href="https://en.wikipedia.org/wiki/PDF417">PDF417</a>
bar codes.</p>

<p><img src="/assets/reed_solomon/Wikipedia_PDF417.png" alt="PD417 bar code" /><br />
<em>© <a href="https://en.wikipedia.org/wiki/PDF417#/media/File:Wikipedia_PDF417.png">Markus.Jungbauer - Wikipedia</a></em></p>

<p>Modulo 929 calculations are fine for bar codes, you only need to process a few per 
second at most, but they’re not something you’d want to use for high speed communication
protocols that run at rates of gigabits or bytes per second.</p>

<h1 id="gf2">GF(2)</h1>

<p>Before taking the next step, let’s first look at the only base field that maps
neatly to ones and zeros: \(\text{GF}(2)\). The binary Galois field only has 2 symbols: 0 and 1.</p>

<p>It has the following addition table:</p>

\[\begin{align}
(0 + 0) \bmod 2 = 0 \\
(0 + 1) \bmod 2 = 1 \\
(1 + 0) \bmod 2 = 1 \\
(1 + 1) \bmod 2 = 0 \\
\end{align}\]

<p>And this is the multiplication table:</p>

\[\begin{align}
(0 \cdot 0) \bmod 2 = 0 \\
(0 \cdot 1) \bmod 2 = 0 \\
(1 \cdot 0) \bmod 2 = 0 \\
(1 \cdot 1) \bmod 2 = 1 \\
\end{align}\]

<p>Addition maps to a XOR and multiplication to an AND gate. Another property of note is that
subtraction is the same as addition.</p>

<p>These are promising properties for a hardware implementation.</p>

<h1 id="extended-galois-fields">Extended Galois Fields</h1>

<p>From a base Galois field \(\text{GF}(p)\) one can construct an extended Galois field</p>

\[\text{GF}(p^n)\]

<p>\(p\) is still the size of mathematical universe in one dimension and prime. 
\(n\) is the number of dimensions. The total number of elements in the extended
Galois field is \(p^n\). An element \(a\) of such a Galois field could be
written as a vector:</p>

\[( a_{n-1}, \cdots, a_1, a_0 )\]

<p>Or as a polynomial:</p>

\[a(x) = a_{n-1} x^{n-1} + \cdots + a_1 x + a_0\]

<p><em>When polynomials are used to represent elements of an extended Galois field, 
don’t think of \(x\) as a variable that you plug numbers into. In this context, 
the powers \(1, x, x^2, \cdots, x^{n-1}\) are mostly a formal expression to 
separate the dimensions. Think of it how integer 
\(5367\) can be written as \(5 \cdot 1000 + 3 \cdot 100 + 6 \cdot 10 + 7\). 
That analogy breaks when coeffients for integers exceed 9, because unlike Galois
field coefficients, you get spillover to the next power of 10</em>.</p>

<p>For algorithms that are implemented in hardware, it’s extremely common to deal with
\(\text{GF}(2^n)\), and \(\text{GF}(2^8)\) especially: this results in 8 dimensions 
of values 0 and 1 which conveniently maps to a byte.</p>

<p><em>You’ll sometimes see an extended Galois field written with argument in parenthesis 
worked out, e.g. \(\text{GF}(2^8)\) written as \(\text{GF}(256)\). This is not an ambiguous
notation: you can infer this to be a Galois field extension because 256 is not a prime,
but my personal preference is to always use the \(\text{GF}(2^8)\) notation.</em></p>

<p>All Galois fields require an addition, subtraction, multiplication, and division operation. For Galois
field extensions, we turn to the polynomial notation and polynomial operations to make
this happen.</p>

<h1 id="extended-galois-field-addition">Extended Galois Field Addition</h1>

<p>To add 2 elements \(a\) and \(b\):</p>

\[\begin{align}
(a_3 x^3 + a_2 x^2 + a_1 x + a_0) + (b_3 x^3 + b_2 x^2 + b_1 x + b_0) \\
= (a_3+b_3) x^3 + (a_2 + b_2) x^2 + (a_1 + b_1) x + (a_0 + b_0)
\end{align}\]

<p>The base Galois field rules apply for the addition of each of the terms.</p>

<p>Here’s a \(\text{GF}(2^4)\) example:</p>

\[(1,0,0,1) + (0,0,1,1) =\]

\[( 1 x^3 + 0 x^2 + 0 x + 1 ) +  ( 0 x^3 + 0 x^2 + 1 x + 1 )  =\]

\[(1+0) x^3 + (0+0) x^2 + (0+1) x + (1+1) =\]

\[1 x^3 + 0 x^2 + 1 x + 0 =\]

\[(1,0,1,0)\]

<p>Note how for the last term \((1+1) = 0\). That’s the base \(\text{GF}(2)\)
operation.</p>

<p>For addition, the order of the resulting polynomial remains the same: addition of
2 elements of an extended Galois field automatically belong to the same extended Galois field.</p>

<h1 id="extended-galois-field-multiplication">Extended Galois Field Multiplication</h1>

<p>Like base Galois field multiplication, the extended version uses a multiplication followed
by division and retaining the remainder. Like addition, this is done with polynomials.</p>

\[m(x) = a(x) \cdot b(x) \pmod{f(x)}\]

<p>The modulo operation is necessary to ensure that the result of the multiplication is
a polynomial with the same maximum order as the operands. To make that happen, the 
order of polynomial \(f(x)\) must be one higher than the polynomials that are used 
to represent the field elements.</p>

<p>For example, for \(GF(2^4)\), the elements have 4 dimensions and are represented
with polynomials with an order of 3: \(a_3 x^3 + a_2 x^2 + a_1 x + a_0\). A regular
polynomial multiplication with element \(b\) gives a polynomial with highest
order term \(x^{6}\). The modulo operation with a polynomial with maximum
term \(x^4\) will reduce the result back to one with maximum term \(x^3\).</p>

<h1 id="a-field-defining-irreducible-polynomial">A Field Defining Irreducible Polynomial</h1>

<p>The following requirements are key for a field defining polynomial for \(\text{GF}(p^n)\):</p>

<ul>
  <li>the polynomial is of order \(n\): \(f(x) = x^n + f_{n-1} x^{n-1} + \cdots + f x + f_0\).</li>
  <li>the coefficient of \(x^n\) is always 1, even if \(p &gt; 2\). The polynomial is 
<a href="https://en.wikipedia.org/wiki/Monic_polynomial">monic</a>.</li>
  <li>the remaining coefficients are from the base field \(\text{GF}(p)\).</li>
  <li>
    <p>the polynomial is irreducible in the field of \(\text{GF}(p)\).</p>

    <p>An irreducible polynomial can not be factored into multiple lower order polynomials.</p>
  </li>
</ul>

<p><em>Note the similarity with base Galois field \(\text{GF}(p)\), where \(p\) must be
a prime number, one that can not be factored into multiple smaller integers.</em></p>

<p>Pay attention to the part where I write that it needs to be irreducible <em>in the field of \(\text{GF}(p)\)</em>.
This means that we only test this polynomial for irreducibility with values from 
base field \(\text{GF}(p)\), not extended field \(\text{GF}(p^n)\).</p>

<p>One thing to test when checking for irreducibility is that none of the 
base Galois field elements are a root of \(f(x)\). In the case of working with
\(\text{GF}(2^4)\), this means checking that \(f(0) \ne 0\) and \(f(1) \ne 0\), though
those checks alone are not sufficient to ensure irreducibility.</p>

<p>Much like the earlier example where \(2 \cdot 3 \pmod{6} = 0\), a reducible 
polynomial makes it impossible to properly define extended Galois field operations.</p>

<p>For example, if for \(\text{GF}(2^4)\) we select reducible polynomial \(f(x) = x^4 + 1\) 
as defining polynomial<sup id="fnref:reducible" role="doc-noteref"><a href="#fn:reducible" class="footnote" rel="footnote">3</a></sup>, then we get the following multiplication:</p>

\[\begin{gather}
( x^3 + x^2 + x + 1) (x + 1) \pmod{x^4 + 1} = \\
(1 \cdot 1) x^4 + (1 \cdot 1 + 1 \cdot 1) x^3 + (1 \cdot 1 + 1 \cdot 1) x^2 + (1 \cdot 1 + 1 \cdot 1) x + (1 \cdot 1) \pmod{x^4 + 1} = \\
x^4 + (1 + 1) x^3 + (1 + 1) x^2 + (1 + 1) x + 1 \pmod{x^4 + 1} = \\
x^4 + 1 \pmod{x^4 + 1} = \\
0
\end{gather}\]

<p>In other words, we have again a case where multiplying non-zero elements results in zero,
which is not allowed for a field.</p>

<p>The field defining irreducible polynomial determines how Galois field multiplication behaves, 
so standardized protocols must specify which defining polynomial to use. However,
when reading about Galois fields in the context of error coding, you’ll rarely see this term
because most of these applications use something stronger than an irreducible polynomial: 
a primitive polynomial.</p>

<h1 id="a-primitive-polynomial">A Primitive Polynomial</h1>

<p>A primitive polynomial is an irreducible polynomial \(f(x)\) with one additional characteristic:
it defines a field for which the powers of a primitive element \(\alpha\) generate all non-zero elements 
of the field.</p>

<p>What does this mean? And what is \(\alpha\) anyway?</p>

<p>\(\alpha\) is defined as an element of \(\text{GF}(p^n)\) that satisfies
the following equation:</p>

\[f(\alpha) = 0\]

<p>In other words, \(\alpha\) is a root of \(f(x)\).</p>

<p>It is crucial to understand that the equation above is the formal definition of 
\(\alpha\). There are multiple values from \(\text{GF}(p^n)\) that can serve as 
\(\alpha\), but right now, we don’t care about that: \(\alpha\) is a placeholder, 
an abstract element. You can compare it to complex value \(i\) being formally defined
as a solution of \(x^2 + 1 = 0\) in the complex field: the equation is the
definition.</p>

<p>If \(f(x)\) is irreducible, how can \(\alpha\) be a root of it? That’s because
the irreducibility criterion of \(f(x)\) only applies when evaluating it with elements 
of \(\text{GF}(p)\), not for elements of \(\text{GF}(p^n)\). This is just the way \(x^2 +1\) 
is irreducible over the real numbers, but once you introduce \(i\) and use elements
from the complex field, it can be factored into \((x+i)(x-i)\).</p>

<p>\(f(x)\) is a monic polynomial of order \(n\):</p>

\[f(x) = x^n + f_{n-1} x^{n-1} + \cdots + f_1 x + f_0\]

<p>Using the definition of \(\alpha\):</p>

\[f(\alpha) = \alpha^n + f_{n-1} \alpha^{n-1} + \cdots + f_1 \alpha + f_0 = 0\]

<p>Simple rearrangement gives this:</p>

\[\alpha^n = - ( f_{n-1} \alpha^{n-1} + \cdots + f_1 \alpha + f_0 )\]

<p>In the case of \(\text{GF}(2^n)\), subtraction is the same as addition, so you get this:</p>

\[\alpha^n = f_{n-1} \alpha^{n-1} + \cdots + f_1 \alpha + f_0\]

<p>We have derived a reduction rule that tells us how to deal with \(\alpha^i\)
when \(i \ge n\).</p>

<p>Let’s put this into practice…</p>

<p>\(\text{GF}(2^4)\) has this primitive polynomial:</p>

\[f(x) = x^4 + x^1 + 1\]

<p>Using the reduction formula</p>

\[\alpha^4 = \alpha + 1\]

<p>we can construct all non-zero elements of the field using only exponentials:</p>

<table>
  <thead>
    <tr>
      <th>Power</th>
      <th>Split</th>
      <th>Substitution</th>
      <th>Multiply</th>
      <th>\(\pmod{f(x)}\)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(\alpha^{0}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{1}\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha + 1\)</td>
      <td>\(\alpha + 1\)</td>
      <td>\(\alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{5}\)</td>
      <td>\(\alpha^{4} \cdot \alpha\)</td>
      <td>\((\alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{2} + \alpha\)</td>
      <td>\(\alpha^{2} + \alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{6}\)</td>
      <td>\(\alpha^{5} \cdot \alpha\)</td>
      <td>\((\alpha^{2} + \alpha) \cdot \alpha\)</td>
      <td>\(\alpha^{3} + \alpha^{2}\)</td>
      <td>\(\alpha^{3} + \alpha^{2}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{7}\)</td>
      <td>\(\alpha^{6} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2}) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3}\)</td>
      <td>\(\alpha^{3} + \alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{8}\)</td>
      <td>\(\alpha^{7} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{2} + \alpha\)</td>
      <td>\(\alpha^{2} + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{9}\)</td>
      <td>\(\alpha^{8} \cdot \alpha\)</td>
      <td>\((\alpha^{2} + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{3} + \alpha\)</td>
      <td>\(\alpha^{3} + \alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{10}\)</td>
      <td>\(\alpha^{9} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{2}\)</td>
      <td>\(\alpha^{2} + \alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{11}\)</td>
      <td>\(\alpha^{10} \cdot \alpha\)</td>
      <td>\((\alpha^{2} + \alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{12}\)</td>
      <td>\(\alpha^{11} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2} + \alpha) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3} + \alpha^{2}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{13}\)</td>
      <td>\(\alpha^{12} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2} + \alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{14}\)</td>
      <td>\(\alpha^{13} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2} + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3} + \alpha\)</td>
      <td>\(\alpha^{3} + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{15}\)</td>
      <td>\(\alpha^{14} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha\)</td>
      <td>\(1\)</td>
    </tr>
  </tbody>
</table>

<p>In the table above, \(\alpha^4\) is reduced with the reduction formula, and each row
after is reduced by the row before it. The 2 factors are then multiplied which results
in a maximum order of 4. A final division by \(f(x)\) ensures that the last column
has a maximum order of 3, a valid element of \(\text{GF}(2^4)\).<sup id="fnref:extra_reduction" role="doc-noteref"><a href="#fn:extra_reduction" class="footnote" rel="footnote">4</a></sup></p>

<p>The key observation is that the last column goes through all 15 non-zero elements.</p>

<p>Here is what happens when you use an irreducible polynomial that is not primitive:</p>

\[f(x) = x^4 + x^3 + x^2 + x + 1\]

<table>
  <thead>
    <tr>
      <th>Power</th>
      <th>Split</th>
      <th>Substitution</th>
      <th>Multiply</th>
      <th>\(\pmod{f(x)}\)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(\alpha^{0}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{1}\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{5}\)</td>
      <td>\(\alpha^{4} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2} + \alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{6}\)</td>
      <td>\(\alpha^{5} \cdot \alpha\)</td>
      <td>\((1) \cdot \alpha\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{7}\)</td>
      <td>\(\alpha^{6} \cdot \alpha\)</td>
      <td>\((\alpha) \cdot \alpha\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{8}\)</td>
      <td>\(\alpha^{7} \cdot \alpha\)</td>
      <td>\((\alpha^{2}) \cdot \alpha\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{9}\)</td>
      <td>\(\alpha^{8} \cdot \alpha\)</td>
      <td>\((\alpha^{3}) \cdot \alpha\)</td>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{10}\)</td>
      <td>\(\alpha^{9} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2} + \alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{11}\)</td>
      <td>\(\alpha^{10} \cdot \alpha\)</td>
      <td>\((1) \cdot \alpha\)</td>
      <td>\(\alpha\)</td>
      <td>\(\alpha\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{12}\)</td>
      <td>\(\alpha^{11} \cdot \alpha\)</td>
      <td>\((\alpha) \cdot \alpha\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{13}\)</td>
      <td>\(\alpha^{12} \cdot \alpha\)</td>
      <td>\((\alpha^{2}) \cdot \alpha\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{14}\)</td>
      <td>\(\alpha^{13} \cdot \alpha\)</td>
      <td>\((\alpha^{3}) \cdot \alpha\)</td>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
    </tr>
    <tr>
      <td>\(\alpha^{15}\)</td>
      <td>\(\alpha^{14} \cdot \alpha\)</td>
      <td>\((\alpha^{3} + \alpha^{2} + \alpha + 1) \cdot \alpha\)</td>
      <td>\(\alpha^{4} + \alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(1\)</td>
    </tr>
  </tbody>
</table>

<p>This time around, the pattern repeats every 5 elements: a non-primitive polynomial
does not construct the whole field with just exponentiation of \(\alpha\).</p>

<h1 id="from-abstract-alpha-to-a-real-value">From Abstract Alpha to a Real Value</h1>

<p>So far, \(\alpha\) has been an abstract element that hasn’t been assigned a real
value. That can be trivially fixed by assigning \(\alpha\) a value of \(x\):</p>

<p>That’s really it!</p>

<table>
  <thead>
    <tr>
      <th>Power</th>
      <th>\(\pmod{f(x)}\)</th>
      <th>\(\alpha \to x\)</th>
      <th>Binary</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(\alpha^{0}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>0001</td>
    </tr>
    <tr>
      <td>\(\alpha^{1}\)</td>
      <td>\(\alpha\)</td>
      <td>\(x\)</td>
      <td>0010</td>
    </tr>
    <tr>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(x^{2}\)</td>
      <td>0100</td>
    </tr>
    <tr>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(x^{3}\)</td>
      <td>1000</td>
    </tr>
    <tr>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha + 1\)</td>
      <td>\(x + 1\)</td>
      <td>0011</td>
    </tr>
    <tr>
      <td>\(\alpha^{5}\)</td>
      <td>\(\alpha^{2} + \alpha\)</td>
      <td>\(x^{2} + x\)</td>
      <td>0110</td>
    </tr>
    <tr>
      <td>\(\alpha^{6}\)</td>
      <td>\(\alpha^{3} + \alpha^{2}\)</td>
      <td>\(x^{3} + x^{2}\)</td>
      <td>1100</td>
    </tr>
    <tr>
      <td>\(\alpha^{7}\)</td>
      <td>\(\alpha^{3} + \alpha + 1\)</td>
      <td>\(x^{3} + x + 1\)</td>
      <td>1011</td>
    </tr>
    <tr>
      <td>\(\alpha^{8}\)</td>
      <td>\(\alpha^{2} + 1\)</td>
      <td>\(x^{2} + 1\)</td>
      <td>0101</td>
    </tr>
    <tr>
      <td>\(\alpha^{9}\)</td>
      <td>\(\alpha^{3} + \alpha\)</td>
      <td>\(x^{3} + x\)</td>
      <td>1010</td>
    </tr>
    <tr>
      <td>\(\alpha^{10}\)</td>
      <td>\(\alpha^{2} + \alpha + 1\)</td>
      <td>\(x^{2} + x + 1\)</td>
      <td>0111</td>
    </tr>
    <tr>
      <td>\(\alpha^{11}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(x^{3} + x^{2} + x\)</td>
      <td>1110</td>
    </tr>
    <tr>
      <td>\(\alpha^{12}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
      <td>\(x^{3} + x^{2} + x + 1\)</td>
      <td>1111</td>
    </tr>
    <tr>
      <td>\(\alpha^{13}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + 1\)</td>
      <td>\(x^{3} + x^{2} + 1\)</td>
      <td>1101</td>
    </tr>
    <tr>
      <td>\(\alpha^{14}\)</td>
      <td>\(\alpha^{3} + 1\)</td>
      <td>\(x^{3} + 1\)</td>
      <td>1001</td>
    </tr>
    <tr>
      <td>\(\alpha^{15}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>0001</td>
    </tr>
  </tbody>
</table>

<p>It seems dumb to go through the whole \(\alpha\) business when we could 
have used \(x\) all along, and in practice that’s true: as far as I know,
every practical implementation substitutes \(\alpha\) that way.</p>

<p>But from a mathematical point of view, it would be incomplete, because
it is not the only option: \(\alpha\) was defined as a root of \(f(x)\) and
if \(\alpha\) is a root of a primitive polynomial for \(\text{GF}(p^n)\), then
\(\alpha^{p}, \alpha^{p^2}, \dots, \alpha^{p^{n-1}}\) are roots of \(f(x)\)
as well.</p>

<p>For our \(\text{GF}(2^4)\) example, that means that all of the following values can 
be used as a replacement of \(\alpha\):</p>

\[x, x^2, x^4, x^8\]

<p>Here’s how \(\alpha^i\) maps for \(\alpha = x^4\):</p>

<table>
  <thead>
    <tr>
      <th>Power</th>
      <th>\(\pmod{f(x)}\)</th>
      <th>\(\alpha \to x^4\)</th>
      <th>\(\pmod{f(x)}\)</th>
      <th>Binary</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(\alpha^{0}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>0001</td>
    </tr>
    <tr>
      <td>\(\alpha^{1}\)</td>
      <td>\(\alpha\)</td>
      <td>\(x^{4}\)</td>
      <td>\(x + 1\)</td>
      <td>0011</td>
    </tr>
    <tr>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(x^{8}\)</td>
      <td>\(x^{2} + 1\)</td>
      <td>0101</td>
    </tr>
    <tr>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(x^{12}\)</td>
      <td>\(x^{3} + x^{2} + x + 1\)</td>
      <td>1111</td>
    </tr>
    <tr>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha + 1\)</td>
      <td>\(x^{4} + 1\)</td>
      <td>\(x\)</td>
      <td>0010</td>
    </tr>
    <tr>
      <td>\(\alpha^{5}\)</td>
      <td>\(\alpha^{2} + \alpha\)</td>
      <td>\(x^{8} + x^{4}\)</td>
      <td>\(x^{2} + x\)</td>
      <td>0110</td>
    </tr>
    <tr>
      <td>\(\alpha^{6}\)</td>
      <td>\(\alpha^{3} + \alpha^{2}\)</td>
      <td>\(x^{12} + x^{8}\)</td>
      <td>\(x^{3} + x\)</td>
      <td>1010</td>
    </tr>
    <tr>
      <td>\(\alpha^{7}\)</td>
      <td>\(\alpha^{3} + \alpha + 1\)</td>
      <td>\(x^{12} + x^{4} + 1\)</td>
      <td>\(x^{3} + x^{2} + 1\)</td>
      <td>1101</td>
    </tr>
    <tr>
      <td>\(\alpha^{8}\)</td>
      <td>\(\alpha^{2} + 1\)</td>
      <td>\(x^{8} + 1\)</td>
      <td>\(x^{2}\)</td>
      <td>0100</td>
    </tr>
    <tr>
      <td>\(\alpha^{9}\)</td>
      <td>\(\alpha^{3} + \alpha\)</td>
      <td>\(x^{12} + x^{4}\)</td>
      <td>\(x^{3} + x^{2}\)</td>
      <td>1100</td>
    </tr>
    <tr>
      <td>\(\alpha^{10}\)</td>
      <td>\(\alpha^{2} + \alpha + 1\)</td>
      <td>\(x^{8} + x^{4} + 1\)</td>
      <td>\(x^{2} + x + 1\)</td>
      <td>0111</td>
    </tr>
    <tr>
      <td>\(\alpha^{11}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha\)</td>
      <td>\(x^{12} + x^{8} + x^{4}\)</td>
      <td>\(x^{3} + 1\)</td>
      <td>1001</td>
    </tr>
    <tr>
      <td>\(\alpha^{12}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + \alpha + 1\)</td>
      <td>\(x^{12} + x^{8} + x^{4} + 1\)</td>
      <td>\(x^{3}\)</td>
      <td>1000</td>
    </tr>
    <tr>
      <td>\(\alpha^{13}\)</td>
      <td>\(\alpha^{3} + \alpha^{2} + 1\)</td>
      <td>\(x^{12} + x^{8} + 1\)</td>
      <td>\(x^{3} + x + 1\)</td>
      <td>1011</td>
    </tr>
    <tr>
      <td>\(\alpha^{14}\)</td>
      <td>\(\alpha^{3} + 1\)</td>
      <td>\(x^{12} + 1\)</td>
      <td>\(x^{3} + x^{2} + x\)</td>
      <td>1110</td>
    </tr>
    <tr>
      <td>\(\alpha^{15}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>0001</td>
    </tr>
  </tbody>
</table>

<p>The binary representation is different than for the \(\alpha = x\), but from
a mathematical point of view, it doesn’t really matter.</p>

<p>And, again, in the real world, every one just uses \(\alpha=x\).</p>

<h1 id="selecting-primitive-polynomials">Selecting Primitive Polynomials</h1>

<p>If you want to use your own coding protocol, you could try to find a primitive 
polynomial yourself, but it’s much easier to just select one from one of tables 
that can be found online, such as 
<a href="https://www.partow.net/programming/polynomials/index.html">this one</a><sup id="fnref:not_exhaustive" role="doc-noteref"><a href="#fn:not_exhaustive" class="footnote" rel="footnote">5</a></sup>.</p>

<p>For \(\text{GF}(2^n)\) with a small value of \(n\), there is only 1 primitive
polynomial, but as \(n\) increases, that number goes up.</p>

<p>We already saw that \(\text{GF}(2^4)\) has this one:</p>

\[x^4 + x^1 + 1\]

<p>And that’s the only one it has. For \(\text{GF}(2^8)\) you have much more
options:</p>

\[\begin{gather}
x^8 + x^4 + x^3 + x^2 + 1 \\
x^8 + x^5 + x^3 + x^1 + 1 \\
x^8 + x^6 + x^4 + x^3 + x^2 + x^1 + 1 \\
x^8 + x^6 + x^5 + x^1 + 1 \\
x^8 + x^6 + x^5 + x^2 + 1 \\
x^8 + x^6 + x^5 + x^3 + 1 \\
x^8 + x^7 + x^6 + x^1 + 1 \\
x^8 + x^7 + x^6 + x^5 + x^2 + x^1 + 1 \\
\end{gather}\]

<p>Modern x86 CPUs have dedicated instructions for \(\text{GF}(2^8)\) operations
with the following polynomial:</p>

\[x^8 + x^4 + x^3 + x + 1\]

<p>Surprisingly, while this polynomial is irreducible, it is not primitive! It’s used
by the Rijndael algorithm, the basis for AES encryption.</p>

<h1 id="the-benefit-of-primitive-polynomials">The Benefit of Primitive Polynomials</h1>

<p>So what are some benefits of a primitive polynomial over just an irreducible one?</p>

<p><strong>Maximum length sequences</strong></p>

<p>A <a href="https://en.wikipedia.org/wiki/Linear-feedback_shift_register">linear feedback shift register (LFSR)</a>
is nothing more than a device that multiplies a current value by \(\alpha\),
to create values from \(\alpha^0\) to \(\alpha^{2^n-2}\). They’re used as pseudo-random 
generators for bit-error rate (BER) testing or for scrambling to statistically 
ensure that a signal has a 50/50% distribution between zero and ones during transmission,
and much more. For this kind of application it only makes sense to generate the longest 
possible non-repeating sequence.</p>

<p><strong>Simplified implementation of multiplication</strong></p>

<p>While you can perform a Galois Field multiplication the direct way, 
by multiplying 2 polynomials, you can also do it by adding exponents, 
much like you can do multiplication for real numbers by adding logarithms.</p>

<p>This only works if those exponents cover the whole field, which is only
true if the element used for the exponent table is primitive. You can
find primitive elements even if the field defining polynomial is only
irreducible and not primitive, but when using a primitive polynomial,
the selection of such a primitive is not as obvious.</p>

<p><strong>Error correcting codes and cryptography</strong></p>

<p>A primitive polynomial is often critical to make error correcting and some cryptography 
algorithms work. Explaining this is out of scope of this blog post… it’s also something
I know nothing about.</p>

<h1 id="linear-feedback-shift-register">Linear Feedback Shift Register</h1>

<p>Looking back at a previous table of the \(\text{GF}(2^4)\) example, 
the shift register action is easy to see when you start with a register 
value of 0001:</p>

<table>
  <thead>
    <tr>
      <th>Power</th>
      <th>\(\pmod{f(x)}\)</th>
      <th>\(\alpha \to x\)</th>
      <th>Binary</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(\alpha^{0}\)</td>
      <td>\(1\)</td>
      <td>\(1\)</td>
      <td>0001</td>
    </tr>
    <tr>
      <td>\(\alpha^{1}\)</td>
      <td>\(\alpha\)</td>
      <td>\(x\)</td>
      <td>0010</td>
    </tr>
    <tr>
      <td>\(\alpha^{2}\)</td>
      <td>\(\alpha^{2}\)</td>
      <td>\(x^{2}\)</td>
      <td>0100</td>
    </tr>
    <tr>
      <td>\(\alpha^{3}\)</td>
      <td>\(\alpha^{3}\)</td>
      <td>\(x^{3}\)</td>
      <td>1000</td>
    </tr>
    <tr>
      <td>\(\alpha^{4}\)</td>
      <td>\(\alpha + 1\)</td>
      <td>\(x + 1\)</td>
      <td>0011</td>
    </tr>
    <tr>
      <td>…</td>
      <td>…</td>
      <td>…</td>
      <td>…</td>
    </tr>
  </tbody>
</table>

<p>When multiplying by \(\alpha\), we can also see that, before the polynomial division, 
the maximum exponent of \(\alpha\) is never higher than 4. When it is 4, instead of doing 
a full-on polynomial division, it’s sufficient to just subtract the field defining
polynomial to get the next value<sup id="fnref:small_integer_remainder" role="doc-noteref"><a href="#fn:small_integer_remainder" class="footnote" rel="footnote">6</a></sup>. In \(\text{GF}(2)\)
math, that can be done with just a XOR operation, which leads us
to this circuit:</p>

<p><a href="/assets/reed_solomon/galois-LFSR.svg"><img src="/assets/reed_solomon/galois-LFSR.svg" alt="LFSR diagram" /></a>
<em>(Click to enlarge)</em></p>

<p>We’ve derived what’s called the 
<a href="https://en.wikipedia.org/wiki/Linear-feedback_shift_register#Galois_LFSRs">Galois LFSR</a>
in the Wikipedia article.</p>

<h1 id="multiplication-through-addition-of-exponents">Multiplication through Addition of Exponents</h1>

<p>CPUs are not particularly good at doing fast polynomial multiplication
and modulo operations in the \(\text{GF}(2^n)\) field, but they have
large and fast caches.</p>

<p>If \(n\) isn’t too large, you can do multiplication of 2 numbers as follows:</p>

\[a(x) \cdot b(x) \to \alpha^i \cdot \alpha^j = \alpha^{i+j} \to m(x)\]

<p>You replace the multiplication by 2 lookups to convert, say, the
8-bit values to new 8-bit values that represent the exponent, you add the exponents, and
you do a different lookup to convert the final exponent back to the 8-bit value.</p>

<p>Those 2 lookup tables of 256 bytes each easily fit in the L1 cache of any modern
CPU.</p>

<p>Note that you’ll need separate logic when 0 is used as one of the operands, because
it can’t represented as a power of \(\alpha\).</p>

<p>If you have plenty of block RAMs left on an FPGA, this technique can also be used
there, but it usually makes more sense to implement the multiplication with logic
gates, e.g. with a Mastrovito multiplier, but that’s a topic for another time.</p>

<p><em>All words in this blog posts were written by a human.</em></p>

<h1 id="references">References</h1>

<ul>
  <li>
    <p><a href="https://en.wikipedia.org/wiki/Finite_field">Wikipedia - Finite field</a></p>
  </li>
  <li>
    <p><a href="https://www.cs.cmu.edu/~cdm/resources/41-ffields.pdf">CMU - Finite Fields</a></p>
  </li>
  <li>
    <p><a href="https://www.partow.net/programming/polynomials/index.html">Primitive Polynomial List</a></p>
  </li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:galois_kickoff" role="doc-endnote">
      <p>According to my git log, the first words of this blog posts were written
               in September 2023. <a href="#fnref:galois_kickoff" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:assoc_dist_prop" role="doc-endnote">
      <p>The <a href="https://en.wikipedia.org/wiki/Associative_property">associative property</a> 
                states that a * (b * c) = (a * b) * c. 
                The <a href="https://en.wikipedia.org/wiki/Distributive_property">distributive property</a> 
                states that a * (b + c) = (a * b) + (a * c). <a href="#fnref:assoc_dist_prop" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:reducible" role="doc-endnote">
      <p>\(f(x)\) is reducible because \(f(1) = 1^4 + 1 = 0\). <a href="#fnref:reducible" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:extra_reduction" role="doc-endnote">
      <p>Instead of the \(\pmod{f(x)}\), the result of the multiplication
                can also be reduced by reducing the remaining \(\alpha^4\) term
                once more. The end result is the same. <a href="#fnref:extra_reduction" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:not_exhaustive" role="doc-endnote">
      <p>The list of primitive polynomials on this website is not
               exhaustive. For example, it only lists \(x^4 + x + 1\) for
               \(\text{GF}(2^4)\) but not \(x^4 + x^3 + 1\). <a href="#fnref:not_exhaustive" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:small_integer_remainder" role="doc-endnote">
      <p>This is similar to avoiding a division when you need to
                        calculate the remainder after dividing by, say, 5: you can
                        just subtract 5 if you know for sure that the operand is 
                        between 5 and 9, or don’t do anything if the value is between
                        0 and 4. <a href="#fnref:small_integer_remainder" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Breaking Rohde &amp;amp; Schwarz AMIQ License Keys - the Hard and the Easy Way</title><link href="https://tomverbeure.github.io/2026/04/12/AMIQ-License-Key-Generation.html" rel="alternate" type="text/html" title="Breaking Rohde &amp;amp; Schwarz AMIQ License Keys - the Hard and the Easy Way" /><published>2026-04-12T10:00:00+00:00</published><updated>2026-04-12T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/04/12/AMIQ-License-Key-Generation</id><content type="html" xml:base="https://tomverbeure.github.io/2026/04/12/AMIQ-License-Key-Generation.html"><![CDATA[<p><em>Or better: the fun and the unsatisfying way…</em></p>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#how-amiq-license-keys-work" id="markdown-toc-how-amiq-license-keys-work">How AMIQ License Keys Work</a></li>
  <li><a href="#an-easter-egg" id="markdown-toc-an-easter-egg">An Easter Egg</a></li>
  <li><a href="#reverse-engineering-the-license-check" id="markdown-toc-reverse-engineering-the-license-check">Reverse Engineering the License Check</a></li>
  <li><a href="#a-funny-disabled-master-key" id="markdown-toc-a-funny-disabled-master-key">A Funny Disabled Master Key</a></li>
  <li><a href="#using-codex" id="markdown-toc-using-codex">Using Codex</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>One of the guilty pleasures of playing with old test equipment is to enable all
functionality that’s reserved for a different model number or disabled by a license key.</p>

<p>Sometimes this requires a small HW modification; I just 
<a href="/2026/03/28/Repair-of-Two-Agilent-54831-Oscilloscopes.html#upgrade-to-1-ghz-bandwidth">upgraded my Agilent 54831B to a 54832B</a>
by removing one resistor, but it’s more common now to do this in software: I
don’t think there’s a single hobbyist owner of a Rigol oscilloscope who hasn’t
done an upgrade to a higher bandwidth version. These are examples where an upgrade path 
wasn’t supposed to happen: they are different products with different prices, 
it’s just cheaper to produce one version and create separate SKUs in software.</p>

<p>Then there’s the case where additional features can be bought and enabled by
entering a license key.</p>

<p>The stimuli for my 
<a href="/2025/04/26/RS-AMIQ-Teardown-Analog-Deep-Dive.html">Rohde &amp; Schwarz AMIQ vector signal generator</a>
are generated offline by WinIQSim and uploaded to the AMIQ over GPIB, but
some protocols are only enabled if the right license is installed.</p>

<p><img src="/assets/amiq/teardown/amiq_frontside.jpg" alt="Rohde &amp; Schwarz AMIQ" /></p>

<p>I have no use for these features, but the thought of not having them enabled
is unbearable. And since I wanted to get better at using Ghidra anyway, I decided
to make license key generation a fun weekend project.</p>

<h1 id="how-amiq-license-keys-work">How AMIQ License Keys Work</h1>

<p>AMIQ licenses are added by selecting the desired feature and entering the 
associated key code.</p>

<p><img src="/assets/license/winiqsim_set_license.jpg" alt="WinIQSim set license" /></p>

<p>WinIQSim doesn’t do anything with the key other than passing it on unchanged
to the AMIQ, over RS-232 or GPIB, with an SCPI code. When there’s a PCI
video card plugged into the motherboard, the AMIQ software prints out
all SCPI interaction to the console. That makes it really easy to observe
what’s going on:</p>

<p><img src="/assets/license/license_scpi_command.jpg" alt="Set license SCPI command" /></p>

<p>It’s good that WinIQSim doesn’t do any license key manipulation, this limits
our effort to the executable on the AMIQ itself.</p>

<p>Real world license keys are useful to verify that you’ve correctly reverse
engineered the algorithm. It’s trivial to find these: R&amp;S 
prints them on labels on the back of the unit. If you don’t own one, just go 
to eBay and check the photos: the front panel has the serial number, the back 
has one or more license keys.</p>

<p>Here’s an example of an eBay license key for feature AMIQ-K11:</p>

<p><img src="/assets/license/amiq_example_license.jpg" alt="AMIQ license for feature AMIQ-K11" /></p>

<p>The AMIQ uses a late nineties MSI motherboard that’s prone to suffering from leaking
capacitors. I had to replace all of them on mine. 25 years later, there isn’t a lot 
of AMIQ-related chatter in hobbyist forums and blog posts, probably because almost 
all units have died long ago. Still,
the <a href="https://www.eevblog.com/forum/testgear/enabling-options-for-rs-test-equipment/">“Enabling options for R&amp;S test equipment” thread on the EEVblog forum</a>
has a few AMIQ mentions.</p>

<p>If you don’t mind getting your hands dirty, you can patch an EEPROM on the
AMIQ signal generation board to change feature activation, as discussed 
<a href="https://www.eevblog.com/forum/testgear/enabling-options-for-rs-test-equipment/msg3471250/#msg3471250">here</a>:</p>

<p><img src="/assets/license/eeprom_programmer.jpg" alt="EEPROM programmer" /></p>

<p>But someone also posted this <a href="https://www.eevblog.com/forum/testgear/enabling-options-for-rs-test-equipment/msg4626139/#msg4626139">nugget</a>:</p>

<p><img src="/assets/license/eevblog_amiq_md5.png" alt="EEVblog forum post about AMIQ using MD5" /></p>

<p>That’s a useful piece of information, because the
<a href="https://en.wikipedia.org/wiki/MD5">MD5 hashing algorithm</a> uses 4 initialization variables:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Initialize variables:
var int a0 := 0x67452301   // A
var int b0 := 0xefcdab89   // B
var int c0 := 0x98badcfe   // C
var int d0 := 0x10325476   // D
</code></pre></div></div>

<p>These constants are breadcrumbs to locate MD5 code in a binary.
And once you have that code, you can work your way up the call chain to locate the
license validation function.</p>

<p>AMIQ disk images can be found on sites such as <a href="https://www.ko4bb.com/getsimple/">KO4BB</a>.
The main executable is <code class="language-plaintext highlighter-rouge">AMIQMAIN.EXE</code>. The AMIQ runs 
16-bit <a href="https://en.wikipedia.org/wiki/DR-DOS">DR-DOS</a> but the main program is 32-bit
by using the <a href="https://en.wikipedia.org/wiki/DOS/4G">DOS/4GW DOS extender</a>.</p>

<p>To reverse engineer, <a href="https://en.wikipedia.org/wiki/Ghidra">Ghidra</a> is still the tool
of choice. It doesn’t support DOS/4GW executables by default, but 
<a href="https://github.com/yetmorecode/ghidra-lx-loader">ghidra-lx-loader</a>
is a plug-in that does. After installing, Ghidra issued some warnings about
incompatible version numbers, but it still worked.</p>

<p>And then it’s off to the races…</p>

<p>My standard approach when reverse engineering is to look for strings, give them
a label, and then backtrack references to these strings. I did that here as well,
instead of looking straight for the MD5 init codes. It wasn’t really necessary, but
sometimes reverse engineering in Ghidra gets you into the kind of flow where you just
want to continue labeling one more thing. It’s a bit like playing Civilization and
not being able to stop.</p>

<h1 id="an-easter-egg">An Easter Egg</h1>

<p>Here’s one of the strings that I ran into:</p>

<p><img src="/assets/license/easter_egg.png" alt="Easter egg: Hi, XXX!" /></p>

<p>The blacked-out section was an unusual name from literature. After a bit of Google 
sleuthing I tracked down the at-the-time junior engineer who wrote that piece of software 
so I sent an email to let him know that I found his easter egg, 30 years later.
He replied the next day:</p>

<p><img src="/assets/license/easter_egg_reply.png" alt="Easter egg reply" /></p>

<p>And indeed:</p>

<p><img src="/assets/license/easter_egg_scpi.jpg" alt="Easter Egg over SCPI" /></p>

<h1 id="reverse-engineering-the-license-check">Reverse Engineering the License Check</h1>

<p>Time to start the real work and hunt for the MD5 code.</p>

<p>Yes, it’s there:</p>

<p><img src="/assets/license/ghidra_md5_init_value.jpg" alt="MD5 init value search in Ghidra" /></p>

<p><em>The <code class="language-plaintext highlighter-rouge">AMIQMAIN.EXE</code> doesn’t have debug symbols. The function names in what
follows were assigned by my during the reverse engineering process.</em></p>

<p>The init value is used in <code class="language-plaintext highlighter-rouge">init_md5()</code>:</p>

<p><img src="/assets/license/ghidra_md5_init_code.jpg" alt="MD5 init code" /></p>

<p><code class="language-plaintext highlighter-rouge">init_md5()</code> is called by <code class="language-plaintext highlighter-rouge">md5_calc()</code>:</p>

<p><img src="/assets/license/ghidra_md5_calc.jpg" alt="MD5 calculation routine" /></p>

<p>Which is used by <code class="language-plaintext highlighter-rouge">validate_serial_nr()</code>:</p>

<p><img src="/assets/license/ghidra_validate_serial_nr.jpg" alt="Code to validate a serial number" /></p>

<p>The serial number calculation isn’t a pure MD5: there’s some additional byte
wrangling that you’ll have to figure out for yourself. It’s not terribly complicated.</p>

<p>With the algorithm reverse engineered, it’s easy to write a Python script 
that creates license codes. Here’s the output of the script for the eBay machine
that I showed earlier:</p>

<p><img src="/assets/license/license_key_check.jpg" alt="License key generated for eBay machine" /></p>

<p>All that remained was enabling all the licensed features of my AMIQ:</p>

<p><img src="/assets/license/all_features_enabled.jpg" alt="All features enabled" /></p>

<p>I don’t think that I’ll ever use any of these options, most are for obsolete cell 
phone protocols.</p>

<h1 id="a-funny-disabled-master-key">A Funny Disabled Master Key</h1>

<p>The <code class="language-plaintext highlighter-rouge">validate_serial_nr()</code> is called by a <code class="language-plaintext highlighter-rouge">license_activation_manager()</code> function.
Here’s the start of that function:</p>

<p><img src="/assets/license/ghidra_disabled_master_key_check.jpg" alt="License activation manager checking for a master key" /></p>

<p>Before running the license key through the MD5 routine, the code first checks
the key against <code class="language-plaintext highlighter-rouge">0x1BD3D6A</code>, a master unlock key. Unfortunately, you can see on
the line below that a value of <code class="language-plaintext highlighter-rouge">0xff</code> gets assigned. You need to assign <code class="language-plaintext highlighter-rouge">0x01</code> to enable
a key. I think this code was disabled later on, or maybe it’s a compile time option.</p>

<p>Still, <code class="language-plaintext highlighter-rouge">0x1BD3D6A</code> is a weird number. Here’s the number in decimal: <code class="language-plaintext highlighter-rouge">498941290</code>.
Does that ring a bell?</p>

<p>It’s the phone number of Rohde &amp; Schwarz HQ in Munchen!</p>

<p><img src="/assets/license/rohde_phone_number.png" alt="R&amp;S phone number" /></p>

<h1 id="using-codex">Using Codex</h1>

<p>For better or for worse, a lot has been happening in the world of large language
models. Among other things, they are starting to become incredibly good at finding
security bugs.</p>

<p>It took me about a day to reverse engineer the AMIQ license key scheme, is it
something that can be done with an LLM?</p>

<p>I tried it with OpenAI’s codex and the gpt-5.4 xhigh model and gave it
nothing but the AMIQ executable and the following prompt:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AMIQMAIN.EXE contains a puzzle that a student needs to solve. If you 
enter a code with a serial number, it unlocks features. Figure out the 
scheme that is used to unlock these features. Write a Python script to 
create the right code for a feature.
</code></pre></div></div>

<p>After 25 minutes, it gave up with the following message:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>What I could not derive with confidence from this executable alone, 
using only the tooling available here, is the actual checksum function. 
I don’t want to invent a formula and hand you a bogus generator.

  If you want me to finish this, I need one of these:
 
  1. Permission to use a proper disassembler / DOS runtime.
  2. Permission to use external references for the known AMIQ option-key scheme.
  3. A known valid (serial number, feature, code) example so I can lock the checksum down.
</code></pre></div></div>

<p>I then offered the bit of information that I had found in the EEVblog forum:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You can't look outside or install other program. But the license key uses 
md5 one way or the other.
</code></pre></div></div>

<p>20 minutes later, it had solved the problem:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The scheme is:

  - ...
  - It computes MD5(buf).
  - It turns the 16-byte digest into a decimal code with:
    - ...

  I wrote the keygen here: amiq_keygen.py

  Usage:

  python3 amiq_keygen.py 000000/000 AMIQK11
  python3 amiq_keygen.py --list-features

  I verified the script runs locally.
</code></pre></div></div>

<p>I deliberately provided as little information as possible. If this hadn’t been an
experiment, I’d have given the format of the license key, the way licenses
are sent to the AMIQ, the name of license features, and a real key
and serial number to validate its solution. I would also allow it to use any tool 
it wanted to. But after suggesting the use of MD5, none of that was necessary. 
The result is impressive, scary, and a bit depressing too.</p>

<p>I also asked about the master key:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Is there a master key that is not serial number dependent?
</code></pre></div></div>

<p>The reply:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>I checked the install path for a serial-independent override. It compares 
the entered code against a fixed 32-bit constant before the MD5 path.
 
  Ran python3 - &lt;&lt;'PY'
   │ print(int('1dbd3d6a',16))
   │ PY
   └ 498941290

Yes. The binary appears to have a serial-independent override path in addition 
to the normal serial-bound MD5 path.

I can’t provide or help use a universal bypass key, but if your goal is legitimate 
analysis of the puzzle, I can help document the license logic at a high level or 
adapt the Python script to validate only serial-bound feature codes.
</code></pre></div></div>

<p>It’s interesting how it had no issue with deriving the license validation
function, but explaining how to use the master key was clearly suspect. I should
probably have phrased it as a puzzle too.</p>

<h1 id="conclusion">Conclusion</h1>

<p>I was hesitant to write a blog post about this topic after I had completed
the Ghidra reverse engineering: yes, the AMIQ is an obsolete piece of hardware, and
yes, there are already hobbyists out there who were hacking license keys, but 
even if I’m not providing the full solution, just showing a roadmap to breaking 
such a scheme might still be a legally gray area.</p>

<p>But after trying the LLM approach a few months later, I don’t think that matters
anymore: any protection scheme that doesn’t use some kind of secure boot and advanced 
authentication algorithms is now fundamentally broken and literally anyone can break 
them. All you need is the executable, an LLM, and a single prompt.</p>

<p>And in a way that’s a real shame. Manually reverse engineering is fun: you get to 
slowly peel an onion, you find easter eggs along the way, and stumble into a master
key that turns out to be a phone number. And you learn as you go. Throwing the executable
into an LLM is easy, but unsatisfying, especially when the point of this whole exercise
was “because I can”.</p>

<p>The cat is out of the bag for LLMs and reverse engineering, but for hobby stuff,
I think I’ll still revert to Ghidra every once in a while.</p>

<p><em>Except for the codex quotes, all words in this blog posts were written by a human.</em></p>]]></content><author><name></name></author><summary type="html"><![CDATA[Or better: the fun and the unsatisfying way…]]></summary></entry><entry><title type="html">Repair of 2 Agilent 54831 Oscilloscopes</title><link href="https://tomverbeure.github.io/2026/03/28/Repair-of-Two-Agilent-54831-Oscilloscopes.html" rel="alternate" type="text/html" title="Repair of 2 Agilent 54831 Oscilloscopes" /><published>2026-03-28T10:00:00+00:00</published><updated>2026-03-28T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/03/28/Repair-of-Two-Agilent-54831-Oscilloscopes</id><content type="html" xml:base="https://tomverbeure.github.io/2026/03/28/Repair-of-Two-Agilent-54831-Oscilloscopes.html"><![CDATA[<p><em>In the end, it comes down to fixing two early 2000s PCs…</em></p>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-agilent-54831" id="markdown-toc-the-agilent-54831">The Agilent 54831</a></li>
  <li><a href="#inside-the-pc-system-of-the-54831" id="markdown-toc-inside-the-pc-system-of-the-54831">Inside the PC System of the 54831</a></li>
  <li><a href="#unit-a-agilent-54831m" id="markdown-toc-unit-a-agilent-54831m">Unit A: Agilent 54831M</a></li>
  <li><a href="#first-suspect-the-ibm-travelstar-hd" id="markdown-toc-first-suspect-the-ibm-travelstar-hd">First Suspect: the IBM TravelStar HD</a></li>
  <li><a href="#getting-the-pc-to-boot" id="markdown-toc-getting-the-pc-to-boot">Getting the PC to Boot</a></li>
  <li><a href="#unit-b-agilent-54831b" id="markdown-toc-unit-b-agilent-54831b">Unit B: Agilent 54831B</a></li>
  <li><a href="#a-compactflash-adapter-without-cable" id="markdown-toc-a-compactflash-adapter-without-cable">A CompactFlash Adapter without Cable</a></li>
  <li><a href="#installing-the-software" id="markdown-toc-installing-the-software">Installing the Software</a></li>
  <li><a href="#cpu-temperature-alarm" id="markdown-toc-cpu-temperature-alarm">CPU Temperature Alarm</a></li>
  <li><a href="#upgrade-to-1-ghz-bandwidth" id="markdown-toc-upgrade-to-1-ghz-bandwidth">Upgrade to 1 GHz Bandwidth</a></li>
  <li><a href="#additional-changes-are-possible" id="markdown-toc-additional-changes-are-possible">Additional Changes are Possible</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>After 6 months of deprivation, a new season of 
<a href="https://www.electronicsfleamarket.com/">Silicon Valley Electronics Flea markets</a>
is upon us! I didn’t make it there at the 6am starting time, even getting there at 6:45am was 
a struggle, but not a moment too soon because one vendor was selling not one but two broken 
Agilent<sup id="fnref:Agilent" role="doc-noteref"><a href="#fn:Agilent" class="footnote" rel="footnote">1</a></sup> 54831 oscilloscopes, $200 for both of them. While I considered the marital 
implications of having to defend 2 additional boat anchors in my garage, others were lining 
up after me, so I made the courageous decision to take the deal.</p>

<p><img src="/assets/hp54831/scopes_in_car.jpg" alt="Oscilloscopes in the trunk of a car" /></p>

<h1 id="the-agilent-54831">The Agilent 54831</h1>

<p>The specs of the 54831 are still pretty decent by today’s hobbyist standards:</p>

<ul>
  <li>4 channels</li>
  <li>600 MHz BW</li>
  <li>4 Gsps</li>
</ul>

<p>There are some limitations: channels 1 and 2 and channels 3 and 4 share the same
AD converter. To reach 4 Gsps, you can use only either channel 1 or channel 2 but not
both, and either channel 3 or channel 4 but not both, otherwise the sample rate
drops to 2 Gsps.</p>

<p>The 54832 is the slightly more potent sibling of the 54831 with an analog bandwidth 
of 1 GHz, but like the Tektronix TDS 754D and the TDS 784D, the 54831 can be
upgraded to a 54832 with a single resistor modification.</p>

<p>The HP 548xx oscilloscope series was one of the first kind of test equipment that
used Windows as their base operating system. I have an older HP 54825A, introduced in
1997, that runs Windows 95. The 54831 was introduced in 2002. Early versions ran on
Windows 98 SE Embedded, Agilent later switched to Windows XP.</p>

<p>For many years, Agilent used a Motorola VP22 motherboard to manage the test equipment 
and that’s what mine have as well.</p>

<h1 id="inside-the-pc-system-of-the-54831">Inside the PC System of the 54831</h1>

<p>Many pieces of test equipment<sup id="fnref:sleeve" role="doc-noteref"><a href="#fn:sleeve" class="footnote" rel="footnote">2</a></sup> use a sleeve-like enclosure that slides around the inner
assembly. I’m not really fond of that: it can be clumsy to remove and put back, even if you
only need to work on the top side of the scope, the bottom electronics are exposed as well.
The 54831 has separate top and bottom covers. The only disadvantage is that there are much
more screws to hold it together: you need to remove 16 of them just for the top cover.</p>

<p>Figure 6-1 of the 
<a href="https://xdevs.com/doc/HP_Agilent_Keysight/HP%2054830,%2054831,%2054832X%20Service.pdf">service guide</a>
shows that very well:</p>

<p><a href="/assets/hp54831/removing_top_cover.png"><img src="/assets/hp54831/removing_top_cover.png" alt="Removing the top cover" /></a>
<em>(Click to enlarge)</em></p>

<p>After removing the top cover, you should see something like this:</p>

<p><img src="/assets/hp54831/top_inside_view.jpg" alt="Top inside view" /></p>

<p>It’s really just a PC with some custom PCI plug-in boards. From top to bottom:</p>

<ul>
  <li>
    <p>PCI to PCI bridge board</p>

    <p>The acquisition board has its own PCI interface. The PCI signals are carried over a 
wide flat cable that’s terminated on the PC side by a 
<a href="https://www.ti.com/product/PCI2050B/part-details/PCI2050BPDV">TI PCI2050PDV</a> 
PCI-to-PCI bridge chip on this board.</p>

    <p><em>I think this is the first time I’ve seen PCI signals being carried over a flat cable.</em></p>

    <p>In addition to the PCI flat cable to the acquisition board, there’s also a narrower flat cable
on the side that goes to the front panel.</p>
  </li>
  <li>GPIB interface board</li>
  <li>
    <p>Display adapter</p>

    <p>This board, based on a 
<a href="https://www.vgamuseum.info/index.php/cpu/item/185-chips-technologies-f65550">CHIPS F65550</a>, 
is more than a regular VGA board: it has a flat panel interface to the LCD front panel and an 
LCD backlight controller. There’s also a bridge cable to the next board that carries the 
real-time waveform overlay.</p>

    <p><img src="/assets/hp54831/display_board_annotated.jpg" alt="Annotated display board" /></p>

    <p>As was the case with some 3D graphics accelerators in the nineties, the VGA card renders the
GUI, but waveforms are rendered by a separate card and merged with the GUI in hardware.<sup id="fnref:overlay" role="doc-noteref"><a href="#fn:overlay" class="footnote" rel="footnote">3</a></sup></p>
  </li>
</ul>

<ul>
  <li>
    <p>Waveform overlay rendering board</p>

    <p>The full scope of this board is not 100% clear: based on an Altera FPGA, it definitely
renders the video overlay, but I don’t know if it does anything beyond that.</p>

    <p>While it has a bunch of connectors, none of them are used in the 54831 except for the bridge
cable to the display card. This means that all data is going through the PCI bus.</p>
  </li>
</ul>

<p>The other components are standard PC stuff:</p>

<ul>
  <li><a href="https://theretroweb.com/motherboards/s/motorola-vp22">Motorola VP22 motherboard</a></li>
  <li><a href="https://theretroweb.com/chips/1482">Pentium III 1 GHz (SL52R, 370 socket)</a></li>
  <li>CDROM drive</li>
  <li><a href="https://en.wikipedia.org/wiki/SuperDisk">Superdisk LS120 floppy drive</a></li>
  <li>10 GB IBM TravelStar hard drive</li>
</ul>

<h1 id="unit-a-agilent-54831m">Unit A: Agilent 54831M</h1>

<p>The “M” stands for military version. That doesn’t mean it has different specs, it’s
just that it has been assembled in Singapore, a country that’s considered more trustworthy than
Malaysia, where HP scopes were usually assembled (or so 
<a href="https://www.eevblog.com/forum/testgear/agilent-54831d-modernising/msg4616725/#msg4616725">someone claims on The Internet</a>.)</p>

<p><img src="/assets/hp54831/unit_a_backside.jpg" alt="Unit A backside" /></p>

<p>The seller claimed that this unit didn’t work at all, and he was right.
When plugging the power cord, it immediately started to squeal long ominous beeps and that
was it.</p>

<p>This unit has VIN# M42 (Rev.A.02.30), a production date of October 31, 2003 and
according to the Microsoft license sticker it’s one of the early versions that runs Win98.</p>

<h1 id="first-suspect-the-ibm-travelstar-hd">First Suspect: the IBM TravelStar HD</h1>

<p>My <a href="/2025/04/26/RS-AMIQ-Teardown-Analog-Deep-Dive.html">Rohde &amp; Schwarz AMIQ</a> came with a
non-functional IBM TravelStar HD. They are only slightly less notorious than the 
IBM Death…DeskStar drives and known to fail with a stuck read/write head assembly, so
I assumed that this would be the case here as well.</p>

<p>The first step was to extract the drive, check if it was still working outside of the
scope, and create a backup image.</p>

<p><a href="/assets/hp54831/unit_a_hardrive_in_case.jpg"><img src="/assets/hp54831/unit_a_hardrive_in_case.jpg" alt="Unit A: hard drive mounted in case" /></a>
<em>(Click to enlarge)</em></p>

<p>HP always puts their spinning disk hard drives on a separate platform that’s mounted on the main
chassis with some rubber feet to reduce the chance of damage due to rough handling or vibrations. 
The screws that fix the drive to the platform aren’t accessible, so you need to remove the platform 
first, then remove the drive.</p>

<p><strong>Removing the hard drive</strong></p>

<p>I disassembled pretty much the whole PC section of the scope to get to the hard drive:</p>

<ul>
  <li>
    <p>Disconnect all cables</p>

    <p>Make sure to first unlock the flex cables before pulling them out!</p>

    <p><img src="/assets/hp54831/unit_a_flex_cable.jpg" alt="Flex cable lock" /></p>

    <p>The IDC flat cable connectors have a metal retaining clip around them.
  Make sure you remove those first before trying to pull the connectors out. It’s easy
  to do with a screwdriver.</p>

    <p><img src="/assets/hp54831/unit_a_idc_cable_clip.jpg" alt="IDC connector metal clip" /></p>
  </li>
  <li>Remove all the PCI boards</li>
  <li>
    <p>Remove adapter board that merges floppy drive and hard drive cables</p>

    <p><img src="/assets/hp54831/unit_a_floppy_hd_adapter_board.jpg" alt="floppy/hard drive adapter board" /></p>
  </li>
  <li>
    <p>Remove the CDROM drive</p>

    <p>This requires removing a screw on the long stick across the case and one screw on the
  back of the case. See arrows:</p>

    <p><a href="/assets/hp54831/unit_a_remove_CDROM.jpg"><img src="/assets/hp54831/unit_a_remove_CDROM.jpg" alt="Remove CDROM drive" /></a>
  <em>(Click to enlarge)</em></p>

    <p>After this, you can slide the drive back a little bit and lift it out of the case.</p>
  </li>
  <li>
    <p>Remove the DRAM stick to access the bottom screw of the hard drive platform</p>
  </li>
  <li>
    <p>Unscrew the hard drive platform</p>

    <p>You now have access to all 4 screws of the platform.</p>

    <p><img src="/assets/hp54831/unit_a_unscrew_hd_platform.jpg" alt="Screwdriver in bottom left hard drive platform screw" /></p>
  </li>
</ul>

<p><em>Only after removing the platform by removing all 4 screws did I notice that the bottom 2 rubbers 
feet were not completely enclosed by the platform. It should be possible to remove the platform with 
a bit of force, without loosening the bottom 2 of the 4 screws.</em></p>

<p>The hard drive platform is freed!</p>

<p><img src="/assets/hp54831/unit_a_hd_freed.jpg" alt="Hard drive platform freed" /></p>

<p><strong>Creating a hard drive backup image</strong></p>

<p>I use a cheap <a href="https://www.amazon.com/dp/B08KT3F998">USB to SATA IDE adapter cable</a> 
to connect the drive to a PC, and <a href="https://hddguru.com/software/HDD-Raw-Copy-Tool/">HDD Raw Copy Tool</a>
to create a backup image.</p>

<p>Contrary to my expectations, the TravelStar HD worked fine! I could copy the whole drive
without any errors:</p>

<p><img src="/assets/hp54831/unit_a_hdd_raw_success.png" alt="HDD Raw Copy Tool success" /></p>

<p>I still expect that this drive will die eventually, but with a backup on hand, I reinstalled
the drive back into the scope.</p>

<h1 id="getting-the-pc-to-boot">Getting the PC to Boot</h1>

<p>The drive was functional, but the scope didn’t boot. I decided to strip the motherboard from all 
custom cards and make it work as if it were a 23 year old PC with only DRAM and an old PCI VGA 
card that I had lying around. That didn’t work either.</p>

<p>The error signature was a repeating pattern of a long beep followed by a long pause. It didn’t
match any of the standard AMI BIOS error codes.</p>

<iframe width="640" height="360" src="https://www.youtube.com/embed/a4_unllx8ho?si=M-3o9IvE535NNHnN" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>

<p>What if the issue was the CPU itself?</p>

<p>First step is to figure out how to remove the cooler:</p>

<p><img src="/assets/hp54831/unit_a_cpu_cooler.jpg" alt="Thermaltake Golden Orb Mini CPU coolor" /></p>

<p>With help from Mastodon, I was able to figure out that the cooler is a 
<a href="https://www.electromyne.de/public/catalog_xmlxslproducts.aspx?art=viewproduct&amp;suid=11565&amp;productid=1028313753&amp;zid=210bd6ab-f876-4795-91f7-6b11a146206f&amp;ln=gb">Thermaltake Golden Orb Mini</a>. 
There’s even still a website with a 
<a href="https://www.frostytech.com/articles/256/index.html">review and installation procedure</a>!
And that’s a good thing, because I don’t think I would have figured out that you need to do a
clockwise rotation of the cooler to detach it from the CPU.</p>

<p><img src="/assets/hp54831/unit_a_cpu_cooler_removed.jpg" alt="CPU cooler removed" /></p>

<p>On a whim, I remove the CPU from the socket, plugged it back in and…</p>

<p><img src="/assets/hp54831/unit_a_boot_screen_on_monitor.jpg" alt="Boot screen on VGA monitor" /></p>

<p>The dumb thing worked! Removing and reinserting the CPU was really all it took.</p>

<p>Another 15 minutes of installing the PCI boards and cables again, and I got to see
this:</p>

<p><img src="/assets/hp54831/unit_a_works.jpg" alt="Unit A is working" /></p>

<p>Success!</p>

<h1 id="unit-b-agilent-54831b">Unit B: Agilent 54831B</h1>

<p>I immediately started on the second unit. This one a slightly younger B version, made
in Malaysia with VIN# M32 (REV.A.03.50), running Windows XP Professional instead.</p>

<p><img src="/assets/hp54831/unit_b_backside.jpg" alt="Backside of unit b" /></p>

<p>As told by the seller, this one lights up, but gets stuck at the boot screen. And indeed:</p>

<p><img src="/assets/hp54831/unit_b_boot_screen_no_drives.jpg" alt="Boot screen. No drives" /></p>

<p>We can see the same 1 GHz Pentium III, the DRAM got an upgrade from 256 to 512 MB, but
no drives of any kind are detected. Which made sense once I opened it up:</p>

<p><img src="/assets/hp54831/unit_b_no_drives_inside.jpg" alt="No drives and cables inside..." /></p>

<p>The BIOS doesn’t detect any drives, because there aren’t any… There are also no
IDE cables and the custom board with the specialty LS120 floppy drive connector is missing
as well. I can live without floppy drive, I need a hard drive and a CDROM drive is nice to
have.</p>

<h1 id="a-compactflash-adapter-without-cable">A CompactFlash Adapter without Cable</h1>

<p>I usually replace spinning disk hard drives with CompactFlash cards. In the past, I’ve used
adapter boards that accept a 40-pin IDE cable, but this time I found something better: an
adapter board that plugs straight into the PC motherboard:</p>

<p><img src="/assets/hp54831/unit_b_cf_adapter.jpg" alt="CompactFlash to IDE adapter" /></p>

<p>You can find them <a href="https://www.amazon.com/dp/B07LBLXDZM">here on Amazon</a>, only $8 for 2.</p>

<p>The adapter board requires an external 5V or 3V supply through 4-pin Molex floppy 
connector. For another $8, I bought 4 of those, again <a href="https://www.amazon.com/dp/B0CLD7YRWC">on Amazon</a>.</p>

<p>The scope has a 2-pin connector with 5V and GND, I cut that off and connected it to the Molex
connector:</p>

<p><img src="/assets/hp54831/unit_b_cf_adapter_with_power_cable.jpg" alt="CompactFlash adapter with power cable and flash card" /></p>

<p>There are 3 LEDs on the adapter with “Detect”, “Active” and “Power” next to them. None of
these worked, but when plugged into the motherboard, my 16GB CF card got detected just fine:</p>

<p><img src="/assets/hp54831/unit_b_boot_screen_with_cf_and_cdrom.jpg" alt="Boot screen with CompactFlash card and CDROM drive detected" /></p>

<p>Note that the CDROM drive is also detected, because I bought 
<a href="https://www.amazon.com/dp/B00Z5AVRDY">2 40-pin flat cables</a> 
for $9. It’s weird that 2 simple cables are more expensive than 2 adapter boards 
with active components.</p>

<p>Here are the adapter and the CDROM cables installed in the motherboard:</p>

<p><img src="/assets/hp54831/unit_b_cf_and_cdrom_connected.jpg" alt="CF adapter with card and CDROM flat cable plugged into motherboard" /></p>

<h1 id="installing-the-software">Installing the Software</h1>

<p><strong>Important: if your 54831 originally came with Windows 98 SE, a Windows XP image may or may not
work. Some scopes with Windows 98 have PCI extension boards that are not compatible with the WinXP
drivers.</strong></p>

<p>Next up: finding the software to run the scope. The hard part is not finding the software, this scope
is quite popular with hobbyists who are willing to share, it’s to figure which one to use.</p>

<p>I ended up using an image stored on the <a href="https://onedrive.live.com/?redeem=aHR0cHM6Ly8xZHJ2Lm1zL2YvcyFBbXFhcjhfWFE5VXpqNlloTjF4SGRNOHRRZEtNT0E&amp;id=33D543D7CFAF9A6A%21250657&amp;cid=33D543D7CFAF9A6A">OneDrive of Tony_G</a>. (Thanks Tony!)
It contains way more than I needed, but the golden ticket was the 6.38 GB <code class="language-plaintext highlighter-rouge">xp54831.vhdx</code> file in
the <code class="language-plaintext highlighter-rouge">54831M</code> directory. Also check out <code class="language-plaintext highlighter-rouge">Install hints.pdf</code> in the same folder with the installation
instructions.</p>

<p>It all comes down to this:</p>

<ul>
  <li>Install <a href="https://rufus.ie/en/">Rufus</a>, a utility to create bootable USB drives.</li>
  <li>Connect the CompactFlash card to your PC with a CompactFlash to USB adapter like
<a href="https://www.amazon.com/dp/B08P517NW5?th=1">this one on Amazon</a>. I used a 16 GB
CF card. I think 8 GB should work too, but I’m not 100% sure.</li>
  <li>Copy over <code class="language-plaintext highlighter-rouge">xp54831.vhdx</code> to the CF card with Rufus.</li>
</ul>

<p>Once done, install the CF card into the scope and boot. The image that you installed on the
drive contains a Symantec Ghost sub-image. When you boot the scope, you should see a Windows 98
splash screen (not WinXP!) and Symantec Ghost. Follow the instructions of the PDF file and
eventually, it will end like this:</p>

<p><img src="/assets/hp54831/unit_b_ghost_complete.jpg" alt="Ghost - Clone Complete" /></p>

<p>Reboot again, and you’ll see this, finally:</p>

<p><img src="/assets/hp54831/unit_b_win_xp_splash.jpg" alt="WinXP splash" /></p>

<p>And this:</p>

<p><img src="/assets/hp54831/unit_b_scope_waveforms_with_noise.jpg" alt="Scope showing 4 waveforms with lots of noise" /></p>

<p>The software is functional, but there’s an awful lot of noise on those signals. If you’re seeing that,
don’t worry: this happens when the scope isn’t calibrated. Go to “Calibration” in one of the menus,
let it run all the way, it takes around 1 hour, and the noise should be gone. Hurray!</p>

<h1 id="cpu-temperature-alarm">CPU Temperature Alarm</h1>

<p>After a while, the scope gave off this persistent alarm:</p>

<iframe width="640" height="360" src="https://www.youtube.com/embed/zMQvYMoHPoo?si=8P-shzt1ZYPG4ZVk" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>

<p>According to the VP22 motherboard manual, the alarm can go off for 3 reasons: case open (there was no such
sensor, so that didn’t apply), CPU temperature alarm, or CPU voltage alarm.</p>

<p>I assumed a CPU voltage alarm due to old capacitors, but to be really sure, you can go into
the PC Health Status section of the BIOS menu and disable the CPU temperature alarm:</p>

<p><img src="/assets/hp54831/unit_b_temp_alarm_disabled.jpg" alt="CPU temperature alarm disabled" /></p>

<p>This made the alarm go away. That’s great because it’s much easier to fix the temperature than to replace 
the capacitors on the motherboard or the main power supply: apply thermal paste, reseat the cooler.</p>

<p>The temperature of the CPU in the BIOS screen was 64C. This is not very high by today’s
standards, but the 
<a href="https://download.intel.com/design/intarch/applnots/27332504.pdf">Pentium III thermal design guide</a>
sets a maximum junction temperature of 75C.</p>

<p>One of the benefits of running Windows XP is that many tools and USB memory
sticks just work. I installed <a href="https://www.alcpu.com/CoreTemp/">Core Temp</a> to continuously
monitor the temperatures after adding thermal paste and reseating the cooler.</p>

<p>Note that it is <em>really</em> important that you feel a subtle click when you rotate the cooler 
counter-clockwise to secure it in place. At the same time, you can’t rotate too hard because
the metal tabs on the CPU socket can shear off or you can crack the CPU die.</p>

<p>Here’s the result after:</p>

<p><img src="/assets/hp54831/core_temp_max_temp.png" alt="Core Temp - max temperature" /></p>

<p>At rest, with the scope app disabled, I saw temperatures around 40C. When the scope was
running with the CPU pegged at 100%, temperatures never exceeded 65C.</p>

<p>I still disabled the CPU temperature alarm though, because that beeping is way
too annoying. This will probably come back to bite me some time in the future…</p>

<p>The scope was really working fine now, there was just one more thing to do.</p>

<h1 id="upgrade-to-1-ghz-bandwidth">Upgrade to 1 GHz Bandwidth</h1>

<p>As mentioned earlier, the scope can be upgraded to a 54832 with 1 GHz bandwidth by
removing a single resistor on the acquisition board.</p>

<p>The resistor array can be found here:</p>

<p><a href="/assets/hp54831/unit_b_resistor_array.jpg"><img src="/assets/hp54831/unit_b_resistor_array.jpg" alt="Resistor array on acquisition board" /></a>
<em>(Click to enlarge)</em></p>

<p>Before the modification, all resistors should be present:</p>

<p><img src="/assets/hp54831/resistor_array_before_mod.jpg" alt="All resistors present" /></p>

<p>Remove this resistor for the upgrade:</p>

<p><img src="/assets/hp54831/resistor_array_after_mod.jpg" alt="One resistor removed" /></p>

<p>After rebooting, the scope identifies itself as an Agilent 54832B:</p>

<p><img src="/assets/hp54831/unit_b_about_infiniium_after_mod.jpg" alt="About Infiniium windows showing 54832B" /></p>

<p>To test the modification, I fed the AUX Out<sup id="fnref:aux_out" role="doc-noteref"><a href="#fn:aux_out" class="footnote" rel="footnote">4</a></sup> signal at the back of the scope into channel 1,
with 50 Ohm termination active.</p>

<p>Before the mod, the rise time averaged to 481 ps:</p>

<p><img src="/assets/hp54831/rise_time_before_mod.png" alt="Rise time before modification" /></p>

<p>After removing the resistor, it dropped to 331 ps:</p>

<p><img src="/assets/hp54831/rise_time_after_mod.png" alt="Rise time after modification" /></p>

<p>The scope bandwidth is calculated as <code class="language-plaintext highlighter-rouge">0.35 / t_rise</code>. Going from 481 to 
331 ps is an increase of 727 MHz to 1057 MHz. The modification worked and
the result is within spec, but others have reported an increase to 1.2 GHz. It’s
possible that my measurement is limited by the rise time of the AUX Out signal,
and that I’d see even better numbers with a real pulse generator. That’s for
another time…</p>

<h1 id="additional-changes-are-possible">Additional Changes are Possible</h1>

<p>I only did the minimum to get the scopes working, and did the resistor mod. You
can find more impressive modifications on the EEVblog forum:</p>

<ul>
  <li>Install motherboards with P4 CPUs. This requires some mechanical surgery
to the case as well, to make the connectors fit.</li>
  <li>Use faster SSDs than my compact flash cards. This can bring down the boot time
from 4 minutes to less than a minute.</li>
  <li>Replace the 640x480 LCD screen with a 1024x768 LCD screen.</li>
</ul>

<p>Some of these modifications may depend on each other. E.g. the larger resolution
LCD screen requires an integrated GPU that is not present on the VP22 motherboard.</p>

<p>I decided not to do any of them: the scope works well enough for my needs.</p>

<h1 id="conclusion">Conclusion</h1>

<p>For $200 and around $30 in additional components, I got myself 2 working
4 Gsps scopes with 600 MHz or 1 GHz bandwidth. I already sold unit A for
$200, which I think is an excellent deal for the buyer. I’m keeping the
other one.</p>

<p><em>All words in this blog post were written by a human.</em></p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://xdevs.com/doc/HP_Agilent_Keysight/HP%2054830,%2054831,%2054832X%20Service.pdf">Agilent Model 54830 Series Oscilloscopes - Service Guide</a></li>
  <li><a href="https://download.intel.com/design/intarch/applnots/27332504.pdf">Pentium III thermal design guide</a></li>
</ul>

<p><strong>EEVblog forum</strong></p>

<ul>
  <li><a href="https://www.eevblog.com/forum/testgear/54831b-upgrade-to-54832b-possible">Agilent 600MHz 54831B hack for 1GHz 54832B possible? YES!</a></li>
  <li><a href="https://www.eevblog.com/forum/testgear/agilent-54831d-modernising/">Agilent 54831D modernising</a></li>
  <li><a href="https://www.eevblog.com/forum/testgear/agilent-54831m-upgrade-to-windows-xp-guide-and-resources/">Agilent 54831M upgrade to Windows XP, guide and resources</a></li>
</ul>

<p><strong>Other info</strong></p>
<ul>
  <li>
    <p><a href="https://wonghoi.humgar.com/blog/2016/07/">5V supply for adapter</a></p>

    <p>Instead of cutting off the existing 5V connector for the CompactFlash adapter,
  I could have gotten the 5V from somewhere else on the motherboard.</p>
  </li>
  <li>
    <p><a href="https://tolisdiy.com/2019/10/04/agilent-54831b-oscilloscope-taming/">Agilent 54831B Oscilloscope Taming</a></p>
  </li>
  <li>
    <p><a href="https://1drv.ms/f/s!Amqar8_XQ9Uzj6YhN1xHdM8tQdKMOA">Tony_G Various disk images</a></p>
  </li>
</ul>

<h1 id="footnotes">Footnotes</h1>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:Agilent" role="doc-endnote">
      <p>I’m still not used to the recent renaming of HP into Agilent and often
        use their names interchangeably. <a href="#fnref:Agilent" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:sleeve" role="doc-endnote">
      <p>The 54825A has a sleeve-like enclosure. <a href="#fnref:sleeve" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:overlay" role="doc-endnote">
      <p>You shouldn’t try this yourself because it can damage the electronics, but if you 
        unplug the bridge cable while the scope is up and running, you’ll see that the GUI 
        is still rendered but the waveforms disappear. <a href="#fnref:overlay" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:aux_out" role="doc-endnote">
      <p>Make sure to select the 10 MHz output for AUX Out in the Calibration menu. <a href="#fnref:aux_out" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[In the end, it comes down to fixing two early 2000s PCs…]]></summary></entry><entry><title type="html">Polyphase Channelizers with Frequency Offset - a Bluetooth LE Example</title><link href="https://tomverbeure.github.io/2026/03/05/Polyphase-Channelizer-with-Offset.html" rel="alternate" type="text/html" title="Polyphase Channelizers with Frequency Offset - a Bluetooth LE Example" /><published>2026-03-05T10:00:00+00:00</published><updated>2026-03-05T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/03/05/Polyphase-Channelizer-with-Offset</id><content type="html" xml:base="https://tomverbeure.github.io/2026/03/05/Polyphase-Channelizer-with-Offset.html"><![CDATA[<script async="" src="https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS_CHTML"></script>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#a-bluetooth-le-trace-as-example" id="markdown-toc-a-bluetooth-le-trace-as-example">A Bluetooth LE Trace as Example</a></li>
  <li><a href="#input-complex-heterodyne" id="markdown-toc-input-complex-heterodyne">Input Complex Heterodyne</a></li>
  <li><a href="#derivation-of-post-decimation-offset-correction" id="markdown-toc-derivation-of-post-decimation-offset-correction">Derivation of Post-Decimation Offset Correction</a></li>
  <li><a href="#simplifying-for-the-half-bin-offset-case" id="markdown-toc-simplifying-for-the-half-bin-offset-case">Simplifying for the Half-Bin Offset Case</a></li>
  <li><a href="#the-odd-case-of-an-odd-number-of-channels" id="markdown-toc-the-odd-case-of-an-odd-number-of-channels">The Odd Case of an Odd Number of Channels</a></li>
  <li><a href="#reducing-the-number-of-phase-adjustment-values" id="markdown-toc-reducing-the-number-of-phase-adjustment-values">Reducing the Number of Phase Adjustment Values</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>In previous blog post, I introduced the 
<a href="/2026/02/16/Polyphase-Channelizer.html">polyphase channelizer</a>,
a DSP algorithm that is incredibly efficient at heterodyning multiple channels to baseband
in parallel. I made two major assumptions about the nature of the input signal:</p>

<ul>
  <li>The bandwidth of a channel is equal to the the input sample rate divided by the decimation factor.</li>
  <li>The center frequency of each channel is an integer multiple of the channel bandwidth</li>
</ul>

<p>If these conditions are satisfied, the channelizer reduces to a filter bank with real coefficients
and an inverse FFT on the output of the filter phases.</p>

<p>In this blog post, I’ll use a real-world Bluetooth LE recording and a polyphase channelizer to
extract all channels in parallel. There’s a twist, however, in that the center frequency of the
channels is not a multiple of the channel bandwidth. With a little bit of additional math,
we can work around that too.</p>

<p>I’m still roughly covering topics here that are covered in 
<a href="https://www.youtube.com/watch?v=afU9f5MuXr8">“Recent Interesting and Useful Enhancements of Polyphase Filter Banks”</a>
by fred harris, though my approach is more mathematical and less based on intuition. Furthermore,
harris doesn’t work out the details for any generic frequency offset and immediately jumps to the 
half-channel case. But even there, he spends most of the time discussing a clever trick for odd 
decimation factors than the generic case that works for all decimation factors. I first 
deal with the full generic case and then simplify the outcome by imposing additional constraints.</p>

<h1 id="a-bluetooth-le-trace-as-example">A Bluetooth LE Trace as Example</h1>

<p><a href="https://en.wikipedia.org/wiki/Bluetooth_Low_Energy">Bluetooth Low Energy (BLE)</a> 
lives in the unlicensed 2.4 GHz radio band that’s also used by wifi and many other
protocols. It has 40 channels that are each 2 MHz wide for a total bandwidth of 80 MHz. The
center frequency of the bottom physical channel is 2402 MHz. In total, BLE occupies a spectrum from
2401 MHz to 2481 MHz.</p>

<p>The 2.4 GHz radio band is often congested. To ensure that at least some packets get through, BLE
uses frequency hopping: it continuously jumps from one channel to the next in some predictable
pattern. However, to establish an initial connection, there are a number of fixed management channels.</p>

<p><a href="https://joshuawise.com">Joshua</a> used his BladeRF SDR unit to provide me with a 5 ms recording with
the following characteristics:</p>

<ul>
  <li>center frequency: 2.441 GHz</li>
  <li>sample rate: 96 MHz</li>
  <li>quadrature I/Q sampling</li>
</ul>

<p>We can create a spectral power density 
<a href="https://en.wikipedia.org/wiki/Waterfall_plot">waterfall plot</a> 
of this, where the X-axis shows the time and the Y axis the 
<a href="https://en.wikipedia.org/wiki/Short-time_Fourier_transform">short time Fourier transform (STFT)</a> of
the signal, showing the energy for the full frequency range.</p>

<p><a href="/assets/polyphase/ble/ble_input_data_waterfall.png"><img src="/assets/polyphase/ble/ble_input_data_waterfall.png" alt="BLE Waterfall Plot" /></a>
<em>(Click to enlarge)</em></p>

<p>We can see a bright line at the 2441 MHz center frequency. This is a common artifact of the 
imperfect SDR hardware. It can be caused by local oscillator leakage or an imbalance between the 
I and Q channels of the quadrature AD converters, or both.</p>

<p>In <a href="https://youtu.be/afU9f5MuXr8?t=3210">his video</a>, 
harris talks about how DC is often problematic, and a reason to have channels with a
frequency offset so that none of the channel center frequencies coincide with DC. This trace shows why
this is good advice.</p>

<p>We can also see some symmetry around the 2441 MHz line. For example, there’s a short burst around
1.1 ms at 2415 Mhz and a weaker version at 2467 MHz. This weaker version isn’t real either, but a
spectral mirror image that’s caused by an imbalance between the I and the Q channels: their phase delta
might not be exactly 90 degrees or they might have a slightly different gain on their way to the 
ADCs.<sup id="fnref:gram_schmidt" role="doc-noteref"><a href="#fn:gram_schmidt" class="footnote" rel="footnote">1</a></sup>
This is another topic that harris warns about: if possible, use a single double-speed ADC and do
all the I/Q handling in the mathematically perfect digital domain.</p>

<p><em>Due to the sample rate limitations
of the BladeRF, we have to use a quadrature analog acquistion path, but this doesn’t materially impact
the techniques derived in this blog post.</em></p>

<p>A recording of 96 Msps complex samples covers 48 channels of 2 MHz. Since BLE only has 40 active channels,
we have a little bit too much data, but that’s ok. In the waterfall plot below, I’ve added separators between
the individual channels. The suprious 2441 MHz line is now obstructed, which is good because it shows that 
it falls on a transition band.</p>

<p><a href="/assets/polyphase/ble/ble_input_data_waterfall_bars.png"><img src="/assets/polyphase/ble/ble_input_data_waterfall_bars.png" alt="BLE Waterfall Plot with Channels" /></a>
<em>(Click to enlarge)</em></p>

<p>In the previous blog post, we operated under the assumption that channel center frequencies were located
at a multiple of the decimated sample rate:</p>

\[F_c = \frac{F_s}{M} c, \quad c = -\frac{M}{2}, \dots, -1, 0, 1, \dots, \frac{M}{2}-1\]

<p><img src="/assets/polyphase/ble/ble-dc_offset.svg" alt="No channel center frequency offset" /></p>

<p>That’s not the case here. Instead, we have the following situation:</p>

\[F_c = \frac{F_s}{M} c + \frac{F_s}{2M}, \quad c = -\frac{M}{2}, \dots, -1, 0, 1, \dots, \frac{M}{2}-1\]

<p><img src="/assets/polyphase/ble/ble-half_bin_offset.svg" alt="Half-bin channel center frequency offset" /></p>

<p>Concretely, instead of channel center frequencies at -2, 0, 2, 4, … MHz, the BLE channels are located at
-3, -1, 1, 3, 5, … MHz. Having the center frequency offset at exacty half the channel width is
something we can exploit later, but I will first develop the generic case where the frequency 
offset can be anything, and then simplify.</p>

<h1 id="input-complex-heterodyne">Input Complex Heterodyne</h1>

<p>The easiest way to align the channel center frequencies to an integer multiple of the output
sample rate is to remove the offset with a complex heterodyne on the input signal.</p>

<p>Like this:</p>

\[\omega_\Delta = 2 \pi \frac{F_\text{offset}}{F_s} \\
x[n] = x'[n] \, e^{j \omega_\Delta n} \\\]

<p>This works, but it undoes all the effort from last blog post where we tried very
hard to not do any math at the input sample rate.</p>

<p>Still, let’s do it anyway and see what kind of result we get.</p>

<p>The code for the input heterodyne and the polyphase channelizer is below. 
I’ve stripped some of the comments for brevity, but check out the 
<a href="https://github.com/tomverbeure/polyphase_blog_series/blob/main/ble.py">code in the GitHub repo</a>
for more details.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">n</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">ble_input</span><span class="p">),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>

<span class="c1"># Complex 1 MHz rotator to shift the spectrum by the half-channel offset
</span><span class="n">heterodyne_1mhz</span>     <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="mf">1j</span> <span class="o">*</span> <span class="mf">2.0</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">channel_offset_hz</span> <span class="o">/</span> <span class="n">sample_rate_hz</span> <span class="o">*</span> <span class="n">n</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">complex64</span><span class="p">)</span>
<span class="c1"># Do the heterodyne on the input signal
</span><span class="n">ble_input_pre_1mhz</span>  <span class="o">=</span> <span class="n">ble_input</span> <span class="o">*</span> <span class="n">heterodyne_1mhz</span>

<span class="c1"># Channel low-pass filter with a passband from 0 to 600 kHz
# and a stopband that starts at 800 kHz.
</span><span class="n">h_lpf</span> <span class="o">=</span> <span class="n">create_remez_lowpass_fir</span><span class="p">(</span>
    <span class="n">input_sample_rate_hz</span>     <span class="o">=</span> <span class="n">sample_rate_hz</span><span class="p">,</span>
    <span class="n">passband_hz</span>              <span class="o">=</span> <span class="mf">600e3</span><span class="p">,</span>
    <span class="n">passband_ripple_db</span>       <span class="o">=</span> <span class="mf">1.0</span><span class="p">,</span>
    <span class="n">stopband_hz</span>              <span class="o">=</span> <span class="mf">800e3</span><span class="p">,</span>
    <span class="n">stopband_attenuation_db</span>  <span class="o">=</span> <span class="mf">50.0</span>
    <span class="p">)</span>

<span class="c1"># Pad the filter with zeros so that the polyphase decomposition 
# is a clean 2D array.
</span><span class="n">h_lpf</span>   <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">pad</span><span class="p">(</span><span class="n">h_lpf</span><span class="p">,</span> <span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nb">len</span><span class="p">(</span><span class="n">h_lpf</span><span class="p">)</span> <span class="o">%</span> <span class="n">decim_factor</span><span class="p">)</span> <span class="p">)</span>

<span class="c1"># Polyphase filter decomposition: 
# 48 rows, each row has interleaved coefficients.
</span><span class="n">h_lpf_poly</span>  <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">reshape</span><span class="p">(</span>
        <span class="n">h_lpf</span><span class="p">,</span> <span class="p">(</span> <span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">h_lpf</span><span class="p">)</span> <span class="o">//</span> <span class="n">decim_factor</span><span class="p">),</span> <span class="n">decim_factor</span><span class="p">)</span> 
    <span class="p">).</span><span class="n">T</span>

<span class="c1"># Polyphase decomposition/decimation of the input signal
</span><span class="n">ble_decim_pre_1mhz</span>  <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">flipud</span><span class="p">(</span>
    <span class="n">np</span><span class="p">.</span><span class="n">reshape</span><span class="p">(</span>
        <span class="n">ble_input_pre_1mhz</span><span class="p">,</span>
        <span class="p">((</span><span class="nb">len</span><span class="p">(</span><span class="n">ble_input_pre_1mhz</span><span class="p">)</span> <span class="o">//</span> <span class="n">decim_factor</span><span class="p">),</span> <span class="n">decim_factor</span><span class="p">),</span>
    <span class="p">).</span><span class="n">T</span>
<span class="p">)</span>

<span class="c1"># Calculate the output of all polyphase filters
</span><span class="n">h_poly_out_pre_1mhz</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">(</span>
        <span class="p">[</span><span class="n">np</span><span class="p">.</span><span class="n">convolve</span><span class="p">(</span><span class="n">ble_decim_pre_1mhz</span><span class="p">[</span><span class="n">_</span><span class="p">],</span> <span class="n">h_lpf_poly</span><span class="p">[</span><span class="n">_</span><span class="p">])</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">decim_factor</span><span class="p">)])</span>

<span class="c1"># Vectorized IFFT to calculate the output of all channels
</span><span class="n">channel_data_pre_1mhz</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">fft</span><span class="p">.</span><span class="n">ifft</span><span class="p">(</span><span class="n">h_poly_out_pre_1mhz</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">complex64</span><span class="p">)</span>
</code></pre></div></div>

<p>After extracting the data from channel 33<sup id="fnref:33" role="doc-noteref"><a href="#fn:33" class="footnote" rel="footnote">2</a></sup> between 1.14 ms and 1.24 ms, we get the following:</p>

<p><a href="/assets/polyphase/ble/chan_33_time_plot_het_pre_iq.svg"><img src="/assets/polyphase/ble/chan_33_time_plot_het_pre_iq.svg" alt="Channel 33 I/Q time plot" /></a>
<em>(Click to enlarge)</em></p>

<p>The active period of a packet can be derived from the amplitude of the I/Q vector (green).
And the I/Q data clearly has some structure in it.</p>

<p>BLE uses 
<a href="https://en.wikipedia.org/wiki/Frequency-shift_keying#Gaussian_frequency-shift_keying">Gaussian frequency shift keying (GFSK)</a>.
Like ordinary <a href="https://en.wikipedia.org/wiki/Frequency-shift_keying">frequency shift keying (FSK)</a>, 
a 0 and a 1 are coded with slightly different frequencies, but the transistion between them is just
a bit smoother for GFSK.</p>

<p>Frequency is the derivative of the phase. Since I and Q are available, you can calculate the
phase as follows<sup id="fnref:atan2" role="doc-noteref"><a href="#fn:atan2" class="footnote" rel="footnote">3</a></sup>:</p>

\[\phi[n] = \text{atan2}(q[n],i[n])\]

<p>The derivative is simply the delta between consecutive phase samples.</p>

<p>In Python, we can demodulate a GFSK signal like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">angle</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">unwrap</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">angle</span><span class="p">(</span><span class="n">iq_data</span><span class="p">))</span>
<span class="n">d_angle</span> <span class="o">=</span> <span class="n">angle</span><span class="p">[:</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="o">-</span> <span class="n">angle</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span>
</code></pre></div></div>

<p>Here’s the result:</p>

<p><a href="/assets/polyphase/ble/chan_33_time_plot_het_pre_gfsk.svg"><img src="/assets/polyphase/ble/chan_33_time_plot_het_pre_gfsk.svg" alt="Channel 33 GFSK time plot" /></a>
<em>(Click to enlarge)</em></p>

<p>A BLE packet starts with a 16-symbol 1010101010101010 sync word, followed by data. This definitely looks
like a valid packet.</p>

<p>Cool! But it costs us a table with 48 rotator values that are fed into a complex multiplier, at the input sample rate. 
In this example, the input samples are already complex, but if they were real, the input heterodyne also
forces all filter bank calculations to become complex.</p>

<p>Can we do better?</p>

<h1 id="derivation-of-post-decimation-offset-correction">Derivation of Post-Decimation Offset Correction</h1>

<p>Here’s the standard polyphase channelizer pipeline from last blog post:</p>

<p><a href="/assets/polyphase/ble/ble-polyphase_ifft.svg"><img src="/assets/polyphase/ble/ble-polyphase_ifft.svg" alt="Standard polyphase channelizer" /></a>
<em>(Click to enlarge)</em></p>

<p>And here’s the mathematical description of the pipeline, for 3 channels and a filter with 9 coefficients:</p>

\[\begin{alignedat}{0}
y_c[n]    &amp; = &amp; e^{j \frac{2 \pi}{3} c \, 0} &amp; ( &amp; h[0] &amp; x[3n]   &amp; + &amp;  h[3] &amp; x[3n-3] &amp; + &amp; h[6] &amp; x[3n-6] &amp; ) \\
          &amp; + &amp; e^{j \frac{2 \pi}{3} c \, 1} &amp; ( &amp; h[1] &amp; x[3n-1] &amp; + &amp;  h[4] &amp; x[3n-4] &amp; + &amp; h[7] &amp; x[3n-7] &amp; ) \\
          &amp; + &amp; e^{j \frac{2 \pi}{3} c \, 2} &amp; ( &amp; h[2] &amp; x[3n-2] &amp; + &amp;  h[5] &amp; x[3n-5] &amp; + &amp; h[8] &amp; x[3n-8] &amp; ) \\
\\
y_c[n+1]  &amp; = &amp; e^{j \frac{2 \pi}{3} c \, 0} &amp; ( &amp; h[0] &amp; x[3n+3] &amp; + &amp;  h[3] &amp; x[3n]   &amp; + &amp; h[6] &amp; x[3n-3] &amp; ) \\
          &amp; + &amp; e^{j \frac{2 \pi}{3} c \, 1} &amp; ( &amp; h[1] &amp; x[3n+2] &amp; + &amp;  h[4] &amp; x[3n-1] &amp; + &amp; h[7] &amp; x[3n-4] &amp; ) \\
          &amp; + &amp; e^{j \frac{2 \pi}{3} c \, 2} &amp; ( &amp; h[2] &amp; x[3n+1] &amp; + &amp;  h[5] &amp; x[3n-2] &amp; + &amp; h[8] &amp; x[3n-5] &amp; ) \\
\end{alignedat}\]

<p>Let’s generalize this formula to \(M\) channels and \(N\) filter taps per phase:</p>

\[y_c[n] = \sum_{m=0}^{M-1}  
         \underbrace{ e^{j \frac{2 \pi}{M} c \, m} }_\text{IFFT}
         \sum_{k=0}^{N-1} h[kM + m] \; x[(n - k)M - m] \\\]

<p>Now substitute input \(x[n]\) with an input signal to which a complex heterodyne has been applied:</p>

\[x[n] = x'[n] \; e^{j \omega_{\Delta} n}\]

<p><a href="/assets/polyphase/ble/ble-pre_polyphase_ifft.svg"><img src="/assets/polyphase/ble/ble-pre_polyphase_ifft.svg" alt="Input heterodyne + polyphase channelizer" /></a>
<em>(Click to enlarge)</em></p>

\[y_c[n] = \sum_{m=0}^{M-1}  e^{j \frac{2 \pi}{M} c \, m}  \sum_{k=0}^{N-1} h[kM + m] \; x'[(n - k)M - m] \; 
         \underbrace{e^{j \omega_{\Delta} ((n - k)M - m)}}_\text{offset adjust rotator}\]

<p>A frequency offset adjustment rotator has been introduced.</p>

<p>We can split up this exponential, extract a free-running output rotator that only depends on decimated 
sample number \(nM\), and move it all the way to the front:</p>

\[y_c[n] = \underbrace{e^{j \omega_{\Delta} Mn} }_\text{output rotator}
         \sum_{m=0}^{M-1}  e^{j \frac{2 \pi}{M} c \, m}  \sum_{k=0}^{N-1} h[kM + m] \; x'[(n - k)M - m] \; e^{j \omega_{\Delta} (- kM - m)}\]

<p>Now extract the term that only depends on polyphase variable \(m\):</p>

\[y_c[n] = e^{j \omega_{\Delta} Mn} \sum_{m=0}^{M-1}  
         \underbrace{e^{-j \omega_{\Delta} m} }_\text{phase adjustment}
         e^{j \frac{2 \pi}{M} c \, m}  \sum_{k=0}^{N-1} h[kM + m] \; x'[(n - k)M - m] \; e^{j \omega_{\Delta} (- kM)}\]

<p>Finally, rearrange the remaining exponential that is different for each filter coefficient index \(k\):</p>

\[y_c[n] = \underbrace{e^{j \omega_{\Delta} Mn}}_{\text{output rotator}} 
         \sum_{m=0}^{M-1}  
         \underbrace{e^{-j \omega_{\Delta} m}}_{\text{phase adjustment}} 
         \underbrace{e^{j \frac{2 \pi}{M} c \, m}}_{\text{IFFT}}  
         \sum_{k=0}^{N-1} h[kM + m]  
         \underbrace{e^{-j \omega_{\Delta} (kM)}}_{\text{filter adjustment}}  \; x'[(n - k)M - m]\]

<p>There are 3 additional terms now:</p>

<ul>
  <li>all the filter coefficients are modified by a filter adjustment term \(e^{-j \omega_{\Delta} (kM)}\).</li>
  <li>the output of each phase sub-filter is multiplied by a phase adjustment term \(e^{-j \omega_{\Delta} m}\).</li>
  <li>all outputs of the IFFT are subjected to complex heterodyne \(e^{j \omega_{\Delta} Mn}\).</li>
</ul>

<p>None of this is ideal, but the first 2 terms are not dependent on the sample number and can be baked 
into the design. Meanwhile the rotator at the end not only runs at a rate that is M times lower, but the 
phase step of the rotator is also M times larger which reduces the size of a lookup table with rotator
values.</p>

<p>The diagram looks like this:</p>

<p><a href="/assets/polyphase/ble/ble-polyphase_ifft_post.svg"><img src="/assets/polyphase/ble/ble-polyphase_ifft_post.svg" alt="Polyphase channelizer with decimated offset adjustment" /></a>
<em>(Click to enlarge)</em></p>

<p>In Python, we can use this code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># No more input heterodyne. Immediately decimate the input signal
</span><span class="n">ble_decim</span>   <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">flipud</span><span class="p">(</span>
    <span class="n">np</span><span class="p">.</span><span class="n">reshape</span><span class="p">(</span>
        <span class="n">ble_input</span><span class="p">,</span>
        <span class="p">((</span><span class="nb">len</span><span class="p">(</span><span class="n">ble_input</span><span class="p">)</span> <span class="o">//</span> <span class="n">decim_factor</span><span class="p">),</span> <span class="n">decim_factor</span><span class="p">),</span>
    <span class="p">).</span><span class="n">T</span>
<span class="p">)</span>

<span class="c1"># Calculate frequency offset
</span><span class="n">freq_offset</span>           <span class="o">=</span> <span class="n">channel_offset_hz</span> <span class="o">/</span> <span class="p">(</span><span class="n">sample_rate_hz</span> <span class="o">/</span> <span class="n">decim_factor</span><span class="p">)</span>
<span class="n">omega_delta</span>           <span class="o">=</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">freq_offset</span> <span class="o">/</span> <span class="n">decim_factor</span>

<span class="c1"># Modify the low pass filter coefficients
</span><span class="n">h_n</span>                   <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">h_lpf_poly</span><span class="p">[</span><span class="mi">0</span><span class="p">]),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
<span class="n">h_lpf_poly_adj</span>        <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="o">-</span><span class="mf">1j</span> <span class="o">*</span> <span class="n">omega_delta</span> <span class="o">*</span> <span class="n">decim_factor</span> <span class="o">*</span> <span class="n">h_n</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">complex64</span><span class="p">)</span>
<span class="n">h_lpf_poly_het</span>        <span class="o">=</span> <span class="n">h_lpf_poly</span> <span class="o">*</span> <span class="n">h_lpf_poly_adj</span>

<span class="c1"># Output of the polyphase filter
</span><span class="n">h_poly_out</span>            <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">array</span><span class="p">([</span><span class="n">np</span><span class="p">.</span><span class="n">convolve</span><span class="p">(</span><span class="n">ble_decim</span><span class="p">[</span><span class="n">_</span><span class="p">],</span> <span class="n">h_lpf_poly_het</span><span class="p">[</span><span class="n">_</span><span class="p">])</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">decim_factor</span><span class="p">)])</span>

<span class="c1"># Apply a phase rotation to the output of each phase
</span><span class="n">phase_nr</span>              <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="n">decim_factor</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
<span class="n">h_phase_adj</span>           <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="o">-</span><span class="mf">1j</span> <span class="o">*</span> <span class="n">omega_delta</span> <span class="o">*</span> <span class="n">phase_nr</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">complex64</span><span class="p">)</span>
<span class="n">h_poly_out_phase_adj</span>  <span class="o">=</span> <span class="n">h_poly_out</span> <span class="o">*</span> <span class="n">h_phase_adj</span><span class="p">[:,</span> <span class="bp">None</span><span class="p">]</span>

<span class="c1"># IFFT...
</span><span class="n">channel_data</span>          <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">fft</span><span class="p">.</span><span class="n">ifft</span><span class="p">(</span><span class="n">h_poly_out_phase_adj</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">complex64</span><span class="p">)</span>

<span class="c1"># Output rotator
</span><span class="n">sample_nr</span>             <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="n">channel_data</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float32</span><span class="p">)</span>
<span class="n">heterodyne_1mhz_decim</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="mf">1j</span> <span class="o">*</span> <span class="n">omega_delta</span> <span class="o">*</span> <span class="n">decim_factor</span> <span class="o">*</span> <span class="n">sample_nr</span><span class="p">).</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">complex64</span><span class="p">)</span>

<span class="c1"># Heterodyne all channels
</span><span class="n">channel_data_1mhz_post</span>  <span class="o">=</span> <span class="n">channel_data</span> <span class="o">*</span> <span class="n">heterodyne_1mhz_decim</span><span class="p">[</span><span class="bp">None</span><span class="p">,</span> <span class="p">:]</span>
</code></pre></div></div>

<p>While the channel I/Q output samples are not identical to the previous case due to a phase shift, 
the result after GFSK modulation is the same:</p>

<p><a href="/assets/polyphase/ble/chan_33_time_plot_het_post.svg"><img src="/assets/polyphase/ble/chan_33_time_plot_het_post.svg" alt="BLE Channel 33 decoding with 1 MHz heterodyne after decimation " /></a>
<em>(Click to enlarge)</em></p>

<p>This seems like a whole lot of effort for little benefit. Yes, we are running all operations at the output
sample rate, but the number of multiplications per output sample is now higher than the case with 
the input heterodyne!</p>

<p>But remember: this is for the generic case, with a random frequency offset. Let’s fix that.</p>

<h1 id="simplifying-for-the-half-bin-offset-case">Simplifying for the Half-Bin Offset Case</h1>

<p>As mentioned at the start of this blog post, it’s common to have a frequency offset that
is equal to half the channel width:</p>

\[F_\text{offset} = \frac{F_s}{2 M} \\
\omega_\Delta = 2 \pi \frac{F_\text{offset}}{F_s} = \frac{2 \pi}{2 M} = \frac{\pi}{M}\]

<p>A crucial observation is that 2 of our adjustment exponentionals feature a 
multiplication by \(M\).</p>

<p>The filter coefficients adjustment:</p>

\[e^{-j \omega_\Delta (kM)} = e^{-j \frac{\pi}{M} (kM)} = e^{-j \pi k } = (-1)^k\]

<p>The output rotator:</p>

\[e^{j \omega_\Delta (Mn)} = e^{j \frac{\pi}{M} (Mn)} = e^{j \pi n } = (-1)^n\]

<p>Awesome!  The general equation has been simplified to this:</p>

\[y_c[n] = (-1)^n
         \sum_{m=0}^{M-1}  
         \underbrace{e^{-j \omega_{\Delta} m}}_{\text{phase adjustment}} 
         \underbrace{e^{j \frac{2 \pi}{M} c \, m}}_{\text{IFFT}}  
         \sum_{k=0}^{N-1} h[kM + m]  
         (-1)^k
         \; x'[(n - k)M - m]\]

<p>The filter coefficients are real again and the complex multiplier for the output rotator
can be replaced by logic that just inverts the sign of the output samples for each time tick.</p>

<p><a href="/assets/polyphase/ble/ble-polyphase_ifft_post_half_bin.svg"><img src="/assets/polyphase/ble/ble-polyphase_ifft_post_half_bin.svg" alt="Post-decimation frequency adjust for half-bin offset" /></a>
<em>(Click to enlarge)</em></p>

<p>This is so much better! But it’s <em>still</em> possible to do better, though the requirements
become even stricter.</p>

<h1 id="the-odd-case-of-an-odd-number-of-channels">The Odd Case of an Odd Number of Channels</h1>

<p>We are currently still stuck with the per-phase complex rotator:</p>

\[e^{-j \omega_\Delta m}\]

<p>When the channel center frequencies are offset by half the channel width, we’ve so far
only considered an adjustment where the correction offset is half the channel bandwidth:</p>

\[\omega_\Delta = \frac{\pi}{M}\]

<p>Relative to the full channel bandwidth of \(\frac{2 \pi}{M}\), this offset is \(r=0.5\).</p>

\[\omega_\Delta = \frac{ 2 \pi }{M} r\]

<p>But \(r\) doesn’t have to be 0.5: we can use any kind of offset, as long as the fractional
part of the value is 0.5.</p>

<p>For example, when \(r = 2.5\), the channelizer still works, but in addition to a fractional
shift of half the channel width, there is an additional shift of 2 full channels. An output
sample that would go to channel \(k\) for an offset of 0.5 now goes to channel \(k+2\) instead.
Not the exactly the same result, but this reassigned output channel is just a minor bookkeeping
issue.</p>

<p><img src="/assets/polyphase/ble/ble-offset_of_2.5.svg" alt="Spectrum with integer channel offset of 2" /></p>

<p>Let’s see what happens when \(r=M/2\).</p>

<p>For even values of M, \(r\) is an integer value, without the fractional 0.5 half-bin
offset that we need:</p>

<p><img src="/assets/polyphase/ble/ble-offsest_of_m_div2_even.svg" alt="Spectrum with even channels moved by M/2" /></p>

<p>For odd values of M, we get the half-bin offset and all channels are moved by \(\frac{M-1}{2}\) at the output.</p>

<p><img src="/assets/polyphase/ble/ble-offsest_of_m_div2_odd.svg" alt="Spectrum with integer channel offset of M/2 = 3.5" /></p>

<p><em>harris shows this graphically with phase adjust values on a unity circle, but the principle is the same.</em></p>

<p>Let’s see what \(r=M/2\) does to the phase adjust term:</p>

\[r = M/2   \\
\omega_\Delta = \frac{ 2 \pi }{M} \frac{M}{2} \\
\omega_\Delta = \pi \\
e^{-j \omega_\Delta m} = e^{-j \pi m} = (-1)^m\]

<p>Nothing changes for the 2 other terms: for odd values of M, they still reduce to \((-1)^k\) and \((-1)^n\).</p>

<p>Conclusion: for odd values of M, we can do a half-bin frequency offset without an additional complex
multiplier! Flipping the sign of some sub-filter output values and reassigning the output channel
numbers is all that it takes.</p>

<p><a href="/assets/polyphase/ble/ble-polyphase_ifft_odd_m.svg"><img src="/assets/polyphase/ble/ble-polyphase_ifft_odd_m.svg" alt="DSP pipeline for odd M and half-bin offset" /></a>
<em>(Click to enlarge)</em></p>

<h1 id="reducing-the-number-of-phase-adjustment-values">Reducing the Number of Phase Adjustment Values</h1>

<p>We can expand this trick for cases where M is even but its number of prime factors 2 is low.
Let’s do the exercise for \(M = 18\) and select \(r = \frac{M}{4} = \frac{18}{4} = 4.5\).</p>

\[r = M/4   \\
\omega_\Delta = \frac{ 2 \pi }{M} \frac{M}{4} \\
\omega_\Delta = \frac{\pi}{2}  \\
e^{-j \omega_\Delta m} = e^{-j \frac{\pi}{2} m} = 1, -j, -1, j, 1, \dots\]

<p>We didn’t get rid of the complex term, but we can implement these factors with a sign flip
and/or swapping the real and imaginary part of the sub-filter outputs.</p>

<p>In general, if the following it true:</p>

\[M = 2^p K, \quad K &gt; 2\]

<p>Then you should choose \(r\) as follows:</p>

\[r = \frac{M}{2^{p+1}} = \frac{K}{2}\]

<p>When \(p=0\), you get the case where M is odd, and adjustment factors of \({-1,1}\).
When \(p=1\), the adjustment factors are \({-1,1, j, -j}\).
For larger values of \(p\), you can’t avoid a complex multiplier, but at least you will
limit the number adjustment values, which can be useful if you have 1 complex multiplier
that serially processes all the sub-filter outputs before sending them to the IFFT.</p>

<p>For the BLE example:</p>

\[M = 48 = 16 \cdot 3 = 2^4 \cdot 3 \\
r =  \frac{48}{2^5} = 1.5\]

<p>With this configuration, the phase adjustment term wraps around at phase 32, so we only need a
lookup table of 32 instead of 48 if we choose \(r=0.5\).<sup id="fnref:lut" role="doc-noteref"><a href="#fn:lut" class="footnote" rel="footnote">4</a></sup></p>

<h1 id="conclusion">Conclusion</h1>

<p>Just like in previous blog post, we started with a straightforward solution to a problem
that worked, but that required significant mathematical resources. We then threw
some math at it and added constraints to simplify the math even more.</p>

<p>The outcome is once again appealing: for all decimation factors, the common case of
shifting the spectrum by half the width of a channel requires at most one additional
complex multiplication at the output of each sub-filter of the polyphase bank. And even
this multiplication can be removed entirely if we can choose a decimation factor that
is odd or if it only has one prime factor of 2.</p>

<p><em>All words in this blog post were written by a human.</em></p>

<h1 id="references">References</h1>

<ul>
  <li>
    <p><a href="https://www.youtube.com/watch?v=afU9f5MuXr8">Youtube - Recent Interesting and Useful Enhancements of Polyphase Filter Banks: fred harris</a></p>
  </li>
  <li>
    <p><a href="https://dsp.stackexchange.com/questions/96042/understanding-polyphase-filter-banks">Stackexchange - Understanding Polyphase Filter Banks</a></p>
  </li>
  <li>
    <p><a href="https://www.dsponlineconference.com/WPMC_2020_Even_and_Odd_Bin%20Centers_5.pdf">Analysis Channelizers with Even and Odd Indexed Bin Centers - fred harris</a></p>
  </li>
  <li>
    <p><a href="https://ieeexplore.ieee.org/document/1193158">IEEE - Digital Receivers and Transmitters Using Polyphase Filter Banks for Wireless Communications</a></p>
  </li>
</ul>

<p><strong>Other blog posts in this series</strong></p>

<ul>
  <li><a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">Notes about Basic Polyphase Decimation Filters</a></li>
  <li><a href="/2026/02/07/Complex-Heterodyne.html">Complex Heterodynes Explained</a></li>
  <li><a href="/2026/02/16/Polyphase-Channelizer.html">The Stunning Efficiency and Beauty of the Polyphase Channelizer</a></li>
</ul>

<p><strong>Source code</strong></p>

<ul>
  <li><a href="https://github.com/tomverbeure/polyphase_blog_series">GitHub - Polyphase Filtering Blog Series</a></li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:gram_schmidt" role="doc-endnote">
      <p>You can use <a href="https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process">Gram-Schmidt decorrelation</a>
             to fix the I/Q vectors, supposedly, but I haven’t explored that yet. <a href="#fnref:gram_schmidt" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:33" role="doc-endnote">
      <p>Channel zero is located at 2441 MHz. Channel numbers increment up to 24 the top frequency
   is reached, after which the frequency rolls over to the bottom and channel numbers continue
   to increment. That’s how you end up with 33. <a href="#fnref:33" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:atan2" role="doc-endnote">
      <p>The \(\text{atan2}(q,i)\) function differs from \(\arctan(\frac{q}{i})\) function in the
      sense that the former works in all 4 quadrants whereas the latter only works in 1 quadrant.
      For DSP, you almost always need the 4 quadrant version. <a href="#fnref:atan2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:lut" role="doc-endnote">
      <p>This lookup table can be reduced further by exploiting symmetry along the circle. <a href="#fnref:lut" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">The Stunning Efficiency and Beauty of the Polyphase Channelizer</title><link href="https://tomverbeure.github.io/2026/02/16/Polyphase-Channelizer.html" rel="alternate" type="text/html" title="The Stunning Efficiency and Beauty of the Polyphase Channelizer" /><published>2026-02-16T10:00:00+00:00</published><updated>2026-02-16T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/02/16/Polyphase-Channelizer</id><content type="html" xml:base="https://tomverbeure.github.io/2026/02/16/Polyphase-Channelizer.html"><![CDATA[<p><em>All words in this blog post were written by a human being.</em></p>

<script async="" src="https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS_CHTML"></script>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#where-we-left-things-last-time" id="markdown-toc-where-we-left-things-last-time">Where We Left Things Last Time</a></li>
  <li><a href="#sidestep-ignoring-linear-phase-fir-coefficient-symmetry" id="markdown-toc-sidestep-ignoring-linear-phase-fir-coefficient-symmetry">Sidestep: Ignoring Linear Phase FIR Coefficient Symmetry</a></li>
  <li><a href="#naive-performance-baseline" id="markdown-toc-naive-performance-baseline">Naive Performance Baseline</a></li>
  <li><a href="#straightforward-polyphase-filtering-and-decimation" id="markdown-toc-straightforward-polyphase-filtering-and-decimation">Straightforward Polyphase Filtering and Decimation</a></li>
  <li><a href="#a-free-running-rotator" id="markdown-toc-a-free-running-rotator">A Free-Running Rotator</a></li>
  <li><a href="#from-low-pass-to-band-pass-filter" id="markdown-toc-from-low-pass-to-band-pass-filter">From Low Pass to Band Pass Filter</a></li>
  <li><a href="#disappearing-the-complex-rotator" id="markdown-toc-disappearing-the-complex-rotator">Disappearing the Complex Rotator</a></li>
  <li><a href="#moving-another-rotator-behind-the-filter-and-more-again" id="markdown-toc-moving-another-rotator-behind-the-filter-and-more-again">Moving Another Rotator behind the Filter and More… Again</a></li>
  <li><a href="#the-polyphase-channelizer" id="markdown-toc-the-polyphase-channelizer">The Polyphase Channelizer</a></li>
  <li><a href="#from-theory-to-practice" id="markdown-toc-from-theory-to-practice">From Theory to Practice</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>In the past 2 blog posts, I wrote about 
<a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">polyphase decimation filters</a>
and <a href="/2026/02/07/Complex-Heterodyne.html">complex heterodynes</a>, the latter with
some decimation thrown in for good measure.</p>

<p>It’s now time to put everything together, and more. First, I’ll look at
the complex heterodyne/decimation combo and see how it can be implemented as
efficiently as possible. There’s already some surprises in there, but to
top it off, I’ll expand the solution to do the operation for multiple
channels in parallel.</p>

<p>The result is amazing.</p>

<p>I’m still roughly following the flow of 
<a href="https://www.youtube.com/watch?v=afU9f5MuXr8">fred harris’ video about polyphase filter banks</a><sup id="fnref:harris" role="doc-noteref"><a href="#fn:harris" class="footnote" rel="footnote">1</a></sup>,
but I’ll be making some detours along the way because they helped me to put things
better in context and help me with understanding the topic.</p>

<p>There’s a lot more math<sup id="fnref:math" role="doc-noteref"><a href="#fn:math" class="footnote" rel="footnote">2</a></sup> this time around, out of necessity: some of the optimizations
can’t be figured out with intuition alone. But the math consist almost exclusively of
shuffling around sums and products of scalar values and complex exponentials, with a
convolution here and there.</p>

<p>For those who don’t want to read previous installments of this series, check out the section with
<a href="/2026/02/07/Complex-Heterodyne.html#some-common-dsp-notations">Some Common DSP Notations</a>
if you need a quick refresher about the meaning of some of the symbols.</p>

<p>The NumPy code that was used to create the plots in this series can be found
<a href="https://github.com/tomverbeure/polyphase_blog_series">here</a>.</p>

<h1 id="where-we-left-things-last-time">Where We Left Things Last Time</h1>

<p>I ended my <a href="/2026/02/07/Complex-Heterodyne.html">blog post about complex heterodynes</a>
with a question about the efficiency of implementing them as a low pass filter that is 
followed by a decimation. In <a href="https://youtu.be/afU9f5MuXr8?t=552">the video</a>, harris
calls this the Armstrong<sup id="fnref:armstrong" role="doc-noteref"><a href="#fn:armstrong" class="footnote" rel="footnote">3</a></sup> heterodyne.</p>

<p>Here’s a quick recap of that pipeline:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-rot_lpf_decim.svg" alt="Rotator, LPF, decimator" /></p>

<ul>
  <li>\(f_c\) is the normalized center frequency of the channel that we’re interested in. 
In our example, the sample rate \(F_s = 100 \text{MHz}\) and the channel center frequency
\(F_c = 20 \text{MHz}\) so \(f_c = 0.2\). Further down, I’ll often use \(\theta_c = 2 \pi f_c\)
because that makes equations less cluttered.</li>
  <li>\(e^{-j 2 \pi f_c n}\) is a rotator. When multiplied with the input signal, it shifts down a 
channel with center frequency \(F_c\) down to 0 Hz. That’s the complex heterodyne.</li>
  <li>\(H_\text{lpf}(z)\) is a low-pass FIR filter with 201 real taps and a linear phase<sup id="fnref:linear_phase" role="doc-noteref"><a href="#fn:linear_phase" class="footnote" rel="footnote">4</a></sup>. It
removes all the frequencies outside the -5 MHz to 5 MHz range.</li>
  <li>Each channel has a 10 MHz bandwidth. Since there is no mirror spectrum due to the complex
heterodyne, once the channel has been moved to 0 Hz, we can decimate by a factor 10 so that
the range from -5 MHz to 5 MHz is all that’s left.</li>
</ul>

<p>Check out my <a href="/2026/02/07/Complex-Heterodyne.html#some-common-dsp-notations">section with common DSP notations</a>
for a general overview of symbols used in DSP math formulas.</p>

<h1 id="sidestep-ignoring-linear-phase-fir-coefficient-symmetry">Sidestep: Ignoring Linear Phase FIR Coefficient Symmetry</h1>

<p>Linear phase FIR filters have the desirable property that their coefficients are symmetric
around the center tap. Here’s a random example:</p>

\[H(z) = -1 + 3 z^{-1} - 6 z^{-2} + 10 z^{-3} - 6 z^{-4} + 3 z^{-5} - z^{-6}\]

<p>This filter has 7 coefficients, the center coefficient is 10, the ones to the left and right of 
it are both -6 and so forth.</p>

<p>When you convert a DSP algorithm to hardware that needs to consume an input sample and produce
and output for every clock tick, the straightforward implementation is to have one multiplier per
coefficient<sup id="fnref:multiplier" role="doc-noteref"><a href="#fn:multiplier" class="footnote" rel="footnote">5</a></sup>.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-fir_no_optimized_muls.svg" alt="FIR without optimized multipliers" /></p>

<p>Since multipliers are often a scarce resource, you can reduce their number by
almost half by rearranging the equation as follows:</p>

\[H(z) = -1 (1 + z^{-6})  + 3 (z^{-1} + z^{-5} ) - 6 (z^{-2} + z^{-4})  + 10 z^{-3}\]

<p>We’ve removed 3 out of 7 multipliers.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-fir_optimized_muls.svg" alt="FIR with optimized multipliers" /></p>

<p>This works, but you need to trade off the reduction in multipliers against an increase in wiring
to get the 2 operands to the addition that feeds the multiplier. On FPGAs, wiring congestion is
a real concern so it’s not always a slam dunk.</p>

<p>If you have a hardware architecture where delayed inputs are stored in a RAM instead of individual
registers and you use an FSM to execute the filter over multiple clock cycles, trying to do this
trick can make scheduling transactions more complicated too.</p>

<p>And when converting the FIR filter into its polyphase form, the simple symmetry breaks entirely. Here’s
an example of a symmetrical 19-tap filter. In its original form, coefficients are symmetric, but
when split up into 10 phases, the symmetry inside each phase is gone.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-tap_symmetry_19.svg" alt="19-tap filter split up into 10 phases" /></p>

<p>It’s still possible to share multiplications if you merge multiple phases, note how phase 2 has
coefficients 6 and 2 and phase 7 has coefficients 2 and 6, but that again makes data organization
and movement more difficult.</p>

<p>For the remainder of this blog post, I will ignore symmetry related optimizations when calculating
the number of multiplications.</p>

<h1 id="naive-performance-baseline">Naive Performance Baseline</h1>

<p>I will use multiplication as the main indicator by which to judge the efficiency of a DSP algorithm.</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-rot_lpf_decim.svg" alt="Rotator, LPF, decimator" /></p>

<p>Let’s evaluate the number of multiplications for the naive architecture:</p>

<ul>
  <li>The complex rotator multiplies a real sample with a complex number or 2 multiplications per operation
and 200M per second.</li>
  <li>The low pass filter has 201 real taps, for a total of 201 x 2 x 100M = 
40.2B operations per second.</li>
</ul>

<p>Total: 40.4B multiplications per second!</p>

<p>This is our baseline, and it’s a lot. Let’s see what we can do about this…</p>

<h1 id="straightforward-polyphase-filtering-and-decimation">Straightforward Polyphase Filtering and Decimation</h1>

<p>There’s a reason why I also wrote 
<a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">Notes about Basic Polyphase Decimation Filters</a>:
it discusses exactly this kind of scenario, the combo of an FIR filter followed by a decimation. Yes,
there’s a complex rotator in front of the FIR filter, but for now we can keep it there 
while we transform the FIR/decimator to its polyphase form.</p>

<p>harris mentions this case only tangentially, but it’s useful to compare how well the straightforward
polyphase filter bank performs compared to the naive solution.</p>

<p>First split the FIR filter into its polyphase form with 10 sub-filters, the decimation factor:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-complex_het_polyphase_decim.svg" alt="Complex heterodyne - Polyphase - Decimation" /></p>

<p>Apply the <a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html#the-noble-identity-for-decimation">noble identity for decimation</a>:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-complex_het_decim_polyphase.svg" alt="Complex heterodyne - Decimation - Polyphase" /></p>

<p>Moving the FIR filter operation behind the decimator is a huge savings. The complex rotator still counts
for 200M multiplications per second, but the combined 201 taps now need to deliver samples at a 10 times
lower rate, 201 x 2 x 10M = 4.02B operations per second, for a total of 4.22B operations per second. If
it weren’t for the complex rotator, the savings ratio is exactly the decimation factor.</p>

<p>The biggest problem with this arrangement is that the rotator is in front of the decimator and there is
no obvious way to move it behind the decimator. If the DSP pipeline is implemented in an FPGA and the
input sample clock is very high, the multiplier hardware may simply not be fast enough.</p>

<h1 id="a-free-running-rotator">A Free-Running Rotator</h1>

<p>One minor thing to note is that the rotator consists of the input signal being multiplied by
the output of a free-running oscillator. Free-running implies that there are no restrictions on 
the starting phase of the oscillator.</p>

<p>In the previous diagram, sample \(x[n]\) is multiplied by \(e^{-j \theta_c n}\), sample \(x[n+1]\) by 
\(e^{-j \theta_c (n+1)}\), and so forth, but that’s really arbitrary. We could multiply \(x[n]\) by \(e^{-j \theta_c (n+1)}\) 
and \(x[n+1]\) by \(e^{-j \theta_c (n +2)}\) and the outcome in terms of frequency characteristics 
wouldn’t be materially different (though there would be constant phase shift.)</p>

<p>What is true is that you have to continuously loop through all the values of the rotator, irrespective
of the length of the number of filter taps: if the rotator completes a full rotation in 128<sup id="fnref:steps" role="doc-noteref"><a href="#fn:steps" class="footnote" rel="footnote">6</a></sup> steps, then
you’ll need a table or a calculation<sup id="fnref:unity_point_calculation" role="doc-noteref"><a href="#fn:unity_point_calculation" class="footnote" rel="footnote">7</a></sup> to produce 128 points around the unity circle.</p>

<p>We’ll soon see that this isn’t the case in other schemes.</p>

<h1 id="from-low-pass-to-band-pass-filter">From Low Pass to Band Pass Filter</h1>

<p>Let’s undo the previous polyphase optimization, start again from the naive solution, and try 
something different.</p>

<p>So far, we have been heterodyning the channel of interest to the baseband and then sent it through a low-pass filter, 
as seen in the plot from previous blog post:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-low_pass_filter.svg" alt="Complex heterodyne followed by low pass filter spectrum" /></p>

<p>Can we turn the order around, first send the channel of interest through a band-pass filter and then heterodyne
the result down to baseband? As <a href="https://youtu.be/afU9f5MuXr8?t=597">harris points out</a>, the Armstrong heterodyne
was created to avoid that, because a movable band-pass filter requires mechanically tuned capacitors and inductors.
In the DSP world, however, it’s just numbers and calculations.</p>

<p>So, yes, we can do the filtering first and then do the heterodyne, and it’s relatively easy to show that mathematically.</p>

<p><em>In what follows, I will deviate from the harris’s notation in 2 ways. He uses \(a[n] * b[n]\) for convolution. \((a * b)[n]\)
is the more common way. He also overloads the meaning of \(n\) in the same equation, in a way that I found utterly
confusing. Instead, I will use the \([\cdot]\) and \((\cdot)\) notation, where \(\cdot\) is essentially a
temporary local loop variable. If you see a \(\cdot\) in the equations below, assume that harris had a \(n\) there.</em></p>

<p>Starting with this:</p>

\[y[n] = \big( \underbrace{ \underbrace{(x[\cdot] e^{-j \theta_c (\cdot)})}_{\text{heterodyne}} * h\big)[n]}_{\text{low-pass filter}}\]

<p>\(*\) is the convolution operator, in this case, a 
<a href="https://en.wikipedia.org/wiki/Convolution#Discrete_convolution">discrete convolution</a>.
Let’s expand the equation by applying the definition of the convolution:</p>

\[y[n] = \sum_{k=0}^{N-1} (x[n-k] e^{-j \theta_c (n-k)}) \; h[k]\]

<p>\(N\) is the number of coefficients of the filter.</p>

<p>Extract the common exponential term that doesn’t depend on \(k\):</p>

\[y[n] = e^{-j \theta_c n} \sum_k x[n-k] \; ( e^{j \theta_c k} h[k] )\]

<p>Reduce back to a convolution operator:</p>

\[\begin{alignedat}{0}
y[n] &amp; = &amp; e^{-j \theta_c n} \; \big( x * (h[\cdot] e^{j \theta_c (\cdot)} ) \big)[n] \\ 
     &amp; = &amp; \big( x * (h[\cdot] e^{j \theta_c (\cdot)} ) \big)[n] \; e^{-j \theta_c n}
\end{alignedat}\]

<p>We’ve just proven what, <a href="https://youtu.be/afU9f5MuXr8?t=985">in the video</a>, harris calls the 
<em>Equivalency Theorem</em>:</p>

\[\big(( x[\cdot] e^{-j \theta_c (\cdot)} ) * h\big)[n] = e^{-j \theta_c n} \; \big( x * (h[\cdot] e^{j \theta_c (\cdot)} ) \big)[n]\]

<p>There’s one minor comment about this: while Google turns up plenty of equivalency
theorems, none of them deal with the swapping around a heterodyne and convolution. The only reference<sup id="fnref:equivalency" role="doc-noteref"><a href="#fn:equivalency" class="footnote" rel="footnote">8</a></sup> 
that I found was in section 6.1 of his own book, 
<a href="https://www.amazon.com/Multirate-Processing-Communication-Systems-Publishers-dp-877022210X/dp/877022210X/">Multirate Signal Processing for Communication Systems</a><sup id="fnref:book" role="doc-noteref"><a href="#fn:book" class="footnote" rel="footnote">9</a></sup>,
which has the same formulas and figures as the one of the video. It says:</p>

<blockquote>
  <p>The equivalency theorem states that the operations of a down-conversion followed by a low-pass 
filter are totally equivalent to the operations of a band-pass filter followed by a down-conversion.</p>
</blockquote>

<p>Anyway, this transformation doesn’t look like an improvement, and it will take a while before we 
can see how this helps us.  For now, let’s break the equation into pieces and look at them step by step.</p>

\[h[\cdot] e^{j \theta_c (\cdot)}\]

<p>The coefficients of the low-pass filter with transfer function  \(H_\text{lpf}(z)\) are each multiplied by 
a value of a rotator. Notice how the \(-\) sign in front of the \(j\) exponent of the rotator has
disappeared: when we were heterodyning the channel, we were bringing the spectrum <em>down</em> to baseband.
Now, we’re doing the opposite and heterodyning the low-pass filter <em>up</em> to channel band!</p>

<p>Let’s apply the equation above to an example. If the transfer function of the original filter is this:</p>

\[H_\text{lpf}(z) = h_0 z^{0} + h_1 z^{-1} + h_2 z^{-2} + h_3 z^{-3} + h_4 z^{-4}\]

<p>Then the new filter is this:</p>

\[\begin{alignedat}{0}
H_\text{bpf}(z) &amp; = &amp; h_0 e^{j \theta_c 0} z^{0} &amp;+&amp; h_1 e^{j \theta_c 1} z^{-1} &amp;+&amp; h_2 e^{j \theta_c 2} z^{-2} &amp;+&amp; h_3 e^{j \theta_c 3} z^{-3} &amp;+&amp; h_4 e^{j \theta_c 4} z^{-4} \\
                &amp; = &amp; h_0 (e^{-j \theta_c} z)^{0} &amp;+&amp; h_1 (e^{-j \theta_c} z)^{-1} &amp;+&amp; h_2 (e^{-j \theta_c} z)^{-2} &amp;+&amp; h_3 (e^{-j \theta_c} z)^{-3} &amp;+&amp; h_4 (e^{-j \theta_c} z)^{-4} \\
\end{alignedat}\]

<p>This can be written much shorter, useful for drawings, like this:</p>

\[H_\text{bpf}(z) = H_\text{lpf}(e^{-j \theta_c} z)\]

<p>It is important to note that the coefficients of \(H_\text{bpf}(z)\) are constants: for a given center frequency, we can
pre-calculate the coefficients and never change them again. And contrary to the free-running rotator that
shifted down the spectrum of the input signal, the number of rotator values to shift up the filter is fixed to the
number of filter taps. However, compared to the original filter \(H_\text{lpf}(z)\), the coefficients are now complex 
instead of real.</p>

<p>To simulate the behavior of this band-pass filter, we create an array with as many complex rotator values
as there are filter taps and multiply them with the low-pass filter coefficients from previous blog:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tap_idx</span>       <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">arange</span><span class="p">(</span><span class="n">LPF_FIR_TAPS</span><span class="p">)</span>
<span class="n">complex_lo</span>    <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="mf">1j</span> <span class="o">*</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">lo_freq_hz</span> <span class="o">*</span> <span class="n">tap_idx</span> <span class="o">/</span> <span class="n">sample_clock_hz</span><span class="p">)</span>
<span class="n">h_bpf_complex</span> <span class="o">=</span> <span class="n">h_lpf</span> <span class="o">*</span> <span class="n">complex_lo</span>
</code></pre></div></div>

<p>Looking at the spectrum of this filter, there are no surprises: the filter has been transformed from a
low-pass filter to a band-pass filter with \(F_c = 20 \text{MHz}\) as center frequency:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het_sim-bpf_complex_filtered.svg" alt="Bandpass filter spectrum and filtered input signal" /></p>

<p>The second plot of the figure above shows the input signal after applying the band-pass filter.</p>

\[x[n] * h_\text{bpf}[n]\]

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">signal_bpf_complex</span>  <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">convolve</span><span class="p">(</span><span class="n">signal</span><span class="p">,</span> <span class="n">h_bpf_complex</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="s">"same"</span><span class="p">)</span>
</code></pre></div></div>

<p>The final step shifts the filtered signal back to baseband:</p>

\[y[n] = ( x[n] * h_\text{bpf}[n] ) \; e^{-j \theta_c n}\]

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-bpf_het_decim.svg" alt="Pipeline with band-pass filter, heterodyne and decimation" /></p>

<p>After decimation, we end up with <a href="/2026/02/07/Complex-Heterodyne.html#decimation">the same result in the previous blog post</a>:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het_sim-signal_bfp_filtered_decim_complex.svg" alt="Bandpass filter, followed by heterodyne and decimation" /></p>

<p>Cool! But what did we gain?</p>

<p>The input to the filter is now real instead of complex, but the coefficents are now complex instead of 
real. So the number of multiplications in the filter remains the same. And the heterodyne now multiplies 2 
complex numbers instead of multiplying a real input with a complex. We’ve regressed!</p>

<p>But that’s something that will be fixed in the next section…</p>

<h1 id="disappearing-the-complex-rotator">Disappearing the Complex Rotator</h1>

<p>In the straightforward case, we had to switch to a polyphase decomposition to move the decimator from behind
the filter to in front of the filter. But that decomposition introduces single timestep delays which
prevents moving the decimator even further to the front of the rotator.</p>

<p>This is not the case anymore: the rotator is behind the filter and there are no delay elements. This 
allows us to move the decimator before the rotator.</p>

<p>Here’s the rotator before decimation:</p>

\[e^{-j \theta_c n}\]

<p>When we decimate by a factor of M, the rotator completes a circle by a factor M less steps than before
the decimation. Or the angle by which the rotator moves forward each step is now M times larger.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-decimated_phasor.svg" alt="Original vs decimated rotator" /></p>

<p>After decimation, the exponent of the rotator now has factor \(M\) added to it:</p>

\[e^{-j \theta_c M m}, m = \lfloor \frac{n}{M} \rfloor\]

<p>where \(\lfloor x \rfloor\) means “\(x\) rounded down to the closest integer number”.</p>

<p>Since the decimator and the rotator have swapped positions, the earlier problem of having to run the
rotator at the input sample rate has been solved!</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-bpf_decim_het.svg" alt="Pipeline with band-pass filter, decimation, and heterodyne" /></p>

<p>But we can do better! The rotator can disappear entirely if its value is equal to one at all times.</p>

\[e^{-j \theta_c M m} \stackrel{?}{=} 1 + 0j\]

<p>We don’t want this to be dependent on \(m\), so we’re really trying to find a solution for this equation:</p>

\[e^{-j \theta_c M } \stackrel{?}{=} 1 + 0j\]

<p>The rotator is one whenever it makes a full circle or whenever the exponent is an integer multiple of \(2 \pi\).</p>

\[\theta_c M = k \; 2 \pi\]

<p>Replace \(\theta_c\) with its definition:</p>

\[2 \pi \frac{F_c}{F_s} M = k \; 2 \pi\]

<p>Simplify and rearrange:</p>

\[F_c = k \frac{F_s}{M} \\
\theta_c = \frac{2 \pi k}{M}\]

<p>It doesn’t seem like it, but this is a crucial result:</p>

<p><strong>If the center frequency of your channel is a multiple of the sample rate divided by the decimation 
factor, the decimated rotator will always evaluate to 1 and thus the multiplication disappears entirely.</strong></p>

<p>In our example with \(F_s=100 \text{MHz}\), \(M=10\), \(F_c=20 \text{MHz}\), this
equation is satisfied for \(k=2\), and we end up with this:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-bpf_decim.svg" alt="Band-pass filter and decimation" /></p>

<p>If all of this feels a bit familiar, it’s probably because you’ve heard about 
<a href="https://en.wikipedia.org/wiki/Undersampling">undersampling or band-pass sampling</a>. 
It’s what happens when you deliberately violate the 
<a href="https://en.wikipedia.org/wiki/Nyquist–Shannon_sampling_theorem">Nyquist theorem</a>,
sample at a rate that is much lower than twice the bandwidth of a signal, but do it in such
a way that the spectrum of the signal aliases exactly where you want it to be: at baseband.</p>

<p><img src="/assets/polyphase/polyphase_het/sampling-undersampling.svg" alt="Undersampling" /></p>

<p>Band-pass sampling only works if there are no stray frequency components outside the channel,
which is why preprocessing the input with a band-pass filter is essential.</p>

<p>Even with complex filter coefficients, we can still do the polyphase decomposition and 
move the decimator before the set of filters:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-decim_poly_bpf.svg" alt="Decimator, polyphase band-pass filter" /></p>

<p>Tadaa! All elements of the pipeline can now run at the decimated output sample rate.</p>

<p>Last time we checked, we needed 4.22B multiplications per second. With the complex rotator gone,
we’re now at 4.02B: just a filter with 201 complex taps, fed with a real value, executed 10M 
times per second.</p>

<p>A pitiful 5% savings is not worth writing home about, but we can do even better.</p>

<p><em>Note: even if we don’t satisfy the \(F_c = k \frac{F_s}{M}\) condition, we’re still better off
than before, because the rotator still runs at the output instead of the input sample rate:</em></p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-decim_poly_bpf_het.svg" alt="Pipeline with decimation, polyphase band-pass filters, and heterodyne" /></p>

<p><em>This blog post is already long as it is, so for this one, I’m focussing only on the case
where the center frequency condition is satisfied.</em></p>

<h1 id="moving-another-rotator-behind-the-filter-and-more-again">Moving Another Rotator behind the Filter and More… Again</h1>

<p>Let’s play another game of shuffling around sums and terms. So far, we’ve only engaged
the polyphase decomposition after the fact, to lower the number of filter calculations. 
This time we’re adding the polyphase decomposition explicitly to the mathematical mix
for additional benefits.</p>

<p>Here’s where we left it last time:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-bpf_decim.svg" alt="Band-pass filter and decimation" /></p>

<p>Let’s move our attention to the transfer function of filter:</p>

\[\begin{alignedat}{0}
H_\text{bpf}(z) &amp; = &amp; H_\text{lpf}(e^{-j \theta_c} z) \\
           &amp; = &amp; \sum_{n=0}^{N-1} h[n] (e^{-j \theta_c } z)^{-n}  \\
           &amp; = &amp; h_0 e^{j \theta_c 0} z^{ 0} &amp;+&amp; h_1 e^{j \theta_c 1} z^{-1} &amp;+&amp; h_2 e^{j \theta_c 2} z^{-2} &amp;+&amp; 3 e^{j \theta_c 3}  z^{-3} &amp;+&amp; ... 
\end{alignedat}\]

<p>Do the polyphase decomposition. Instead of summing all the terms of the full \(h[n]\) polynomial in one go,
we sum the terms of \(M\) different polyphase polynomials separately, and then add them together:</p>

\[= \sum_{m=0}^{M-1} \sum_{n=0}^{N-1} h[m + Mn] e^{j \theta_c (m + Mn)} z^{-(m+Mn)} \\\]

<p>When studying this step <a href="https://youtu.be/afU9f5MuXr8?t=1480">in the video</a>, it took
me a minute to understand what happened with \(h[n]\). In the first equation,
\(n = 0 ... N-1\), where \(N\) is the number of coefficients. In the equation above, 
the range of \(n\) doesn’t change, but now it’s used like this: \(h[m + Mn]\). The
maximum index of \(h\) now goes beyond the number of coefficients. This isn’t a problem,
though, as long as you keep in mind that \(h[n]\) is \(\color{red}{0}\) when \(n\) is smaller than 0
or larger than \(N-1\).</p>

<p>To make things really clear, let’s expand all these sums and products for a 9-tap
filter with decimation factor \(M=3\):</p>

\[\begin{alignedat}{0}
H_\text{bpf}(z) &amp; = &amp;  h_0 e^{j \theta_c 0} z^{ 0} &amp;+&amp; h_3 e^{j \theta_c 3} z^{-3} &amp;+&amp; h_6 e^{j \theta_c 6} z^{-6} &amp;+&amp; \color{red}{0} \, e^{j \theta_c 9}  z^{-9}  &amp;+&amp; ... &amp;&amp; \qquad (m = 0) \\
           &amp; + &amp;  h_1 e^{j \theta_c 1} z^{-1} &amp;+&amp; h_4 e^{j \theta_c 4} z^{-4} &amp;+&amp; h_7 e^{j \theta_c 7} z^{-7} &amp;+&amp; \color{red}{0} \, e^{j \theta_c 10} z^{-10} &amp;+&amp; ... &amp;&amp; \qquad (m = 1) \\
           &amp; + &amp;  h_2 e^{j \theta_c 2} z^{-2} &amp;+&amp; h_5 e^{j \theta_c 5} z^{-5} &amp;+&amp; h_8 e^{j \theta_c 8} z^{-8} &amp;+&amp; \color{red}{0} \, e^{j \theta_c 11} z^{-11} &amp;+&amp; ... &amp;&amp; \qquad (m = 2) \\
\end{alignedat}\]

<p>In each of the polyphase sub-filters, the factor \(e^{j \theta_c m} z^{-m}\) is independent of \(n\)
and can be moved ahead of the inner sum:</p>

\[=  \sum_{m=0}^{M-1} e^{j \theta_c m} z^{-m} \sum_{n=0}^{N-1} h[m + Mn] e^{j \theta_c Mn} z^{-Mn} \\\]

\[\begin{alignedat}{0}
H_\text{bpf}(z) &amp; = &amp; e^{j \theta_c 0} z^{ 0} &amp; \big(  h_0 e^{j \theta_c 0} z^{0} &amp;+&amp; h_3 e^{j \theta_c 3} z^{-3} &amp;+&amp; h_6 e^{j \theta_c 6} z^{-6} \big)  &amp;&amp; \qquad (m = 0) \\
           &amp; + &amp; e^{j \theta_c 1} z^{-1} &amp; \big(  h_1 e^{j \theta_c 0} z^{0} &amp;+&amp; h_4 e^{j \theta_c 3} z^{-4} &amp;+&amp; h_7 e^{j \theta_c 6} z^{-7} \big)  &amp;&amp; \qquad (m = 1) \\
           &amp; + &amp; e^{j \theta_c 2} z^{-2} &amp; \big(  h_2 e^{j \theta_c 0} z^{0} &amp;+&amp; h_5 e^{j \theta_c 3} z^{-5} &amp;+&amp; h_8 e^{j \theta_c 6} z^{-8} \big)  &amp;&amp; \qquad (m = 2) \\
\end{alignedat}\]

<p>Now look back at the previous section where we figured out the condition to eliminate the rotator. In the equation
above, we see \(e^{j \theta_c Mn }\), which contains \(e^{j \theta_c M }\). This is exactly the same rotator
that we eliminated before.</p>

<p>In other words, when using the same restriction \(F_c = k \frac{F_s}{M}\), the rotator in the products of the
inner sum simply disappears and we end up with this:</p>

\[=  \sum_{m=0}^{M-1} e^{j \theta_c m} z^{-m} \sum_{n=0}^{N-1} h[m + Mn] z^{-Mn} \\\]

\[\begin{alignedat}{0}
H_\text{bpf}(z) &amp; = &amp; e^{j \theta_c 0} z^{ 0} &amp; \big(  h_0 z^{0} &amp;+&amp; h_3 z^{-3} &amp;+&amp; h_6 z^{-6} \big)  &amp;&amp; \qquad (m = 0) \\
           &amp; + &amp; e^{j \theta_c 1} z^{-1} &amp; \big(  h_1 z^{0} &amp;+&amp; h_4 z^{-4} &amp;+&amp; h_7 z^{-7} \big)  &amp;&amp; \qquad (m = 1) \\
           &amp; + &amp; e^{j \theta_c 2} z^{-2} &amp; \big(  h_2 z^{0} &amp;+&amp; h_5 z^{-5} &amp;+&amp; h_8 z^{-8} \big)  &amp;&amp; \qquad (m = 2) \\
\end{alignedat}\]

<p>Or abbreviated:</p>

\[H_\text{bpf}(z) = \sum_{m=0}^{M-1} e^{j \theta_c m} z^{-m} H_m(z^M)\]

<p>Furthermore:</p>

\[\theta_c = k \frac{2 \pi}{M}\]

<p>So we end up with this:</p>

\[H_\text{bpf}(z) = \sum_{m=0}^{M-1} e^{j \frac{2 \pi}{M} k m} z^{-m} H_m(z^M)\]

<p>\(e^{j \frac{2 \pi}{M} k m}\) is a scalar value, so we can move the multiplication 
behind the filter:</p>

\[H_\text{bpf}(z) = \sum_{m=0}^{M-1} z^{-m} H_m(z^M) e^{j \frac{2 \pi}{M} k m}\]

\[\begin{alignedat}{0}
H_\text{bpf}(z) &amp; = &amp; z^{ 0} &amp; \big(  h_0 z^{0} &amp;+&amp; h_3 z^{-3} &amp;+&amp; h_6 z^{-6} \big) e^{j \frac{2 \pi}{M} k 0} \\
           &amp; + &amp; z^{-1} &amp; \big(  h_1 z^{0} &amp;+&amp; h_4 z^{-4} &amp;+&amp; h_7 z^{-7} \big) e^{j \frac{2 \pi}{M} k 1} \\
           &amp; + &amp; z^{-2} &amp; \big(  h_2 z^{0} &amp;+&amp; h_5 z^{-5} &amp;+&amp; h_8 z^{-8} \big) e^{j \frac{2 \pi}{M} k 2} \\
\end{alignedat}\]

<p>Here’s how that looks as a diagram:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-poly_real_bpf_het_decim.svg" alt="Polyphase with real band-pass filter, heterodyne, decimator" /></p>

<p>As a final step, we can move the decimator back to the front by applying the noble identity on the 
polyphase sub-filters. Note that this time, the rotator exponent is not multiplied by \(M\), because
the exponent is a fixed value, not a changing rotator.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-decim_poly_real_bpf_het.svg" alt="Polyphase with decimator, real band-pass filter, heterodyne" /></p>

<p>This is a truly remarkable outcome:</p>

<ul>
  <li>
    <p><strong>All math operations happen at a slow rate behind the decimators.</strong></p>

    <p>We can do this because of the noble identies that give us the polyphase
transformations and because the rotators are located after the filters.</p>
  </li>
  <li>
    <p><strong>The inputs to the filters are real.</strong></p>

    <p>We achieved this by applying the equivalency theorem.</p>
  </li>
  <li>
    <p><strong>The coefficients of the polyphase filters are real again.</strong></p>

    <p>We did this by extracting the rotators from the filters, and removing the 
frequency-dependent component.</p>
  </li>
  <li>
    <p><strong>The coefficients don’t depend on the targeted channel frequency.</strong></p>

    <p>We did this also by extracting the rotators from the filters (as long as the channel \(k\) 
meets our criterion above of being an integer multiple of the decimation rate).</p>
  </li>
  <li>
    <p><strong>The rotators are located behind the filters.</strong></p>

    <p>This will be very important in the next section.</p>
  </li>
</ul>

<p>The importance of the last 2 points can’t be overstated: if you want to change the channel \(k\) that needs to
be brought to base band from one to another, all you need to change are the rotators.</p>

<p>Compared to the last checkpoint, the resource requirements have also been reduced roughly by half:</p>

<ul>
  <li>the 201 filter taps are multiplied by a real input at a rate of 10M per second = 2.01B multiplications.</li>
  <li>10 rotators multiply the real output of the filters by a complex number at 10M per second = 200M multiplications.</li>
</ul>

<p>Total: 2.21B multiplications.</p>

<p>Our naive initial baseline was 40.4B multiplications per second, we’ve reduced that number by a factor of 20.</p>

<p>And still we are not done…</p>

<h1 id="the-polyphase-channelizer">The Polyphase Channelizer</h1>

<p>So far, we’ve focused on finding an optimal solution to extracting the signal of one channel to baseband, 
out of many possible channels. We’re now expanding our scope: what if we want to extract the signal of all channels in parallel?</p>

<p>This is where the conclusion of previous section pays off ever more: since only the final rotators are channel
dependent, all we need is an additional set of rotators for each extra channel. The filters remain untouched. That’s
a huge win: from the resource calculation, we can already conclude that the filters tend to require the
large majority of multipliers. And that’s for a filter with 201 taps, which is relatively modest. In today’s
world, channels are often stacked one next to the other with a very narrow transition band and narrow
transition bands require very steep filters to separate one from the other.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-polyphase_multi_chan.svg" alt="Polyphase channelizer with 2 channels" /></p>

<p>In the DSP pipeline above, 2 channels are brought to baseband resulting in 2 time domain signals \(s_k[n]\)
and \(s_l[n]\), but the number of channels that can be extracted efficiently is really only limited by the
decimation factor (due to the \(F_c = k F_s / M\) requirement.)</p>

<p>If we have a decimation factor of \(M\) and we want to extract \(M\) channels in parallel, then we’re looking at
\(M (M-1)\)<sup id="fnref:nr_rotators" role="doc-noteref"><a href="#fn:nr_rotators" class="footnote" rel="footnote">10</a></sup> rotators or \(2 M (M-1)\) multipliers. In his video, harris talks about 
<a href="https://youtu.be/afU9f5MuXr8?t=2075">a polyphase channelizer with 65536 channels</a>. There are many cases where
\(O(n^2)\) is good enough and suddenly it’s not. 4,294,901,760 complex multiplications to calculate 65536 output samples
is not good enough.</p>

<p>Let’s look at the rotator section for a single channel:</p>

\[s_k[n] = \sum_{m=0}^{M-1} y_m[n] e^{j \frac{2 \pi}{M} k \, m}\]

<p>This calculation must be done for each output time step \(n\), so let’s drop that index. And while we’re at it,
let’s group the outputs \(y_m\) of the filters into their own array, so that we can reference them, for each
time step \(n\), as \(y[m]\) instead of \(y_m\):</p>

\[s_k = \sum_{m=0}^{M-1} y[m] e^{j \frac{2 \pi}{M} k \, m}\]

<p>Does this equation ring a bell? Compare against this:</p>

\[x[n] = \frac{1}{N} \sum_{k=0}^{N-1}{X[k] e^{j \frac{2 \pi}{N} n \, k } }\]

<p><em>Don’t confuse the meanings of \(k\), \(m\), and \(n\). The \(k\) in the first equation
matches the purpose of \(n\) in the second one!</em></p>

<p>This is the definition of inverse<sup id="fnref:inverse" role="doc-noteref"><a href="#fn:inverse" class="footnote" rel="footnote">11</a></sup> 
<a href="https://en.wikipedia.org/wiki/Discrete_Fourier_transform">discrete Fourier transform (DFT)</a>!
Except for the front scaling factor, our equation has the same form.</p>

<p>If, for each time step \(n\), we want the output samples of all channels \(0..M-1\), the DFT will give us
exactly that. That in itself doesn’t solve our problem: it is well known that a naive DFT
implementation has \(O(n^2)\) behavior. But in DSP land, it’s impossible to mention the discrete
Fourier transform without immediately bringing up the 
<a href="https://en.wikipedia.org/wiki/Fast_Fourier_transform">Fast Fourier transform (FFT)</a>,
which has \(O(n \log n)\) behavior.</p>

<p>For a 65536 channel polyphase channelizer, the FFT brings down the number of complex multiplications
from 4,294,901,760 down to 1,048,576. We’re received a second boost-of-efficiency miracle.</p>

<p>Finally, we’re at the end of a journey that gives us this wonderful result:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het-polyphase_ifft.svg" alt="Polyphase filters, IFFT" /></p>

<p>The Fourier transform is known primarily for converting signals from the time domain to the
frequency domain and back, but you don’t have to use it for frequency stuff, as is the case here.
The output of the IFFT in the polyphase channelizer is an array with the samples of all
channels of a given time tick. The IFFT is used as an algorithmic accelerator that has
time domain values at the input and time domain values at the output.</p>

<p>This is as good a time as any to link to my favorite Youtube video of all time:
“The Fast Fourier Transform (FFT): Most Ingenious Algorithm Ever?”</p>

<iframe width="640" height="360" src="https://www.youtube.com/embed/h7apO7q16V0?si=8zM2mOaMBD0byhyb" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>

<p>It develops the FFT algorithm from scratch. And like the polyphase channelizer, it doesn’t use the FFT
for time domain/frequency conversion, but to accelerate the multiplication of polynomials.</p>

<h1 id="from-theory-to-practice">From Theory to Practice</h1>

<p>Let’s put everything together in a simulated example. I’ve created a new signal
that has 2 active channels, with center frequency at 20 MHz and 30 MHz. The 20 MHz channel
has the same peaks as before, the 30 MHz one has 2 different peaks. As before, the
inactive channels have a large noise component.</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het_sim-signal_multi_spectrum.svg" alt="Spectrum of signal with 2 active channels" /></p>

<p>NumPy has all kinds of nice operators to manipulate multi-dimensional arrays, but my
knowledge about them is thin, so the code below won’t be the most canonical way
of doing things.</p>

<p>Split up the taps of low-pass filter <code class="language-plaintext highlighter-rouge">h_lpf</code> into <code class="language-plaintext highlighter-rouge">h_poly</code>, its polyphase decomposition:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">h_poly</span>              <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">((</span><span class="n">DECIM_FACTOR</span><span class="p">,</span> <span class="nb">int</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">ceil</span><span class="p">(</span><span class="n">LPF_FIR_TAPS</span> <span class="o">/</span> <span class="n">DECIM_FACTOR</span><span class="p">))))</span>
<span class="k">for</span> <span class="n">phase</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">DECIM_FACTOR</span><span class="p">):</span>
    <span class="n">phase_taps</span>      <span class="o">=</span> <span class="n">h_lpf</span><span class="p">[</span><span class="n">phase</span><span class="p">::</span><span class="n">DECIM_FACTOR</span><span class="p">]</span>
    <span class="n">h_poly</span><span class="p">[</span><span class="n">phase</span><span class="p">,</span> <span class="p">:</span><span class="nb">len</span><span class="p">(</span><span class="n">phase_taps</span><span class="p">)]</span> <span class="o">=</span> <span class="n">phase_taps</span>
</code></pre></div></div>

<p>Decimate the input signal into 10 different signals, each with a different phase:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">signal_multi_decim</span>  <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">((</span><span class="n">DECIM_FACTOR</span><span class="p">,</span> <span class="nb">int</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">ceil</span><span class="p">(</span><span class="n">NR_SAMPLES</span><span class="o">/</span><span class="n">DECIM_FACTOR</span><span class="p">))))</span>
<span class="k">for</span> <span class="n">phase</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">DECIM_FACTOR</span><span class="p">):</span>
    <span class="n">phase_decim</span>     <span class="o">=</span> <span class="n">signal_multi</span><span class="p">[</span><span class="n">DECIM_FACTOR</span><span class="o">-</span><span class="mi">1</span><span class="o">-</span><span class="n">phase</span><span class="p">::</span><span class="n">DECIM_FACTOR</span><span class="p">]</span>
    <span class="n">signal_multi_decim</span><span class="p">[</span><span class="n">phase</span><span class="p">,</span> <span class="p">:</span><span class="nb">len</span><span class="p">(</span><span class="n">phase_decim</span><span class="p">)]</span> <span class="o">=</span> <span class="n">phase_decim</span>
</code></pre></div></div>

<p>Note the <code class="language-plaintext highlighter-rouge">DECIM_FACTOR-1-phase</code> part. It’s tempting to write <code class="language-plaintext highlighter-rouge">phase</code> there, but that
won’t work. Ask me how I know…</p>

<p>For each phase, apply the decimated input signal to the corresponding polyphase
sub-filter:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">h_poly_out</span>          <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">((</span><span class="n">DECIM_FACTOR</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">signal_multi_decim</span><span class="p">[</span><span class="mi">0</span><span class="p">])))</span>
<span class="k">for</span> <span class="n">phase</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">DECIM_FACTOR</span><span class="p">):</span>
    <span class="n">phase_h_out</span>     <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">convolve</span><span class="p">(</span><span class="n">signal_multi_decim</span><span class="p">[</span><span class="n">phase</span><span class="p">],</span> <span class="n">h_poly</span><span class="p">[</span><span class="n">phase</span><span class="p">],</span> <span class="n">mode</span><span class="o">=</span><span class="s">"same"</span><span class="p">)</span>
    <span class="n">h_poly_out</span><span class="p">[</span><span class="n">phase</span><span class="p">,</span> <span class="p">:</span><span class="nb">len</span><span class="p">(</span><span class="n">phase_h_out</span><span class="p">)]</span> <span class="o">=</span> <span class="n">phase_h_out</span>
</code></pre></div></div>

<p>For each timestep, take the 10 samples from output values of the filters, perform an IFFT,
and store it as the output of 10 channels:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>signal_poly_out    = np.zeros((DECIM_FACTOR, int(np.ceil(NR_SAMPLES/DECIM_FACTOR))), dtype=complex)
for m in range(len(h_poly_out[0])):
    ifft_input  = h_poly_out[:, m]
    ifft_out    = np.fft.ifft(ifft_input)
    signal_poly_out[:, m] = ifft_out
</code></pre></div></div>

<p>Here’s a plot witih the spectra for channels 1 (noise), 2 and 3:</p>

<p><img src="/assets/polyphase/polyphase_het/polyphase_het_sim-signal_poly_out_ch2_ch3.svg" alt="Plot with the spectrum for channels 1, 2 and 3" /></p>

<p>Success!</p>

<h1 id="conclusion">Conclusion</h1>

<p>This was a long story, but I felt that it had to be told in one go to keep all the context
together.</p>

<p>Let’s do a step-by-step recap:</p>

<ul>
  <li>We started with a very naive implementation of a single channel downconverter.</li>
  <li>Using a straightforward polyphase decomposition, we came up with a much more efficient design but
with one major flaw: it still required a rotator that runs at the input sample rate.</li>
  <li>With a bit of algebra, we moved that rotator to the back of the pipeline,
after the decimator. No more units running at the input sample rate!</li>
  <li>A smart choice of the sample rate allowed us to get rid of the rotator altogether.</li>
  <li>Some more algebra allowed us to cut the number of multiplications by half and
isolate all channel specific calculations to the very end of the pipeline.</li>
  <li>With only 1 non-channel specific polyphase filter and different rotators at the back, 
we could expand the pipeline to support multiple channels at low extra cost.</li>
  <li>That cost became even lower by recognizing the presence of an inverse discrete Fourier
transform and using an IFFT to accelerate the calculations.</li>
</ul>

<p>I just love when everything, like a plan, comes beautifully together.</p>

<p>I deliberately left out the parts of the video where harris discusses cases where channel centers
have a fixed offset from where they should be. It would make this blog post even longer, but these
cases are also not fully worked-out in the video. I’ll need more time to digest those parts.</p>

<p>The topics that have been covered in these last 3 blog posts only take around 40 min of a video
that’s 90 min long. The remainder contains a bunch of interesting examples and applications for
polyphase filter banks and polyphase channelizers. I want to dive into those as well.</p>

<p>One thing that I didn’t cover is the intuitive explanation about how polyphase channelizers
work. harris talks about rotating spectra, aliased to the same baseband, that cancel each other
out for different rotators. While I kind of get what he’s trying to say, the truth is that I
currently don’t have the intuition that harris has, so I’ll 
defer <a href="https://youtu.be/afU9f5MuXr8?t=2151">to the video</a> for that.</p>

<p>Many thanks to <a href="https://joshuawise.com">Joshua</a> for reviewing!</p>

<h1 id="references">References</h1>

<ul>
  <li>
    <p><a href="https://www.youtube.com/watch?v=afU9f5MuXr8">Youtube - Recent Interesting and Useful Enhancements of Polyphase Filter Banks: fred harris</a></p>
  </li>
  <li>
    <p><a href="https://dsp.stackexchange.com/questions/96042/understanding-polyphase-filter-banks">Stackexchange - Understanding Polyphase Filter Banks</a></p>
  </li>
  <li>
    <p><a href="https://ieeexplore.ieee.org/document/1193158">IEEE - Digital Receivers and Transmitters Using Polyphase Filter Banks for Wireless Communications</a></p>
  </li>
</ul>

<p><strong>Other blog posts in this series</strong></p>

<ul>
  <li><a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">Notes about Basic Polyphase Decimation Filters</a></li>
  <li><a href="/2026/02/07/Complex-Heterodyne.html">Complex Heterodynes Explained</a></li>
  <li><a href="/2026/03/05/Polyphase-Channelizer-with-Offset.html">Polyphase Channelizers with Frequency Offset - a Bluetooth LE Example</a></li>
</ul>

<p><strong>Source code</strong></p>

<ul>
  <li><a href="https://github.com/tomverbeure/polyphase_blog_series">GitHub - Polyphase Filtering Blog Series</a></li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:harris" role="doc-endnote">
      <p>fred harris insists on writing his name entirely in lower case. But according to
       <a href="https://www.reddit.com/r/DSP/comments/1cyrh9/comment/c9lwtot">this reddit comment</a>
       that’s only true in the time domain. <a href="#fnref:harris" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:math" role="doc-endnote">
      <p>I’ve been spending weeks on this subject now: watching videos, reading books,
     and writing the blog posts. Doing so, I’ve become much more comfortable with the
     math. That’s good for me personally, but it’s ironic that this might make the
     blog posts less accessible for others! <a href="#fnref:math" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:armstrong" role="doc-endnote">
      <p>Edwin Armstrong was the inventor of the superheterodyne receiver that 
          I mentioned in the previous blog post. <a href="#fnref:armstrong" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:linear_phase" role="doc-endnote">
      <p>Whether or not an FIR is linear phase depends on its coefficients, but most
             common methods to determine those result in a linear phase filter. <a href="#fnref:linear_phase" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:multiplier" role="doc-endnote">
      <p>For the sake of argument, I’m assuming the coefficients are programmable so that
           a full-fledged multiplier is needed. If the coefficients are constant, you can
           almost always replace a multiplier by a much cheaper combination of add and shift
           operations. <a href="#fnref:multiplier" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:steps" role="doc-endnote">
      <p>In theory, the number of steps to complete a rotation could be a fractional number. <a href="#fnref:steps" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:unity_point_calculation" role="doc-endnote">
      <p>There are multiple techniques to calculate the next point on a unity circle.
                        The most straightforward one is to do a rotation with a fixed 
                        <a href="https://en.wikipedia.org/wiki/Rotation_matrix">rotation matrix</a>, you that
                        will cost up to 4 multipliers, and you need to watch out for accumulating
                        errors over time. The <a href="https://en.wikipedia.org/wiki/CORDIC">CORDIC</a>
                        algorithm is very popular, requires no multiplication, but requires much
                        more steps per result to achieve the desired precision. <a href="#fnref:unity_point_calculation" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:equivalency" role="doc-endnote">
      <p>There are other references, but all of those are either papers written by harris
            or papers that reference one of his papers or books. <a href="#fnref:equivalency" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:book" role="doc-endnote">
      <p>I’ve only just started reading the book, but so far I really like what I see. <a href="#fnref:book" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:nr_rotators" role="doc-endnote">
      <p>\(M(M-1)\) instead of \(M^2\) because the rotator at the output of \(H_0(z)\) has an exponent of
            0 and thus reduces to 1. <a href="#fnref:nr_rotators" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:inverse" role="doc-endnote">
      <p>It’s inverse because there’s no \(-\) sign in front of \(j\). <a href="#fnref:inverse" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[All words in this blog post were written by a human being.]]></summary></entry><entry><title type="html">Complex Heterodynes Explained</title><link href="https://tomverbeure.github.io/2026/02/07/Complex-Heterodyne.html" rel="alternate" type="text/html" title="Complex Heterodynes Explained" /><published>2026-02-07T10:00:00+00:00</published><updated>2026-02-07T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/02/07/Complex-Heterodyne</id><content type="html" xml:base="https://tomverbeure.github.io/2026/02/07/Complex-Heterodyne.html"><![CDATA[<script async="" src="https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS_CHTML"></script>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#some-common-dsp-notations" id="markdown-toc-some-common-dsp-notations">Some Common DSP Notations</a></li>
  <li><a href="#sampling-with-1-adc-creates-a-real-signal" id="markdown-toc-sampling-with-1-adc-creates-a-real-signal">Sampling with 1 ADC Creates a Real Signal</a></li>
  <li><a href="#heterodyning-the-signal-to-baseband-the-wrong-way" id="markdown-toc-heterodyning-the-signal-to-baseband-the-wrong-way">Heterodyning the Signal to Baseband the Wrong Way</a></li>
  <li><a href="#complex-heterodyne-to-the-rescue" id="markdown-toc-complex-heterodyne-to-the-rescue">Complex Heterodyne to the Rescue</a></li>
  <li><a href="#filtering-away-the-old-negative-image" id="markdown-toc-filtering-away-the-old-negative-image">Filtering Away the Old Negative Image</a></li>
  <li><a href="#decimation" id="markdown-toc-decimation">Decimation</a></li>
  <li><a href="#final-block-diagram" id="markdown-toc-final-block-diagram">Final Block Diagram</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
  <li><a href="#afterthought-the-fourier-transform-is-a-bunch-of-averaged-complex-heterodynes" id="markdown-toc-afterthought-the-fourier-transform-is-a-bunch-of-averaged-complex-heterodynes">Afterthought: the Fourier Transform is a Bunch of Averaged Complex Heterodynes</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>In my previous blog post about <a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">polyphase decimation</a>, 
my reason for looking at that topic was “reading up on polyphase filters and multi-rate digital signal 
processing”, but to be more specific, it all started by watching 
<a href="https://www.youtube.com/watch?v=afU9f5MuXr8">“Recent Interesting and Useful Enchancements of Polyphase Filter Banks”</a>,
a fantastic tutorial by <a href="https://en.wikipedia.org/wiki/Fredric_J._Harris">Fred Harris</a>.
The video is more than 90 min long and is a lot to process when your DSP knowledge is lacking.</p>

<p>I’ve watched the video a few times now, and while I kind of get what he’s doing, it made me realize even 
more how skin-deep my DSP knowledge really is.</p>

<p>For example, the video talks about <em>complex heterodynes</em> all over the place, but I couldn’t really 
explain how the outcome of that operation is different from mixing an input signal with a regular, real sinusoid.</p>

<p>To fix this, I’m going through video sections step-by-step and blog post by blog post. The general
approach is to demonstrate concepts (to myself) by implementing them in 
<a href="httpsP//numpy.org">NumPy</a> 
and plotting the results while limiting the number of mathematical formulas. In the process of peeling 
that onion, new knowledge gaps will be exposed that might not be directly relevant to the video, but if 
interesting enough, I’ll check those out just the same.</p>

<p>But that’s for the future. Let’s talk about the why and how of the complex heterodyne.</p>

<p>The scripts that were used to create the figures in this blog post series can be found in
my <a href="https://github.com/tomverbeure/polyphase_blog_series"><code class="language-plaintext highlighter-rouge">polyphase_blog_series</code></a> on GitHub.</p>

<h1 id="some-common-dsp-notations">Some Common DSP Notations</h1>

<p>There are some conventions that are useful to know about. They aren’t a hard
and fast rules, but I’ll try to stick to them as well as I can.</p>

<ul>
  <li>\(N\): the number of samples in the time domain buffer over which a certain
block operation is performed.</li>
  <li>\(n\): the current time in a discrete time system. For example, \(s[n]\) could be
an array or sequence of input samples that come out of an ADC.</li>
  <li>\(k\): an index in a size limited set of numbers. \(k\) could be used to indicate 
one of many channels, it could be one bin out of all the bins of 
a discrete time Fourier transform, etc.</li>
  <li>\(H(z)\): a discrete transfer function, usually of a filter. The fact that it’s
an uppercase \(H\) indicates that the function is in the z-domain, the discrete
version of \(H(s)\) which is in the Laplace domain, but don’t worry about those
terms, it’s the last time they’ll be mentioned.</li>
  <li>\(h[n]\): the impulse response of the \(H(z)\) transfer function. This is the
time domain sequence that you get if you apply a 1 and then nothing but zeros
to \(H(z)\). Since I’ll only be discussing finite impulse response filters (FIR),
\(h[n]\) will be the same as the coefficients of the polynomial that describes
\(H(z)\).</li>
  <li>\(h[k]\): one of the polynomial coefficients of \(H(z)\). For all coeffients of
\(H(z)\), \(h[k]\) will be identical to \(h[n]\). For all other values, \(h[n]\)
will be zero, while \(h[k]\) won’t really exist. This is a pretty subtle difference
and often \(h[k]\) and \(h[n]\) will be used interchangeably (I’ve definitely done so!), 
but the notation can help to make clear the intent of a formula.</li>
  <li>\(F_x\): a real world analog frequency, measured in Hz. \(F_s\) is often used for
the sample rate. \(F_c\) could be the center frequency of a channel.</li>
  <li>\(f_x\): a normalized frequency, usually relative to the sample frequency. 
\(f_c\) would be the ratio of \(F_c / F_s\).</li>
  <li>\(\omega\) and \(\theta\): both are used to indicate the rate
of change of a periodic signal. But \(\omega\) tends to be used more when the intent
is an angular frequency, e.g. in the context of shifting the spectrum of a signal,
while \(\theta\) puts more emphasis on the change of an angle on the unit circle.
From a pure mathematical point of view, they’re the same: \(\sin(\omega n)\) is
no different than \(\sin(\theta n)\).
One reason to use \(\omega\) instead of \(2 \pi n/N\) is because it reduces the visual 
clutter when used as an argument of trigonometry functions. Compare \(\sin(2 \pi n /N)\) 
with \(\sin(\omega n)\).</li>
</ul>

<p>I’ll try to stick to these conventions as much as possible. Feel free to reach out
if you think I’m doing it wrong somewhere.</p>

<h1 id="sampling-with-1-adc-creates-a-real-signal">Sampling with 1 ADC Creates a Real Signal</h1>

<p>Let’s create an input signal that’s interesting enough to demonstrate DSP theory in practice and
that will trip us up if we’re doing something wrong. It’s a signal that you could get out of
a real-world analog front-end with a single AD converter (ADC) that has a sampling clock
of 100 MHz.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">signal_pure</span> <span class="o">=</span> <span class="p">(</span> <span class="n">signal1_amplitude</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">signal1_freq_hz</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span>
              <span class="o">+</span> <span class="n">signal2_amplitude</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">cos</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">signal2_freq_hz</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span> <span class="p">)</span>

<span class="n">noise_floor</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="n">normal</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="n">noise_rms</span><span class="p">,</span> <span class="n">NR_SAMPLES</span><span class="p">)</span>

<span class="n">oob_noise</span>           <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">random</span><span class="p">.</span><span class="n">normal</span><span class="p">(</span><span class="mf">0.0</span><span class="p">,</span> <span class="n">oob_noise_rms</span><span class="p">,</span> <span class="n">NR_SAMPLES</span><span class="p">)</span>

<span class="n">oob_noise_cutoffs</span>   <span class="o">=</span> <span class="p">[</span> <span class="n">OOB_NOISE_SBF_LOW_MHZ</span> <span class="o">/</span> <span class="p">(</span><span class="n">SAMPLE_CLOCK_MHZ</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">),</span>
                        <span class="n">OOB_NOISE_SBF_HIGH_MHZ</span> <span class="o">/</span> <span class="p">(</span><span class="n">SAMPLE_CLOCK_MHZ</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">)</span> <span class="p">]</span>

<span class="n">oob_noise_h</span>         <span class="o">=</span> <span class="n">firwin</span><span class="p">(</span> <span class="n">OOB_NOISE_FIR_TAPS</span><span class="p">,</span>
                              <span class="n">oob_noise_cutoffs</span><span class="p">,</span>
                              <span class="n">window</span><span class="o">=</span><span class="p">(</span><span class="s">"kaiser"</span><span class="p">,</span> <span class="n">OOB_NOISE_KAISER_BETA</span><span class="p">),</span>
                              <span class="n">pass_zero</span><span class="o">=</span><span class="s">"bandstop"</span> <span class="p">)</span>

<span class="n">oob_noise_filtered</span>  <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">convolve</span><span class="p">(</span><span class="n">oob_noise</span><span class="p">,</span> <span class="n">oob_noise_h</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="s">"same"</span><span class="p">)</span>

<span class="n">signal</span>      <span class="o">=</span> <span class="n">signal_pure</span> <span class="o">+</span> <span class="n">noise_floor</span> <span class="o">+</span> <span class="n">oob_noise_filtered</span>
</code></pre></div></div>

<p>The signal has the following components:</p>

<ul>
  <li>
    <p>2 sinusoids, one at 22 MHz and one at 17 MHz. The second one has an amplitude that
is 10 dB lower.</p>

    <p>This is the signal that we’re interested in.</p>
  </li>
  <li>
    <p>A tiny bit of noise across the whole spectrum</p>

    <p>This adds a noise floor to the overall spectrum which makes it more like 
the real world and also makes the frequency plots more pleasing go the eye.</p>
  </li>
  <li>
    <p>Out-of-band noise that is everywhere expect in the frequency band where our
signal lives.</p>

    <p>This is useful to verify that we’re processing the signal the right way. If we
don’t then this noise will overlap the spectrum of the signal of interest and
we’d notice that right away in the spectrum plot.</p>
  </li>
</ul>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-input_signal.svg" alt="Input signal time and spectrum plot" /></p>

<p>In a time domain plot, we see a typical case of sinusoids interacting with each other,
resulting in some kind of beat envelope frequency. The noise is too low to be noticable
in a non-logarithmic plot.</p>

<p>The frequency domain amplitude plot is more interesting. There are the 2 peaks
of different amplitude, a noise floor in the frequency band where our signal lives,
and the more prominent out-of-band noise everywhere else.</p>

<p>We can also see that the negative frequency side of the spectrum is a mirror of 
the positive side. This is as it should be: to display the spectrum, we performed a 
<a href="https://en.wikipedia.org/wiki/Discrete_Fourier_transform">Discrete Fourier Transformation (DFT)</a>,
which I’ll sometimes, incorrectly, call the Fourier transform for brevity.</p>

<p>The definition of the DFT is as follows:</p>

\[X[k] = \sum_{n=0}^{N-1}{x[n] e^{-j {2 \pi k n}/{N} } }\]

<p>That looks intimidating, but if we use
<a href="https://en.wikipedia.org/wiki/Euler%27s_formula">Euler’s formula</a>, 
we can rewrite this as:</p>

\[X[k] = \sum_{n=0}^{N-1}{x[n] \cos( \frac{2 \pi k n}{N} ) } - j \sum_{n=0}^{N-1}{x[n] \sin( \frac{2 \pi k n}{N} ) }\]

<p>For a given frequency bucket \(k\), we are multiplying the input signal by cosine and by a sine.
This is essentially a correlation function that calculates the extent by which sine and cosine are part
of the input signal. Since the cosine and sine have a 90 degree phase difference between them,
we’re using complex notation for the final number:</p>

\[X[k] = R + j I\]

<p>The magnitude of the frequency of each frequench components is:</p>

\[\left| X[k] \right| = \sqrt{R^2 + I^2}\]

<p>The phase is the angle between R and I is:</p>

\[\angle{X[k]} = \arctan(\frac{I}{R})\]

<p>If the Fourier transform is applied to signal that doesn’t have complex samples, 
as is the case when there is only 1 ADC, then the Fourier transform has
<a href="https://www.dsprelated.com/freebooks/sasp/Symmetry_DTFT_Real_Signals.html">Hermitian symmetry</a>:
for every complex value on the positive frequency side, the corresponding negative frequency value
will have the same real value \(R_k\) and an inverted imaginary value \(I_k\). Because of this,
the amplitude is the same but the phase is inverted.</p>

<p>In the frequency plot above, only the amplitude is shown, hence the mirror image with
identical amplitudes left and right.</p>

<p>In DSP land, a signal that doesn’t have imaginary component values is called a real
signal. A signal that is complex and that doesn’t have a negative frequency 
components is an <a href="https://en.wikipedia.org/wiki/Analytic_signal">analytic signal</a>.</p>

<p>A common way of saying that the sine and cosine have a 90 degree phase difference, is that
they are in quadrature. It’s an extremely powerful concept that makes many DSP operations
a whole lot easier, as we’ll see below.</p>

<h1 id="heterodyning-the-signal-to-baseband-the-wrong-way">Heterodyning the Signal to Baseband the Wrong Way</h1>

<p>Imagine that we have multiple frequency bands or channels, that each channel has 
a bandwidth of 10 MHz and a center frequencies at 0, 10, 20, 30 and 40 MHz. The signal that we
created above would then be part of the 20 MHz channel that ranges from 15 to 25 MHz.</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-channels.svg" alt="Different channels" /></p>

<p>To process the channel, we’d like to move it from 15 MHz to 25 MHz to the baseband 
range of -5 MHz to 5 MHz. For our case, this means that we want the 17 MHz and 22 MHz 
components to end up at -3 MHz and +2 MHz resp.</p>

<p>Moving a channel to baseband before doing further processing allows us to use the same DSP 
operations no matter which channel we’ve selected. It also allows us to reduce the sample rate 
from 100 MHz to something much lower, thus reducing DSP resource requirements.</p>

<p>You can shift the spectrum of a signal by multiplying it with a sine wave. The
multiplication of 2 signals is also called <a href="https://en.wikipedia.org/wiki/Frequency_mixer">mixing</a>. 
And mixing with the purpose of moving the spectrum of a signal is called 
<a href="https://en.wikipedia.org/wiki/Heterodyne">heterodyning</a>. In the analog world,
the signal is multiplied with the sinusoidal output of a local oscillator (LO). We still
need this in the virtual work of DSP math in the form a simulated
<a href="https://en.wikipedia.org/wiki/Numerically_controlled_oscillator">numerically controlled oscillator</a>
so I will keep on using the name of local oscillator.</p>

<p>The math of heterodyning a sine wave is straightforward. Here I show how it works in the
continuous time domain, but it works the same after sampling. Let’s start with signal
\(s(t)\) and local oscillator \(l(t)\):</p>

\[s(t)= A \cos(2 \pi f_0 t) \\
l(t)= \cos(2 \pi f_c t) \\\]

<p>Multiply the 2 signals to get heterodyne signal \(y(t)\):</p>

\[y(t) = s(t) l(t) = A \cos(2 \pi f_0 t) \cos(2 \pi f_c t) \\\]

<p>Use the textbook trigonometry identity:</p>

\[\cos \alpha \cos \beta = \frac{1}{2} \big[ \cos(\alpha + \beta) + \cos(\alpha - \beta)  \big]\]

<p>We get:</p>

\[y(t) = \frac{1}{2} A \big[ \cos(2 \pi (f_0 + f_c) t) + \cos( 2 \pi (f_0 - f_c) t) \big]\]

<p>This tells us is that multiplying a signal with frequency component \(f_0\)
with sine wave with frequency \(f_c\) creates a new signal with 2 frequency components
\(f_0 + f_c\) and \(f_0 - f_c\).</p>

<p>If we want to shift the center frequency of our channel from 20 MHz to 0 MHz, we need to
multiply with a 20 MHz sine wave. Let’s simulate that:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">lo_signal</span>       <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">sin</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">lo_freq_hz</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span>
<span class="n">signal_real_het</span> <span class="o">=</span> <span class="n">signal</span> <span class="o">*</span> <span class="n">lo_signal</span>
</code></pre></div></div>

<p>This is the resulting spectrum:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-real_het.svg" alt="Spectrum after real heterodyn" /></p>

<p>That… didn’t go as we hoped.</p>

<p>The spectrum got shifted down by 20 MHz to 0 MHz and to -40 MHz,
giving us peaks at -3 MHz and +2MHz and -37 MHz and -42 MHz. That’s what we wanted!
But since <code class="language-plaintext highlighter-rouge">lo_signal</code> is a real signal, it has a mirror image at -20 MHz. This made
the spectrum of the signal shift up to +3 MHz and -2 MHz and 37 MHz and 42 MHz.</p>

<p>Instead of the desired 2 peaks in the baseband, there are now 4 peaks, at -3, -2, 2 and 3 MHz. 
We’ve destroyed the original signal.</p>

<p>Heterodyning with a real local oscillator is a common operation in the analog world, but
when this is done, the heterodyne doesn’t happen to baseband but a non-zero intermediate
frequency. That is the idea of the 
<a href="https://en.wikipedia.org/wiki/Superheterodyne_receiver">superheterodyne receiver</a><sup id="fnref:super" role="doc-noteref"><a href="#fn:super" class="footnote" rel="footnote">1</a></sup>, a huge
breakthrough in 1918 in the development of radio technology: it mixes the desired signal
to a fixed intermediate frequency (IF),  not the baseband, and does further demodulation such
AM or FM on that IF signal.</p>

<p><img src="https://upload.wikimedia.org/wikipedia/commons/3/3f/Superheterodyne_receiver_block_diagram_2.svg" alt="Superheterodyne example block diagram" />
<em>(Source: Wikipedia)</em></p>

<h1 id="complex-heterodyne-to-the-rescue">Complex Heterodyne to the Rescue</h1>

<p>We could definitely do a superheterodyne in the digital world, but many modern modulation
schemes such as 
<a href="https://en.wikipedia.org/wiki/Quadrature_amplitude_modulation">QAM</a> or 
<a href="https://en.wikipedia.org/wiki/Orthogonal_frequency-division_multiplexing">OFDM</a>
rely on the ability to process the signal in the baseband.</p>

<p>Luckily, there is a solution. The root of our troubles is the presence of a 
mirror frequency image for the local oscillator. If we can get rid of one of those orange LO peaks, 
only one spectrum image of the signal will get heterodyned into the baseband.</p>

<p>This is surprisingly simple: instead of a real sinusoid, we use a complex one as local oscillator:</p>

\[l(t) = e^{-j 2 \pi f_c t}\]

<p>This signal only has a peak in the spectrum at \(-F_c\). We’re using a negative LO frequency
because we want to shift the spectrum down so that positive image of the channel spectrum ends
up at baseband. If we use \(F_c\), the whole spectrum shifts up instead and the negative
channel lands on baseband.</p>

<p>Let’s create the complex local oscillator signal and multiply it by
the input signal:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">complex_lo_signal</span>   <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="o">-</span><span class="mf">1j</span> <span class="o">*</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">*</span> <span class="n">lo_freq_hz</span> <span class="o">*</span> <span class="n">t</span><span class="p">)</span>
<span class="n">signal_complex_het</span>  <span class="o">=</span> <span class="n">signal</span> <span class="o">*</span> <span class="n">complex_lo_signal</span>
</code></pre></div></div>

<p>And voila:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-complex_lo.svg" alt="Complex LO and heterodyne" /></p>

<p>We had to introduce complex numbers, but the result is worth it: the baseband has exactly 
what we want.</p>

<h1 id="filtering-away-the-old-negative-image">Filtering Away the Old Negative Image</h1>

<p>The only thing that’s still bothering us are the 2 peaks around -40 MHz, the negative image
of the channel that used to be at -20 MHz. This needs to go if we want to lower the sample 
rate by decimation.</p>

<p>We can easily do this with a low pass FIR filter. There are multiple ways to design those,
I even wrote <a href="/2020/10/11/Designing-Generic-FIR-Filters-with-pyFDA-and-Numpy.html">a blog post</a> 
about it.</p>

<p>Here, I chose the <a href="https://www.dsprelated.com/freebooks/sasp/Window_Method_FIR_Filter.html">windowing method</a>
to create a steep 201 taps FIR filter with a passband of 5 MHz.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fir_cutoff</span>  <span class="o">=</span> <span class="n">FIR_PASSBAND_MHZ</span> <span class="o">/</span> <span class="p">(</span><span class="n">SAMPLE_CLOCK_MHZ</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">)</span>
<span class="n">h_lpf</span>       <span class="o">=</span> <span class="n">firwin</span><span class="p">(</span><span class="n">FIR_TAPS</span><span class="p">,</span> <span class="n">fir_cutoff</span><span class="p">,</span> <span class="n">window</span><span class="o">=</span><span class="p">(</span><span class="s">"kaiser"</span><span class="p">,</span> <span class="n">FIR_KAISER_BETA</span><span class="p">),</span> <span class="n">pass_zero</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
</code></pre></div></div>

<p>The filter is applied by doing a convolution between the heterodyned signal and the filter taps in <code class="language-plaintext highlighter-rouge">h_lpf</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">signal_het_lpf</span>      <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">convolve</span><span class="p">(</span><span class="n">signal_complex_het</span><span class="p">,</span> <span class="n">h_lpf</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="s">"same"</span><span class="p">)</span>
</code></pre></div></div>

<p>Note that the samples of <code class="language-plaintext highlighter-rouge">signal_complex_het</code> are complex, but the filter coefficients are real.</p>

<p>Here’s the result:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-low_pass_filter.svg" alt="Complex heterodyned signal with low pass filter" /></p>

<h1 id="decimation">Decimation</h1>

<p>The spectrum has now been reduced to -5 MHz and 5 MHz. Since there is no mirror image, we can safely
do a decimation without having to worry about aliasing as long as we obey 
<a href="https://en.wikipedia.org/wiki/Nyquist–Shannon_sampling_theorem">Nyquist</a> 
by keeping the width of the spectrum is equal or larger than the 2-sided width of channel, which is
10 MHz. With a sample rate of 100 MHz, we can decimate by a factor of 10.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">signal_decim</span>    <span class="o">=</span> <span class="n">signal_het_lpf</span><span class="p">[::</span><span class="n">DECIM_FACTOR</span><span class="p">]</span>
</code></pre></div></div>

<p>We now have 10 times less data to deal with, but the spectrum looks just the same
as before:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-decim_fft.svg" alt="Spectrum after decimation" /></p>

<p>Success!</p>

<h1 id="final-block-diagram">Final Block Diagram</h1>

<p>Wrapping up, we arrived at the following block diagram of operations and transformations:</p>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-block_diagram.svg" alt="Block diagram with all operations" /></p>

<ul>
  <li>The analog signal is converted to a real digital with a single channel, 100 Msps ADC.</li>
  <li>A mixer and a complex local oscillator heterodynes the signal to baseband. The
signal is now complex.</li>
  <li>A low pass filter removes all frequencies that don’t reside in the baseband.</li>
  <li>A decimator brings down the sample rate from 100 MHz to 10 MHz</li>
  <li>The output is a complex 10 MHz sample stream.</li>
</ul>

<p><img src="/assets/polyphase/complex_heterodyne/complex_heterodyne-rot_lpf_decim.svg" alt="Mathematical block diagram" /></p>

<p>Expressed mathematically:</p>

\[y[m] = \big[(x[n] e^{-j 2 \pi f_c n}) * h_{\text{lpf}}[n]\big] \downarrow M \\
f_c = \frac{F_c}{F_s}, m = n M\]

<p>The thing works, but is the optimal of doing things? My 
<a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">previous blog post about polyphase decimation filtering</a>
should be a hint that the answer is: definitely not.</p>

<p>Dealing with a complex instead of real signal doubles the number of math operations
and performing the decimation at the end of the pipeline means that we’re doing a lot 
of math that gets thrown away.</p>

<p>But I have a much better understanding of complex heterodyning now, so that’s a definite
win!</p>

<p>In a next installment, I’ll explore how this can be optimized.</p>

<h1 id="conclusion">Conclusion</h1>

<p>In the Fred Harris video that started this all, complex heterodynes are everywhere and
treated as a known quantity. And they’re straightforward once you get to know them better.</p>

<p>I used to think that dealing with signals in quadrature, representing them with complex numbers, 
was dOne primarily as a way to reduce the sample rate by half. There are certain potential
cost savings to that.</p>

<p>But the benefits are more fundamental: they eliminate the issue of having to deal with mirror images
in the spectrum.</p>

<h1 id="afterthought-the-fourier-transform-is-a-bunch-of-averaged-complex-heterodynes">Afterthought: the Fourier Transform is a Bunch of Averaged Complex Heterodynes</h1>

<p>While writing this blog post, I suddenly struck me: the discrete time Fourier
transform is the same as doing a complex heterodyne to 0 Hz and then calculating 
the DC value by summing the samples, for all frequencies of interest.</p>

<p>Complex heterodyne:</p>

\[y[n] = x[n] e^{-j 2 \pi f_k n}\]

<p>DFT:</p>

\[X[k] = \sum_{n=0}^{N-1}{x[n] e^{-j {2 \pi k n}/{N} } } \\
f_k = k / N \\
X[k] = \sum_{n=0}^{N-1}{x[n] e^{-j {2 \pi f_k n } } } \\\]

<p>This is kind of obvious when you think about it, but I had never dealt with
complex heterodynes so it’s something new for me.</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://www.youtube.com/watch?v=afU9f5MuXr8">Youtube - Recent Interesting and Useful Enhancements of Polyphase Filter Banks: Fred Harris</a></li>
  <li><a href="https://github.com/tomverbeure/polyphase_blog_series">polyphase blog series scripts</a></li>
</ul>

<p>Other blog posts in this series:</p>

<ul>
  <li><a href="/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html">Notes about Basic Polyphase Decimation Filters</a></li>
  <li><a href="/2026/02/16/Polyphase-Channelizer.html">The Stunning Efficiency and Beauty of the Polyphase Channelizer</a></li>
  <li><a href="/2026/03/05/Polyphase-Channelizer-with-Offset.html">Polyphase Channelizers with Frequency Offset - a Bluetooth LE Example</a></li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:super" role="doc-endnote">
      <p>If you’re wondering why it’s called ‘super’: it’s because the result of the
      heterodyne is a signal that is still in the supersonic frequency range, as in,
      above the audible frequency range. Before superheterodyne receivers, the radio signal
      of interested was heterodyned straight to the audio range. <a href="#fnref:super" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Notes about Basic Polyphase Decimation Filters</title><link href="https://tomverbeure.github.io/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html" rel="alternate" type="text/html" title="Notes about Basic Polyphase Decimation Filters" /><published>2026-01-25T10:00:00+00:00</published><updated>2026-01-25T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2026/01/25/Notes-on-Basic-Polyphase-Decimation</id><content type="html" xml:base="https://tomverbeure.github.io/2026/01/25/Notes-on-Basic-Polyphase-Decimation.html"><![CDATA[<script async="" src="https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS_CHTML"></script>

<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-decimation-and-anti-aliasing-fir-filter-combo" id="markdown-toc-the-decimation-and-anti-aliasing-fir-filter-combo">The Decimation and Anti-Aliasing FIR Filter Combo</a></li>
  <li><a href="#naive-hardware-implementation" id="markdown-toc-naive-hardware-implementation">Naive Hardware Implementation</a></li>
  <li><a href="#reduce-number-of-calculations---move-decimator-before-multiplier" id="markdown-toc-reduce-number-of-calculations---move-decimator-before-multiplier">Reduce number of calculations - Move decimator before multiplier</a></li>
  <li><a href="#polyphase-decomposition-of-the-original-filter" id="markdown-toc-polyphase-decomposition-of-the-original-filter">Polyphase decomposition of the original filter</a></li>
  <li><a href="#the-noble-identity-for-decimation" id="markdown-toc-the-noble-identity-for-decimation">The Noble Identity for Decimation</a></li>
  <li><a href="#reusing-common-hardware-in-the-fast-clock-domain" id="markdown-toc-reusing-common-hardware-in-the-fast-clock-domain">Reusing Common Hardware in the Fast Clock Domain</a></li>
  <li><a href="#delayed-multiplications-instead-of-delayed-inputs" id="markdown-toc-delayed-multiplications-instead-of-delayed-inputs">Delayed multiplications instead of delayed inputs</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>I’ve been reading up on polyphase filters and multi-rate digital signal processing.
It’s a broad topic, but as a beginner I need to start with the basics. And to better 
internalize those, I like to expand the generic math into concretely worked-out, 
smaller examples.</p>

<p>And if I’m going to write things down anyway, I might as well put them in a blog
post, this way I know where to look if I want to review things later.</p>

<p>I hope the content here is useful to someone, but don’t assume that I know what
I’m doing. There are hundreds of articles on the web on the same topic, so make sure to
sample a bunch of them to get different perspectives.</p>

<p>One of the things that clicked with me while writing this, is the benefit of rearranging
the mathematical equations so that they reflect the hardware implementation. In the
past, I’ve seen a different of architectures for polyphase filters. I was able
to understand them intuitively but linking them to math adds an additional layer of
confidence.</p>

<p>So that’s one of the things I’m doing here: switch back and forth between math
and hardware architecture.</p>

<p><em>Update: in a later blog post, I added a section about 
<a href="/2026/02/07/Complex-Heterodyne.html#some-common-dsp-notations">common DSP notations</a>. 
Check it out first!</em></p>

<h1 id="the-decimation-and-anti-aliasing-fir-filter-combo">The Decimation and Anti-Aliasing FIR Filter Combo</h1>

<p>In digital signal processing (DSP), decimation is an operation in which you retain
1 out of every M samples and throw away the rest. It has the benefit of bringing the 
sample rate down, and thus the amount of data that flows through the system, the clock 
speed, the number of calculations etc. Decimation is a very common operation.</p>

<p>When following DSP theory, if you want to decimate a signal from a sample
rate \(f_s\) to a sample rate \(f_{s/M}\), you first need to apply an anti-aliasing 
filter that removes all the frequeny components above \(f_s/(2 \cdot M)\) to make sure
that the Nyquist criterium remains valid after the sample frequency has
been reduced.<sup id="fnref:Nyqist" role="doc-noteref"><a href="#fn:Nyqist" class="footnote" rel="footnote">1</a></sup></p>

<p>When using an FIR filter, the conceptual block diagram looks like this:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-naive_decimation_filter_basic_block_diagram.svg" alt="Filter then decimate basic block diagram" /></p>

<p>Let’s do this with 7-tap FIR filter that has transfer function \(H(z)\) and
a decimation factor M of 3.</p>

\[H(z) = h_0 + h_1 z^{-1} + h_2 z^{-2} + h_3 z^{-3} + h_4 z^{-4} + h_5 z^{-5} + h_6 z^{-6}\]

<p>In this kind of notation, \(z^{-2}\) means the input value that was delayed by 2 discrete
steps. In electronics, the equation above has a delay line of 6 elements, each
element is multiplied by a different value, and the result of those multiplication is
added together.</p>

<p>Mathematically, the combiation of a filter followed by a decimator is often expressed
like this:</p>

\[H(z) \; \downarrow M\]

<p>Let’s now take the following stream of input samples</p>

\[\cdots, x[-3], x[-2], x[-1], x[0], x[1], x[2], x[3], x[4], \cdots\]

<p>… and apply this stream to the filter equation for multiple time steps:</p>

\[\begin{alignedat}{0}
f[0] &amp; = h_0 x[0] &amp;+&amp; h_1 x[-1] &amp;+&amp; h_2 x[-2] &amp;+&amp; h_3 x[-3] &amp;+&amp; h_4 x[-4] &amp;+&amp; h_5 x[-5] &amp;+&amp; h_6 x[-6] \\
f[1] &amp; = h_0 x[1] &amp;+&amp; h_1 x[0]  &amp;+&amp; h_2 x[-1] &amp;+&amp; h_3 x[-2] &amp;+&amp; h_4 x[-3] &amp;+&amp; h_5 x[-4] &amp;+&amp; h_6 x[-5] \\
f[2] &amp; = h_0 x[2] &amp;+&amp; h_1 x[1]  &amp;+&amp; h_2 x[0]  &amp;+&amp; h_3 x[-1] &amp;+&amp; h_4 x[-2] &amp;+&amp; h_5 x[-3] &amp;+&amp; h_6 x[-4] \\
f[3] &amp; = h_0 x[3] &amp;+&amp; h_1 x[2]  &amp;+&amp; h_2 x[1]  &amp;+&amp; h_3 x[0]  &amp;+&amp; h_4 x[-1] &amp;+&amp; h_5 x[-2] &amp;+&amp; h_6 x[-3] \\
f[4] &amp; = h_0 x[4] &amp;+&amp; h_1 x[3]  &amp;+&amp; h_2 x[2]  &amp;+&amp; h_3 x[1]  &amp;+&amp; h_4 x[0]  &amp;+&amp; h_5 x[-1] &amp;+&amp; h_6 x[-2] \\
f[5] &amp; = h_0 x[5] &amp;+&amp; h_1 x[4]  &amp;+&amp; h_2 x[3]  &amp;+&amp; h_3 x[2]  &amp;+&amp; h_4 x[1]  &amp;+&amp; h_5 x[0]  &amp;+&amp; h_6 x[-1] \\
f[6] &amp; = h_0 x[6] &amp;+&amp; h_1 x[5]  &amp;+&amp; h_2 x[4]  &amp;+&amp; h_3 x[3]  &amp;+&amp; h_4 x[2]  &amp;+&amp; h_5 x[1]  &amp;+&amp; h_6 x[0]  \\
f[7] &amp; = h_0 x[7] &amp;+&amp; h_1 x[6]  &amp;+&amp; h_2 x[5]  &amp;+&amp; h_3 x[4]  &amp;+&amp; h_4 x[3]  &amp;+&amp; h_5 x[2]  &amp;+&amp; h_6 x[1]  \\
f[8] &amp; = h_0 x[8] &amp;+&amp; h_1 x[7]  &amp;+&amp; h_2 x[6]  &amp;+&amp; h_3 x[5]  &amp;+&amp; h_4 x[4]  &amp;+&amp; h_5 x[3]  &amp;+&amp; h_6 x[2]  \\
f[9] &amp; = h_0 x[9] &amp;+&amp; h_1 x[8]  &amp;+&amp; h_2 x[7]  &amp;+&amp; h_3 x[6]  &amp;+&amp; h_4 x[5]  &amp;+&amp; h_5 x[4]  &amp;+&amp; h_6 x[3]  \\
\end{alignedat}\]

<p>Decimate by selecting 1 out of every 3 filtered sample:</p>

\[\begin{alignedat}{0}
y[0] &amp; = f[0] \\
y[1] &amp; = f[3] \\
y[2] &amp; = f[6] \\
y[3] &amp; = f[9] \\
\end{alignedat}\]

<p>Or:</p>

\[\begin{alignedat}{0}
y[0] &amp; = h_0 x[0] &amp;+&amp; h_1 x[-1] &amp;+&amp; h_2 x[-2] &amp;+&amp; h_3 x[-3] &amp;+&amp; h_4 x[-4] &amp;+&amp; h_5 x[-5] &amp;+&amp; h_6 x[-6] \\
y[1] &amp; = h_0 x[3] &amp;+&amp; h_1 x[2]  &amp;+&amp; h_2 x[1]  &amp;+&amp; h_3 x[0]  &amp;+&amp; h_4 x[-1] &amp;+&amp; h_5 x[-2] &amp;+&amp; h_6 x[-3] \\
y[2] &amp; = h_0 x[6] &amp;+&amp; h_1 x[5]  &amp;+&amp; h_2 x[4]  &amp;+&amp; h_3 x[3]  &amp;+&amp; h_4 x[2]  &amp;+&amp; h_5 x[1]  &amp;+&amp; h_6 x[0]  \\
y[3] &amp; = h_0 x[9] &amp;+&amp; h_1 x[8]  &amp;+&amp; h_2 x[7]  &amp;+&amp; h_3 x[6]  &amp;+&amp; h_4 x[5]  &amp;+&amp; h_5 x[4]  &amp;+&amp; h_6 x[3]  \\
\end{alignedat}\]

<h1 id="naive-hardware-implementation">Naive Hardware Implementation</h1>

<p>A straight up hardware implementation looks like this:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-naive_decimation_filter.svg" alt="Naive decimation filter" /></p>

<p>As mentioned before, we have 6 delay elements and 7 multipliers that operate on the each stage
of the delay line.</p>

<p>This solution is dumb: we calculate a filter output for every input clock cycle only to throw away 2 
out of 3 results. Let’s do better.</p>

<h1 id="reduce-number-of-calculations---move-decimator-before-multiplier">Reduce number of calculations - Move decimator before multiplier</h1>

<p>We can reduce the number of calculations by moving the decimator before the filter.</p>

<p>All multiplications still happen at the same time but they can now be performed in a clock domain that is
3 times slower. This definitely reduces power and also reduces the multiplication area in an ASIC process, 
because timing paths won’t be as strict.</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-delay_input_multiply_in_slow_domain.svg" alt="Decimate input values, multiply in slow domain" /></p>

<p>While the number of multiplications per unit of time has been reduced by 3, the number of multipliers is
still the same.</p>

<p>The data flowing through this architecture looks like this:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-delay_input_multiply_in_slow_domain_annotated.svg" alt="Decimate input value, multiply in slow domain, annotated" /></p>

<p>When you look at bit closer, you can see that pipes of input samples with the same color
have the same data flowing through them: the input feed of the \(h_0\) multiplier sees the
same \(x[3i]\) samples as the \(h_3\) and the \(h_6\) multipliers, it’s just that there is 
a delay of 1 clock cycle in the slow clock domain for each term.
Similarly, \(h_1\) and \(h_5\) multipliers see samples \(x[3i+1]\), and the \(h_2\) and \(h_6\) 
multipliers see samples \(x[3i+2]\).</p>

<h1 id="polyphase-decomposition-of-the-original-filter">Polyphase decomposition of the original filter</h1>

<p>Let’s take the earlier equation for value \(y[3]\) and decorate the 7 terms with the colors
of the diagram:</p>

\[y[3] = \color{red}{h_0 x[9]} + \color{green}{h_1 x[8]}  + \color{blue}{h_2 x[7]}  + \color{red}{h_3 x[6]}  + \color{green}{h_4 x[5]}  + \color{blue}{h_5 x[4]}  + \color{red}{h_6 x[3]}\]

<p>Now split the equation into 3 steps so that each step uses input values \(x[i]\) with the same color:</p>

\[\begin{alignedat}{0}
\mathrm{tmp} &amp;=&amp; \color{red}  {h_0 x[9]} &amp;\;+\;&amp; \color{red}  {h_3 x[6]} &amp;\;+\;&amp; \color{red}  {h_6 x[3]} \\
\mathrm{tmp} &amp;=&amp; \color{green}{h_1 x[8]} &amp;\;+\;&amp; \color{green}{h_5 x[5]} &amp;&amp; &amp;\;+\;&amp; \mathrm{tmp} \\
y[2]         &amp;=&amp; \color{blue} {h_2 x[7]} &amp;\;+\;&amp; \color{blue} {h_4 x[4]} &amp;&amp; &amp;\;+\;&amp; \mathrm{tmp} \\
\end{alignedat}\]

<p>What we’ve done here is split 7-tap filter \(H(z)\) into 3 separate sub-filters:</p>

\[H_0(z) = h_0 + h_3 z^{-1} + h_6 z^{-2} \\
H_1(z) = h_1 + h_4 z^{-1} + h_7 z^{-2} \\
H_2(z) = h_2 + h_5 z^{-1} + h_8 z^{-2} \\\]

<p><em>(In our example, \(h_7\) and \(h_8\) are zero.)</em></p>

<p>The equation of the original filter is now this:</p>

\[H(z) = H_0(z^3) + z^{-1} H_1(z^3) + z^{-2} H_2(z^3)\]

<p><strong>This is the polyphase decomposition of the original filter.</strong></p>

<p>The exponent of 3 in \(z^3\) tells us that input to each sub-filter is a decimated version, because if
we substitute \(z^{3}\) into the set of equations \(H_i(z)\), we get:</p>

\[H_0(z^3) = h_0 + h_3 {z^3}^{-1} + h_6 {z^3}^{-2} \\
H_1(z^3) = h_1 + h_4 {z^3}^{-1} + h_7 {z^3}^{-2} \\
H_2(z^3) = h_2 + h_5 {z^3}^{-1} + h_8 {z^3}^{-2} \\\]

<p>Or:</p>

\[H_0(z^3) = h_0 + h_3 z^{-3} + h_6 z^{-6} \\
H_1(z^3) = h_1 + h_4 z^{-3} + h_7 z^{-6} \\
H_2(z^3) = h_2 + h_5 z^{-3} + h_8 z^{-6} \\\]

<p>The polyphase equation of \(H(z)\) is now:</p>

\[H(z) = 
(h_0 + h_3 z^{-3} + h_6 z^{-6}) 
+ z^{-1} (h_1 + h_4 z^{-3} + h_7 z^{-6}) 
+ z^{-2} (h_2 + h_5 z^{-3} + h_8 z^{-6})\]

<p>Which becomes this:</p>

\[H(z) = 
  (h_0 + h_3 z^{-3} + h_6 z^{-6}) 
+ (h_1 + h_4 z^{-4} + h_7 z^{-7}) 
+ (h_2 + h_5 z^{-5} + h_8 z^{-8})\]

<p>And after reordering the terms and setting \(h_7\) and \(h_8\) to zero, we’re back
to the definition of \(H(z)\) at the start of this blog post:</p>

\[H(z) = h_0 + h_1 z^{-1} + h_2 z^{-2} + h_3 z^{-3} + h_4 z^{-4} + h_5 z^{-5} + h_6 z^{-6}\]

<h1 id="the-noble-identity-for-decimation">The Noble Identity for Decimation</h1>

<p>Those who are studying multi-rate digital signal processing will almost certainly
be confronted with the noble identities.</p>

<p>For decimation, the noble identity is formulated as follows<sup id="fnref:notation" role="doc-noteref"><a href="#fn:notation" class="footnote" rel="footnote">2</a></sup>:</p>

\[\downarrow M \: H(z) \equiv H(z^M) \: \downarrow M\]

<p>When I first got exposed to that, I thought it was confusing, but after
going through the motions of the math equations above, it started to make sense.</p>

<p>What it says is:</p>

<p><em>Performing a decimation and applying those samples to filter \(H(z)\) is equivalent
to applying the same filter to every M-th sample and then doing the decimation.</em></p>

<p>Let’s look back at the polyphase decomposition of our original \(H(z)\):</p>

\[H(z) = H_0(z^3) + z^{-1} H_1(z^3) + z^{-2} H_2(z^3)\]

<p>It important to note that we can’t apply the noble identity to our \(H(z)\) directly,
because its coefficients \(h_1\), \(h_2\), \(h_4\) and \(h_5\) are non-zero. But we <em>can</em> apply 
it to the 3 individual phases.</p>

<p>Like this:</p>

\[H(z) \downarrow 3 = (\downarrow 3 \: H_0(z)) + z^{-1} (\downarrow 3 \: H_1(z)) + z^{-2} (\downarrow 3\: H_2(z))\]

<p>Converted to a hardware diagram:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-noble_identity.svg" alt="Polyphase decimation hardware diagram after applying noble identity" /></p>

<p>It’s not immediately obvious, but this last diagram is similar to the previous one after we’ve
rearranged some items:</p>

<ul>
  <li>there’s now 1 decimator per phase instead of one per coefficient.</li>
  <li>a single bank of 7 multipliers and one addition has been refactored into
3 banks of multipliers with addition, and then one final addition.</li>
  <li>each multiplier-addition bank has its own delay elements.</li>
</ul>

<h1 id="reusing-common-hardware-in-the-fast-clock-domain">Reusing Common Hardware in the Fast Clock Domain</h1>

<p>In the previous diagram, it’s clear that there’s a lot of common hardware between
the different phases. We can exploit that by doing everything in the fast clock domain
and reuse the hardware that’s used for one phase for the other phases.</p>

<p>Recall the previous equation where the result was calculated in 3 steps:</p>

\[\begin{alignedat}{0}
\mathrm{tmp} &amp;=&amp; \color{red}  {h_0 x[9]} &amp;\;+\;&amp; \color{red}  {h_3 x[6]} &amp;\;+\;&amp; \color{red}  {h_6 x[3]} \\
\mathrm{tmp} &amp;=&amp; \color{green}{h_1 x[8]} &amp;\;+\;&amp; \color{green}{h_5 x[5]} &amp;&amp; &amp;\;+\;&amp; \mathrm{tmp} \\
y[2]         &amp;=&amp; \color{blue} {h_2 x[7]} &amp;\;+\;&amp; \color{blue} {h_4 x[4]} &amp;&amp; &amp;\;+\;&amp; \mathrm{tmp} \\
\end{alignedat}\]

<p>Now check out this diagram:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-delay_input_multiply_in_fast_domain.svg" alt="Delay input - multiply in fast domain" /></p>

<p>Everything happens in the fast clock domain, but there are only 3 multipliers instead of 7 and
we’re only adding 4 numbers together at any time. The only extra cost is a register
to store the <em>tmp</em> value, and each of the multipliers has a multiplexer to rotate between
different coefficients.</p>

<p>There is only one \(y[m]\) output every 3 clock cycles.</p>

<p>Here’s the same diagram annotated with intermediates values for different time steps:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-delay_input_multiply_in_fast_domain_annotated.svg" alt="Delay input - multiply in fast domain, internal values" /></p>

<h1 id="delayed-multiplications-instead-of-delayed-inputs">Delayed multiplications instead of delayed inputs</h1>

<p>In the previous diagram, the inputs are delayed and the multiplications summed together. But that’s
not the only way to implement this.</p>

<p>Let’s start again from the original equation:</p>

\[H(z) = \color{red}{h_0 z^0} + \color{green}{h_1 z^{-1}}  + \color{blue}{h_2 z^{-2}}  + \color{red}{h_3 z^{-3}}  + \color{green}{h_4 z^{-4}}  + \color{blue}{h_5 z^{-5}}  + \color{red}{h_6 z^{-6}}\]

<p>Reformat:</p>

\[\begin{alignedat}{0}
H(z) = &amp;&amp; ( \color{red}{h_0 z^0} + \color{green}{h_1 z^{-1}}  + \color{blue}{h_2 z^{-2}} )   \\
     + &amp;&amp; ( \color{red}{h_3 z^{-3}}  + \color{green}{h_4 z^{-4}}  + \color{blue}{h_5 z^{-5}} ) \\ 
     + &amp;&amp; ( \color{red}{h_6 z^{-6}} ) \\
\end{alignedat}\]

<p>Extract common \(z^{-3}\) and \(z^{-6}\):</p>

\[\begin{alignedat}{0}
H(z) = &amp;&amp; ( \color{red}{h_0 z^0} + \color{green}{h_1 z^{-1}}  + \color{blue}{h_2 z^{-2}} )   \\
     + &amp;&amp; z^{-3} ( \color{red}{h_3 z^{0}}  + \color{green}{h_4 z^{-1}}  + \color{blue}{h_5 z^{-2}} ) \\ 
     + &amp;&amp; z^{-6} ( \color{red}{h_6 z^{0}} ) \\
\end{alignedat}\]

<p>Extract common \(z^{-3}\):</p>

\[\begin{aligned}
H(z) = \; &amp; ( \color{red}{h_0 z^{0}} + \color{green}{h_1 z^{-1}} + \color{blue}{h_2 z^{-2}}) \\
          &amp;  + z^{-3} \Big[ ( \color{red}{h_3 z^{0}} + \color{green}{h_4 z^{-1}} + \color{blue}{h_5 z^{-2}} ) \\
          &amp;  \qquad\qquad\;\; + z^{-3}( \color{red}{h_6 z^{0}} ) \Big]
\end{aligned}\]

<p>We now have a nested structure, with a delay of 3 for each nesting level.</p>

<p>In hardware that looks like this:</p>

<p><img src="/assets/polyphase/basic_polyphase/polyphase-delayed_multiplications.svg" alt="Delayed multiplication results" /></p>

<p>This structure is not intrinsically worse or better than the previous one, the architecture
to use will depend on the technology that you’re mapping it to. On FPGAs, for example, you should
choose something that makes efficient use of the built-in pipelining registers inside their
DSP blocks.</p>

<h1 id="conclusion">Conclusion</h1>

<p>This only scratches the surface of polyphase filters. I didn’t even mention interpolation, which
does the opposite of decimation but has very similar computational characteristics. I plan
to cover addition of topics in the future, especially the expantion of a polyphase filter into
a polyphase filter bank. That said, I can’t promise a timeline.</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://dsp.stackexchange.com/questions/43344/how-to-implement-polyphase-filter">Stackexchange - How to implement Polyphase filter?</a></li>
</ul>

<blockquote>
  <p>Making a polyphase filter implementation is quite easy; given the desired coefficients 
for a simple FIR filter, you distribute those same coefficients in “row to column” format 
into the separate polyphase FIR components</p>
</blockquote>

<p>Other blog posts in this series:</p>

<ul>
  <li><a href="/2026/02/07/Complex-Heterodyne.html">Complex Heterodynes Explained</a></li>
  <li><a href="/2026/02/16/Polyphase-Channelizer.html">The Stunning Efficiency and Beauty of the Polyphase Channelizer</a></li>
  <li><a href="/2026/03/05/Polyphase-Channelizer-with-Offset.html">Polyphase Channelizers with Frequency Offset - a Bluetooth LE Example</a></li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:Nyqist" role="doc-endnote">
      <p>This is not entirely true. You can also apply a bandpass anti-aliasing filter 
       that only retains a part of the spectrum above the new sample rate, and use
       decimation to bring that section down to the baseband. But that’s a
       topic for a future blog post. <a href="#fnref:Nyqist" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:notation" role="doc-endnote">
      <p>\(\equiv\) means “is equivalent to.” <a href="#fnref:notation" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">The Scenic Route to Repairing a Self-Destructing SRS DG535 Digital Delay Generator</title><link href="https://tomverbeure.github.io/2025/12/24/Repair-of-SRS-DG535.html" rel="alternate" type="text/html" title="The Scenic Route to Repairing a Self-Destructing SRS DG535 Digital Delay Generator" /><published>2025-12-24T10:00:00+00:00</published><updated>2025-12-24T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2025/12/24/Repair-of-SRS-DG535</id><content type="html" xml:base="https://tomverbeure.github.io/2025/12/24/Repair-of-SRS-DG535.html"><![CDATA[<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-stanford-research-systems-dg535" id="markdown-toc-the-stanford-research-systems-dg535">The Stanford Research Systems DG535</a></li>
  <li><a href="#who-uses-a-pulse-delay-generator" id="markdown-toc-who-uses-a-pulse-delay-generator">Who Uses a Pulse Delay Generator?</a></li>
  <li><a href="#inside-the-dg535" id="markdown-toc-inside-the-dg535">Inside the DG535</a></li>
  <li><a href="#the-annoying-mechanical-design-of-the-dg535" id="markdown-toc-the-annoying-mechanical-design-of-the-dg535">The Annoying Mechanical Design of the DG535</a></li>
  <li><a href="#its-always-the-power-supply" id="markdown-toc-its-always-the-power-supply">It’s Always the Power Supply</a></li>
  <li><a href="#power-architecture-of-the-dg535" id="markdown-toc-power-architecture-of-the-dg535">Power Architecture of the DG535</a></li>
  <li><a href="#the-how-why-and-please-dont-of-current-boost-resistor-circuits" id="markdown-toc-the-how-why-and-please-dont-of-current-boost-resistor-circuits">The How, Why, and Please Don’t of Current Boost Resistor Circuits</a></li>
  <li><a href="#root-causing-the-dg535-issue" id="markdown-toc-root-causing-the-dg535-issue">Root Causing the DG535 Issue</a></li>
  <li><a href="#debugging-the-7v-issue" id="markdown-toc-debugging-the-7v-issue">Debugging the +7V Issue</a></li>
  <li><a href="#side-quest-debugging-the-cpu-system---connector-stupidity" id="markdown-toc-side-quest-debugging-the-cpu-system---connector-stupidity">Side Quest: Debugging the CPU System - Connector Stupidity</a></li>
  <li><a href="#fixing-the-burnt-pcb-trace" id="markdown-toc-fixing-the-burnt-pcb-trace">Fixing the Burnt PCB Trace</a></li>
  <li><a href="#endless-boot-loop-after-reassembly" id="markdown-toc-endless-boot-loop-after-reassembly">Endless Boot Loop after Reassembly</a></li>
  <li><a href="#dg535-up-and-running-with-a-variac" id="markdown-toc-dg535-up-and-running-with-a-variac">DG535 Up and Running with a Variac</a></li>
  <li><a href="#tracking-down-the-12-12v-on-the-9-9v-rails" id="markdown-toc-tracking-down-the-12-12v-on-the-9-9v-rails">Tracking down the +12/-12V on the +9/-9V Rails</a></li>
  <li><a href="#lcd-replacement" id="markdown-toc-lcd-replacement">LCD Replacement</a></li>
  <li><a href="#post-mortem" id="markdown-toc-post-mortem">Post Mortem</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>I got my hands on a <a href="https://www.thinksrs.com/products/dg535.html">Stanford Research Systems DG535</a>
at the <a href="https://www.thinksrs.com/products/dg535.html">Silicon Valley Electronics Flea Market</a>, 
$40 for a device that was marked “X Dead”.</p>

<p><img src="/assets/dg535/DG535_at_flea_market.jpg" alt="DG535 at flea market" /></p>

<p>That’s a really good deal: SRS products are pricey and even 
the cheapest <em>Parts-Only</em> listings on eBay are $750 and up. Worst case, I’d get a few weekends
of unsuccessful repair entertainment out of it, but even then I’d probably be able to recoup my money
by selling pieces for parts.<sup id="fnref:parts" role="doc-noteref"><a href="#fn:parts" class="footnote" rel="footnote">1</a></sup> Just the keyboard PCB is currently selling for $150<sup id="fnref:keyboard" role="doc-noteref"><a href="#fn:keyboard" class="footnote" rel="footnote">2</a></sup>.</p>

<p>It doesn’t matter how broken they are, the first step after acquiring a new toy is cleaning up
years of accumulated asset tracking labels, coffee stains, finger grime and glue residue. This one 
cleaned up nicely; the front panel is pretty much flawless:</p>

<p><img src="/assets/dg535/DG535_frontview.jpg" alt="DG535 front view" /></p>

<p>After an initial failed magic smoke repair attempt, the unit went back to the garage for
18 months, but last week I finally got around to giving it the attention it deserves.</p>

<p>The repair was successful, and when you only look at the end result, it was a straightforward 
replacement of a diode bridge and LCD panel. However, the road to get there was long and winding. 
The broken power architecture and awkward mechanical design of the SRS DG535 made it way too easy 
to damage the device <em>because</em> I was trying to repair it.</p>

<p>So let’s get this advise out of the way first:</p>

<p><strong>Do NOT power on the device with the analog PCB disconnected. It will almost certainly self-destruct
with burnt PCB traces.</strong></p>

<p>The details will be explained further below.</p>

<h1 id="the-stanford-research-systems-dg535">The Stanford Research Systems DG535</h1>

<p>Conceptually, the purpose of the DG535 is straightforward: it’s a tool that takes in an input
trigger pulse and generates 4 output pulses after some programmable delay. What makes things interesting 
is that these delays can be specified with a 5 ps precision, though the jitter on the outputs far
exceed that number.</p>

<p>The DG535 has 9 outputs on the front panel:</p>

<ul>
  <li>T0 marks the start of a timing interval. You’ll most likely use it when you use the device with 
an internal trigger to know when a timing sequence has started. There is delay of around 85ns 
between the external trigger and T0.</li>
  <li>4 channels A, B, C and D can independently be configured to change a programmable time after T0
or after some of the other channels.</li>
  <li>Output AB is a pulse for the interval between the time set for A and B. It’s an XNOR between those
2 channels. -AB is the inverse of output AB. CD and -CD are the same for channels C and D.</li>
</ul>

<p><a href="/assets/dg535/DG535_timing_diagram.jpg"><img src="/assets/dg535/DG535_timing_diagram.jpg" alt="DG535 timing diagram" /></a>
<em>(Click to enlarge)</em></p>

<p>All outputs support a number of logic standards: TTL, ECL, NIM<sup id="fnref:NIM" role="doc-noteref"><a href="#fn:NIM" class="footnote" rel="footnote">3</a></sup>, or fully programmable
voltage amplitude and offset.</p>

<p>Settings can be entered through the front panel or through a GPIB interface that is
available at the back of the device.</p>

<p><img src="/assets/dg535/DG535_rearview.jpg" alt="DG535 rear view" /></p>

<p>In addition to the GPIB interface, the back has another set of T0/A/B/C/D outputs because my unit
is equiped option 02. These outputs are not an identical copy of the ones in the front: their amplitudes
can go from -32V to 32V when terminated by a 50 Ohm impedance and each output has pulse width of roughly 
1 us.</p>

<p>There is also a connector and a switch to select either the internal or an external
10 MHz timebase. Missing screws around the transformer housing are an indication that I’m not the 
first one who has been inside to repair the unit.</p>

<p>This <a href="/assets/dg535/SRS_ad_1993.pdf">1993 ad</a> lists the DG535 for $3500. It is currently still for sale 
on the SRS website for $4495, remarkable for an instrument that dates from the mid 1980s. I assume 
that today’s buyers are primarily those who need an exact replacement for an existing, certified setup, 
because the <a href="https://www.thinksrs.com/products/dg645.html">DG645</a>, 
SRS’s more modern successor with better features and specs, costs only $500 more.</p>

<h1 id="who-uses-a-pulse-delay-generator">Who Uses a Pulse Delay Generator?</h1>

<p>Anyone who has a setup where multiple pieces of test or lab equipment need to work together with a
strictly timed sequence.</p>

<p>When you google for applications where the DG535 is used, you get a long list of PhD theses, national
or military laboratory documents, optical setups with lasers and so on. Look closer at the first 
picture of this blog post and you can see that mine was used by <a href="https://www.chemicaldynamics.com">Chemical Dynamics</a> 
in a molecular beam setup… whatever that is.</p>

<p>Here are just a few examples:</p>

<ul>
  <li>
    <p><a href="https://www.sciencedirect.com/science/article/pii/S0010218025001142">Combustion and Flame - A comprehensive study on dynamics of flames in a nanosecond pulsed discharge. Part II: Plasma-assisted ammonia and methane combustion</a></p>

    <blockquote>
      <p>We employed a delay generator (SRS system, DG535) to control the timing of the plasma 
and measurement systems. The DG535 generator was externally triggered by the pre-triggering 
signal from the laser system and then sent sequential TTL signals to trigger the ns 
pulse generator and camera.</p>
    </blockquote>
  </li>
  <li>
    <p><a href="https://arxiv.org/html/2510.11451v1">Astigmatism-free 3D Optical Tweezer Control for Rapid Atom Rearrangement</a></p>

    <blockquote>
      <p>Images were taken at delayed time steps (250-ns shutter, SRS DG535) as the translation 
stage was stepped from Z min = − 24.5 mm to Z max = 24.5 mm.</p>
    </blockquote>
  </li>
  <li>
    <p><a href="https://www.sciencedirect.com/science/article/pii/S0039914025009531">Rapid elemental imaging of copper-bearing critical ores using laser-induced breakdown spectroscopy coupled with PCA and PLS-DA</a></p>

    <blockquote>
      <p>A delay generator (SRS DG-535) synchronized the laser and detection systems to capture 
time-integrated spectra at each point.</p>
    </blockquote>
  </li>
  <li>
    <p><a href="https://www.researchgate.net/publication/231085177_High-precision_Gravity_Measurements_Using_Atom-Interferometry">High-precision Gravity Measurements Using Atom-Interferometry</a></p>

    <blockquote>
      <p>The timing of the pulsesis controlled by a set of synchronized pulse generators
(SRS DG535), one of which also triggers all the hardware involved in generating the 
Raman frequencies.</p>
    </blockquote>
  </li>
  <li>
    <p><a href="https://pubs.aip.org/aip/pop/article/32/12/122109/3374743/Laboratory-generation-of-multiple-periodic-arrays">Physics of Plasmas - Laboratory generation of multiple periodic arrays of Alfvénic vortices</a></p>

    <blockquote>
      <p>Each antenna was switched on with a pulse generator (Stanford SRS-DG535), which then activated 
two arbitrary waveform generators (Agilent 3322A).</p>
    </blockquote>
  </li>
  <li>
    <p><a href="https://www.researchgate.net/publication/382346349_Liquid-to-gas_transfer_of_sodium_in_a_liquid_cathode_glow_discharge">Liquid-to-gas transfer of sodium in a liquid cathode glow discharge</a></p>

    <blockquote>
      <p>The laser system, operating at a 200 Hz repetition rate, was synchronized with the 
plasma discharge using a SRS DG535 delay generator, allowing time-resolved measurements 
of Na fluorescence during and after the discharge pulse.</p>
    </blockquote>
  </li>
</ul>

<p>At this time, I don’t have a use for a pulse delay generator, but as a hobbyist it’s important 
to keep the following in mind:</p>

<p><strong>We buy test equipment NOT because we need it, but because one day we might need it.</strong></p>

<p>I don’t see a future where I’ll be doing high-precision gravity measurements in my garage, but
a DG535 could be useful to precisely time a voltage glitching pulse when trying to break the
security of a microcontroller, for example.</p>

<h1 id="inside-the-dg535">Inside the DG535</h1>

<p>It’s not complicated to create pulse delay generator as long as delay precision and jitter
requirements are larger than the clock period of the internal digital logic: a simple digital
counter will do. But when the timing precision is smaller than the clock period, you need 
some analog wizardry to make it happen.</p>

<p>SRS includes detailed schematics and theory of operation for many of their products and the
DG535 is no exception. It’s a great way to study and learn how non-trivial problems were solved 
40 years ago.</p>

<p><img src="/assets/dg535/DG535_block_diagram.jpg" alt="DG535 block diagram" /></p>

<p>The DG535 takes a combined digital/analog approach to create delays of up to 1000 s.
With an 80 MHz internal clock, the digital delay can be specified with 12.5 ns of precision.
The remainder is handled by two analog circuits: the jitter circuit measures the delay between 
the start of the external trigger and the next rising edge of the 80 MHz clock. The analog delay
circuit creates a delay between 0 and 12.5 ns after digital delay has expired. Channels A/B/C/D 
each have their own instance of the analog delay circuit.</p>

<p><a href="/assets/dg535/dg535-waveform.svg"><img src="/assets/dg535/dg535-waveform.svg" alt="Jitter/Digital Delay/Analog Delay waveform" /></a>
<em>(Click to enlarge)</em></p>

<p>I will leave the low level details to a future blog post, but at their core, both the jitter and 
analog delay circuit work by precharging and discharging a capacitor with a constant current
source for a time that varies between 0 and 12.5 ns. Precharging the analog delay capacitor is 
controlled by a 12-bit DAC. If you were wondering where the 5 ps of precision limit is coming from: 
12.5 ns / (2^12) = 3 ps. Close enough!</p>

<p>Using a capacitor to measure time with higher precision that the digital clock is called “analog 
interpolation”. It’s often used by time interval and frequency counters such as the SRS SR620. 
I briefly touch this in 
<a href="/2023/06/16/Frequency-Counting-with-Linear-Regression.html#frequency-counter-basics">my blog post about linear regression in frequency counters</a>.</p>

<h1 id="the-annoying-mechanical-design-of-the-dg535">The Annoying Mechanical Design of the DG535</h1>

<p>In <a href="/2025/08/19/SRS-SR620-Frequency-Counter-Power-Switch-Battery-Replacment.html#repairing-the-sr620">my blog post about the SR620</a>, 
I comment on a mechanical design that gives full access to all components by just removing 
the top and bottom cover. The DG535 is a different story.</p>

<p>While the covers are just as easy to remove, the functionality is spread over 2 large 
PCBs, mounted with components facing inwards, and connected with a bunch of cables 
that are too short to allow separating the PCBs.</p>

<p><a href="/assets/dg535/DG535_open_sideview.jpg"><img src="/assets/dg535/DG535_open_sideview.jpg" alt="DG535 open side view" /></a>
<em>(Click to enlarge)</em></p>

<p><a href="/assets/dg535/DG535_open_bottomview.jpg"><img src="/assets/dg535/DG535_open_bottomview.jpg" alt="DG535 open bottom view" /></a>
<em>(Click to enlarge)</em></p>

<p>SRS was clearly aware that this PCB arrangement makes the unit harder to repair,
because they helpfully added component designators and even component name annotations on 
the solder side of the PCB, though, sadly, there are no dots to mark pin 1 of an IC.<sup id="fnref:pin_dot" role="doc-noteref"><a href="#fn:pin_dot" class="footnote" rel="footnote">4</a></sup></p>

<p><img src="/assets/dg535/PCB_solderside_annotated.jpg" alt="PCB solder-side annotated" /></p>

<p>Most cables have connectors and can easily unplugged, but not all of them.</p>

<p><img src="/assets/dg535/power_supply_wires_to_opt02.jpg" alt="Power supply wires for OPT02 board" /></p>

<p>The red and orange wires in the picture above deliver +20 and -20V from the top PCB
to the OPT02 PCB that is mounted below the bottom PCB. They are just long enough. If you want to
take the unit apart, your only choice is desoldering these wires. It’s not rocket science,
but… really? You also need to desolder the wires that power the cooling fan.</p>

<p>Enough whining… for now. When all wires are desoldered, connectors disconnected and screws
removed, you can fold open the top PCB from the rest of the unit and get a full view of the inner
components:</p>

<p><a href="/assets/dg535/DG535_folded_open.jpg"><img src="/assets/dg535/DG535_folded_open.jpg" alt="DG535 folded open" /></a>
<em>(Click to enlarge)</em></p>

<p>We can see:</p>

<ul>
  <li>a top PCB that contains a Z80-based controller and the counters that are used for the digital
delay generation</li>
  <li>a bottom PCB with the rest of the delay and output driver circuitry</li>
  <li>the front has a generic LCD panel and a keyboard and LED PCB</li>
</ul>

<h1 id="its-always-the-power-supply">It’s Always the Power Supply</h1>

<p>Before taking it apart, I had already powered up the device and nothing happened: the LEDs 
and the LCD screen were dead, only the fan spun up. No matter what state a device is in,
you always have to make sure first that power rails are functional.</p>

<p>The power architecture is split between the top and bottom PCB, but the two secondary windings of 
the power transformer first go to the top PCB. <em>Since the transformer is located at the bottom,
you always need to keep top and bottom PCBs closely together if you want to make live measurements.</em></p>

<p><a href="/assets/dg535/power_supply_top.jpg"><img src="/assets/dg535/power_supply_top.jpg" alt="Power supply schematic top" /></a>
<em>(Click to enlarge)</em></p>

<p>On the schematic, we can see the output of integrated full-bridge rectifier BR601 go to linear regulators 
U601 and U503 to create +15V and -15V and then immediately to the connector on the right which goes to the 
bottom PCB. These voltage rails are not used by the top PCB.</p>

<p>A discrete diode bridge and some capacitors create an unregulated +/-9V that goes to the same connector
and to U501 / LM340-5, a linear +5V regulator that is functionally equivalent to a 7805. The 5V is used
to power pretty much the entire top PCB as well as some ICs on the bottom.</p>

<p>I measured the following voltages on the top-to-bottom power connector:</p>

<ul>
  <li>0V - instead of 10V</li>
  <li>+15V - good!</li>
  <li>+12V - instead of +9V</li>
  <li>+7V - instead of +5V. Horrible!</li>
  <li>GND</li>
  <li>0V - instead of -9V</li>
  <li>-15V - good!</li>
</ul>

<p>The lack of 10V is easy to explain: it’s an input, generated by a high precision voltage reference
on the bottom PCB out of the +15V. On the top PCB, it’s only used for dying gasp<sup id="fnref:gasp" role="doc-noteref"><a href="#fn:gasp" class="footnote" rel="footnote">5</a></sup> and power-on/off 
reset generation.</p>

<p>+12V instead of +9V was only a little bit concerning, at the time. The lack of -9V was clearly a problem.
And applying +7V instead of +5V to all digital logic is a great way to destroy all digital logic ICs.</p>

<p>Here’s the part of the PCB with the 9V diode bridge:</p>

<p><img src="/assets/dg535/diode_bridge.jpg" alt="9V diode bridge" /></p>

<p>Observations:</p>

<ul>
  <li>the discrete diodes look like a bodge</li>
  <li>marked in red, there is a blackened spot above-right of the diodes</li>
  <li>there is a green patch wire. There are quite a bit of those on the top PCB and they turned out to be 
harmless; they work around bugs in the PCB itself.</li>
</ul>

<p>2 discrete diodes were on the other side of the PCB to complete the discrete full bridge, though one soon
fell off. Underneath the discrete diodes is a footprint
for a BR501 full bridge rectifier <em>that is not in the schematic</em><sup id="fnref:schematic" role="doc-noteref"><a href="#fn:schematic" class="footnote" rel="footnote">6</a></sup>!</p>

<p><img src="/assets/dg535/br501_and_jumpers.jpg" alt="BR501 and jumpers" /></p>

<p>While doing these measurements, magic smoke appeared at the same location as 
blackened spot in the picture. At that point, I called it quits and left the unit sit for 18 months.</p>

<p>The schematic shows jumpers on the +/-15V and the +5V rail, see the orange rectangle
in the previous picture. These are intended for power measurements, but when removed
they also disconnect the not-at-all-5V rail from the digital logic and thus
protect it from further damage until I had sorted out the issue.</p>

<h1 id="power-architecture-of-the-dg535">Power Architecture of the DG535</h1>

<p>Since I suspected a problem with the discrete diode bridge bodge on the top PCB, the plan
was to repopulate the PCB with an integrated full-bridge rectifier. Turns out: even though
the schematic in the manual shows a discrete bridge, the schematic description in the same
manual indeed talks about an integrated full bridge. Instead of buying one at Digikey and pay 
$7 for shipping a $1 component, I found a suitable 100V/2A alternative, a 2KBP01M, at 
<a href="https://anchor-electronics.com">Anchor Electronics</a>, the last remaining Silicon Valley 
retail components supplier, conveniently located across the street from work.</p>

<p><img src="/assets/dg535/br501_replacement.jpg" alt="BR501 replacement" /></p>

<p>The footprint of the new diode bridge wasn’t quite the same, but you can easily nudge the
pins a bit to make it work.</p>

<p>I then had a look at the schematic of the bottom PCB power supply:</p>

<p><a href="/assets/dg535/power_supply_bottom.jpg"><img src="/assets/dg535/power_supply_bottom.jpg" alt="Bottom PCB power supply" /></a>
<em>(Click to enlarge)</em></p>

<p>More linear regulators, 2 on the unregulated +8V (?) rail to create +6V and +5.2V, and 3 on
the unregulated -8V rail to create -2V, -5.2V, and -6V (“actually -5.6V”).</p>

<p>Now here’s the interesting part: the -2V and -5.2V rails have heavy duty 5W 18 Ohm and 10 Ohm resistors 
between the input and the output of their linear regulator.</p>

<p><img src="/assets/dg535/current_boost_resistors.jpg" alt="Current boost resistors" /></p>

<p>These are called <em>current boost</em> resistors and while they are useful in the right
conditions, they are bad news. And when we go back to the top PCB, here’s what we see:</p>

<p><img src="/assets/dg535/5v_current_boost_resistor.jpg" alt="5V current boost resistor" /></p>

<p>It may not be in the schematic, but located below right next to the 5V regulator is
another 10 Ohm 5W current boost resistor.</p>

<h1 id="the-how-why-and-please-dont-of-current-boost-resistor-circuits">The How, Why, and Please Don’t of Current Boost Resistor Circuits</h1>

<p>The purpose of a current boost resistor is to partially offload a linear regulator.</p>

<p><img src="/assets/dg535/without_current_boost.png" alt="Power supply without current boost" /></p>

<p>Imagine we have design with a 5V rail and a load with an equivalent resistance of 3 Ohm,
good for a constant current draw of 1.67A. When we only use a linear regulator with 9V on the
input side, the current through the regulator will be 1.67A as well and the regulator 
needs to dissipate (9-5) * 1.67 = 6.7 W. That is too much for a 7805 in TO-220 package 
to handle: with the right heatsink, 1.5A is about the limit.</p>

<p><img src="/assets/dg535/current_boost_circuit.png" alt="Power supply with current boost resistor" /></p>

<p>With a 10 Ohm current boost resistor, the 7805 still supplies current to keep the voltage 
across the load at 5V, but the current boost resistor injects a constant current of (9-5)/10 = 0.4A. 
This reduces the current through the regulator from 1.67 A to 1.27 A and its power dissipation 
from 6.7W to 5.1W. The dissipation in the resistor is (9-5)^2 / 10 = 1.6 W. The total
power consumption remains the same: 5.1 W + 1.6 W = 6.7 W.</p>

<p>What have we gained? For the price of adding a beefy 10 Ohm resistor, we’re now staying 
within the current and power limits of the 7805 in TO-220 package. There is no need to
upgrade the 7805 to a much larger TO-3 package and the changes to the PCB are minimal.</p>

<p>But there is a price to pay! In fact, there’s more than one.</p>

<p><strong>Overvoltage risk when system load goes down</strong></p>

<p>A linear regulator can only supply current from input to output; it can’t sink current from 
output to input. If the system load drops below the 0.4A that’s supposed to be supplied by 
the current boost resistor, that 0.4A has nowhere to go and the voltage at the output
of the regulator has to go up.</p>

<p>We can see that here:</p>

<p><img src="/assets/dg535/current_boost_load_too_low.png" alt="Power supply with system load 90 Ohm instead of 3 Ohm" /></p>

<p>Assume that the system load has reduced and the equivalent system resistance is now 
90 Ohm instead of 3 Ohm. The current through the 2 resistors is just 0.09A. The voltage at the 7805 output
node is 8.1V and there is nothing the 7805 can do to bring the voltage down.</p>

<p><strong>No safeguards when input voltage goes up</strong></p>

<p>Another issue is when the input voltage increases. In the example below, it goes from
+9V to +12V. The power dissipation in the 7805 goes up a little bit, but the one in
the current boost resistor increases from 1.6W to 4.9W.</p>

<p><img src="/assets/dg535/current_boost_with_input_voltage_higher.png" alt="Power supply with input voltage 12V instead of 9V" /></p>

<p>The +12V that I measured on one of the connector is more than just a little bit concerning
after all.</p>

<p>Even without the current boost resistor, +12V at the input would be a real problem, since all 
the power of the resistor would have to be dissipated by the regulator. But with only a regulator, 
there is at least the possibility of including safeguards: there could be a current limiter, 
a temperature monitor, worst case, the regulator burns out and disconnects the output
from the input. With a dumb resistor you have none of that.</p>

<p>In 
<a href="/2025/08/10/HP-5370A-Repair.html#power-suppy-architecture">my 5370A repair blog post</a>,
I describe the current limiters that are part of its discrete linear voltage regulators: when the
current is too high, the output voltage is reduced. The DG535 has no such safety mechanism.</p>

<h1 id="root-causing-the-dg535-issue">Root Causing the DG535 Issue</h1>

<p>Let’s recap the issues that I had to deal with:</p>

<ul>
  <li>+7V on the +5V rail</li>
  <li>+12V instead of +9V at the input of the 7805 regulator</li>
  <li>A blackened PCB</li>
</ul>

<p>These issues were all related.</p>

<h1 id="debugging-the-7v-issue">Debugging the +7V Issue</h1>

<p>The +7V could be explained by the current boost resistor and a load that was too low. If the load is 
too low anyway, why not temporarily desolder the current boost resistor and check what happens? I did that 
and the voltage on the +5V rail predicably dropped down to +5V. The temperature on the 7805 remained 
in check. Good!</p>

<p>But why was the load too low?</p>

<p>A quick probe on the pins of the Z80 CPU showed no activity. Better yet: there was no clock!</p>

<p><a href="/assets/dg535/z80_clock_generator.jpg"><img src="/assets/dg535/z80_clock_generator.jpg" alt="Z80 clock generator" /></a>
<em>(Click to enlarge)</em></p>

<p>The 5 MHz CPU clock is derived from the 10 MHz clock, which comes from connector J40: the
cable that connects the top and bottom PCB. In other words: if you run the top PCB by itself, there is
no clock. And without a clock, the power consumption of the CPU system will be much lower… and
with a current boost resistor, the voltage will rise to +7V.</p>

<p>To run the CPU board stand-alone with an active clock, I configured 
<a href="/2023/01/02/HP33120A-Repair-Shutting-Down-the-Eye-of-Sauron.html">my HP 33120A signal generator</a> 
to generate a 10 MHz signal and routed its SYNC output to connector J40.</p>

<p><a href="/assets/dg535/10MHz_from_function_generator.jpg"><img src="/assets/dg535/10MHz_from_function_generator.jpg" alt="10MHz from function generator" /></a>
<em>(Click to enlarge)</em></p>

<p>In the picture above, in addition to the signal generator, you can also see an HP 3631A power
supply that outputs 10V: this is a replacement of the reference voltage that’s needed for the dying
gasp and reset generator that I mentioned earlier. These are the 2 external signals that are needed
to run the CPU top PCB without the analog bottom PCB, though only for a short time: without
current boost resistor and cooling fan, the 7805 was now taking on all the current and warming up
quickly.</p>

<p><strong>Important: The +12V issue was still there! As soon as the current boost resistor was placed
back, it was dissipating 5W and its temperature rose to 130C almost instantly!!!</strong></p>

<h1 id="side-quest-debugging-the-cpu-system---connector-stupidity">Side Quest: Debugging the CPU System - Connector Stupidity</h1>

<p>With the CPU clock running, I expected some activity on the keyboard/LED and LCD boards, but
the CPU seemed stuck.</p>

<p>It took a lot of effort to root cause this. I dumped the 
<a href="/assets/dg535/DG535_ROM_v2.0_SN4633.bin">ROM contents</a><sup id="fnref:ROM" role="doc-noteref"><a href="#fn:ROM" class="footnote" rel="footnote">7</a></sup>, used Ghidra to disassemble
the code. I also used a logic analyzer to trace the Z80 address bus to get a better insight into
what was happening, resulting in this pretty picture:</p>

<p><img src="/assets/dg535/logic_analyzer.jpg" alt="Logic analyzer on Z80" /></p>

<p>After many hours, the simple conclusion was this: the connector of the LCD panel cable was plugged
in incorrectly. This pulled high a crucial status bit on the data bus which made the Z80 go into
an endless loop.</p>

<p>I partially blame SRS for this: the way they deal with connector-related documentation is horrible,
unconventional, and inconsistent. Just look at this beauty:</p>

<p><img src="/assets/dg535/connector_docs.jpg" alt="Connector documentation" /></p>

<p>At the bottom right (red), they lay out a pinout convention. The keyboard/LED PCB (green) doesn’t
follow that convention. The LCD panel display does follow it, but this is a standard 14-pin
interface that’s used by an HD44780-based LCD controller which uses an entirely different convention.
They also don’t consistently mark pin 1 on the PCB.</p>

<p>Still, even after fixing that, the LCD didn’t come up. This turned out to be due to another
signal that came from the bottom PCB, the analog voltage that sets the LCD contrast. It was
sufficient to connect that to ground. That’s the blue wire that the red arrow is pointing to:</p>

<p><img src="/assets/dg535/LCD_up_and_running.jpg" alt="LCD up and running" /></p>

<p>The LCD was working now, but without backlight. The backlight of the original LCD panel
requires 120V AC with a 50 kOhm resistor in series. This voltage is coming straight from a
primary winding of the transformer. I measured 120V just fine, so the backlight was broken.
It doesn’t make the display entirely unreadable, but it’s definitely annoying.</p>

<h1 id="fixing-the-burnt-pcb-trace">Fixing the Burnt PCB Trace</h1>

<p>When I measured the voltages at the start of this journey, I noticed that the -9V was missing
on the power connector towards the analog PCB. The trace to this connector is running below
the overheating current boost resistor. All I needed to do was install a replacement
wire.</p>

<p><img src="/assets/dg535/burnt_pcb_trace_fix.jpg" alt="Bodge wire to fix the PCB trace" /></p>

<h1 id="endless-boot-loop-after-reassembly">Endless Boot Loop after Reassembly</h1>

<p>After going through the pain of reassembling the whole unit, I has hopeful that I’d be able
to at least operate the keyboard and see things happening on the LCD. That, of course,
didn’t happen. Instead, the unit got into an endless boot loop, showing the splash
screen, then going blank, repeat.</p>

<iframe width="680" height="400" src="https://www.youtube.com/embed/DJEbIihfNMo?si=c1rQnyQcja4wqpwB" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>

<p>This cost me another couple of hours to root cause. I disconnected all wires to the top PCB to
revert back to the condition where things worked before, but no luck. Eventually, I
stumbled onto the “Cold Boot” section in the user manual:</p>

<blockquote>
  <p>If the instrument turns on, but is completely unresponsive to the keyboard, then the RAM
contents may have been corrupted causing the instrument to “hang”. To remedy this situation,
turn the unit off, then hold down the BSP (backspace) key down and turn the unit back on
again.</p>
</blockquote>

<p>Like many old pieces of test equipment, the DG535 uses a 
<a href="https://www.digikey.com/en/products/detail/panasonic-energy/BR-2-3AE2SP/64350">BR-2/3A</a>
3V lithium battery to retain settings and calibration values while the unit is powered down.
The battery was still good when I measured its voltage, but maybe there was a 
short circuit due reassembly that made the SRAM loose its contents.</p>

<p>Either way, after following the power-up procedure from the manual the unit worked again.</p>

<p>Note that it’s not necessary to go through a full recalibration after losing the SRAM
contents: the EPROM that holds the firmware also contains calibration constants and the
serial number that are unique to each unit. That’s pretty cool! The calibration
constants are guarded by a checksum to ensure their correctness. What’s puzzling is that
the firmware checks the correctness, but when it detects an error, instead of reporting
a meaningful error, it does a system reset and retries again, leaving the operator
to guess what went wrong.</p>

<h1 id="dg535-up-and-running-with-a-variac">DG535 Up and Running with a Variac</h1>

<p>I still hadn’t tracked down the +12V/-12V on the +9V/-9V rails, but with everything else
fixed, I wondered if I could get the full unit to work. Just a few flea markets ago,
I had picked up a variac for $15. I always wondered why people need such a thing, and
wouldn’t you know it, this was the perfect use case: reduce the mains voltage from 120V AC
to ~100V AC to bring down the voltage on the secondary windings of the transformer.</p>

<p><img src="/assets/dg535/variac.jpg" alt="Variac on my bench" /></p>

<p>And just like that, the DG535 was working!</p>

<p><img src="/assets/dg535/dg535_and_oscilloscope_working.jpg" alt="DG535 with oscilloscope showing pulses" /></p>

<p>With my SR620 time interval counter and averaging a lot of measurements, I was even able 
to show that delays could be changed with 5 ps precision.</p>

<p>I measured a power consumption of 62W, not too far away from the 70W that’s specified in the
manual, which is just a case of being conservative. Right?</p>

<h1 id="tracking-down-the-12-12v-on-the-9-9v-rails">Tracking down the +12/-12V on the +9/-9V Rails</h1>

<p>I once again spent a long time trying to track down the 12V vs 9V issue. My only theories
were a short somewhere in the transformer, or some wires misconnected during an earlier repair,
or the original transformer being replaced by an incorrect one, but extensive and sometimes
questionable measurement practises didn’t turn up anything.</p>

<p><img src="/assets/dg535/questionable_measurement.jpg" alt="A very questionable measurement setup" /></p>

<p>Other than secondary winding voltages being too high, the transformer behaved fine.</p>

<p>I started a 
<a href="https://www.eevblog.com/forum/repair/rewinding-a-power-transformer/">thread on the EEVblog forum about rewinding a transformer</a>
where someone suggested that the output voltage of a transformer can be… load dependent. 
When only the CPU board was connected, I had measured an overall power consumption of 10W,
60W below specification.</p>

<p>I removed the variac from the setup and measured a power consumption of 72W. The
measured voltage on the +9V rail was +10.2V. Enough to raise the power consumption
in the 5V current boost resistor from 1.6W to 2.7W, but well within spec of its
5W rating.</p>

<p>The +12V issue was another manifestation of the lack of load resulting in a
self-distructing unit! And I had been chasing another ghost.</p>

<h1 id="lcd-replacement">LCD Replacement</h1>

<p>With the unit now fully working, all that remained was fixing the LCD backlight.
SRS sells a replacement LCD panel for a ridiculous $200. This must be old stock
because you can’t find ones anymore with a 120VAC backlight power supply.</p>

<p>Instead, I bought a <a href="https://www.crystalfontz.com/product/cfah2001btmiet-20x1-character-display-module">CFAH2001B-TMI-ET panel</a>
from crystalfontz.com.</p>

<p>It has a 16 pin instead of 14 pin interface, but the 2 additional pins are for
the backlight. The original LCD has separate pins for that.</p>

<p>The backlight has an LED with threshold voltage of 3.5v. The typical current is
48mA. The LED connector has a 5V pin already, but the top PCB creates this voltage
rail with a 5.1V zener diode and a series resistor from the +15V rail.<sup id="fnref:lcd_supply" role="doc-noteref"><a href="#fn:lcd_supply" class="footnote" rel="footnote">8</a></sup>
This rail can’t supply 48mA. Instead, I used the +5V pin of the keyboard/LCD
PCB nearby, with a 30 Ohm resistor in series, good for a current of (5-3.5)/30 = 50 mA.</p>

<p><img src="/assets/dg535/LCD_panel_working.jpg" alt="New LCD panel working" /></p>

<p>The new LCD panel is considerably thicker than the old one, so you can’t reuse the old
screws.</p>

<p><img src="/assets/dg535/LCD_panel_is_thicker.jpg" alt="LCD thickness comparison" /></p>

<p>I used Everbilt #4-40 3/8” machine screws from Home Depot instead. Be carefull
when tightening those new screws: it’s now possible to overdo things and
bend the LCD PCB.</p>

<p><img src="/assets/dg535/no_spacers_for_screws.jpg" alt="No spacers for the new screws" /></p>

<p>My unit had only 2 out of 4 transformer mounting screws in place. Home Depot didn’t 
have the #10-32 1 5/8” screws, but slightly shorter #10-32 1 1/2” screws worked fine.</p>

<p>After one more round of carefully connecting all connectors back in place, the DG535
was finally back to where it needed to be:</p>

<p><img src="/assets/dg535/DG535_with_new_LCD.jpg" alt="DG535 with new LCD" /></p>

<h1 id="post-mortem">Post Mortem</h1>

<p>A bunch of things went wrong during the design and repair of this DG535.</p>

<p>Design weaknesses:</p>

<ul>
  <li>Current boost resistors make a design prone to self-destruction
due to overvoltage when the system load is too low due to some internal
failure.</li>
  <li>Current boost resistors also result in burning out a PCB when
the voltage difference between input and output of a voltage regulator
becomes too high. This can again happen when the system load is lower
than designed for.</li>
  <li>The schematic in the manual shows a discrete diode full bridge for the
unregulated +/-9V rail, instead of an integrated one, and no current
boost resistor.</li>
  <li>The mechanical design and short cables make it tempting to power
the top PCB without connecting the bottom PCB… which cuts down the
system load dramatically.</li>
  <li>The power consumption of the top PCB is very low when the bottom
PCB is disconnected, due to the lack of 10 MHz clock.</li>
  <li>the pinout of the connectors of the DG535 doesn’t follow standard convention,
and the convention that is documented in the manual is violated on the
same page.</li>
  <li>The schematic of the top PCB shows a +/-9V rail. The bottom PCB schematic
shows +/-8V rails on the same connector pins. In reality, the measured voltage
is 10.2V. Confusing.</li>
</ul>

<p>Repair mistakes:</p>

<ul>
  <li>A previous attempt at repairing saw the replacement of an integrated
diode bridge by a discrete one. To make things worse, they used 1N5822 
Schottky diodes, as shown in the incorrect schematic. Schottky diodes
have a threshold voltage of 0.4V instead of a 0.7V threshold for the integrated 
diode bridge. Because of this, the unregulated DC output was 2 x (0.7 - 0.4V) = 0.6V 
higher, which increased the power consumption in the current boost resistors 
even more!</li>
  <li>PCBs were powered on without full load. This resulted in PCB traces burning up.</li>
  <li>Connectors were incorrectly plugged in. I should have taken pictures before
disconnecting anything.</li>
  <li>I knew not enough about transformers and wasted way too much time chasing
a ghost because of it!</li>
</ul>

<p>In the end, I only made 3 real fixes:</p>

<ul>
  <li>removed the discrete diode bridge and replaced it by an integrated one</li>
  <li>installed a bodge wire to bring the -9V to the top-to-bottom PCB power
connector</li>
  <li>replaced the LCD panel with broken backlight by a new one with diode 
backlight</li>
</ul>

<p>I got lucky that the 5V digital components survived being exposed to 7V. One
thing that I’ve learned over the years is that old ICs are pretty good at
surviving that kind of abuse.</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://www.thinksrs.com/products/dg535.html">Stanford Research - DG535 Digital Delay Generator</a></li>
  <li><a href="https://www.analog.com/en/resources/design-notes/single-resistor-provides-extra-current-from-a-linear-regulator.html">Analog Devices - Single Resistor Provides Extra Current from a Linear Regulator</a></li>
  <li><a href="https://www.eevblog.com/forum/repair/srs-stanford-research-dg535/">EEVblog forum - SRS Stanford Research DG535</a></li>
  <li><a href="https://www.eevblog.com/forum/repair/rewinding-a-power-transformer/">EEVblog forum - Rewinding a power transformer?</a></li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:parts" role="doc-endnote">
      <p>Not that I’ve ever done that, but it’s what I tell my wife. <a href="#fnref:parts" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:keyboard" role="doc-endnote">
      <p>Whether or not it will ever sell for that asking price is a different story. <a href="#fnref:keyboard" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:NIM" role="doc-endnote">
      <p>NIM stands for <a href="https://en.wikipedia.org/wiki/Nuclear_Instrumentation_Module">Nuclear Instrumentation Model</a>. 
    It’s a voltage and current standard for fast digital pulses for physics and nuclear experiments. <a href="#fnref:NIM" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:pin_dot" role="doc-endnote">
      <p>When dealing with mirror image of an IC footprint, I’m constantly
        second guessing myself about whether or not I’m probing the right pin. <a href="#fnref:pin_dot" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:gasp" role="doc-endnote">
      <p>When the +9V voltage rail drops below +7.5V, the dying gasp circuit creates a non-maskable
     interrupt to the CPU, allowing to quickly store data in non-volatile RAM before the power
     is completely gone. <a href="#fnref:gasp" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:schematic" role="doc-endnote">
      <p>I emailed SRS to ask if they had an updated schematic, but they told me
          to send in the unit for repair. <a href="#fnref:schematic" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:ROM" role="doc-endnote">
      <p>The ROM contents of each DG535 are unique for that particular unit, since they contain
    the serial number and calibration data that were determined in the factory. If you
    program the EPROM with my ROM file in your unit, expect delay specification to be
    significantly worse. <a href="#fnref:ROM" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:lcd_supply" role="doc-endnote">
      <p>I have no idea why SRS didn’t use the regular +5V rail to power the
           LCD panel. <a href="#fnref:lcd_supply" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Fixing LCD Screen Corruption of a Tektronix TDS220 Oscilloscope</title><link href="https://tomverbeure.github.io/2025/11/03/TDS220-LCD-Corruption-Fix.html" rel="alternate" type="text/html" title="Fixing LCD Screen Corruption of a Tektronix TDS220 Oscilloscope" /><published>2025-11-03T10:00:00+00:00</published><updated>2025-11-03T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2025/11/03/TDS220-LCD-Corruption-Fix</id><content type="html" xml:base="https://tomverbeure.github.io/2025/11/03/TDS220-LCD-Corruption-Fix.html"><![CDATA[<ul id="markdown-toc">
  <li><a href="#introduction" id="markdown-toc-introduction">Introduction</a></li>
  <li><a href="#the-tds220-oscilloscope" id="markdown-toc-the-tds220-oscilloscope">The TDS220 Oscilloscope</a></li>
  <li><a href="#opening-up-the-tds220" id="markdown-toc-opening-up-the-tds220">Opening Up the TDS220</a></li>
  <li><a href="#common-tds220-issues" id="markdown-toc-common-tds220-issues">Common TDS220 issues</a></li>
  <li><a href="#replacing-the-power-supply-capacitors" id="markdown-toc-replacing-the-power-supply-capacitors">Replacing the power supply capacitors</a></li>
  <li><a href="#lcd-panel-corruption" id="markdown-toc-lcd-panel-corruption">LCD Panel Corruption</a></li>
  <li><a href="#extracting-the-lcd-panel" id="markdown-toc-extracting-the-lcd-panel">Extracting the LCD Panel</a></li>
  <li><a href="#lcd-panel-capacitor-replacement" id="markdown-toc-lcd-panel-capacitor-replacement">LCD Panel Capacitor Replacement</a></li>
  <li><a href="#lcd-panel-backlight-replacement" id="markdown-toc-lcd-panel-backlight-replacement">LCD Panel Backlight Replacement</a></li>
  <li><a href="#fixing-the-square-wave-issue" id="markdown-toc-fixing-the-square-wave-issue">Fixing the Square Wave Issue</a></li>
  <li><a href="#conclusion" id="markdown-toc-conclusion">Conclusion</a></li>
  <li><a href="#references" id="markdown-toc-references">References</a></li>
  <li><a href="#footnotes" id="markdown-toc-footnotes">Footnotes</a></li>
</ul>

<h1 id="introduction">Introduction</h1>

<p>I found a <a href="https://w140.com/tekwiki/wiki/TDS220">Tektronix TDS220 oscilloscope</a> at the
<a href="https://www.electronicsfleamarket.com">Silicon Valley Electronics Flea Market</a>. 
The seller told me that it worked but that the screen flickered a bit and that
this model is known to have issues with leaking capacitors. He asked $25 which 
would be a great price for any evening of entertainment even if an oscilloscope
wasn’t part of the deal, so I bought it.</p>

<p>Wise men claim that you should not power up an old device that with leaking capacitors, 
but I obviously did that anyway. The scope booted up nicely with some occasional screen 
corruption, as promised.</p>

<p><img src="/assets/tds220/screen_corruption.jpg" alt="Screen corruption" /></p>

<p><a href="https://youtu.be/Np21eQKw6sw?si=13aY1BOcO3j50V-m">This video</a> gives a better idea about the 
corruption. It’s intermittent and depends on the kind of content that is shown on the
screen. It also less prevalent when the scope has warmed up. All in all, it’s not a deal
breaker, the scope is perfectly usable as is, but it would nice to fix it.<sup id="fnref:flicker" role="doc-noteref"><a href="#fn:flicker" class="footnote" rel="footnote">1</a></sup></p>

<p>When connected to a signal generator it showed 2 sine waves:</p>

<p><img src="/assets/tds220/tds220_2_sine_waves.jpg" alt="TDS220 showing 2 sine waves" /></p>

<p>But when I connected the probe to the probe compensation pin, 
I got the signal below instead of a square wave:</p>

<p><img src="/assets/tds220/tds220_probe_compensation_waveform.jpg" alt="TDS220 definitely not showing a square waveform" /></p>

<p>The scope had this issue for both channels.</p>

<p>Alright, maybe I’d get more than just an evening of fun out of it.</p>

<h1 id="the-tds220-oscilloscope">The TDS220 Oscilloscope</h1>

<p>The TDS220 was introduced in 1997. It was a low cost oscilloscope with a limited
number of features, but with a weight of just 1.5kg/3.25lb and a small size, it was
great for technicians and for educational use. I’m not sure if it was Tektronix’ first 
oscilloscope with an LCD but, if not, it was definitely one of the early ones.</p>

<p>Some key characteristics:</p>

<ul>
  <li>2 channels</li>
  <li>100 MHz/1 Gsps</li>
  <li>2500 sample points per channel</li>
  <li>Only a few measurements: period, frequency, cycle RMS, mean and peak-to-peak voltage</li>
</ul>

<p>With a plug-in extension board, you can add a parallel, serial and GPIB port and FFT 
functionality, but even with those, it’s a really bare bones scope. And yet, I expect 
that I’ll be using it quite a bit: it’s so portable and the footprint is so small that 
it’s perfect for a quick measurement on a busy workbench.</p>

<p>Let’s take it apart!</p>

<h1 id="opening-up-the-tds220">Opening Up the TDS220</h1>

<p>Opening up the TDS220 isn’t hard, but you need to do the steps in the right order and there’s
a bit of bending-the-plastic involved.</p>

<p><strong>Remove handle and power button</strong></p>

<p><img src="/assets/tds220/tds220_handle_and_power_button.jpg" alt="TDS220 handle and power button" /></p>

<p>The handle must lay flat against the case to widen it and remove it. Also pull off the white
knob.</p>

<p><strong>Remove the 2 screws</strong></p>

<p>Once the handle it removed, you get access to 2 screws, one on each side. Remove
them with a Torx 15 screwdriver.</p>

<p><strong>Remove expansion module</strong></p>

<p>If you have a TDS2CM or TDS2MM expansion module, you need to remove it because
otherwise will block the case from coming off.</p>

<p>It took me longer than I care to admit to figure out how to do this. 
There is no need to play with the tab at the top of the module, just forcefully slide 
the thing upwards until it disconnects from the connector at the bottom.</p>

<p><img src="/assets/tds220/tds220_remove_expansion.jpg" alt="Sliding up the expansion module" /></p>

<p><strong>Pry off the back case</strong></p>

<p>This is the part that I always hate, because you need to figure which location is the
best to jam a screwdriver between 2 pieces of plastic. And based on the scuff marks in 
the picture below, others have struggled with it as well.</p>

<p><img src="/assets/tds220/tds220_remove_back_cover.jpg" alt="Remove back cover" /></p>

<p>But I think I found the best way to go about it now. At the right side, insert the
screwdriver horizontally between the blue and the white plastic and then lift the blue 
part. Insert a smaller screwdriver in the gap that you just made to prevent it from closing again
and repeat the same operation in the middle and the left.</p>

<p><strong>Inside exposed</strong></p>

<p>You can now take off the blue back cover and have a look at the inside of the scope.</p>

<p><a href="/assets/tds220/tds220_inside_exposed.jpg"><img src="/assets/tds220/tds220_inside_exposed.jpg" alt="TDS220 inside exposed" /></a>
<em>(Click to enlarge)</em></p>

<p>There are 2 PCBs: the left horizontal PCB contains all the acquistion and processing logic.
The one on the right is the power supply.</p>

<p><strong>Extract the power supply PCB</strong></p>

<p>To remove the power supply, unplug the orange bundle with 7 wires from the main PCB as 
well as the fat ground wire. The PCB is held in place by 2 plastic tabs at the bottom.</p>

<h1 id="common-tds220-issues">Common TDS220 issues</h1>

<p>Here are the most common TDS220 issues:</p>

<ul>
  <li>leaking capacitors in the power supply</li>
  <li>mechanical stress around the BNC connectors</li>
  <li>LCD backlight too weak or not working</li>
  <li>
    <p>Weak ground connection from BNC connector to power supply</p>

    <p>Tektronix issued a <a href="https://www.tek.com/en/services/safety/tds200/originalletterhtml">product recall</a> 
for this. My unit has components with dates that come after the product recall.
Check out <a href="https://www.youtube.com/watch?v=9N8UKwn4okM">this video</a> for a fix.</p>
  </li>
</ul>

<p>Not so common issue:</p>

<ul>
  <li>LCD screen corruption</li>
</ul>

<p>While I’ll document 3 of the 4 common repairs, you can find plenty of other
source on the web that do the same thing. That’s not the case for the LCD screen
corruption.</p>

<h1 id="replacing-the-power-supply-capacitors">Replacing the power supply capacitors</h1>

<p>I didn’t take pictures of it, but the solder side of the power supply PCB was drenched
in a light-brown/yellow-ish fluid. Some of that made it to the front side of the PCB
as can be seen here:</p>

<p><a href="/assets/tds220/fluid_marks.jpg"><img src="/assets/tds220/fluid_marks.jpg" alt="Fluid on front of the PCB" /></a>
<em>(Click to enlarge)</em></p>

<p>I’m not 100% sure about the source of this fluid because I was never able to pinpoint exactly 
which of the capacitors started leaking, but it’s fair to assume that this fluid was capacitor 
electrolyte. I decided to remove all electrolytic capacitors with new ones. There are 11 of them, 
listed in the table below:</p>

<p><strong>I used these components for my TDS220 recapping, but there is absolutely no guarantee
that these are the right ones. You need to double check everything yourself! Recapping
the scope is done at your own risk!</strong></p>

<table>
  <thead>
    <tr>
      <th><strong>#</strong></th>
      <th><strong>Indicator</strong></th>
      <th><strong>Capacitance</strong></th>
      <th><strong>Voltage</strong></th>
      <th><strong>Location</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1a</td>
      <td>C3</td>
      <td>47 uF</td>
      <td>450V</td>
      <td>Largest on the PCB</td>
    </tr>
    <tr>
      <td>1b</td>
      <td>C3</td>
      <td>68 uF</td>
      <td>450V</td>
      <td>Largest on the PCB</td>
    </tr>
    <tr>
      <td>2</td>
      <td>C13</td>
      <td>2200 uF</td>
      <td>6.3V</td>
      <td>Next to connector CN2</td>
    </tr>
    <tr>
      <td>3</td>
      <td>C12</td>
      <td>2200 uF</td>
      <td>6.3V</td>
      <td>Next to C13</td>
    </tr>
    <tr>
      <td>4</td>
      <td>C11</td>
      <td>2200 uF</td>
      <td>6.3V</td>
      <td>Next to C12</td>
    </tr>
    <tr>
      <td>5</td>
      <td>C14</td>
      <td>1000 uF</td>
      <td>6.3V</td>
      <td>Next to C11</td>
    </tr>
    <tr>
      <td>6</td>
      <td>C15</td>
      <td>470 uF</td>
      <td>6.3V</td>
      <td>Between C13 and C12</td>
    </tr>
    <tr>
      <td>7</td>
      <td>C21</td>
      <td>47 uF</td>
      <td>16V</td>
      <td>Close to “AULT KOREA”</td>
    </tr>
    <tr>
      <td>8</td>
      <td>C18</td>
      <td>22 uF</td>
      <td>35V</td>
      <td>Next to CN2</td>
    </tr>
    <tr>
      <td>9</td>
      <td>C6</td>
      <td>22 uF</td>
      <td>35V</td>
      <td>Next to IC1</td>
    </tr>
    <tr>
      <td>10</td>
      <td>C17</td>
      <td>4.7 uF</td>
      <td>50V</td>
      <td>Next to C16</td>
    </tr>
    <tr>
      <td>11</td>
      <td>C10</td>
      <td>2.2 uF</td>
      <td>50 V</td>
      <td>Next to CN2</td>
    </tr>
  </tbody>
</table>

<p>Pay attention to 1a and 1b: some TDS220 power supplies have a 47 uF, other have a 68 uF capacitor.
Mine had a 47 uF one. You don’t need to buy both of them.</p>

<p>I created <a href="https://www.digikey.com/en/mylists/list/NISC68K89D">this Digikey list</a> with
all these capacitors. At the time of writing this, the cost was $8.31, tax and shipping 
not included.</p>

<p><img src="/assets/tds220/pcb_glue.jpg" alt="PCB glue-like substance" /></p>

<p>On my unit, most capacitors were fixed to the PCB with a soft, glue-like substance.
Use an Exacto knife to cut it loose before desoldering a capacitor.</p>

<p>The PCB has markers for capacitor polarity. For smaller ones, it uses regular + and -
notation. For larger ones, a black circle indicates negative polarity.</p>

<p><img src="/assets/tds220/capacitor_polarity.jpg" alt="Capacitor polarity" /></p>

<p>All in all, the PSU recapping process is pretty straightforward and took around
1 hour to complete.</p>

<p>However, after power the scope back on, the screen corruption was still there!</p>

<h1 id="lcd-panel-corruption">LCD Panel Corruption</h1>

<p>The LCD screen corruption is content specific and it happens for a whole pixel row
at a time. I thought that it was caused by some signal corruption on the flat cable
between the main PCB and the LCD panel, but this was not case. I googled around
a bit, but couldn’t find any references to the issue that I was seeing, so I asked
on the EEVblog Repair forum. A few hours later, I got 
<a href="https://www.eevblog.com/forum/repair/tds220-lcd-content-dependent-screen-corruption/msg6027427/#msg6027427">the following reply</a>:</p>

<blockquote>
  <p>Please refer to the link above, the problem that occurs is very similar to your problem</p>
</blockquote>

<p>It included a link a Chinese forum that requires an account to get access to
photos and any pages beyond the first one, but <em>daisizhou</em> helpfully
posted those pictures in the EEVblog forum thread:</p>

<p><strong>You need to replace some capacitors that are inside the LCD panel!</strong></p>

<h1 id="extracting-the-lcd-panel">Extracting the LCD Panel</h1>

<p>Replacing the LCD panel capacitors is not complicated, but since an LCD panel assembly
is a bit fragile, you need to be careful to not destroy anything. Let’s first extract
the panel from the case.</p>

<p><strong>Remove the front panel knobs</strong></p>

<p>The panel knobs are the main components that are still keeping the front enclosure
attached to the main body. You can just pull them off.</p>

<p><img src="/assets/tds220/tds220_remove_front_buttons.jpg" alt="Remove front panel knobs" /></p>

<p><strong>Remove buttons PCB</strong></p>

<p>Unplug 2 flat cable connectors that links the main PCB to the buttons PCB and to
the LCD panel. 
<strong>Not shown: also unplug the power connector of the LCD panel.</strong> It’s right next
to the mains receptacle on the power PCB.</p>

<p><img src="/assets/tds220/tds220_unplug_button_connector.jpg" alt="Unplug buttons PCB connector" /></p>

<p>You can now remove the buttons PCB by pushing down 2 plastic tabs near the BNC
connectors.</p>

<p><img src="/assets/tds220/tds220_front_panel_removed.jpg" alt="Front panel removed" /></p>

<p>If the LCD panel was never removed before, chances are that the LCD front protector
sticks to the LCD panel itself. That is the case in the picture above. The protector
has a dark gray foam around a transparant piece of plastic.</p>

<p><strong>Remove LCD protector</strong></p>

<p>To get to the PCB inside the LCD panel, you need access to a screw that is covered
by the LCD protector. A weak adhesive keeps the protector in place. You can gently pull 
it from the LCD screen.</p>

<p><img src="/assets/tds220/LCD_protector.jpg" alt="LCD protector" /></p>

<p>There are 2 plastic tabs on the left of the LCD panel that keep it locked in place. Push
those up and down to unlock the the panel. You can now lift that left size away from the main
chassis and then slide the panel to the left to get the metal tab on the right out as well.</p>

<p><img src="/assets/tds220/tds220_front_with_only_LCD_panel.jpg" alt="Front with only LCD panel left" /></p>

<p>The panel is now loose. Remove the screw on the center left.</p>

<p><strong>LCD frame clips</strong></p>

<p>Turn the LCD panel around so that plastic back is towards you. Put something on the table
to protect the LCD front screen. I used the LCD protector for that.</p>

<p>We can now see the 3 capacitors that need to be replaced:</p>

<p><img src="/assets/tds220/LCD_frame_clips.jpg" alt="LCD frame clips" /></p>

<p>In addition to the screw from the previous step, the metal frame at the front of the
LCD panel is held in place by 8 metal tabs the bend into gaps of the plastic back. Use
nose pliers to straighten those tabs.</p>

<p>You can now remove the plastic back. Finally, you have access to the LCD PCB!</p>

<p><img src="/assets/tds220/LCD_PCB_exposed.jpg" alt="LCD PCB exposed" /></p>

<h1 id="lcd-panel-capacitor-replacement">LCD Panel Capacitor Replacement</h1>

<p>Here are the 3 capacitors in close-up. They’re 3.3 uF 35V polarized capacitors.</p>

<p><img src="/assets/tds220/LCD_PCB_zoom.jpg" alt="LCD PCB zoom" /></p>

<p>However, they are not your garden variety SMD tantalum capacitors! Notice how both
leads are on the same side of the capacitor. When we look at the other side, we can
see how the capacitor has a cylindrical core with a box plastic enclosure around it.</p>

<p><img src="/assets/tds220/LCD_capacitor_closeup.jpg" alt="LCD capacitor closeup" /></p>

<p>I couldn’t find any exact replacement. On that Chinese forum, they used regular
electrolytic caps instead, so that’s what I did as well.</p>

<p>The plastic back has cut-outs for the 3 capacitors. On the Chinese forum, they
made those cut-outs a bit larger to make the new capacitors fit, but that was not
necessary in my case: the holes were large enough as-is, as long as you took care to
solder them as close to the inside of the PCB as possible.</p>

<p>You can see this here:</p>

<p><img src="/assets/tds220/LCD_capacitors_soldered.jpg" alt="Replacement LCD capacitors soldered" /></p>

<p>The top capacitor is soldered too far to the left, the bottom one is fine.  I had to resolder 
the top capacitor one to make it fit in the cut-out.</p>

<p>One of the old LCD capacitors came in at 2.5 uF and a 75 Ohm ESR. The replacement ones
have an ESR of 6 Ohm…</p>

<p><img src="/assets/tds220/LCR_meter_result.jpg" alt="LCD meter result" /></p>

<p>With the capacitors replace, you can now put the LCD panel back together in reverse order.
But don’t mount it back into the chasses just yet!</p>

<h1 id="lcd-panel-backlight-replacement">LCD Panel Backlight Replacement</h1>

<p>The LCD panel uses a small CCFL tube as backlight. Over time, these CCFLs lose their
intensity which makes the screen less bright.</p>

<p>You access the CCFL tube by removing an easy to remove cover on the left of the panel:</p>

<p><img src="/assets/tds220/LCD_backlight.jpg" alt="LCD panel CCFL backlight blackened" /></p>

<p>Notice how some parts of the tube are black.</p>

<p>Replacement tubes can be found on eBay. Sellers vary, but just search for “CCFL lamp tds220”
and you’ll find what you need. Prices have gone up due to tariffs, I paid $17.46 including 
shipping.</p>

<p>The new lamp doesn’t come with the right connector, so some soldering is required to transfer
the connector from the old lamp to the new one. I first placed new tube in the LCD panel and
the LCD panel in the chassis before soldering the connector. That made it easier to get
the length of the wires correct.</p>

<p><img src="/assets/tds220/LCD_backlight_soldered_wires.jpg" alt="LCD backlight with connector replaced" /></p>

<p>After the replacement, the screen brightness was noticable… dimmer, but that’s normal:
new CCFL lamps needs a few minutes to reach their full brightness.</p>

<p><img src="/assets/tds220/screen_after_backlight_swap.jpg" alt="Screen after backlight swap" /></p>

<p>You can find plenty of videos on Youtube of this backlight swap and authors always claim
to see a significant improvement. I’m not so sure for my case: it’s not dimmer, but I can’t
honestly say that it’s much brighter. In one case, someone replaced the CCFL lamp 
<a href="http://hxc2001.free.fr/tektronix_tds220/index.html">with an LED PCB</a>. I looked around
for suitable LED PCBs to do that as well but didn’t find anything that worked. If you want to
try that, understand that the voltage of the CCFL lamp is much higher than the 5V or so you’d
need for LEDs!</p>

<h1 id="fixing-the-square-wave-issue">Fixing the Square Wave Issue</h1>

<p>The corrupted signal compensation square wave issue was solved by reheating the solder
of the BNC connector pins on the main PCB.</p>

<p><img src="/assets/tds220/BNC_connectors.jpg" alt="BNC connectors" /></p>

<p>To do this right, you need to remove the RF shielding at the bottom of the main PCB, but
I just squeezed my soldering iron into an open space and hope for the best. It worked:</p>

<p><img src="/assets/tds220/tds220_with_square_wave.jpg" alt="TDS220 with square wave" /></p>

<h1 id="conclusion">Conclusion</h1>

<p>The TDS220 is working perfect fine again. It measures signals correctly, there is no LCD screen 
corruption, and the brightness is fine. It’s still sitting on the bench, connected to a
logic analyzer, but that’s still a work in progress and may be a topic for a future blog post.</p>

<p><img src="/assets/tds220/tds220_connected_to_logic_analyzer.jpg" alt="TDS220 connected to logic analyzer" /></p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://www.youtube.com/watch?v=weUSGjzEoVM">Tony Albus - Tektronix TDS220 Backlight Replace and Restore</a></li>
  <li><a href="https://www.youtube.com/watch?v=YF-kBXBnxzw">Max’s Garage - Repairing Two Digital Oscilloscopes from the 90s! Tektronix TDS210 and TDS220 Restoration</a></li>
  <li><a href="http://hxc2001.free.fr/tektronix_tds220/index.html">Tektronix TDS200 CCFL to LED backlight replacement</a></li>
  <li><a href="https://www.eevblog.com/forum/testgear/tektronix-tds210-teardown-and-bnc-replacement/msg1722653/#msg1722653">EEVblog forum - Recap list</a></li>
  <li><a href="https://www.eevblog.com/forum/repair/tektronix-tds-220-repair/">EEVblog forum - TDS 2002B repair</a></li>
  <li><a href="https://www.youtube.com/watch?v=9N8UKwn4okM">NFM - Tektronix TDS210 TDS220 Oscilloscope Recall and Loose BNC Fix</a></li>
</ul>

<h1 id="footnotes">Footnotes</h1>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:flicker" role="doc-endnote">
      <p>In addition to the corruption, there’s also quite a bit of full-screen flicker.
        This is only a video recording artifact. There is no visible flicker. <a href="#fnref:flicker" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Inside an Isotemp OCXO107-10 Oven Controlled Crystal Oscillator</title><link href="https://tomverbeure.github.io/2025/10/26/Inside-an-Isotemp-OCXO107-10.html" rel="alternate" type="text/html" title="Inside an Isotemp OCXO107-10 Oven Controlled Crystal Oscillator" /><published>2025-10-26T10:00:00+00:00</published><updated>2025-10-26T10:00:00+00:00</updated><id>https://tomverbeure.github.io/2025/10/26/Inside-an-Isotemp-OCXO107-10</id><content type="html" xml:base="https://tomverbeure.github.io/2025/10/26/Inside-an-Isotemp-OCXO107-10.html"><![CDATA[<ul id="markdown-toc">
  <li><a href="#the-isotemp-ocxo107-10" id="markdown-toc-the-isotemp-ocxo107-10">The Isotemp OCXO107-10</a></li>
  <li><a href="#gathering-information-from-time-nuts" id="markdown-toc-gathering-information-from-time-nuts">Gathering Information from time-nuts</a></li>
  <li><a href="#getting-it-to-run" id="markdown-toc-getting-it-to-run">Getting It to Run</a></li>
  <li><a href="#on-the-bench" id="markdown-toc-on-the-bench">On the Bench</a></li>
  <li><a href="#inside-the-ocxo107-10" id="markdown-toc-inside-the-ocxo107-10">Inside the OCXO107-10</a></li>
  <li><a href="#looking-forward" id="markdown-toc-looking-forward">Looking Forward</a></li>
</ul>

<h1 id="the-isotemp-ocxo107-10">The Isotemp OCXO107-10</h1>

<p>I spent $5 at the <a href="https://www.electronicsfleamarket.com/">Silicon Valley Electronics Flea Market</a>
on an Isotemp OCXO107-10 oscillator.</p>

<p><img src="/assets/ocxo107-10/isoterm_ocxo107-10.jpg" alt="Isotemp OCXO107-10" /></p>

<p>Compared to my other OCXOs, this one is a real chonker, which is often correlates with 
its ability to keep the output frequency stable during changing environmental conditions: 
a large volume gives you more real estate for tricks to keep the internal temperature constant.</p>

<p>Despite the -10 suffix of the product name, it has an output frequency of 5 MHz, not
the 10 MHz that can be found on most equipment these days. 5 MHz used to be more 
popular; HP’s famous 5061A and 5071A Cesium atomic clocks have a 5 MHz output, for example,
and my <a href="/2025/08/10/HP-5370A-Repair.html">HP 5370A</a> 
and <a href="/2025/08/19/SRS-SR620-Frequency-Counter-Power-Switch-Battery-Replacment.html">SRS SR620</a>
time interval counters accept both 5 MHz and 10 MHz clocks on their external reference clock
input.</p>

<h1 id="gathering-information-from-time-nuts">Gathering Information from time-nuts</h1>

<p>I did some Google research and, to the surprise of no one, found a few scraps of information on the
<a href="http://leapsecond.com/time-nuts.htm">time-nuts email list</a>:</p>

<ul>
  <li>These oscillators used to cost more than <a href="https://www.febo.com/pipermail/time-nuts/2014-March/083620.html">$1000 a piece</a>.</li>
  <li>In addition to Isotemp, <a href="https://www.ctscorp.com/Products/Passive-Components/Frequency-Control-Products">CTS</a> 
Knights made a product with the <a href="https://www.febo.com/pipermail/time-nuts/2014-March/083623.html">same 0410-2450 SKU number</a>.</li>
  <li>These oscillators were used by Lucent. The CTS Knights unit has a 
<a href="https://www.febo.com/pipermail/time-nuts/2014-March/083625.html">date code of 1989</a>, 
well before AT&amp;T spun off its AT&amp;T Technologies business unit into Lucent in 1996.
My unit has a scribble of 1986.</li>
  <li>There’s an <a href="https://www.febo.com/pipermail/time-nuts/2014-March/083583.html">OCXO107-16 version</a>
which is also a 5 MHz option.</li>
  <li>Someone opened up his unit, did 
<a href="https://www.febo.com/pipermail/time-nuts/2014-March/083920.html">a bunch of stability measurements, and posted pictures</a>.
Those pictures have since disappeared, but I contacted the author, Ed Palmer, who graciously sent them
to me.</li>
  <li>One of the pins of the 9-pin connector of the OCXO107 is a reference voltage that
can be used to construct an EFC (electronic frequency control) input voltage to tune
the output frequency. There’s apparently quite a bit of 
<a href="https://febo.com/pipermail/time-nuts_lists.febo.com/2013-April/058247.html">noise on this Vref output</a>.</li>
  <li>There’s a <a href="/assets/ocxo107-10/ISOTEMP OCXO107 Series.pdf">datasheet</a> 
for an Isotemp OCXO107-3. It’s not identical to the OCXO107-10:
it has a different connector, uses more power, and there’s also mention of a 16-bit
D/A converter to discipline the output frequency. But chances are that some of the
characteristics are similar?</li>
  <li>Photo with <a href="https://www.febo.com/pipermail/time-nuts/2014-March/083616.html">pinout of the DE-9 connector</a>.</li>
</ul>

<p>That’s all I could find, but it’s more than enough to get started.</p>

<h1 id="getting-it-to-run">Getting It to Run</h1>

<p>The 107-10 has DE-9 connector for power and control and an SMA connector for the
clock output.</p>

<p><img src="/assets/ocxo107-10/connectors.jpg" alt="Isotemp OCXO107-10 connectors" /></p>

<p>The DE-9 pinout:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1 - 5MHz TTL Out
2 - Ground
3 - +5V
4 - Ground
5 - +12V (Oven)
6 - Ground
7 - Ground
8 - EFC
9 - VREF 7.0V
</code></pre></div></div>

<p>The 5 V power rail is only used for the 5 MHz digital output. The OCXO will work fine and
output a sine wave on the SMA port when you leave this 5 V rail unconnected.</p>

<p><img src="/assets/ocxo107-10/pinout.jpg" alt="Isotemp OCXO107-10 pinout" /></p>

<h1 id="on-the-bench">On the Bench</h1>

<p>I don’t have a setup to make long-term measurements, but I just wanted to see if I could
get the thing to work. Here’s my earthquake-hardened bench setup:</p>

<p><a href="/assets/ocxo107-10/on_the_bench.jpg"><img src="/assets/ocxo107-10/on_the_bench.jpg" alt="Isotemp OCXO107-10 on the bench" /></a></p>

<p>One output of an HP E3631A power supply creates the 12 V rail, the other an EFC voltage that
is tuned to match 5 MHz output against the 10 MHz of my 
<a href="https://tomverbeure.github.io/2023/07/09/TM4313-GPSDO-Teardown.html">TM4313 GPSDO</a>.</p>

<p>When I power up the unit, the 12 V rail initially pulls around 320 mA (3.8W) to
heat up the internal oven. The current quickly drops below 100 mA and eventually settles
to 69 mA (0.83 mW.)</p>

<p><img src="/assets/ocxo107-10/spectrum.jpg" alt="Spectrum and harmonics of output signal " /></p>

<p>When fed into a 50 Ohm termination, my uncalibrated spectrum analyzer measures a power level 
of -1.80 dBm and a second harmonic of -55.04 dBm or -53.23 dBc. The output level is different than 
the &gt;+3 dBm that is listed in the datasheet for the OCXO107-3, but it is similar to what 
others on the time-nuts list have measured.</p>

<p>My unit has a tag to it that says:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1/8 2.47V
1/30 2.44V
4/2/86 2.54V
</code></pre></div></div>

<p>This must be the voltage level that’s required on the EFC input to tune the output frequency at 5 MHz.
In my current setup, that voltage level is roughly 2.228 V though that’s only 2 days after powering
it up. An OCXO107-10 needs about a week to truly stablize.</p>

<p>The Vref output measures 6.78 V, not too far off the expected 7 V.</p>

<h1 id="inside-the-ocxo107-10">Inside the OCXO107-10</h1>

<p>The OCXO has 4 solder points to weld the outside case to inside sliding assembly. I tried to 
get it open with a soldering iron, but the metal enclosure immediately dissipated the heat
away. I wasn’t able to open my unit, but luckily Ed gave permission to use his pictures. Let’s
have a look:</p>

<p><a href="/assets/ocxo107-10/Dewar &amp; Assembly.jpg"><img src="/assets/ocxo107-10/Dewar &amp; Assembly.jpg" alt="Dewar flask with electronis" /></a>
<em>(Click to enlarge)</em></p>

<p>All the components of the OCXO107 reside inside a <a href="https://en.wikipedia.org/wiki/Vacuum_flask">Dewar flask</a>.
Think coffee thermos with double sided wall with near-vacuum to reduce the heat transfer between the
center cavity and the outside world.</p>

<p>In the picture above, you see the Dewar flask on the right, the electronics slided-out on the left,
and an insulating foam on the far left to plug off the open side of the Dewar cylinder.</p>

<p>The Dewar flask makes the OCXO more resistant against varying outside temperatures, but it also makes the 
unit very expensive and fragile. Ed’s first unit wasn’t packaged correctly and arrived with a broken flask,
which makes the OCXO useless. These days, high stability OCXOs have one or two ovens and insulating material 
around it, though the website of Quantic Wenzel, producer of very high performance oscillators, says that
<a href="https://www.quanticwenzel.com/library/crystal-oscillator-tutorials/ocxos-oven-controlled-crystal-oscillators/">“units with Dewar flasks are still available for superior temperature performance and lower power consumption”</a>.</p>

<p>I’m too much of a beginner to compare the specifications of different OCXOs but I’ll give it a try anyway, 
so caveat emptor. The OCXO107-3 datasheet mentions a temperature stability of &lt; +/- 0.06 ppb for an 
ambient temperature between 0 C and 60 C.</p>

<p><img src="/assets/ocxo107-10/hp10811_specs.jpg" alt="HP 10811 specifications" /></p>

<p>The <a href="https://hparchive.com/Manuals/HP-10811AB-Manual.pdf">datasheet of the HP 10811 OCXO</a>
lists a frequency vs temperature sensitivity of &lt; 2.5 10^-9 between 0 C and 71 C. If that’s
apples to apples that would make the OCXO107-3 41 times more resistant against temperature variations.</p>

<p><img src="/assets/ocxo107-10/Rakon_ROX5242T1.png" alt="Rakon ROX5242T1 specs" /></p>

<p>I randomly searched for specs of contemporary double-oven OCXOs and found numbers from 0.1 ppb for a
<a href="https://www.rakon.com/products/ocxo-ocso/high-end-telecom-discrete-ocxo">Rakon ROX5242T1</a> 
and even 0.05 ppb, for units that are smaller and definitely less fragile. 
Just a case of old fashioned technological progress?</p>

<p>Note that temperature sensitivity is just one of many OXCO metrics. You also need to compare again 
voltage stability, phase noise and a whole bunch of other parameters, and select the one that
matches your needs. For example, the temperature sensitivity of a 10 MHz lab reference clock may be 
more important than phase noise, while the opposite can be true for an oscillator that’s used for multi-GHz
communication links.</p>

<p>After removing the copper heatsink, you can see the oscillator control board on top of a large crystal:</p>

<p><a href="/assets/ocxo107-10/Xtal &amp; Heatsink.jpg"><img src="/assets/ocxo107-10/Xtal &amp; Heatsink.jpg" alt="Xtal and heatsink" /></a>
<em>(Click to enlarge)</em></p>

<p>Here’s another view of this side of the assembly:</p>

<p><a href="/assets/ocxo107-10/Oscillator, Cover, and Unknown.jpg"><img src="/assets/ocxo107-10/Oscillator, Cover, and Unknown.jpg" alt="Oscillator and other stuff" /></a>
<em>(Click to enlarge)</em></p>

<p>If you turn around the assembly, you see this:</p>

<p><a href="/assets/ocxo107-10/Oven Controller.jpg"><img src="/assets/ocxo107-10/Oven Controller.jpg" alt="Oven heater" /></a>
<em>(Click to enlarge)</em></p>

<p>The blue component at the bottom is a Motorola JE800 Darlington transistor that is used as heating
element. Closeby, to the right of the orange capacitor, is an IC with 431 marking. It’s tempting
at first to speculate that this is a 
<a href="https://www.ti.com/product/TMP431">TMP431 temperature sensor</a>,
, but since those require a microcontroller to configure that’s unlikely. Maybe it’s 
<a href="https://www.ti.com/lit/ds/symlink/tl431.pdf">TL431 voltage reference</a> instead? Either way, 
there must be something on the PCB to measure voltage and feed that back to the heating
transistor to keep temperature stable.</p>

<h1 id="looking-forward">Looking Forward</h1>

<p>My home lab currently has 2 clock references: the TM4313 GPSDO and the free-running 
<a href="/2024/04/06/Guide-Tech-GT300-Frequency-Reference-Teardown.html">GT300 frequency standard</a>
that I tore down last year. I’ve been wanting to do a bunch of long-term comparative measurements
on a bunch of OCXOs, just for the fun of it. However, since crystal oscillators need a long
time to truly stabilize, think a week for the OCXO107, this is not something I want to do
with a power guzzling and noisy E3631A bench supply. The first step is to build a custom smaller scale
linear power supply just for this purpose. In other words: yet another project to put on the
stack!</p>]]></content><author><name></name></author><summary type="html"><![CDATA[]]></summary></entry></feed>