From 7e0c6b28a1c4890f63fe79cc78e0e463d6d0cdc9 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 00:52:38 -0400 Subject: [PATCH 01/27] First cut of written portion and a few diagrams --- _images/tdoa_gdop.svg | 1642 ++++++++++++++++++++++++++++++++++++ _images/tdoa_hyperbola.svg | 766 +++++++++++++++++ _images/tdoa_principle.svg | 619 ++++++++++++++ content/tdoa.rst | 502 +++++++++++ index.rst | 1 + 5 files changed, 3530 insertions(+) create mode 100644 _images/tdoa_gdop.svg create mode 100644 _images/tdoa_hyperbola.svg create mode 100644 _images/tdoa_principle.svg create mode 100644 content/tdoa.rst diff --git a/_images/tdoa_gdop.svg b/_images/tdoa_gdop.svg new file mode 100644 index 00000000..7f7b32ca --- /dev/null +++ b/_images/tdoa_gdop.svg @@ -0,0 +1,1642 @@ + + + + + + + + 2026-06-23T03:53:09.404485 + image/svg+xml + + + Matplotlib v3.10.8, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + −8 + + + + + + + + + + −6 + + + + + + + + + + −4 + + + + + + + + + + −2 + + + + + + + + + + 0 + + + + + + + + + + 2 + + + + + + + + + + 4 + + + + + + + + + + 6 + + + + + + + + + + 8 + + + + x + + + + + + + + + + + + + + −8 + + + + + + + + + + −6 + + + + + + + + + + −4 + + + + + + + + + + −2 + + + + + + + + + + 0 + + + + + + + + + + 2 + + + + + + + + + + 4 + + + + + + + + + + 6 + + + + + + + + + + 8 + + + + y + + + + + + + + + + + + + + + + + + + + + S1 + + + S2 + + + S3 + + + triangular array + + + + 2 + + + + + 3 + + + + + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + −8 + + + + + + + + + + −6 + + + + + + + + + + −4 + + + + + + + + + + −2 + + + + + + + + + + 0 + + + + + + + + + + 2 + + + + + + + + + + 4 + + + + + + + + + + 6 + + + + + + + + + + 8 + + + + x + + + + + + + + + + + −8 + + + + + + + + + + −6 + + + + + + + + + + −4 + + + + + + + + + + −2 + + + + + + + + + + 0 + + + + + + + + + + 2 + + + + + + + + + + 4 + + + + + + + + + + 6 + + + + + + + + + + 8 + + + + y + + + + + + + + + + + + + + + + + + + + + S1 + + + S2 + + + S3 + + + nearly collinear array + + + + 2 + + + + + 2 + + + + + 3 + + + + + 3 + + + + + 5 + + + + + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.2 + + + + + + + + + + 2 + + + + + + + + + + 3 + + + + + + + + + + 5 + + + + + + + + + + ≥8 + + + + + + + + + + + + + + + 4×100 + + + + + + + + + + + + + 6×100 + + + + + + + + + + + + GDOP (position error / range-difference error) + + + + + + + + + + + + + + + + diff --git a/_images/tdoa_hyperbola.svg b/_images/tdoa_hyperbola.svg new file mode 100644 index 00000000..22329c3f --- /dev/null +++ b/_images/tdoa_hyperbola.svg @@ -0,0 +1,766 @@ + + + + + + + + 2026-06-23T03:53:09.170870 + image/svg+xml + + + Matplotlib v3.10.8, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + −3 + + + + + + + + + + + + + −2 + + + + + + + + + + + + + −1 + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 1 + + + + + + + + + + + + + 2 + + + + + + + + + + + + + 3 + + + + x + + + + + + + + + + + + + + + + + −3 + + + + + + + + + + + + + −2 + + + + + + + + + + + + + −1 + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 1 + + + + + + + + + + + + + 2 + + + + + + + + + + + + + 3 + + + + y + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + S₁ + + + S₂ + + + baseline + + + Δr > 0 + (source nearer S₁) + + + Δr < 0 + (source nearer S₂) + + + Δr = 0 + + + + + + + + + + + + + + + + + + + + + + diff --git a/_images/tdoa_principle.svg b/_images/tdoa_principle.svg new file mode 100644 index 00000000..a9bdac04 --- /dev/null +++ b/_images/tdoa_principle.svg @@ -0,0 +1,619 @@ + + + + + + + + 2026-06-23T03:58:57.663473 + image/svg+xml + + + Matplotlib v3.10.8, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wavefront + expands at + speed c + + + Source + (unknown position, + unknown transmit time t₀) + + + + + + r₁ + + + S₁ + + + + + + r₂ + + + S₂ + + + + + + r₃ + + + S₃ + + + The TDOA principle: different path lengths give different arrival times + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + time + + + + + + + t₀ (unknown + transmit time) + + + S₁ + + + t₁ + + + S₂ + + + t₂ + + + S₃ + + + t₃ + + + + + + + + τ₂₁ = t₂ − t₁ + + + + + + + + τ₃₁ = t₃ − t₁ + + + + TDOA measures only the gaps τ between arrivals — the unknown transmit time t₀ cancels in every difference. + + + + + + + + + + + diff --git a/content/tdoa.rst b/content/tdoa.rst new file mode 100644 index 00000000..8f56ef5a --- /dev/null +++ b/content/tdoa.rst @@ -0,0 +1,502 @@ +.. _tdoa-chapter: + +#### +TDOA +#### + +Time Difference of Arrival (TDOA) is a technique that localizes an emitter from differences in signal arrival time across synchronized sensors, without needing the transmitter's clock. This chapter covers the full TDOA pipeline, geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA can be used in RF, acoustic, and sonar geolocation. + +************ +Introduction +************ + +A recurring problem across acoustics, radio engineering, and defense systems is this: a source emits a signal, several spatially separated sensors receive it, and we wish to recover the source's position from those received signals alone. The source may be cooperative (a cell phone trying to be found) or non-cooperative (a radar emitter that would rather not be), stationary or moving, and the medium may be air, water, or free space. Despite this diversity, the geometry and estimation theory that solve the problem are remarkably uniform, and *Time Difference of Arrival* (TDOA) sits at the center of them. + +TDOA-based localization appears in cellular emergency-caller location, acoustic with microphone arrays (e.g., gunshot-detection systems mounted on city streetlights), passive sonar, passive (non-emitting) radar, electronic warfare and signals intelligence, and even wildlife tracking. In each case the engineering details differ, but the mathematical skeleton is the same one developed in this chapter. + +****************** +TDOA in a Nutshell +****************** + +Consider the propagation time from a source to sensor :math:`i`, namely :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) instant of transmission, :math:`r_i` is the source-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, + +.. math:: + + \tau_{ij} = t_i - t_j = \frac{r_i - r_j}{c}, + +the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on source and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters: we never need to know when the source transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. + +The price we pay is that the receivers must share a precise common time reference — a requirement that, as Section 7.8 shows, is itself a demanding engineering problem because a timing error of one nanosecond corresponds to about 0.3 m of range error. + +************************* +Geometric Foundations +************************* + +From Time Difference to Range Difference +=============================================== + +Multiplying a measured time difference by the propagation speed converts it into a *range difference*: + +.. math:: + + \Delta r_{ij} = c\,\tau_{ij} = r_i - r_j . + +This is the quantity we actually localize with. For acoustic problems :math:`c \approx 343` m/s in air; for radio problems :math:`c \approx 2.998\times10^8` m/s. Note immediately the consequence for accuracy: in air, a :math:`0.1` ms timing error is only :math:`\sim`\3 cm, whereas in free space the same timing error is 30 km. Radio TDOA therefore demands extraordinarily precise timing, a theme we return to repeatedly. + +The diagram below shows an example of an emitter and three sensors, with a time domain plot of the signal being received by each sensor at different times. + +.. image:: ../_images/tdoa_principle.svg + :align: center + :target: ../_images/tdoa_principle.svg + :alt: An emitter and three sensors, with a time domain plot of the signal being received by each sensor at different times. + +The Hyperbola +=================== + +Fix two sensors at positions :math:`\mathbf{s}_i` and :math:`\mathbf{s}_j`, the *foci*. The set of source positions :math:`\mathbf{u}` consistent with a measured range difference satisfies + +.. math:: + + |\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_j| = \Delta r_{ij} = \text{constant}. + +This is the defining property of a **hyperbola** (in 3D, a hyperboloid of two sheets): the locus of points whose *difference* of distances to two fixed foci is constant. Several features matter in practice: + +* The constant equals :math:`2a`, where :math:`a` is the hyperbola's semi-transverse axis, so :math:`|\Delta r_{ij}| < |\mathbf{s}_i - \mathbf{s}_j|` always — a range difference can never exceed the baseline between the sensors. Measurements that violate this bound signal an error (noise, multipath, or a synchronization fault). +* The *sign* of :math:`\Delta r_{ij}` selects which of the two branches the source lies on (the branch nearer the closer sensor). +* As :math:`|\Delta r_{ij}| \to |\mathbf{s}_i-\mathbf{s}_j|`, the hyperbola degenerates toward the baseline ray; as :math:`\Delta r_{ij}\to 0`, it flattens into the perpendicular bisector of the baseline. Geometry near these extremes is ill-conditioned. + +A single TDOA thus constrains the source to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line but you would need a third sensor to get more specific. + +.. image:: ../_images/tdoa_hyperbola.svg + :align: center + :target: ../_images/tdoa_hyperbola.svg + :alt: Two sensors, and several hyperbola branches drawn + +Multilateration +===================== + +With :math:`N` sensors we can form pairs and intersect their hyperbolae; the source lies at (or near) their common intersection. This process is **hyperbolic multilateration**. Counting degrees of freedom tells us how many sensors we need: + +* In **2D** the source has 2 unknowns :math:`(x,y)`. Each independent TDOA gives one equation, so we need at least 2 independent TDOAs, which requires **3 sensors**. +* In **3D** the source has 3 unknowns :math:`(x,y,z)`, requiring 3 independent TDOAs and therefore **4 sensors**. + +In the noiseless, exactly-determined case the hyperbolae meet at a single point (with an occasional geometric ambiguity resolved by branch signs or an extra sensor). With more sensors than the minimum the system is *overdetermined*: noisy hyperbolae no longer share an exact common point, and we must solve a least-squares or maximum-likelihood problem (Sections 7.5-7.6). + +Reference Sensor and Independent Pairs +============================================= + +From :math:`N` sensors one can form :math:`\binom{N}{2}` pairwise TDOAs, but they are not all independent. Choosing one sensor as a **reference** (say sensor 1) and forming :math:`\tau_{i1}` for :math:`i = 2,\dots,N` yields :math:`N-1` TDOAs from which every other pairwise difference can be reconstructed, since :math:`\tau_{ij} = \tau_{i1} - \tau_{j1}`. These :math:`N-1` are the *independent* measurements that carry all the geometric information. + +The redundant pairs are not worthless, however. Because each measured TDOA carries independent *noise*, using all :math:`\binom{N}{2}` pairs (with a correctly modeled, correlated noise covariance — the reference sensor's noise is common to every :math:`\tau_{i1}`) can improve the estimate. For clarity of exposition we develop the algorithms with the reference-sensor formulation and note where the full covariance enters. + +Example: A Three-Sensor 2D Fix +============================================ + +Place three sensors at + +.. math:: + + \mathbf{s}_1=(0,0),\quad \mathbf{s}_2=(100,0),\quad \mathbf{s}_3=(0,100)\ \text{(meters)}, + +and suppose the true source is at :math:`\mathbf{u}=(40,30)`. The source-to-sensor distances are + +.. math:: + + r_1=\sqrt{40^2+30^2}=50,\quad + r_2=\sqrt{60^2+30^2}=\sqrt{4500}\approx 67.08,\quad + r_3=\sqrt{40^2+70^2}=\sqrt{6500}\approx 80.62 . + +Taking sensor 1 as reference, the range-difference measurements are + +.. math:: + + \Delta r_{21}=r_2-r_1\approx 17.08\ \text{m},\qquad + \Delta r_{31}=r_3-r_1\approx 30.62\ \text{m}. + +Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the source. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization of Section 7.5 — where we will recover :math:`(40,30)` from exactly these numbers in closed form. + +************************************* +The Signal and Measurement Model +************************************* + +Received-Signal Model +============================ + +Let :math:`s(t)` be the (unknown) source waveform. Sensor :math:`i` receives an attenuated, delayed, noise-corrupted copy: + +.. math:: + + x_i(t) = a_i \, s(t - t_i) + n_i(t), \qquad i = 1,\dots,N, + +where :math:`a_i` is a real (or complex, for passband signals) gain capturing propagation loss and antenna response, :math:`t_i = t_0 + r_i/c` is the absolute arrival time, and :math:`n_i(t)` is additive noise. This model assumes a single dominant line-of-sight path; multipath and non-line-of-sight effects are deferred to Section 7.8. + +Defining the TDOA +======================== + +The pairwise TDOA is the difference of arrival times, + +.. math:: + + \tau_{ij} = t_i - t_j = \frac{r_i - r_j}{c} = \frac{|\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_j|}{c}. + +The right-hand side makes explicit that the TDOA is a nonlinear function of the source coordinates :math:`\mathbf{u}`. The *measurement* problem (Section 7.4) is to estimate :math:`\tau_{ij}` from the waveforms :math:`x_i, x_j`; the *localization* problem (Sections 7.5-7.6) is to invert the nonlinear map from :math:`\mathbf{u}` to the collection of TDOAs. + +Noise Assumptions +======================== + +The standard working assumptions are that each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and statistically independent of the source signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is + +.. math:: + + \mathrm{SNR}_i = \frac{a_i^2 \sigma_s^2}{\sigma_{n_i}^2}, + +with :math:`\sigma_s^2` and :math:`\sigma_{n_i}^2` the signal and noise powers. These assumptions are idealizations — real noise is often colored and partially correlated across sensors — but they yield tractable estimators and tight bounds that perform well in practice, and the framework extends to a general noise covariance when needed. + +The Nonlinear Measurement Equations +========================================== + +Collecting the :math:`N-1` reference-based range differences into a vector :math:`\mathbf{m}` with entries :math:`m_i = c\,\tau_{i1} = r_i - r_1`, the noiseless model is + +.. math:: + + \mathbf{m} = \mathbf{h}(\mathbf{u}), \qquad + h_i(\mathbf{u}) = |\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_1|, + +and the noisy measurement is :math:`\tilde{\mathbf{m}} = \mathbf{h}(\mathbf{u}) + \boldsymbol{\varepsilon}`, where :math:`\boldsymbol{\varepsilon}` is the range-difference error induced by the time-delay estimation errors of Section 7.4. The function :math:`\mathbf{h}` is nonlinear because of the Euclidean norms, and this nonlinearity is the source of every algorithmic complication that follows. Two broad strategies address it: algebraically *linearize* by introducing an auxiliary variable (Section 7.5), or *iteratively* linearize about a current estimate (Section 7.6). + +************************************************* +Time-Delay Estimation (the Measurement Front End) +************************************************* + +Before any geometry can be exploited we must extract the delays :math:`\tau_{ij}` from the raw waveforms. This is the *time-delay estimation* (TDE) problem, and its accuracy ultimately caps the accuracy of the entire system. + +Cross-Correlation +======================== + +The natural estimator exploits the fact that :math:`x_i` and :math:`x_j` are noisy, shifted copies of the same waveform. Their cross-correlation, + +.. math:: + + R_{x_i x_j}(\tau) = \mathbb{E}\!\left[ x_i(t)\, x_j(t+\tau) \right], + +is maximized when the shift :math:`\tau` aligns the two copies, i.e. at :math:`\tau = \tau_{ij}`. The estimator is therefore + +.. math:: + + \hat{\tau}_{ij} = \arg\max_{\tau} \, \hat{R}_{x_i x_j}(\tau), + +with the sample cross-correlation computed from a finite record of length :math:`T`. Under the independent-noise assumption the noise contributes no systematic peak, so the correlation peak rides on the signal alignment. In practice the correlation is computed efficiently in the frequency domain via the FFT, using the cross-power spectral density :math:`G_{x_i x_j}(f) = \mathcal{F}\{R_{x_i x_j}(\tau)\}` and an inverse transform. + +The Generalized Cross-Correlation Framework +================================================== + +Plain cross-correlation is fragile: if the source spectrum is concentrated or the channel is reverberant, the correlation peak is broad and easily shifted by noise. Knapp and Carter's *Generalized Cross-Correlation* (GCC) addresses this by inserting a frequency weighting :math:`\Psi(f)` before transforming back to the lag domain: + +.. math:: + + R^{\mathrm{GCC}}_{x_i x_j}(\tau) = \int_{-\infty}^{\infty} \Psi(f)\, G_{x_i x_j}(f)\, e^{j 2\pi f \tau}\, df . + +The weighting reshapes the spectrum to sharpen and stabilize the peak. Different choices of :math:`\Psi(f)` correspond to different classical estimators, and selecting it well is the heart of robust TDE. + +Weighting Functions +========================== + +Common weightings include: + +* **Cross-correlation** (:math:`\Psi = 1`): the maximum-likelihood choice only in the high-SNR, broadband-flat limit; otherwise suboptimal. +* **Roth** (:math:`\Psi = 1/G_{x_i x_i}(f)`): suppresses frequencies where one sensor is noisy. +* **SCOT** (Smoothed Coherence Transform, :math:`\Psi = 1/\sqrt{G_{x_i x_i}G_{x_j x_j}}`): symmetric whitening of both channels. +* **PHAT** (Phase Transform, :math:`\Psi = 1/|G_{x_i x_j}(f)|`): the most widely used choice in acoustics. + +The **GCC-PHAT** estimator deserves emphasis. By dividing out the magnitude of the cross-spectrum it retains *only the phase*: + +.. math:: + + R^{\mathrm{PHAT}}_{x_i x_j}(\tau) = \int \frac{G_{x_i x_j}(f)}{\bigl|G_{x_i x_j}(f)\bigr|} e^{j2\pi f \tau} df . + +Because the delay between two copies of a signal is encoded entirely in the *linear phase* term :math:`e^{-j2\pi f \tau_{ij}}`, while the magnitude carries the (often unhelpful) spectral shape and reverberant coloring, whitening to unit magnitude weights every frequency equally and produces a sharp, near-impulsive peak at the true delay. This makes PHAT strikingly robust to reverberation. Its weakness is that it also whitens noise-dominated frequencies, so at low SNR the equal weighting amplifies noise; SNR-aware variants reintroduce a coherence-based weighting to compensate. + +Resolution and Sub-Sample Estimation +========================================== + +With sampling rate :math:`f_s`, the correlation is computed on a lag grid spaced :math:`1/f_s` apart, so the naive peak resolution is one sample, i.e. :math:`c/f_s` in range. This is usually far too coarse. Sub-sample refinement fits a model to the samples around the discrete peak — parabolic interpolation through the peak and its two neighbors is the simplest, while sinc-based interpolation is more accurate because the true correlation of a band-limited signal is a sinc-like function. Good interpolation routinely yields delay estimates one to two orders of magnitude finer than the sample period. + +Practical Considerations +================================ + +Several effects govern real performance. The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the assumption of stationarity and, for moving sources, against blurring of the delay over the window. **Coherence bandwidth** limits which frequencies actually carry usable phase. **Signal bandwidth** is decisive: as the Cramér-Rao analysis of Section 7.7 shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. Finally, the entire computation is dominated by FFTs and is therefore :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large microphone arrays and dense sensor networks tractable. + +Worked Example: GCC-PHAT in Practice +============================================ + +Suppose two microphones sample at :math:`f_s = 48` kHz a transient whose true inter-microphone delay is :math:`\tau_{12} = 0.521` ms. In samples this is :math:`0.521\times10^{-3}\times 48000 \approx 25.0` samples. The processing chain is: (1) take an :math:`M`-point FFT of each record; (2) form the cross-spectrum :math:`X_1(f)\,X_2^*(f)`; (3) divide by its magnitude to apply PHAT; (4) inverse-FFT to obtain the lag-domain function; (5) locate the peak near lag 25 and refine by parabolic interpolation. If the discrete peak sits at lag 25 with neighbors at 24 and 26 having correlation values :math:`y_{-},y_0,y_{+}`, the sub-sample offset is + +.. math:: + + \delta = \frac{1}{2}\,\frac{y_{-}-y_{+}}{y_{-}-2y_0+y_{+}}, + +so a refined estimate of, say, lag :math:`25.02` corresponds to :math:`\hat\tau_{12} = 25.02/48000 \approx 0.5213` ms, and a range difference :math:`c\,\hat\tau_{12}\approx 0.179` m in air. The same code path, with :math:`c = 3\times10^8` m/s, serves a radio system — only the timing precision demanded of the hardware changes. + +************************************* +Closed-Form Localization Algorithms +************************************* + +The measurement equations of Section 7.3 are nonlinear and, taken directly, require iterative solution with a good starting point. *Closed-form* (non-iterative) estimators sidestep this by an algebraic trick: introduce an auxiliary variable that absorbs the nonlinearity and renders the system linear. They are fast, need no initial guess, and cannot get stuck in local minima — making them invaluable both on their own and as initializers for the iterative methods of Section 7.6. + +The Linearization Strategy +================================== + +Write the squared range from the source :math:`\mathbf{u}=(x,y)` to sensor :math:`i` at :math:`\mathbf{s}_i=(x_i,y_i)` as + +.. math:: + + r_i^2 = (x-x_i)^2 + (y-y_i)^2 = K_i - 2x_i x - 2y_i y + (x^2+y^2), + \qquad K_i \equiv x_i^2 + y_i^2 . + +The troublesome term is :math:`x^2+y^2`, common to every sensor. Take sensor 1 as reference and subtract its equation from sensor :math:`i`'s: + +.. math:: + + r_i^2 - r_1^2 = (K_i - K_1) - 2(x_i-x_1)x - 2(y_i-y_1)y . + +Now use the measured range difference :math:`r_{i1}\equiv r_i - r_1 = c\,\tau_{i1}`. Since :math:`r_i = r_{i1}+r_1`, we have :math:`r_i^2 = r_{i1}^2 + 2r_{i1}r_1 + r_1^2`, so :math:`r_i^2 - r_1^2 = r_{i1}^2 + 2 r_{i1} r_1`. Substituting and rearranging, + +.. math:: + + \boxed{2(x_i-x_1)\,x + 2(y_i-y_1)\,y + 2 r_{i1} r_1 = K_i - K_1 - r_{i1}^2} + +This equation is **linear** in the unknowns :math:`(x, y, r_1)`, where the range to the reference :math:`r_1` is treated as an auxiliary variable. Stacking it for :math:`i=2,\dots,N` gives a linear system :math:`\mathbf{A}\boldsymbol{\theta} = \mathbf{b}` with :math:`\boldsymbol{\theta}=[x,y,r_1]^\top`, solvable by ordinary or weighted least squares. The nonlinearity has been quarantined into the single extra unknown :math:`r_1`. + +Spherical Interpolation and Spherical Intersection +========================================================= + +The earliest closed-form estimators, Spherical Interpolation (SI) and Spherical Intersection (SX) of Schau and Robinson, exploit exactly this structure. They first solve the linear system for :math:`(x,y)` as a function of :math:`r_1`, then impose the constraint that ties them together — namely :math:`r_1^2 = (x-x_1)^2+(y-y_1)^2` — to pin down :math:`r_1`. SI obtains :math:`r_1` by a least-squares projection; SX substitutes the linear solution into the quadratic constraint and solves the resulting scalar quadratic. They are simple and fast but treat the auxiliary variable somewhat crudely, leaving accuracy on the table at higher noise. + +Fang's Method +==================== + +Fang's algorithm provides an exact algebraic solution for the *minimum* configuration (3 sensors in 2D, 4 in 3D), giving a determined system rather than an overdetermined one. It is elegant and computationally trivial but does not use redundant sensors, so it cannot average down measurement noise and is sensitive to geometry. It is best viewed as the exact-determined special case that the least-squares methods generalize. + +Chan's Method (Two-Step Weighted Least Squares) +====================================================== + +The estimator that became the practical standard is Chan and Ho's two-step weighted least squares (WLS). It is built on the linear system above but treats the statistics correctly and refines the auxiliary variable, achieving accuracy close to the Cramér-Rao bound at small-to-moderate noise. + +**First step.** Treat :math:`\boldsymbol{\theta}=[x,y,r_1]^\top` as if its three components were independent and solve the linear system by weighted least squares, + +.. math:: + + \hat{\boldsymbol{\theta}} = (\mathbf{A}^\top \mathbf{W}\mathbf{A})^{-1}\mathbf{A}^\top \mathbf{W}\,\mathbf{b}, + +with the weight :math:`\mathbf{W}` chosen as the inverse covariance of the equation errors. Because that covariance itself depends on the unknown ranges, in practice one first solves with :math:`\mathbf{W}=\mathbf{I}` (or the raw TDOA noise covariance), then recomputes :math:`\mathbf{W}` from the resulting range estimates and re-solves — a one- or two-pass refinement. + +**Second step.** The first step ignored the known relationship :math:`r_1^2 = (x-x_1)^2+(y-y_1)^2` that couples the auxiliary variable to the position. The second step restores it: form a new small least-squares problem in the squared quantities :math:`[(x-x_1)^2,(y-y_1)^2,r_1^2]`, using the first-step covariance to weight it, and solve for a corrected position. This second WLS removes much of the bias of the naive linear solution and is what brings Chan's estimator close to optimal. + +The method returns a position directly, with computational cost dominated by inverting small :math:`3\times3` matrices — negligible compared with the FFTs of the front end. Its limitations appear at high noise or unfavorable geometry, where the squared-range manipulation amplifies errors and the second step can pick the wrong root; there, the iterative refinement of Section 7.6 seeded by Chan's output is the standard remedy. + +Example, Continued: Solving the Three-Sensor Fix in Closed Form +================================================================ + +Return to the geometry of Section 7.2.5: :math:`\mathbf{s}_1=(0,0)`, :math:`\mathbf{s}_2=(100,0)`, :math:`\mathbf{s}_3=(0,100)`, with measured range differences :math:`r_{21}=17.08` m and :math:`r_{31}=30.62` m. Here :math:`K_1=0`, :math:`K_2=K_3=10{,}000`. The boxed linear equations become, for :math:`i=2` and :math:`i=3`, + +.. math:: + + 200\,x + 34.16\,r_1 = 10{,}000 - (17.08)^2 = 9708.3, + +.. math:: + + 200\,y + 61.24\,r_1 = 10{,}000 - (30.62)^2 = 9062.5 . + +Solving each for the position coordinate in terms of :math:`r_1`: + +.. math:: + + x = 48.54 - 0.1708\,r_1, \qquad y = 45.31 - 0.3062\,r_1 . + +Now impose the constraint :math:`r_1^2 = x^2 + y^2` (since :math:`\mathbf{s}_1` is at the origin). Substituting, + +.. math:: + + r_1^2 = (48.54 - 0.1708\,r_1)^2 + (45.31 - 0.3062\,r_1)^2, + +which expands to the scalar quadratic + +.. math:: + + 0.8771\,r_1^2 + 44.33\,r_1 - 4409.1 = 0 . + +The positive root is :math:`r_1 = 50.0` m (the negative root is non-physical and is discarded). Back-substituting, + +.. math:: + + x = 48.54 - 0.1708(50) = 40.0, \qquad y = 45.31 - 0.3062(50) = 30.0 . + +We recover the true source :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolae of Section 7.2 represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. + +***************************************** +Iterative and Statistical Estimation +***************************************** + +Closed-form methods are fast but make algebraic approximations that cost accuracy at high noise or poor geometry. When the best possible estimate is required, we solve the nonlinear estimation problem directly, typically initialized by a closed-form result. + +Nonlinear Least Squares +============================== + +Define the residual between measured and predicted range differences and minimize its weighted squared norm: + +.. math:: + + \hat{\mathbf{u}} = \arg\min_{\mathbf{u}} \bigl[\tilde{\mathbf{m}} - \mathbf{h}(\mathbf{u})\bigr]^\top \mathbf{C}^{-1} \bigl[\tilde{\mathbf{m}} - \mathbf{h}(\mathbf{u})\bigr], + +where :math:`\mathbf{C}` is the covariance of the range-difference errors. This cost has no closed-form minimizer because :math:`\mathbf{h}` is nonlinear, so we descend it iteratively. + +Taylor-Series (Gauss-Newton) Method +========================================== + +Foy's classical approach linearizes :math:`\mathbf{h}` about the current estimate :math:`\mathbf{u}^{(k)}` using its Jacobian :math:`\mathbf{J}`, whose row :math:`i` is the gradient of :math:`h_i`: + +.. math:: + + \frac{\partial h_i}{\partial \mathbf{u}} = \frac{\mathbf{u}-\mathbf{s}_i}{|\mathbf{u}-\mathbf{s}_i|} - \frac{\mathbf{u}-\mathbf{s}_1}{|\mathbf{u}-\mathbf{s}_1|} + = \hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1, + +a difference of *unit vectors* pointing from the candidate source toward sensor :math:`i` and the reference. The Gauss-Newton update is + +.. math:: + + \mathbf{u}^{(k+1)} = \mathbf{u}^{(k)} + (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}\mathbf{J}^\top \mathbf{C}^{-1}\bigl[\tilde{\mathbf{m}}-\mathbf{h}(\mathbf{u}^{(k)})\bigr], + +iterated to convergence. Each step solves a small linear system. The method converges quickly *when started near the solution*, which is exactly why Chan's closed-form estimate is the preferred seed: it places the iteration in the basin of the global minimum and avoids the spurious local minima that plague hyperbolic cost surfaces, especially in poor geometry. + +Maximum-Likelihood Estimation +===================================== + +Under the zero-mean Gaussian noise model the negative log-likelihood of the measurements is, up to constants, exactly the weighted squared residual above with :math:`\mathbf{C}` the true noise covariance. Hence **the maximum-likelihood estimator coincides with weighted nonlinear least squares**, and the Gauss-Newton iteration is the practical route to it. This identification is important: it means the iterative estimator is not merely a heuristic but the statistically optimal estimator for the assumed model, and it is the estimator whose covariance the Cramér-Rao bound of Section 7.7 predicts. + +Robust, Recursive, and Bayesian Extensions +================================================== + +Real measurements contain outliers — a multipath-corrupted TDOA can be wildly wrong while the rest are fine. Plain least squares, which squares residuals, is badly distorted by such outliers. *Robust* estimators replace the squared loss with one that grows more slowly (e.g. Huber's), or explicitly detect and discard inconsistent TDOAs via residual tests or RANSAC-style consensus. + +When the source *moves*, we want to fuse measurements over time rather than localize each instant independently. State-space filtering does this by modeling the source's position (and velocity) as an evolving state. The **Kalman filter** is optimal for linear-Gaussian dynamics, but the TDOA measurement is nonlinear, so practitioners use the **Extended Kalman Filter** (which linearizes the measurement with the same Jacobian as above), the **Unscented Kalman Filter** (which propagates a deterministic set of sigma points through the nonlinearity, avoiding explicit Jacobians and handling stronger nonlinearity better), or, for multimodal or heavily non-Gaussian problems, the **particle filter** (which represents the posterior by a weighted sample cloud). These trackers also naturally enforce motion continuity, which suppresses the per-snapshot ambiguities of static localization. + +*********************************************** +Performance Analysis and Fundamental Bounds +*********************************************** + +Having estimators in hand, we ask: how accurate *can* a TDOA system be, and what governs that accuracy? Two ideas answer this — the Cramér-Rao bound, which sets a noise floor from the signals, and geometric dilution of precision, which describes how sensor-source geometry amplifies that floor. + +Error Propagation +======================== + +System accuracy is a two-stage cascade. First, finite SNR and bandwidth limit how precisely each delay can be measured (TDE error). Second, the geometry maps those range-difference errors into a position error. Writing :math:`\delta\mathbf{u}` for the position error and :math:`\boldsymbol{\varepsilon}` for the range-difference errors, the linearized relation near the solution is :math:`\boldsymbol{\varepsilon}\approx \mathbf{J}\,\delta\mathbf{u}`, so the position-error covariance is + +.. math:: + + \mathrm{Cov}(\hat{\mathbf{u}}) \approx (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}. + +This single expression contains both stages: :math:`\mathbf{C}` is the measurement quality (from TDE) and :math:`\mathbf{J}` is the geometry. + +The Time-Delay Estimation Bound +======================================= + +The first stage has its own Cramér-Rao bound. For a single delay estimated from a signal of bandwidth observed over time :math:`T`, the variance obeys (Stein; Quazi) + +.. math:: + + \mathrm{var}(\hat\tau_{ij}) \gtrsim \frac{1}{8\pi^2 \beta^2 T \gamma}, + +where :math:`\beta` is the *RMS (Gabor) bandwidth* of the signal and :math:`\gamma` is an effective SNR factor combining the two sensors' SNRs. Three design lessons fall straight out: variance improves with **integration time** :math:`T`, with **effective SNR** :math:`\gamma`, and — most strikingly — with the **square of bandwidth** :math:`\beta^2`. Doubling the bandwidth quarters the delay variance. This is why wideband and spread-spectrum waveforms are so prized for ranging, and why narrowband emitters are intrinsically hard to localize by TDOA alone. + +The Localization Cramér-Rao Lower Bound +============================================== + +Combining the stages, the Fisher information matrix for the source position is + +.. math:: + + \mathbf{F} = \mathbf{J}^\top \mathbf{C}^{-1} \mathbf{J}, + +and the Cramér-Rao Lower Bound states that *any* unbiased estimator has covariance no smaller than its inverse: + +.. math:: + + \mathrm{Cov}(\hat{\mathbf{u}}) \succeq \mathbf{F}^{-1} = (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}. + +The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator of Section 7.6 attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise — which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. + +Geometric Dilution of Precision +======================================= + +Even with perfect measurements, geometry can ruin a fix. **Geometric Dilution of Precision** (GDOP) quantifies how the sensor-source configuration amplifies measurement error into position error. If the range-difference errors are independent with common standard deviation :math:`\sigma`, so :math:`\mathbf{C}=\sigma^2\mathbf{I}`, then + +.. math:: + + \mathrm{GDOP} = \sqrt{\mathrm{tr}\bigl[(\mathbf{J}^\top\mathbf{J})^{-1}\bigr]}, \qquad + \sigma_{\text{position}} = \mathrm{GDOP}\cdot \sigma . + +GDOP is a pure number :math:`\ge 1`: it is the factor by which the underlying ranging error is magnified at a given source location. The geometric intuition follows from the Jacobian rows being differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1`: + +* When the sensors surround the source so that the bearing vectors point in well-spread directions, the hyperbolae cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). +* When the source lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolae intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). + +This is the geometric counterpart of the warning in Section 7.2 that hyperbolae degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. + +The figure below shows GDOP heat maps over a plane for (left) three sensors at the vertices of an equilateral triangle and (right) three nearly collinear sensors, showing a broad low-GDOP region inside the triangle versus a narrow usable corridor for the collinear array, with GDOP rising sharply outside the convex hull in both cases. + +.. image:: ../_images/tdoa_gdop.svg + :align: center + :target: ../_images/tdoa_gdop.svg + :alt: GDOP heat maps over a plane for (left) three sensors at the vertices of an equilateral triangle and (right) three nearly collinear sensors, showing a broad low-GDOP region inside the triangle versus a narrow usable corridor for the collinear array, with GDOP rising sharply outside the convex hull in both cases. + +Sensor-Placement Optimization +===================================== + +Because geometry is often a *design* variable, we can place sensors to minimize error. Formally one minimizes a scalar functional of :math:`\mathbf{F}^{-1}` over sensor positions — minimizing the trace (A-optimality, equivalent to minimizing GDOP), the determinant (D-optimality, minimizing the confidence-ellipse volume), or the largest eigenvalue (E-optimality, minimizing worst-case error). The qualitative results are intuitive and worth remembering: spread the sensors widely (long baselines improve angular resolution), surround the region of interest so sources fall inside the convex hull, avoid collinear or coplanar layouts that create ambiguous or ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or a large coverage area, placement is optimized over the whole region (e.g. minimizing average or worst-case GDOP), often by numerical search. + +***************************************** +Practical Challenges in Real Systems +***************************************** + +The clean model of the preceding sections omits the effects that, in deployment, usually dominate the error budget. Three deserve detailed treatment. + +Receiver Synchronization +================================ + +TDOA's defining advantage — that it needs no synchronized transmitter — comes paired with its defining burden: the *receivers* must share a common time reference, and any error in that reference enters the measurement directly. If sensor :math:`i`'s clock is offset from truth by :math:`\delta t_i`, the measured TDOA is corrupted by :math:`\delta t_i - \delta t_j`, an error multiplied by :math:`c` in range. The scale is unforgiving for radio systems: + +.. math:: + + c \times 1\ \text{ns} = (3\times10^8\,\text{m/s})(10^{-9}\,\text{s}) = 0.30\ \text{m}. + +So a 1 ns synchronization error already costs :math:`\sim`\0.3 m, and 100 ns costs 30 m. Achieving and holding nanosecond-level synchronization across distributed sensors is therefore central to system design. Common mechanisms include GPS-disciplined oscillators (each sensor recovers a :math:`\sim`\10-100 ns timing reference from satellites), the Precision Time Protocol (IEEE 1588, distributing time over a network to sub-microsecond or, with hardware timestamping, sub-100 ns accuracy), and for the most demanding installations White Rabbit, which reaches sub-nanosecond synchronization over fiber. Two further subtleties matter: clocks not only have a static *offset* but *drift* over time, requiring continual discipline; and in acoustic systems, where :math:`c` is a million times smaller, the same absolute timing error is a million times less harmful, which is why microphone-array TDOA is comparatively forgiving while radio TDOA lives or dies by its clocks. + +Multipath and Non-Line-of-Sight Propagation +=================================================== + +The signal model assumed a single direct path. Real environments add reflections (multipath) and can block the direct path entirely (non-line-of-sight, NLOS). Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. NLOS is more insidious: when the direct path is obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying NLOS links statistically (NLOS measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them, and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators of Section 7.6.4. In dense indoor multipath, model-based delay estimation and machine-learning approaches (Section 7.9.5) increasingly outperform classical correlation. + +Sensor-Position Uncertainty and Calibration +=================================================== + +The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s}_i`. Errors in those coordinates propagate into the position estimate just as measurement errors do, and for distant sources can be amplified by the same poor geometry that inflates GDOP. Careful survey of fixed installations, GPS positioning of mobile sensors, and *self-calibration* — jointly estimating sensor positions and source locations from sources of opportunity at known or constrained locations — are the standard responses. A full error budget must include sensor-position uncertainty alongside timing and TDE error; in well-synchronized systems it is often the next-largest term. + +Bandwidth and SNR Limits +================================ + +Section 7.7.2 already quantified the dependence: delay variance scales as :math:`1/(\beta^2 T\,\gamma)`. The practical reading is that the most effective levers on accuracy are usually *more bandwidth* (quadratic payoff) and *more integration time* or *power* (linear payoff). A system designer who cannot move the sensors and cannot improve the clocks can still often improve the fix by capturing a wider slice of the emitter's spectrum. + +******************* +Advanced Topics +******************* + +Joint TDOA/FDOA Estimation +================================== + +When the source, the sensors, or both are *moving*, the relative motion imparts a Doppler shift that differs between sensors — a **Frequency Difference of Arrival** (FDOA). FDOA carries information about the source's *velocity* and, crucially, adds an independent geometric constraint that improves position observability, especially for the difficult far-field and few-sensor cases where TDOA alone is poorly conditioned. TDOA and FDOA are estimated jointly by maximizing the **Complex Ambiguity Function** (CAF) over both delay and frequency offset: + +.. math:: + + A(\tau,\nu) = \int_0^T x_i(t)\, x_j^{*}(t-\tau)\, e^{-j2\pi \nu t}\, dt, + +whose two-dimensional peak gives :math:`(\hat\tau_{ij},\hat\nu_{ij})` simultaneously. The CAF generalizes the cross-correlation of Section 7.4 by adding a frequency-search dimension, at correspondingly higher computational cost. Joint TDOA/FDOA processing is the backbone of satellite and airborne geolocation of radio emitters, where a single pair of moving platforms can localize a stationary emitter from the combined delay and Doppler constraints. diff --git a/index.rst b/index.rst index d897abd9..ebe6c0f9 100644 --- a/index.rst +++ b/index.rst @@ -35,6 +35,7 @@ content/pyqt content/detection content/fpv_video + content/tdoa content/about_author .. raw:: html From e54ab69ce44a4a7b274a033273950a9602e10abb Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 00:56:24 -0400 Subject: [PATCH 02/27] remove references to section numbers --- content/tdoa.rst | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index 8f56ef5a..2c073a54 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -26,7 +26,7 @@ Consider the propagation time from a source to sensor :math:`i`, namely :math:`t the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on source and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters: we never need to know when the source transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. -The price we pay is that the receivers must share a precise common time reference — a requirement that, as Section 7.8 shows, is itself a demanding engineering problem because a timing error of one nanosecond corresponds to about 0.3 m of range error. +The price we pay is that the receivers must share a precise common time reference — a requirement that, as discussed later, is itself a demanding engineering problem because a timing error of one nanosecond corresponds to about 0.3 m of range error. ************************* Geometric Foundations @@ -80,7 +80,7 @@ With :math:`N` sensors we can form pairs and intersect their hyperbolae; the sou * In **2D** the source has 2 unknowns :math:`(x,y)`. Each independent TDOA gives one equation, so we need at least 2 independent TDOAs, which requires **3 sensors**. * In **3D** the source has 3 unknowns :math:`(x,y,z)`, requiring 3 independent TDOAs and therefore **4 sensors**. -In the noiseless, exactly-determined case the hyperbolae meet at a single point (with an occasional geometric ambiguity resolved by branch signs or an extra sensor). With more sensors than the minimum the system is *overdetermined*: noisy hyperbolae no longer share an exact common point, and we must solve a least-squares or maximum-likelihood problem (Sections 7.5-7.6). +In the noiseless, exactly-determined case the hyperbolae meet at a single point (with an occasional geometric ambiguity resolved by branch signs or an extra sensor). With more sensors than the minimum the system is *overdetermined*: noisy hyperbolae no longer share an exact common point, and we must solve a least-squares or maximum-likelihood problem, as described below. Reference Sensor and Independent Pairs ============================================= @@ -113,7 +113,7 @@ Taking sensor 1 as reference, the range-difference measurements are \Delta r_{21}=r_2-r_1\approx 17.08\ \text{m},\qquad \Delta r_{31}=r_3-r_1\approx 30.62\ \text{m}. -Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the source. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization of Section 7.5 — where we will recover :math:`(40,30)` from exactly these numbers in closed form. +Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the source. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization developed below — where we will recover :math:`(40,30)` from exactly these numbers in closed form. ************************************* The Signal and Measurement Model @@ -128,7 +128,7 @@ Let :math:`s(t)` be the (unknown) source waveform. Sensor :math:`i` receives an x_i(t) = a_i \, s(t - t_i) + n_i(t), \qquad i = 1,\dots,N, -where :math:`a_i` is a real (or complex, for passband signals) gain capturing propagation loss and antenna response, :math:`t_i = t_0 + r_i/c` is the absolute arrival time, and :math:`n_i(t)` is additive noise. This model assumes a single dominant line-of-sight path; multipath and non-line-of-sight effects are deferred to Section 7.8. +where :math:`a_i` is a real (or complex, for passband signals) gain capturing propagation loss and antenna response, :math:`t_i = t_0 + r_i/c` is the absolute arrival time, and :math:`n_i(t)` is additive noise. This model assumes a single dominant line-of-sight path; multipath and non-line-of-sight effects are deferred to a later section. Defining the TDOA ======================== @@ -139,7 +139,7 @@ The pairwise TDOA is the difference of arrival times, \tau_{ij} = t_i - t_j = \frac{r_i - r_j}{c} = \frac{|\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_j|}{c}. -The right-hand side makes explicit that the TDOA is a nonlinear function of the source coordinates :math:`\mathbf{u}`. The *measurement* problem (Section 7.4) is to estimate :math:`\tau_{ij}` from the waveforms :math:`x_i, x_j`; the *localization* problem (Sections 7.5-7.6) is to invert the nonlinear map from :math:`\mathbf{u}` to the collection of TDOAs. +The right-hand side makes explicit that the TDOA is a nonlinear function of the source coordinates :math:`\mathbf{u}`. The *measurement* problem is to estimate :math:`\tau_{ij}` from the waveforms :math:`x_i, x_j`; the *localization* problem is to invert the nonlinear map from :math:`\mathbf{u}` to the collection of TDOAs. Noise Assumptions ======================== @@ -162,7 +162,7 @@ Collecting the :math:`N-1` reference-based range differences into a vector :math \mathbf{m} = \mathbf{h}(\mathbf{u}), \qquad h_i(\mathbf{u}) = |\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_1|, -and the noisy measurement is :math:`\tilde{\mathbf{m}} = \mathbf{h}(\mathbf{u}) + \boldsymbol{\varepsilon}`, where :math:`\boldsymbol{\varepsilon}` is the range-difference error induced by the time-delay estimation errors of Section 7.4. The function :math:`\mathbf{h}` is nonlinear because of the Euclidean norms, and this nonlinearity is the source of every algorithmic complication that follows. Two broad strategies address it: algebraically *linearize* by introducing an auxiliary variable (Section 7.5), or *iteratively* linearize about a current estimate (Section 7.6). +and the noisy measurement is :math:`\tilde{\mathbf{m}} = \mathbf{h}(\mathbf{u}) + \boldsymbol{\varepsilon}`, where :math:`\boldsymbol{\varepsilon}` is the range-difference error induced by the time-delay estimation errors of Section 7.4. The function :math:`\mathbf{h}` is nonlinear because of the Euclidean norms, and this nonlinearity is the source of every algorithmic complication that follows. Two broad strategies address it: algebraically *linearize* by introducing an auxiliary variable (described in the next section), or *iteratively* linearize about a current estimate (described further below). ************************************************* Time-Delay Estimation (the Measurement Front End) @@ -224,7 +224,7 @@ With sampling rate :math:`f_s`, the correlation is computed on a lag grid spaced Practical Considerations ================================ -Several effects govern real performance. The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the assumption of stationarity and, for moving sources, against blurring of the delay over the window. **Coherence bandwidth** limits which frequencies actually carry usable phase. **Signal bandwidth** is decisive: as the Cramér-Rao analysis of Section 7.7 shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. Finally, the entire computation is dominated by FFTs and is therefore :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large microphone arrays and dense sensor networks tractable. +Several effects govern real performance. The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the assumption of stationarity and, for moving sources, against blurring of the delay over the window. **Coherence bandwidth** limits which frequencies actually carry usable phase. **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. Finally, the entire computation is dominated by FFTs and is therefore :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large microphone arrays and dense sensor networks tractable. Worked Example: GCC-PHAT in Practice ============================================ @@ -241,7 +241,7 @@ so a refined estimate of, say, lag :math:`25.02` corresponds to :math:`\hat\tau_ Closed-Form Localization Algorithms ************************************* -The measurement equations of Section 7.3 are nonlinear and, taken directly, require iterative solution with a good starting point. *Closed-form* (non-iterative) estimators sidestep this by an algebraic trick: introduce an auxiliary variable that absorbs the nonlinearity and renders the system linear. They are fast, need no initial guess, and cannot get stuck in local minima — making them invaluable both on their own and as initializers for the iterative methods of Section 7.6. +The measurement equations above are nonlinear and, taken directly, require iterative solution with a good starting point. *Closed-form* (non-iterative) estimators sidestep this by an algebraic trick: introduce an auxiliary variable that absorbs the nonlinearity and renders the system linear. They are fast, need no initial guess, and cannot get stuck in local minima — making them invaluable both on their own and as initializers for the iterative methods described below. The Linearization Strategy ================================== @@ -292,12 +292,12 @@ with the weight :math:`\mathbf{W}` chosen as the inverse covariance of the equat **Second step.** The first step ignored the known relationship :math:`r_1^2 = (x-x_1)^2+(y-y_1)^2` that couples the auxiliary variable to the position. The second step restores it: form a new small least-squares problem in the squared quantities :math:`[(x-x_1)^2,(y-y_1)^2,r_1^2]`, using the first-step covariance to weight it, and solve for a corrected position. This second WLS removes much of the bias of the naive linear solution and is what brings Chan's estimator close to optimal. -The method returns a position directly, with computational cost dominated by inverting small :math:`3\times3` matrices — negligible compared with the FFTs of the front end. Its limitations appear at high noise or unfavorable geometry, where the squared-range manipulation amplifies errors and the second step can pick the wrong root; there, the iterative refinement of Section 7.6 seeded by Chan's output is the standard remedy. +The method returns a position directly, with computational cost dominated by inverting small :math:`3\times3` matrices — negligible compared with the FFTs of the front end. Its limitations appear at high noise or unfavorable geometry, where the squared-range manipulation amplifies errors and the second step can pick the wrong root; there, the iterative refinement described below, seeded by Chan's output, is the standard remedy. Example, Continued: Solving the Three-Sensor Fix in Closed Form ================================================================ -Return to the geometry of Section 7.2.5: :math:`\mathbf{s}_1=(0,0)`, :math:`\mathbf{s}_2=(100,0)`, :math:`\mathbf{s}_3=(0,100)`, with measured range differences :math:`r_{21}=17.08` m and :math:`r_{31}=30.62` m. Here :math:`K_1=0`, :math:`K_2=K_3=10{,}000`. The boxed linear equations become, for :math:`i=2` and :math:`i=3`, +Return to the three-sensor geometry from the example above: :math:`\mathbf{s}_1=(0,0)`, :math:`\mathbf{s}_2=(100,0)`, :math:`\mathbf{s}_3=(0,100)`, with measured range differences :math:`r_{21}=17.08` m and :math:`r_{31}=30.62` m. Here :math:`K_1=0`, :math:`K_2=K_3=10{,}000`. The boxed linear equations become, for :math:`i=2` and :math:`i=3`, .. math:: @@ -331,7 +331,7 @@ The positive root is :math:`r_1 = 50.0` m (the negative root is non-physical and x = 48.54 - 0.1708(50) = 40.0, \qquad y = 45.31 - 0.3062(50) = 30.0 . -We recover the true source :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolae of Section 7.2 represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. +We recover the true source :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolae illustrated above represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. ***************************************** Iterative and Statistical Estimation @@ -371,7 +371,7 @@ iterated to convergence. Each step solves a small linear system. The method conv Maximum-Likelihood Estimation ===================================== -Under the zero-mean Gaussian noise model the negative log-likelihood of the measurements is, up to constants, exactly the weighted squared residual above with :math:`\mathbf{C}` the true noise covariance. Hence **the maximum-likelihood estimator coincides with weighted nonlinear least squares**, and the Gauss-Newton iteration is the practical route to it. This identification is important: it means the iterative estimator is not merely a heuristic but the statistically optimal estimator for the assumed model, and it is the estimator whose covariance the Cramér-Rao bound of Section 7.7 predicts. +Under the zero-mean Gaussian noise model the negative log-likelihood of the measurements is, up to constants, exactly the weighted squared residual above with :math:`\mathbf{C}` the true noise covariance. Hence **the maximum-likelihood estimator coincides with weighted nonlinear least squares**, and the Gauss-Newton iteration is the practical route to it. This identification is important: it means the iterative estimator is not merely a heuristic but the statistically optimal estimator for the assumed model, and it is the estimator whose covariance the Cramér-Rao bound below predicts. Robust, Recursive, and Bayesian Extensions ================================================== @@ -423,7 +423,7 @@ and the Cramér-Rao Lower Bound states that *any* unbiased estimator has covaria \mathrm{Cov}(\hat{\mathbf{u}}) \succeq \mathbf{F}^{-1} = (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}. -The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator of Section 7.6 attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise — which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. +The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise — which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. Geometric Dilution of Precision ======================================= @@ -440,7 +440,7 @@ GDOP is a pure number :math:`\ge 1`: it is the factor by which the underlying ra * When the sensors surround the source so that the bearing vectors point in well-spread directions, the hyperbolae cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). * When the source lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolae intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). -This is the geometric counterpart of the warning in Section 7.2 that hyperbolae degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. +This is the geometric counterpart of the observation above that hyperbolae degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. The figure below shows GDOP heat maps over a plane for (left) three sensors at the vertices of an equilateral triangle and (right) three nearly collinear sensors, showing a broad low-GDOP region inside the triangle versus a narrow usable corridor for the collinear array, with GDOP rising sharply outside the convex hull in both cases. @@ -458,7 +458,7 @@ Because geometry is often a *design* variable, we can place sensors to minimize Practical Challenges in Real Systems ***************************************** -The clean model of the preceding sections omits the effects that, in deployment, usually dominate the error budget. Three deserve detailed treatment. +The clean model developed above omits the effects that, in deployment, usually dominate the error budget. Three deserve detailed treatment. Receiver Synchronization ================================ @@ -474,7 +474,7 @@ So a 1 ns synchronization error already costs :math:`\sim`\0.3 m, and 100 ns cos Multipath and Non-Line-of-Sight Propagation =================================================== -The signal model assumed a single direct path. Real environments add reflections (multipath) and can block the direct path entirely (non-line-of-sight, NLOS). Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. NLOS is more insidious: when the direct path is obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying NLOS links statistically (NLOS measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them, and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators of Section 7.6.4. In dense indoor multipath, model-based delay estimation and machine-learning approaches (Section 7.9.5) increasingly outperform classical correlation. +The signal model assumed a single direct path. Real environments add reflections (multipath) and can block the direct path entirely (non-line-of-sight, NLOS). Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. NLOS is more insidious: when the direct path is obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying NLOS links statistically (NLOS measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them, and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators described earlier. In dense indoor multipath, model-based delay estimation and machine-learning approaches (Section 7.9.5) increasingly outperform classical correlation. Sensor-Position Uncertainty and Calibration =================================================== @@ -484,7 +484,7 @@ The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s} Bandwidth and SNR Limits ================================ -Section 7.7.2 already quantified the dependence: delay variance scales as :math:`1/(\beta^2 T\,\gamma)`. The practical reading is that the most effective levers on accuracy are usually *more bandwidth* (quadratic payoff) and *more integration time* or *power* (linear payoff). A system designer who cannot move the sensors and cannot improve the clocks can still often improve the fix by capturing a wider slice of the emitter's spectrum. +The bound derived above already quantified the dependence: delay variance scales as :math:`1/(\beta^2 T\,\gamma)`. The practical reading is that the most effective levers on accuracy are usually *more bandwidth* (quadratic payoff) and *more integration time* or *power* (linear payoff). A system designer who cannot move the sensors and cannot improve the clocks can still often improve the fix by capturing a wider slice of the emitter's spectrum. ******************* Advanced Topics @@ -499,4 +499,4 @@ When the source, the sensors, or both are *moving*, the relative motion imparts A(\tau,\nu) = \int_0^T x_i(t)\, x_j^{*}(t-\tau)\, e^{-j2\pi \nu t}\, dt, -whose two-dimensional peak gives :math:`(\hat\tau_{ij},\hat\nu_{ij})` simultaneously. The CAF generalizes the cross-correlation of Section 7.4 by adding a frequency-search dimension, at correspondingly higher computational cost. Joint TDOA/FDOA processing is the backbone of satellite and airborne geolocation of radio emitters, where a single pair of moving platforms can localize a stationary emitter from the combined delay and Doppler constraints. +whose two-dimensional peak gives :math:`(\hat\tau_{ij},\hat\nu_{ij})` simultaneously. The CAF generalizes the cross-correlation technique above by adding a frequency-search dimension, at correspondingly higher computational cost. Joint TDOA/FDOA processing is the backbone of satellite and airborne geolocation of radio emitters, where a single pair of moving platforms can localize a stationary emitter from the combined delay and Doppler constraints. From 24660cf90bd13a993bcdc0b66430acf9a5a7d873 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 01:04:24 -0400 Subject: [PATCH 03/27] mention USRPs --- content/tdoa.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/tdoa.rst b/content/tdoa.rst index 2c073a54..58efab3e 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -471,6 +471,8 @@ TDOA's defining advantage — that it needs no synchronized transmitter — come So a 1 ns synchronization error already costs :math:`\sim`\0.3 m, and 100 ns costs 30 m. Achieving and holding nanosecond-level synchronization across distributed sensors is therefore central to system design. Common mechanisms include GPS-disciplined oscillators (each sensor recovers a :math:`\sim`\10-100 ns timing reference from satellites), the Precision Time Protocol (IEEE 1588, distributing time over a network to sub-microsecond or, with hardware timestamping, sub-100 ns accuracy), and for the most demanding installations White Rabbit, which reaches sub-nanosecond synchronization over fiber. Two further subtleties matter: clocks not only have a static *offset* but *drift* over time, requiring continual discipline; and in acoustic systems, where :math:`c` is a million times smaller, the same absolute timing error is a million times less harmful, which is why microphone-array TDOA is comparatively forgiving while radio TDOA lives or dies by its clocks. +Off-the-shelf SDRs that can be easily synchronized include any of the Ettus Research USRPs that can take a GPS disciplined oscillator (GPSDO), such as a `B200 `_ with a `TCXO `_ (a type of GPSDO). If the sensors are close enough, they can also be synchronized by sharing a PPS signal over a cable, e.g., generated using an `OctoClock `_, which also generates a 10 MHz signal used to frequency synchronize them. Nearly all of the USRPs have a PPS and 10 MHz input, and most have room for a GPSDO, or come with one. + Multipath and Non-Line-of-Sight Propagation =================================================== From 1179413a19f9094fa834ac0657cf3bea119be4c0 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 01:06:44 -0400 Subject: [PATCH 04/27] tweaks --- content/tdoa.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index 58efab3e..f3645fb9 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -12,7 +12,7 @@ Introduction A recurring problem across acoustics, radio engineering, and defense systems is this: a source emits a signal, several spatially separated sensors receive it, and we wish to recover the source's position from those received signals alone. The source may be cooperative (a cell phone trying to be found) or non-cooperative (a radar emitter that would rather not be), stationary or moving, and the medium may be air, water, or free space. Despite this diversity, the geometry and estimation theory that solve the problem are remarkably uniform, and *Time Difference of Arrival* (TDOA) sits at the center of them. -TDOA-based localization appears in cellular emergency-caller location, acoustic with microphone arrays (e.g., gunshot-detection systems mounted on city streetlights), passive sonar, passive (non-emitting) radar, electronic warfare and signals intelligence, and even wildlife tracking. In each case the engineering details differ, but the mathematical skeleton is the same one developed in this chapter. +TDOA-based localization appears in cellular emergency-caller location, acoustics with microphone arrays (e.g., gunshot-detection systems mounted on city streetlights), passive sonar, passive (non-emitting) radar, electronic warfare and signals intelligence, and even wildlife tracking. In each case the engineering details differ, but the mathematical skeleton is the same one developed in this chapter. ****************** TDOA in a Nutshell @@ -162,7 +162,7 @@ Collecting the :math:`N-1` reference-based range differences into a vector :math \mathbf{m} = \mathbf{h}(\mathbf{u}), \qquad h_i(\mathbf{u}) = |\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_1|, -and the noisy measurement is :math:`\tilde{\mathbf{m}} = \mathbf{h}(\mathbf{u}) + \boldsymbol{\varepsilon}`, where :math:`\boldsymbol{\varepsilon}` is the range-difference error induced by the time-delay estimation errors of Section 7.4. The function :math:`\mathbf{h}` is nonlinear because of the Euclidean norms, and this nonlinearity is the source of every algorithmic complication that follows. Two broad strategies address it: algebraically *linearize* by introducing an auxiliary variable (described in the next section), or *iteratively* linearize about a current estimate (described further below). +and the noisy measurement is :math:`\tilde{\mathbf{m}} = \mathbf{h}(\mathbf{u}) + \boldsymbol{\varepsilon}`, where :math:`\boldsymbol{\varepsilon}` is the range-difference error induced by time-delay estimation errors. The function :math:`\mathbf{h}` is nonlinear because of the Euclidean norms, and this nonlinearity is the source of every algorithmic complication that follows. Two broad strategies address it: algebraically *linearize* by introducing an auxiliary variable (described in the next section), or *iteratively* linearize about a current estimate (described further below). ************************************************* Time-Delay Estimation (the Measurement Front End) @@ -400,7 +400,7 @@ This single expression contains both stages: :math:`\mathbf{C}` is the measureme The Time-Delay Estimation Bound ======================================= -The first stage has its own Cramér-Rao bound. For a single delay estimated from a signal of bandwidth observed over time :math:`T`, the variance obeys (Stein; Quazi) +The first stage has its own Cramér-Rao bound. For a single delay estimated from a signal of RMS bandwidth :math:`\beta` observed over time :math:`T`, the variance obeys (Stein; Quazi) .. math:: From 2162401967e334855476651bf51c4aed1c826496 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 01:15:53 -0400 Subject: [PATCH 05/27] tweaks --- AGENTS.md | 2 ++ content/tdoa.rst | 26 ++++++++++++++++---------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43bb42e9..561a6299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ Marc uses AI to help create the JavaScript mini-apps and solve issues like when ## How to build locally +If the prompt does not ask to build it, then don't build it. + - Activate the project virtual environment which should be in the root of this repo under .venv - Build the site using: diff --git a/content/tdoa.rst b/content/tdoa.rst index f3645fb9..175f5078 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -18,7 +18,7 @@ TDOA-based localization appears in cellular emergency-caller location, acoustics TDOA in a Nutshell ****************** -Consider the propagation time from a source to sensor :math:`i`, namely :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) instant of transmission, :math:`r_i` is the source-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, +The key insight is that when the same wavefront hits two sensors, the difference in arrival times depends only on geometry — not on when the source transmitted. To see why, consider the propagation time from a source to sensor :math:`i`: :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) instant of transmission, :math:`r_i` is the source-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, .. math:: @@ -122,7 +122,7 @@ The Signal and Measurement Model Received-Signal Model ============================ -Let :math:`s(t)` be the (unknown) source waveform. Sensor :math:`i` receives an attenuated, delayed, noise-corrupted copy: +Each sensor receives a time-delayed, scaled, noisy copy of whatever the source is transmitting. Specifically, let :math:`s(t)` be the source waveform. Sensor :math:`i` receives: .. math:: @@ -144,13 +144,13 @@ The right-hand side makes explicit that the TDOA is a nonlinear function of the Noise Assumptions ======================== -The standard working assumptions are that each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and statistically independent of the source signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is +We assume each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and independent of the source signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is .. math:: \mathrm{SNR}_i = \frac{a_i^2 \sigma_s^2}{\sigma_{n_i}^2}, -with :math:`\sigma_s^2` and :math:`\sigma_{n_i}^2` the signal and noise powers. These assumptions are idealizations — real noise is often colored and partially correlated across sensors — but they yield tractable estimators and tight bounds that perform well in practice, and the framework extends to a general noise covariance when needed. +with :math:`\sigma_s^2` and :math:`\sigma_{n_i}^2` the signal and noise powers. These are idealizations — real noise is often colored and partially correlated across sensors — but they lead to estimators and bounds that perform well in practice, and the framework extends to a general noise covariance when needed. The Nonlinear Measurement Equations ========================================== @@ -224,7 +224,13 @@ With sampling rate :math:`f_s`, the correlation is computed on a lag grid spaced Practical Considerations ================================ -Several effects govern real performance. The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the assumption of stationarity and, for moving sources, against blurring of the delay over the window. **Coherence bandwidth** limits which frequencies actually carry usable phase. **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. Finally, the entire computation is dominated by FFTs and is therefore :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large microphone arrays and dense sensor networks tractable. +Several effects govern real performance: + +* The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the stationarity assumption and, for moving sources, against blurring of the delay over the window. +* **Coherence bandwidth** limits which frequencies actually carry usable phase. +* **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. + +Finally, the entire computation is dominated by FFTs and is :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large microphone arrays and dense sensor networks tractable. Worked Example: GCC-PHAT in Practice ============================================ @@ -246,7 +252,7 @@ The measurement equations above are nonlinear and, taken directly, require itera The Linearization Strategy ================================== -Write the squared range from the source :math:`\mathbf{u}=(x,y)` to sensor :math:`i` at :math:`\mathbf{s}_i=(x_i,y_i)` as +The trick is to square the range equations and subtract pairs, which cancels the nonlinear :math:`x^2+y^2` term and introduces :math:`r_1`, the range to the reference sensor, as a single auxiliary unknown. Starting with the squared range from the source :math:`\mathbf{u}=(x,y)` to sensor :math:`i` at :math:`\mathbf{s}_i=(x_i,y_i)`: .. math:: @@ -371,7 +377,7 @@ iterated to convergence. Each step solves a small linear system. The method conv Maximum-Likelihood Estimation ===================================== -Under the zero-mean Gaussian noise model the negative log-likelihood of the measurements is, up to constants, exactly the weighted squared residual above with :math:`\mathbf{C}` the true noise covariance. Hence **the maximum-likelihood estimator coincides with weighted nonlinear least squares**, and the Gauss-Newton iteration is the practical route to it. This identification is important: it means the iterative estimator is not merely a heuristic but the statistically optimal estimator for the assumed model, and it is the estimator whose covariance the Cramér-Rao bound below predicts. +Under Gaussian noise, the negative log-likelihood is, up to constants, exactly the weighted squared residual above. So **the maximum-likelihood estimator coincides with weighted nonlinear least squares** — the Gauss-Newton iteration is not a heuristic, it is the statistically optimal estimator under the assumed model. This is also the estimator whose covariance the Cramér-Rao bound below predicts. Robust, Recursive, and Bayesian Extensions ================================================== @@ -400,7 +406,7 @@ This single expression contains both stages: :math:`\mathbf{C}` is the measureme The Time-Delay Estimation Bound ======================================= -The first stage has its own Cramér-Rao bound. For a single delay estimated from a signal of RMS bandwidth :math:`\beta` observed over time :math:`T`, the variance obeys (Stein; Quazi) +We can bound how well any estimator can measure a single delay. For a signal of RMS bandwidth :math:`\beta` observed over time :math:`T`, the variance of any unbiased delay estimate obeys .. math:: @@ -411,7 +417,7 @@ where :math:`\beta` is the *RMS (Gabor) bandwidth* of the signal and :math:`\gam The Localization Cramér-Rao Lower Bound ============================================== -Combining the stages, the Fisher information matrix for the source position is +Combining measurement quality and geometry, the Fisher information matrix for the source position is .. math:: @@ -452,7 +458,7 @@ The figure below shows GDOP heat maps over a plane for (left) three sensors at t Sensor-Placement Optimization ===================================== -Because geometry is often a *design* variable, we can place sensors to minimize error. Formally one minimizes a scalar functional of :math:`\mathbf{F}^{-1}` over sensor positions — minimizing the trace (A-optimality, equivalent to minimizing GDOP), the determinant (D-optimality, minimizing the confidence-ellipse volume), or the largest eigenvalue (E-optimality, minimizing worst-case error). The qualitative results are intuitive and worth remembering: spread the sensors widely (long baselines improve angular resolution), surround the region of interest so sources fall inside the convex hull, avoid collinear or coplanar layouts that create ambiguous or ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or a large coverage area, placement is optimized over the whole region (e.g. minimizing average or worst-case GDOP), often by numerical search. +Because geometry is often a *design* variable, we can place sensors to minimize error. Common objectives minimize a scalar derived from :math:`\mathbf{F}^{-1}` — its trace (equivalent to GDOP), its determinant (the confidence-ellipse volume), or its largest eigenvalue (worst-case error). The qualitative results are intuitive: spread the sensors widely so long baselines sharpen angular resolution, surround the region of interest so sources fall inside the convex hull, avoid collinear or coplanar layouts that create ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or large coverage area, placement is optimized over the whole region — minimizing average or worst-case GDOP — usually by numerical search. ***************************************** Practical Challenges in Real Systems From ad881681c62f9408ec43b80a26c9cf2eab4a5ce5 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 01:20:51 -0400 Subject: [PATCH 06/27] tweaks --- content/tdoa.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index 175f5078..249de927 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -482,7 +482,7 @@ Off-the-shelf SDRs that can be easily synchronized include any of the Ettus Rese Multipath and Non-Line-of-Sight Propagation =================================================== -The signal model assumed a single direct path. Real environments add reflections (multipath) and can block the direct path entirely (non-line-of-sight, NLOS). Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. NLOS is more insidious: when the direct path is obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying NLOS links statistically (NLOS measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them, and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators described earlier. In dense indoor multipath, model-based delay estimation and machine-learning approaches (Section 7.9.5) increasingly outperform classical correlation. +Everything so far has assumed a single line-of-sight path between the emitter and receivers. Real environments add reflections (multipath) and can block the direct path entirely. Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. When the direct path is entirely obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying these non-line-of-sight links statistically (non-line-of-sight measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them (requires having way more sensors than three), and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators described earlier. In dense indoor multipath, which is an extremely difficult envrionment for TDOA, model-based delay estimation and machine-learning approaches increasingly outperform classical correlation. Sensor-Position Uncertainty and Calibration =================================================== @@ -492,7 +492,7 @@ The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s} Bandwidth and SNR Limits ================================ -The bound derived above already quantified the dependence: delay variance scales as :math:`1/(\beta^2 T\,\gamma)`. The practical reading is that the most effective levers on accuracy are usually *more bandwidth* (quadratic payoff) and *more integration time* or *power* (linear payoff). A system designer who cannot move the sensors and cannot improve the clocks can still often improve the fix by capturing a wider slice of the emitter's spectrum. +The bound derived above already quantified the dependence: delay variance scales as :math:`1/(\beta^2 T\,\gamma)`. The practical reading is that the most effective levers on accuracy are usually *more bandwidth* (quadratic payoff) and *more integration time* or *power* (linear payoff). A system designer who cannot move the sensors and cannot improve the clocks can still often improve the fix by capturing a wider slice of the emitter's spectrum. That being said, you don't need to capture the entire signal (from a frequency domain perspective) to perform TDOA, if you are limited by your SDR's maximum sample rate, and you can only receive a portion of the signal bandwidth, you can still do TDOA! ******************* Advanced Topics From 6aea699395152c4184a77c26590d1cdb94cdf782 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 01:39:15 -0400 Subject: [PATCH 07/27] js app --- _static/js/tdoa.js | 310 +++++++++++++++++++++++++++++++++++++++++++++ conf.py | 3 +- content/tdoa.rst | 9 ++ 3 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 _static/js/tdoa.js diff --git a/_static/js/tdoa.js b/_static/js/tdoa.js new file mode 100644 index 00000000..c5f81eea --- /dev/null +++ b/_static/js/tdoa.js @@ -0,0 +1,310 @@ +function tdoa_app(containerId) { + // ----- configuration ------------------------------------------------------- + const c = 3e8; // propagation speed [m/s] (free space / RF) + const W = 600; // canvas width [px] + const H = 480; // canvas height [px] + const worldSpan = 1000; // world width represented across the canvas [m] + const nodeRadius = 9; // hit/draw radius for draggable handles [px] + const edgeMargin = 12; // keep handles at least this far inside the canvas [px] + const pairColors = ["#e6550d", "#3182bd", "#31a354"]; // one per sensor pair + + // ----- DOM setup ----------------------------------------------------------- + const container = document.getElementById(containerId || "tdoaApp") || document.body; + + // canvas sits in a flex row next to the noise slider on its right + const row = document.createElement("div"); + row.style.display = "flex"; + row.style.alignItems = "stretch"; + row.style.gap = "10px"; + container.appendChild(row); + + const canvas = document.createElement("canvas"); + canvas.width = W; + canvas.height = H; + canvas.style.border = "1px solid #888"; + canvas.style.touchAction = "none"; // let us handle touch-drag ourselves + canvas.style.cursor = "grab"; + row.appendChild(canvas); + + // vertical noise slider: standard deviation of the Gaussian noise [m] + const sliderBox = document.createElement("div"); + sliderBox.style.display = "flex"; + sliderBox.style.flexDirection = "column"; + sliderBox.style.alignItems = "center"; + sliderBox.style.fontFamily = "sans-serif"; + sliderBox.style.fontSize = "12px"; + row.appendChild(sliderBox); + + const sliderLabel = document.createElement("div"); + sliderLabel.style.textAlign = "center"; + sliderLabel.style.marginBottom = "6px"; + sliderBox.appendChild(sliderLabel); + + let noiseStd = 0; // std dev of Gaussian noise added to each range diff [m] + function updateSliderLabel() { + sliderLabel.innerHTML = `Noise
${noiseStd.toFixed(0)} m`; + } + updateSliderLabel(); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "100"; + slider.step = "1"; + slider.value = "0"; + // make the range input vertical (with a fallback for older browsers) + slider.setAttribute("orient", "vertical"); + slider.style.writingMode = "vertical-lr"; + slider.style.direction = "rtl"; // 0 at the bottom, max at the top + slider.style.height = H / 2 + "px"; // half the canvas height + slider.style.width = "24px"; + sliderBox.appendChild(slider); + + slider.addEventListener("input", () => { + noiseStd = parseFloat(slider.value); + updateSliderLabel(); + render(); + }); + + const readout = document.createElement("div"); + readout.style.fontFamily = "monospace"; + readout.style.fontSize = "13px"; + readout.style.marginTop = "6px"; + container.appendChild(readout); + + const ctx = canvas.getContext("2d"); + + // ----- scene state (world coordinates, meters, origin at center) ----------- + // y points up in world coordinates (flipped when drawing to the canvas). + const emitter = { x: 0, y: 120, label: "Emitter" }; + const sensors = [ + { x: -350, y: -200, label: "Sensor 0" }, + { x: 350, y: -200, label: "Sensor 1" }, + { x: 0, y: 300, label: "Sensor 2" } + ]; + + // ----- coordinate transforms ---------------------------------------------- + const scale = W / worldSpan; // px per meter + function worldToPx(p) { + return { x: W / 2 + p.x * scale, y: H / 2 - p.y * scale }; + } + function pxToWorld(px) { + return { x: (px.x - W / 2) / scale, y: (H / 2 - px.y) / scale }; + } + + function dist(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + + // ----- TDOA simulation ----------------------------------------------------- + // Standard normal sample via the Box-Muller transform. + function randn() { + let u = 0; + let v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); + } + + // Returns the per-sensor TOA and, for each pair, the TDOA and range diff. + function simulate() { + const toa = sensors.map((s) => dist(emitter, s) / c); // seconds + const pairs = [ + [0, 1], + [0, 2], + [1, 2] + ]; + const measurements = pairs.map(([i, j]) => { + // ideal TDOA, then add Gaussian noise (slider sets its std dev in meters, + // so we convert that range error into the equivalent time error) + const tdoa = toa[i] - toa[j] + (noiseStd * randn()) / c; // seconds + return { i, j, tdoa, dr: c * tdoa }; // dr = r_i - r_j [m] + }); + return { toa, measurements }; + } + + // ----- hyperbola drawing --------------------------------------------------- + // Locus of points u with |u - s_i| - |u - s_j| = dr is a hyperbola with foci + // at the two sensors. We parametrize it in a frame centered on the midpoint + // of the foci, with the transverse axis along the baseline. + function drawHyperbola(si, sj, dr, color) { + const mid = { x: (si.x + sj.x) / 2, y: (si.y + sj.y) / 2 }; + const baseline = dist(si, sj); + const cFoci = baseline / 2; // half the focal separation + const a = dr / 2; // signed semi-transverse axis; sign picks the branch + if (Math.abs(a) >= cFoci) return; // |dr| can't exceed the baseline + const b = Math.sqrt(cFoci * cFoci - a * a); + + // Unit vector u from s_i toward s_j (axis), and perpendicular v. + const ux = (sj.x - si.x) / baseline; + const uy = (sj.y - si.y) / baseline; + const vx = -uy; + const vy = ux; + + // Sweep the parameter t; x = a*cosh(t) keeps us on the correct branch + // because a carries the sign of dr. + ctx.beginPath(); + let first = true; + for (let t = -3; t <= 3.0001; t += 0.05) { + const xl = a * Math.cosh(t); + const yl = b * Math.sinh(t); + const wx = mid.x + xl * ux + yl * vx; + const wy = mid.y + xl * uy + yl * vy; + const p = worldToPx({ x: wx, y: wy }); + if (first) { + ctx.moveTo(p.x, p.y); + first = false; + } else { + ctx.lineTo(p.x, p.y); + } + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // ----- rendering ----------------------------------------------------------- + function drawGrid() { + ctx.clearRect(0, 0, W, H); + ctx.strokeStyle = "#eee"; + ctx.lineWidth = 1; + const step = 100 * scale; // grid every 100 m + for (let x = (W / 2) % step; x < W; x += step) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, H); + ctx.stroke(); + } + for (let y = (H / 2) % step; y < H; y += step) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(W, y); + ctx.stroke(); + } + // axes + ctx.strokeStyle = "#ccc"; + ctx.beginPath(); + ctx.moveTo(W / 2, 0); + ctx.lineTo(W / 2, H); + ctx.moveTo(0, H / 2); + ctx.lineTo(W, H / 2); + ctx.stroke(); + + // axis labels + ctx.fillStyle = "#999"; + ctx.font = "13px sans-serif"; + ctx.textBaseline = "alphabetic"; + ctx.textAlign = "right"; + ctx.fillText("X", W - 6, H / 2 - 6); + ctx.textAlign = "left"; + ctx.fillText("Y", W / 2 + 6, 14); + ctx.textAlign = "left"; + } + + function drawSensor(s) { + const p = worldToPx(s); + ctx.fillStyle = "#222"; + ctx.beginPath(); + ctx.moveTo(p.x, p.y - nodeRadius); + ctx.lineTo(p.x + nodeRadius, p.y + nodeRadius); + ctx.lineTo(p.x - nodeRadius, p.y + nodeRadius); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = "#222"; + ctx.font = "bold 16px sans-serif"; + ctx.fillText(s.label, p.x + nodeRadius + 2, p.y - 2); + } + + function drawEmitter() { + const p = worldToPx(emitter); + ctx.fillStyle = "#d62728"; + ctx.beginPath(); + ctx.arc(p.x, p.y, nodeRadius, 0, 2 * Math.PI); + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = "#d62728"; + ctx.font = "bold 16px sans-serif"; + ctx.fillText(emitter.label, p.x + nodeRadius + 2, p.y - 2); + } + + function render() { + const { toa, measurements } = simulate(); + + drawGrid(); + measurements.forEach((m, k) => { + drawHyperbola(sensors[m.i], sensors[m.j], m.dr, pairColors[k]); + }); + sensors.forEach(drawSensor); + drawEmitter(); + + // text readout of the simulated quantities + let html = ""; + toa.forEach((t, i) => { + html += `TOA(${sensors[i].label}) = ${(t * 1e9).toFixed(1)} ns   (range ${dist(emitter, sensors[i]).toFixed(0)} m)
`; + }); + measurements.forEach((m, k) => { + html += `TDOA(${sensors[m.i].label},${sensors[m.j].label}) = ${(m.tdoa * 1e9).toFixed(1)} ns   Δr = ${m.dr.toFixed(0)} m
`; + }); + readout.innerHTML = html; + } + + // ----- dragging ------------------------------------------------------------ + let dragTarget = null; + + function eventPx(e) { + const rect = canvas.getBoundingClientRect(); + const src = e.touches ? e.touches[0] : e; + return { x: src.clientX - rect.left, y: src.clientY - rect.top }; + } + + function pickNode(px) { + const all = [emitter, ...sensors]; + for (const node of all) { + if (dist(worldToPx(node), px) <= nodeRadius + 4) return node; + } + return null; + } + + function onDown(e) { + const px = eventPx(e); + dragTarget = pickNode(px); + if (dragTarget) { + canvas.style.cursor = "grabbing"; + e.preventDefault(); + } + } + + function onMove(e) { + const px = eventPx(e); + if (!dragTarget) { + canvas.style.cursor = pickNode(px) ? "grab" : "default"; + return; + } + // keep the handle inside the canvas (with a small margin) so it can't be + // dragged off-screen and lost + px.x = Math.max(edgeMargin, Math.min(W - edgeMargin, px.x)); + px.y = Math.max(edgeMargin, Math.min(H - edgeMargin, px.y)); + const w = pxToWorld(px); + dragTarget.x = w.x; + dragTarget.y = w.y; + render(); + e.preventDefault(); + } + + function onUp() { + dragTarget = null; + canvas.style.cursor = "grab"; + } + + canvas.addEventListener("mousedown", onDown); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + canvas.addEventListener("touchstart", onDown, { passive: false }); + canvas.addEventListener("touchmove", onMove, { passive: false }); + canvas.addEventListener("touchend", onUp); + + // ----- go ------------------------------------------------------------------ + render(); +} diff --git a/conf.py b/conf.py index 08c8b1b1..5750e653 100644 --- a/conf.py +++ b/conf.py @@ -201,7 +201,8 @@ def setup(app): 'js/beamforming_slider_app.js', 'js/FFT.js', 'js/cyclostationary_app.js', - 'js/homepage_app.js' + 'js/homepage_app.js', + 'js/tdoa.js' # we also include the index.js file from the PhasedArrayVisualizer directory in setup() above ] diff --git a/content/tdoa.rst b/content/tdoa.rst index 249de927..a7ca4b2a 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -6,6 +6,15 @@ TDOA Time Difference of Arrival (TDOA) is a technique that localizes an emitter from differences in signal arrival time across synchronized sensors, without needing the transmitter's clock. This chapter covers the full TDOA pipeline, geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA can be used in RF, acoustic, and sonar geolocation. +Try the interactive demo below to get a quick feel for how TDOA works. + +.. raw:: html + +
+ + ************ Introduction ************ From e034c69ce4c5df02a9e68f9aba9f00bf790196f5 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 01:47:43 -0400 Subject: [PATCH 08/27] asd --- _static/js/tdoa.js | 136 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 124 insertions(+), 12 deletions(-) diff --git a/_static/js/tdoa.js b/_static/js/tdoa.js index c5f81eea..12abff3d 100644 --- a/_static/js/tdoa.js +++ b/_static/js/tdoa.js @@ -6,7 +6,15 @@ function tdoa_app(containerId) { const worldSpan = 1000; // world width represented across the canvas [m] const nodeRadius = 9; // hit/draw radius for draggable handles [px] const edgeMargin = 12; // keep handles at least this far inside the canvas [px] - const pairColors = ["#e6550d", "#3182bd", "#31a354"]; // one per sensor pair + const maxSensors = 10; // upper bound on how many sensors the user can add + const palette = ["#e6550d", "#3182bd", "#31a354"]; // first few sensor-pair colors + + // Color for the k-th sensor pair: use the fixed palette first, then spread the + // remaining hues around the color wheel so every pair stays distinguishable. + function pairColor(k) { + if (k < palette.length) return palette[k]; + return `hsl(${(k * 47) % 360}, 65%, 45%)`; + } // ----- DOM setup ----------------------------------------------------------- const container = document.getElementById(containerId || "tdoaApp") || document.body; @@ -18,13 +26,19 @@ function tdoa_app(containerId) { row.style.gap = "10px"; container.appendChild(row); + // wrapper lets us overlay controls (the Add-sensor button) on top of the canvas + const canvasWrap = document.createElement("div"); + canvasWrap.style.position = "relative"; + canvasWrap.style.lineHeight = "0"; // avoid extra space under the canvas + row.appendChild(canvasWrap); + const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; canvas.style.border = "1px solid #888"; canvas.style.touchAction = "none"; // let us handle touch-drag ourselves canvas.style.cursor = "grab"; - row.appendChild(canvas); + canvasWrap.appendChild(canvas); // vertical noise slider: standard deviation of the Gaussian noise [m] const sliderBox = document.createElement("div"); @@ -66,11 +80,61 @@ function tdoa_app(containerId) { render(); }); + // buttons to add/remove a sensor, overlaid on the top-left corner of the canvas + const addBtn = document.createElement("button"); + addBtn.style.position = "absolute"; + addBtn.style.top = "8px"; + addBtn.style.left = "8px"; + addBtn.style.fontFamily = "sans-serif"; + addBtn.style.fontSize = "13px"; + addBtn.style.lineHeight = "normal"; + addBtn.style.padding = "4px 10px"; + addBtn.style.cursor = "pointer"; + canvasWrap.appendChild(addBtn); + + const removeBtn = document.createElement("button"); + removeBtn.style.position = "absolute"; + removeBtn.style.top = "40px"; + removeBtn.style.left = "8px"; + removeBtn.style.fontFamily = "sans-serif"; + removeBtn.style.fontSize = "13px"; + removeBtn.style.lineHeight = "normal"; + removeBtn.style.padding = "4px 10px"; + removeBtn.style.cursor = "pointer"; + canvasWrap.appendChild(removeBtn); + + // collapsible details panel: a thick bar you click to reveal the text readout, + // collapsed by default to keep the figure compact + const details = document.createElement("details"); + details.style.marginTop = "8px"; + details.style.width = W + "px"; + details.style.border = "1px solid #ccc"; + details.style.borderRadius = "4px"; + details.style.overflow = "hidden"; + container.appendChild(details); + + const summary = document.createElement("summary"); + summary.textContent = "Show Debug Info"; + summary.style.cursor = "pointer"; + summary.style.userSelect = "none"; + summary.style.fontFamily = "sans-serif"; + summary.style.fontSize = "13px"; + summary.style.fontWeight = "bold"; + summary.style.padding = "10px 12px"; + summary.style.background = "#f0f0f0"; + summary.style.color = "#333"; + details.appendChild(summary); + + // swap the label between collapsed/expanded states + details.addEventListener("toggle", () => { + summary.textContent = details.open ? "Hide Debug Info" : "Show Debug Info"; + }); + const readout = document.createElement("div"); readout.style.fontFamily = "monospace"; readout.style.fontSize = "13px"; - readout.style.marginTop = "6px"; - container.appendChild(readout); + readout.style.padding = "8px 12px"; + details.appendChild(readout); const ctx = canvas.getContext("2d"); @@ -106,15 +170,19 @@ function tdoa_app(containerId) { return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } + // Every unique sensor pair (i < j); order matches the original [0,1],[0,2],[1,2]. + function sensorPairs() { + const pairs = []; + for (let i = 0; i < sensors.length; i++) { + for (let j = i + 1; j < sensors.length; j++) pairs.push([i, j]); + } + return pairs; + } + // Returns the per-sensor TOA and, for each pair, the TDOA and range diff. function simulate() { const toa = sensors.map((s) => dist(emitter, s) / c); // seconds - const pairs = [ - [0, 1], - [0, 2], - [1, 2] - ]; - const measurements = pairs.map(([i, j]) => { + const measurements = sensorPairs().map(([i, j]) => { // ideal TDOA, then add Gaussian noise (slider sets its std dev in meters, // so we convert that range error into the equivalent time error) const tdoa = toa[i] - toa[j] + (noiseStd * randn()) / c; // seconds @@ -234,7 +302,7 @@ function tdoa_app(containerId) { drawGrid(); measurements.forEach((m, k) => { - drawHyperbola(sensors[m.i], sensors[m.j], m.dr, pairColors[k]); + drawHyperbola(sensors[m.i], sensors[m.j], m.dr, pairColor(k)); }); sensors.forEach(drawSensor); drawEmitter(); @@ -245,7 +313,7 @@ function tdoa_app(containerId) { html += `TOA(${sensors[i].label}) = ${(t * 1e9).toFixed(1)} ns   (range ${dist(emitter, sensors[i]).toFixed(0)} m)
`; }); measurements.forEach((m, k) => { - html += `TDOA(${sensors[m.i].label},${sensors[m.j].label}) = ${(m.tdoa * 1e9).toFixed(1)} ns   Δr = ${m.dr.toFixed(0)} m
`; + html += `TDOA(${sensors[m.i].label},${sensors[m.j].label}) = ${(m.tdoa * 1e9).toFixed(1)} ns   Δr = ${m.dr.toFixed(0)} m
`; }); readout.innerHTML = html; } @@ -305,6 +373,50 @@ function tdoa_app(containerId) { canvas.addEventListener("touchmove", onMove, { passive: false }); canvas.addEventListener("touchend", onUp); + // ----- adding / removing sensors ------------------------------------------- + const minSensors = 2; // at least 2 sensors to form one TDOA pair + + function updateSensorButtons() { + const atMax = sensors.length >= maxSensors; + addBtn.disabled = atMax; + addBtn.textContent = atMax + ? `Max sensors reached (${maxSensors})` + : `Add sensor`; + + const atMin = sensors.length <= minSensors; + removeBtn.disabled = atMin; + removeBtn.textContent = atMin + ? `Min sensors reached (${minSensors})` + : "Remove sensor"; + } + + function addSensor() { + if (sensors.length >= maxSensors) return; + const idx = sensors.length; + // place the new sensor on a ring, stepping by the golden angle so successive + // sensors spread out rather than landing on top of each other + const ang = idx * 2.399963229728653; + const r = 320; + sensors.push({ + x: r * Math.cos(ang), + y: r * Math.sin(ang), + label: "Sensor " + idx + }); + updateSensorButtons(); + render(); + } + + function removeSensor() { + if (sensors.length <= minSensors) return; + sensors.pop(); // drop the most recently added sensor + updateSensorButtons(); + render(); + } + + addBtn.addEventListener("click", addSensor); + removeBtn.addEventListener("click", removeSensor); + updateSensorButtons(); + // ----- go ------------------------------------------------------------------ render(); } From 94dc0836c8675201b32bb031fbe810bc4f6278af Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 02:10:44 -0400 Subject: [PATCH 09/27] heatmap --- _static/js/tdoa.js | 243 +++++++++++++++++++++++++++++++++++++++++++-- content/tdoa.rst | 2 +- 2 files changed, 236 insertions(+), 9 deletions(-) diff --git a/_static/js/tdoa.js b/_static/js/tdoa.js index 12abff3d..dcf15875 100644 --- a/_static/js/tdoa.js +++ b/_static/js/tdoa.js @@ -9,6 +9,12 @@ function tdoa_app(containerId) { const maxSensors = 10; // upper bound on how many sensors the user can add const palette = ["#e6550d", "#3182bd", "#31a354"]; // first few sensor-pair colors + // heatmap: give each hyperbola some width and add them together so their + // overlap lights up where the emitter actually is + let heatHalfWidth = 150; // band half-width around each hyperbola [m] + const heatRes = 2; // pixel block size of the heatmap grid (quality vs speed) + const heatMaxAlpha = 0.55; // overlay opacity where every band coincides + // Color for the k-th sensor pair: use the fixed palette first, then spread the // remaining hues around the color wheel so every pair stays distinguishable. function pairColor(k) { @@ -80,6 +86,87 @@ function tdoa_app(containerId) { render(); }); + // vertical dynamic-range slider: a gamma exponent applied to the heatmap + // intensity. Higher values darken the weak single bands and let only the + // strong overlaps near the emitter stand out; lower values flatten it out. + let heatGamma = 1.5; // exponent applied to the normalized heatmap intensity + const drBox = document.createElement("div"); + drBox.style.display = "flex"; + drBox.style.flexDirection = "column"; + drBox.style.alignItems = "center"; + drBox.style.fontFamily = "sans-serif"; + drBox.style.fontSize = "12px"; + row.appendChild(drBox); + + const drLabel = document.createElement("div"); + drLabel.style.textAlign = "center"; + drLabel.style.marginBottom = "6px"; + drBox.appendChild(drLabel); + + function updateDrLabel() { + drLabel.innerHTML = `Dyn.
range
${heatGamma.toFixed(1)}`; + } + updateDrLabel(); + + const drSlider = document.createElement("input"); + drSlider.type = "range"; + drSlider.min = "0.5"; + drSlider.max = "5"; + drSlider.step = "0.1"; + drSlider.value = String(heatGamma); + drSlider.setAttribute("orient", "vertical"); + drSlider.style.writingMode = "vertical-lr"; + drSlider.style.direction = "rtl"; // low at the bottom, high at the top + drSlider.style.height = H / 2 + "px"; + drSlider.style.width = "24px"; + drBox.appendChild(drSlider); + + drSlider.addEventListener("input", () => { + heatGamma = parseFloat(drSlider.value); + updateDrLabel(); + render(false); // visual-only: keep the existing noise samples + }); + + // vertical width slider: half-width of the band drawn around each hyperbola. + // Wider bands overlap more readily (good with lots of noise); narrow bands + // pin the emitter down tightly. + const widthBox = document.createElement("div"); + widthBox.style.display = "flex"; + widthBox.style.flexDirection = "column"; + widthBox.style.alignItems = "center"; + widthBox.style.fontFamily = "sans-serif"; + widthBox.style.fontSize = "12px"; + row.appendChild(widthBox); + + const widthLabel = document.createElement("div"); + widthLabel.style.textAlign = "center"; + widthLabel.style.marginBottom = "6px"; + widthBox.appendChild(widthLabel); + + function updateWidthLabel() { + widthLabel.innerHTML = `Width
${heatHalfWidth.toFixed(0)} m`; + } + updateWidthLabel(); + + const widthSlider = document.createElement("input"); + widthSlider.type = "range"; + widthSlider.min = "10"; + widthSlider.max = "200"; + widthSlider.step = "5"; + widthSlider.value = String(heatHalfWidth); + widthSlider.setAttribute("orient", "vertical"); + widthSlider.style.writingMode = "vertical-lr"; + widthSlider.style.direction = "rtl"; // narrow at the bottom, wide at the top + widthSlider.style.height = H / 2 + "px"; + widthSlider.style.width = "24px"; + widthBox.appendChild(widthSlider); + + widthSlider.addEventListener("input", () => { + heatHalfWidth = parseFloat(widthSlider.value); + updateWidthLabel(); + render(false); // visual-only: keep the existing noise samples + }); + // buttons to add/remove a sensor, overlaid on the top-left corner of the canvas const addBtn = document.createElement("button"); addBtn.style.position = "absolute"; @@ -103,6 +190,35 @@ function tdoa_app(containerId) { removeBtn.style.cursor = "pointer"; canvasWrap.appendChild(removeBtn); + // checkbox to toggle the heatmap overlay, overlaid below the buttons + let showHeatmap = true; // heatmap is on by default + const heatToggle = document.createElement("label"); + heatToggle.style.position = "absolute"; + heatToggle.style.top = "72px"; + heatToggle.style.left = "8px"; + heatToggle.style.display = "flex"; + heatToggle.style.alignItems = "center"; + heatToggle.style.gap = "4px"; + heatToggle.style.fontFamily = "sans-serif"; + heatToggle.style.fontSize = "13px"; + heatToggle.style.color = "#222"; + heatToggle.style.cursor = "pointer"; + heatToggle.style.userSelect = "none"; + + const heatCheckbox = document.createElement("input"); + heatCheckbox.type = "checkbox"; + heatCheckbox.checked = showHeatmap; + heatCheckbox.style.cursor = "pointer"; + heatCheckbox.style.margin = "0"; + heatToggle.appendChild(heatCheckbox); + heatToggle.appendChild(document.createTextNode("Heatmap")); + canvasWrap.appendChild(heatToggle); + + heatCheckbox.addEventListener("change", () => { + showHeatmap = heatCheckbox.checked; + render(false); // visual-only: keep the existing noise samples + }); + // collapsible details panel: a thick bar you click to reveal the text readout, // collapsed by default to keep the figure compact const details = document.createElement("details"); @@ -140,7 +256,7 @@ function tdoa_app(containerId) { // ----- scene state (world coordinates, meters, origin at center) ----------- // y points up in world coordinates (flipped when drawing to the canvas). - const emitter = { x: 0, y: 120, label: "Emitter" }; + const emitter = { x: -155, y: 226, label: "Emitter" }; const sensors = [ { x: -350, y: -200, label: "Sensor 0" }, { x: 350, y: -200, label: "Sensor 1" }, @@ -231,6 +347,71 @@ function tdoa_app(containerId) { ctx.stroke(); } + // ----- heatmap ------------------------------------------------------------- + // Reuse the same hyperbolas, but instead of an infinitely thin curve give + // each one a band of width 2*heatHalfWidth whose intensity follows a raised + // sine (raised cosine): 1 right on the hyperbola, tapering to 0 at the band + // edges. Summing every band makes their common crossing — the emitter — the + // brightest spot. We compute on a coarse grid and let drawImage smooth it. + const heatCanvas = document.createElement("canvas"); + const heatCtx = heatCanvas.getContext("2d"); + + // warm colormap: yellow at low intensity ramping to red at high intensity + function heatColor(t) { + return [255, Math.round(220 * (1 - t)), Math.round(40 * (1 - t))]; + } + + function drawHeatmap(measurements) { + if (measurements.length === 0) return; + const gw = Math.ceil(W / heatRes); + const gh = Math.ceil(H / heatRes); + heatCanvas.width = gw; + heatCanvas.height = gh; + const img = heatCtx.createImageData(gw, gh); + const data = img.data; + const nPairs = measurements.length; + + for (let gy = 0; gy < gh; gy++) { + for (let gx = 0; gx < gw; gx++) { + // world coordinates at the center of this grid block + const w = pxToWorld({ + x: gx * heatRes + heatRes / 2, + y: gy * heatRes + heatRes / 2 + }); + + let sum = 0; + for (let k = 0; k < nPairs; k++) { + const m = measurements[k]; + const si = sensors[m.i]; + const sj = sensors[m.j]; + const ri = Math.hypot(w.x - si.x, w.y - si.y); + const rj = Math.hypot(w.x - sj.x, w.y - sj.y); + // how far this point's range difference is from the measured one; + // 0 means the point sits exactly on pair k's hyperbola + const residual = ri - rj - m.dr; + if (Math.abs(residual) < heatHalfWidth) { + sum += 0.5 * (1 + Math.cos((Math.PI * residual) / heatHalfWidth)); + } + } + + // normalized intensity (1 where all bands overlap, the emitter), then + // a gamma to set the dynamic range: >1 suppresses the weak bands + const norm = Math.pow(sum / nPairs, heatGamma); + const idx = (gy * gw + gx) * 4; + const rgb = heatColor(norm); + data[idx] = rgb[0]; + data[idx + 1] = rgb[1]; + data[idx + 2] = rgb[2]; + data[idx + 3] = Math.round(norm * heatMaxAlpha * 255); + } + } + heatCtx.putImageData(img, 0, 0); + + // scale the coarse buffer up to full size; bilinear smoothing hides the grid + ctx.imageSmoothingEnabled = true; + ctx.drawImage(heatCanvas, 0, 0, gw, gh, 0, 0, W, H); + } + // ----- rendering ----------------------------------------------------------- function drawGrid() { ctx.clearRect(0, 0, W, H); @@ -283,30 +464,61 @@ function tdoa_app(containerId) { ctx.fillText(s.label, p.x + nodeRadius + 2, p.y - 2); } + // small label near a node showing its world coordinates in meters + function drawCoordTooltip(node) { + const p = worldToPx(node); + const text = `(${node.x.toFixed(0)}, ${node.y.toFixed(0)}) m`; + ctx.font = "12px monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + const padX = 5; + const padY = 3; + const tw = ctx.measureText(text).width; + const boxW = tw + padX * 2; + const boxH = 16 + padY * 2; + // sit the box just below the node, nudged on-screen if it would clip + let bx = p.x + nodeRadius + 2; + let by = p.y + nodeRadius + 2; + if (bx + boxW > W) bx = W - boxW; + if (by + boxH > H) by = p.y - nodeRadius - boxH - 2; + ctx.fillStyle = "rgba(0, 0, 0, 0.8)"; + ctx.fillRect(bx, by, boxW, boxH); + ctx.fillStyle = "#fff"; + ctx.fillText(text, bx + padX, by + boxH - padY - 3); + } + function drawEmitter() { const p = worldToPx(emitter); - ctx.fillStyle = "#d62728"; + // black outline, no fill, so the heatmap underneath stays visible ctx.beginPath(); ctx.arc(p.x, p.y, nodeRadius, 0, 2 * Math.PI); - ctx.fill(); - ctx.strokeStyle = "#fff"; + ctx.strokeStyle = "#000"; ctx.lineWidth = 2; ctx.stroke(); - ctx.fillStyle = "#d62728"; + ctx.fillStyle = "#000"; ctx.font = "bold 16px sans-serif"; ctx.fillText(emitter.label, p.x + nodeRadius + 2, p.y - 2); } - function render() { - const { toa, measurements } = simulate(); + // cache the last simulation so purely-visual changes (heatmap width, dynamic + // range, hover) can repaint without drawing fresh noise samples + let lastSim = null; + function render(resimulate = true) { + if (resimulate || !lastSim) lastSim = simulate(); + const { toa, measurements } = lastSim; drawGrid(); + if (showHeatmap) drawHeatmap(measurements); measurements.forEach((m, k) => { drawHyperbola(sensors[m.i], sensors[m.j], m.dr, pairColor(k)); }); sensors.forEach(drawSensor); drawEmitter(); + // coordinate tooltip for the node under the cursor (or being dragged) + const activeNode = dragTarget || hoverNode; + if (activeNode) drawCoordTooltip(activeNode); + // text readout of the simulated quantities let html = ""; toa.forEach((t, i) => { @@ -320,6 +532,7 @@ function tdoa_app(containerId) { // ----- dragging ------------------------------------------------------------ let dragTarget = null; + let hoverNode = null; // node currently under the cursor (for the tooltip) function eventPx(e) { const rect = canvas.getBoundingClientRect(); @@ -347,7 +560,14 @@ function tdoa_app(containerId) { function onMove(e) { const px = eventPx(e); if (!dragTarget) { - canvas.style.cursor = pickNode(px) ? "grab" : "default"; + const hit = pickNode(px); + canvas.style.cursor = hit ? "grab" : "default"; + // only repaint when the hovered node actually changes, so the tooltip + // appears/disappears without re-noising the scene on every mouse move + if (hit !== hoverNode) { + hoverNode = hit; + render(false); // visual-only: keep the existing noise samples + } return; } // keep the handle inside the canvas (with a small margin) so it can't be @@ -366,6 +586,13 @@ function tdoa_app(containerId) { canvas.style.cursor = "grab"; } + canvas.addEventListener("mouseleave", () => { + if (hoverNode) { + hoverNode = null; + render(false); // visual-only: keep the existing noise samples + } + }); + canvas.addEventListener("mousedown", onDown); window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); diff --git a/content/tdoa.rst b/content/tdoa.rst index a7ca4b2a..f90e562b 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -10,7 +10,7 @@ Try the interactive demo below to get a quick feel for how TDOA works. .. raw:: html -
+
From b53e13835b3c744507d33d190714cdcc1ea989df Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Tue, 23 Jun 2026 20:49:23 -0400 Subject: [PATCH 10/27] tweaks --- content/tdoa.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index f90e562b..622a457a 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -6,11 +6,11 @@ TDOA Time Difference of Arrival (TDOA) is a technique that localizes an emitter from differences in signal arrival time across synchronized sensors, without needing the transmitter's clock. This chapter covers the full TDOA pipeline, geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA can be used in RF, acoustic, and sonar geolocation. -Try the interactive demo below to get a quick feel for how TDOA works. +Try the interactive demo below to get a quick feel for how TDOA works, it involves intersection of hyperbolas. .. raw:: html -
+
From cb31ead302ea165753a6b3729bf0526bf3320763 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 24 Jun 2026 21:59:01 -0400 Subject: [PATCH 11/27] mention how to calc the number of pairs --- content/tdoa.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/content/tdoa.rst b/content/tdoa.rst index 622a457a..5db22fa1 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -35,6 +35,14 @@ The key insight is that when the same wavefront hits two sensors, the difference the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on source and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters: we never need to know when the source transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. +Each pair of sensors yields one TDOA, and each TDOA traces out one hyperbola, so the number of hyperbolas we can draw is just the number of sensor pairs. With :math:`N` sensors that is + +.. math:: + + \binom{N}{2} = \frac{N(N-1)}{2}, + +i.e. 3 sensors give 3 hyperbolas, 4 give 6, 5 give 10, and so on. Not all of these are independent — as we will see below, only :math:`N-1` carry new geometric information — but the full set is still useful for averaging down noise. + The price we pay is that the receivers must share a precise common time reference — a requirement that, as discussed later, is itself a demanding engineering problem because a timing error of one nanosecond corresponds to about 0.3 m of range error. ************************* From fe1d213223333909cec2bce5293867e62eaf0edd Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 24 Jun 2026 23:17:43 -0400 Subject: [PATCH 12/27] before removing solver --- figure-generating-scripts/tdoa.py | 247 ++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 figure-generating-scripts/tdoa.py diff --git a/figure-generating-scripts/tdoa.py b/figure-generating-scripts/tdoa.py new file mode 100644 index 00000000..a54c5584 --- /dev/null +++ b/figure-generating-scripts/tdoa.py @@ -0,0 +1,247 @@ +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from itertools import combinations +from scipy.signal import firwin, lfilter + +np.random.seed(0) + +sample_rate = 50e6 +c = 3e8 # speed of light [m/s] +rx_positions = np.array([ + [0.0, 0.0], # Rx0 + [600.0, 100.0], # Rx1 + [150.0, 500.0], # Rx2 +]) +num_rx = rx_positions.shape[0] +tx_position = np.array([150.0, 350.0]) +pairs = list(combinations(range(num_rx), 2)) # For 3 receivers it's (Rx0,Rx1), (Rx0,Rx2), (Rx1,Rx2) -> 3 pairs + +# For the tx signal itself it's arbitrary, although bandwidth matters, we'll transmit band-limited noise +N = 10000 # samples to transmit +bandwidth = 20e6 +taps = firwin(numtaps=129, cutoff=bandwidth / 2, fs=sample_rate) +tx_signal = lfilter(taps, 1.0, np.random.randn(N) + 1j * np.random.randn(N)) + +# Simulate what each receiver records +true_distances = np.linalg.norm(rx_positions - tx_position, axis=1) +true_delays = true_distances / c +unknown_tx_time = 1.234e-5 # seconds. arbitray, unknown to receivers and we wont use it in any TDOA calcs + +# Figure out how many samples we have to simulate +total_delay_samples = (unknown_tx_time + true_delays.max()) * sample_rate +buffer_len = N + int(np.ceil(total_delay_samples)) + 10 + +# Taken from Synchronization chapter +def frac_delay_filter(delay): # delay is in samples, but it can (and will be) not an integer + N = 21 # number of taps, keep this odd + n = np.arange(-(N-1)//2, N//2+1) # -10,-9,...,0,...,9,10 + h = np.sinc(n - delay) # calc filter taps + h *= np.hamming(N) # window the filter to make sure it decays to 0 on both sides + h /= np.sum(h) # normalize to get unity gain, we don't want to change the amplitude/power + return h + +# Simulate the delayed signal being received by each sensor +rx_signals = np.zeros((num_rx, buffer_len), dtype=complex) +for i in range(num_rx): + tau = unknown_tx_time + true_delays[i] # absolute delay at this Rx, in seconds + tau_samples = tau * sample_rate + tau_integer_samps = int(np.round(tau_samples)) + tau_frac_samps = tau_samples - tau_integer_samps + rx = np.zeros(buffer_len, dtype=complex) + rx[tau_integer_samps:tau_integer_samps+N] = tx_signal + frac_delay_i = frac_delay_filter(tau_frac_samps) + rx = np.convolve(rx, frac_delay_i, "same") + + # Each receiver adds its own thermal noise + noise_power = 0.05 + noise = np.sqrt(noise_power / 2) * (np.random.randn(buffer_len) + 1j * np.random.randn(buffer_len)) + rx_signals[i] = rx + noise + +# ============================================================================= +# METHOD 1: integer-only TDOA via a plain time-domain cross-correlation +# ============================================================================= +# 4. Estimate the time differences. np.correlate just slides Rx_b past Rx_a and +# reports how well they overlap at every integer shift -- about as simple as DSP +# gets. Two consequences: it resolves the lag only to the nearest WHOLE sample +# (6 m of range at 50 MHz), and being a direct O(N^2) correlation it is slow, +# which is exactly why N is modest and why Method 2 later switches to the FFT. +range_diff_int = np.zeros(len(pairs)) +for k, (a, b) in enumerate(pairs): + xcorr = np.correlate(rx_signals[b], rx_signals[a], mode='full') + # 'full' puts zero lag at index buffer_len-1; subtract it to get the lag. + peak_lag = np.argmax(np.abs(xcorr)) - (buffer_len - 1) # +ve => Rx_b farther + range_diff_int[k] = (peak_lag / sample_rate) * c + +print("METHOD 1 (integer-only, time domain)") +print(" Pair | true range diff [m] | measured range diff [m]") +for k, (a, b) in enumerate(pairs): + true_rd = true_distances[b] - true_distances[a] + print(f"Rx{b}-Rx{a} | {true_rd:9.1f} | {range_diff_int[k]:9.1f}") + +# 5. Solve for the transmitter with a grid search. Each measurement says: "for +# the true Tx location, distance to Rx_b minus distance to Rx_a should equal +# range_diff[k]." Rather than solving the hyperbola equations algebraically, we +# brute-force it: score every candidate cell by how well it matches all the +# measured range differences, and take the best. This naturally shows the cost +# surface and is dead simple to read. +# Precompute the distance from each receiver to every grid point, since the solver and the hyperbola overlays both need it. +grid_x = np.linspace(-200, 800, 400) +grid_y = np.linspace(-200, 800, 400) +GX, GY = np.meshgrid(grid_x, grid_y) +rx_dist = [] +for i in range(num_rx): + rx_dist.append(np.sqrt((GX - rx_positions[i, 0])**2 + (GY - rx_positions[i, 1])**2)) +error_surface_int = np.zeros_like(GX) +for k, (a, b) in enumerate(pairs): + predicted = rx_dist[b] - rx_dist[a] + error_surface_int += (predicted - range_diff_int[k])**2 +best = np.unravel_index(np.argmin(error_surface_int), error_surface_int.shape) +est_int = np.array([GX[best], GY[best]]) +print(f"True Tx: ({tx_position[0]:.0f}, {tx_position[1]:.0f}) " + f"Estimate: ({est_int[0]:.0f}, {est_int[1]:.0f}) " + f"Error: {np.linalg.norm(est_int - tx_position):.1f} m\n") + +# 6. FIGURE 1: the integer-only result. +fig1, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) +fig1.suptitle('Method 1: integer-only TDOA (time-domain cross-correlation)') + +# Left: cost surface + hyperbolas. The hyperbolas all cross at the transmitter, +# and the cost surface is darkest there. +im = ax1.pcolormesh(GX, GY, np.log10(error_surface_int + 1), shading='auto', cmap='viridis') +fig1.colorbar(im, ax=ax1, label='log10 of total squared range-difference error') +hyperbola_handles = [] +pair_colors = ['white', 'orange', 'magenta'] +for k, (a, b) in enumerate(pairs): + ax1.contour(GX, GY, (rx_dist[b] - rx_dist[a]) - range_diff_int[k], levels=[0], + colors=pair_colors[k], linewidths=1.5, linestyles='--') + hyperbola_handles.append(Line2D([0], [0], color=pair_colors[k], + linestyle='--', label=f'Rx{a}-Rx{b}')) +ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='cyan', marker='^', + s=120, edgecolors='k', label='Receivers', zorder=5) +for i in range(num_rx): + ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', + xytext=(8, 8), color='white', fontweight='bold', zorder=6) +ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', + label='True Tx', zorder=5) +ax1.scatter(*est_int, c='lime', marker='x', s=150, linewidths=3, + label='Estimated Tx', zorder=5) +ax1.set_xlabel('x [m]'); ax1.set_ylabel('y [m]') +ax1.set_title('TDOA cost surface and hyperbolas') +ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') +ax1.set_aspect('equal') + +# Right: the raw time-domain cross-correlation of one pair, with the integer +# (whole-sample) peak we picked. The peak sits on a sample; we can't do better. +a, b = pairs[1] # the (Rx0, Rx2) pair +xcorr = np.abs(np.correlate(rx_signals[b], rx_signals[a], mode='full')) +lags = np.arange(xcorr.size) - (buffer_len - 1) +peak = lags[np.argmax(xcorr)] +ax2.plot(lags, xcorr, 'o-', markersize=5, label='correlation samples') +ax2.axvline(peak, color='red', linestyle='--', label=f'integer peak = {peak} samples') +ax2.set_xlim(peak - 6, peak + 6) +ax2.set_xlabel('lag [samples]'); ax2.set_ylabel('|cross-correlation|') +ax2.set_title(f'Cross-correlation of Rx{b} vs Rx{a}') +ax2.legend() +fig1.tight_layout() + +# ============================================================================= +# METHOD 2: sub-sample TDOA via a zero-padded frequency-domain correlation +# ============================================================================= +# 7. Same idea, but in the frequency domain. The cross-correlation is the IFFT +# of the cross-power spectrum conj(A)*B, which for large signals is far cheaper +# than the direct correlation above. And we get sub-sample resolution almost for +# free: ZERO-PAD the spectrum before the inverse FFT. Padding a spectrum is +# exact sinc interpolation in the time domain, so the IFFT lands on a grid U +# times finer, and we just take its argmax. +U = 16 # correlation upsampling factor +L = buffer_len +half = (L + 1) // 2 # number of DC + positive-frequency bins +range_diff = np.zeros(len(pairs)) +for k, (a, b) in enumerate(pairs): + X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) + # Insert zeros in the high-frequency MIDDLE: DC + positive freqs at the + # front, negative freqs at the back, so it stays a valid FFT layout. + X_padded = np.zeros(U * L, dtype=complex) + X_padded[:half] = X[:half] + X_padded[U * L - (L - half):] = X[half:] + cc = np.abs(np.fft.ifft(X_padded)) * U + # Peak index -> signed lag; indices past the midpoint are negative lags. + peak_idx = np.argmax(cc) + if peak_idx > U * L // 2: + peak_idx -= U * L + peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther + range_diff[k] = (peak_lag / sample_rate) * c + +print("METHOD 2 (sub-sample, zero-padded FFT)") +print(" Pair | true range diff [m] | measured range diff [m]") +for k, (a, b) in enumerate(pairs): + true_rd = true_distances[b] - true_distances[a] + print(f"Rx{b}-Rx{a} | {true_rd:9.1f} | {range_diff[k]:9.1f}") + +# 8. Solve again, identical grid search but with the refined range differences. +error_surface = np.zeros_like(GX) +for k, (a, b) in enumerate(pairs): + predicted = rx_dist[b] - rx_dist[a] + error_surface += (predicted - range_diff[k])**2 +best = np.unravel_index(np.argmin(error_surface), error_surface.shape) +est_position = np.array([GX[best], GY[best]]) +print(f"True Tx: ({tx_position[0]:.0f}, {tx_position[1]:.0f}) " + f"Estimate: ({est_position[0]:.0f}, {est_position[1]:.0f}) " + f"Error: {np.linalg.norm(est_position - tx_position):.1f} m") + +# 9. FIGURE 2: the sub-sample result, same layout as Figure 1. +fig2, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) +fig2.suptitle('Method 2: sub-sample TDOA (zero-padded FFT cross-correlation)') + +im = ax1.pcolormesh(GX, GY, np.log10(error_surface + 1), shading='auto', cmap='viridis') +fig2.colorbar(im, ax=ax1, label='log10 of total squared range-difference error') +hyperbola_handles = [] +for k, (a, b) in enumerate(pairs): + ax1.contour(GX, GY, (rx_dist[b] - rx_dist[a]) - range_diff[k], levels=[0], + colors=pair_colors[k], linewidths=1.5, linestyles='--') + hyperbola_handles.append(Line2D([0], [0], color=pair_colors[k], + linestyle='--', label=f'Rx{a}-Rx{b}')) +ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='cyan', marker='^', + s=120, edgecolors='k', label='Receivers', zorder=5) +for i in range(num_rx): + ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', + xytext=(8, 8), color='white', fontweight='bold', zorder=6) +ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', + label='True Tx', zorder=5) +ax1.scatter(*est_position, c='lime', marker='x', s=150, linewidths=3, + label='Estimated Tx', zorder=5) +ax1.set_xlabel('x [m]'); ax1.set_ylabel('y [m]') +ax1.set_title('TDOA cost surface and hyperbolas') +ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') +ax1.set_aspect('equal') + +# Right: coarse (1 sample/lag) correlation as dots vs the U-times upsampled +# correlation as a smooth curve, so the sub-sample shift is visible. +a, b = pairs[1] # the (Rx0, Rx2) pair +X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) +cc_coarse = np.abs(np.fft.ifft(X)) +X_padded = np.zeros(U * L, dtype=complex) +X_padded[:half] = X[:half] +X_padded[U * L - (L - half):] = X[half:] +cc_fine = np.abs(np.fft.ifft(X_padded)) * U +lags_coarse = np.where(np.arange(L) <= L // 2, np.arange(L), np.arange(L) - L) +lags_fine = np.arange(U * L) / U +lags_fine = np.where(lags_fine <= L / 2, lags_fine, lags_fine - L) +peak = lags_coarse[np.argmax(cc_coarse)] +subsample_peak = lags_fine[np.argmax(cc_fine)] +# The lag axes wrap from + back to - partway through, so sort before plotting, +# otherwise the connecting line jumps across the figure. +order_c = np.argsort(lags_coarse) +order_f = np.argsort(lags_fine) +ax2.plot(lags_coarse[order_c], cc_coarse[order_c], 'o', markersize=6, label='coarse (1 sample/lag)') +ax2.plot(lags_fine[order_f], cc_fine[order_f], '-', label=f'{U}x zero-padded') +ax2.axvline(subsample_peak, color='red', linestyle='--', + label=f'sub-sample peak = {subsample_peak:.3f}') +ax2.set_xlim(peak - 6, peak + 6) +ax2.set_xlabel('lag [samples]'); ax2.set_ylabel('|cross-correlation|') +ax2.set_title(f'Cross-correlation of Rx{b} vs Rx{a}') +ax2.legend() +fig2.tight_layout() + +plt.show() From df94ac766e3fd16f5d1db28f0183c2742e9c61c1 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 24 Jun 2026 23:36:39 -0400 Subject: [PATCH 13/27] asd --- figure-generating-scripts/tdoa.py | 125 ++++++++++-------------------- 1 file changed, 41 insertions(+), 84 deletions(-) diff --git a/figure-generating-scripts/tdoa.py b/figure-generating-scripts/tdoa.py index a54c5584..01d1042b 100644 --- a/figure-generating-scripts/tdoa.py +++ b/figure-generating-scripts/tdoa.py @@ -8,9 +8,11 @@ sample_rate = 50e6 c = 3e8 # speed of light [m/s] +snr_db = 10 # SNR of the received signal at each receiver [dB] +tx_len_samples = 1000 # samples to transmit rx_positions = np.array([ [0.0, 0.0], # Rx0 - [600.0, 100.0], # Rx1 + [600.0, 100.0], # Rx1 [150.0, 500.0], # Rx2 ]) num_rx = rx_positions.shape[0] @@ -18,19 +20,22 @@ pairs = list(combinations(range(num_rx), 2)) # For 3 receivers it's (Rx0,Rx1), (Rx0,Rx2), (Rx1,Rx2) -> 3 pairs # For the tx signal itself it's arbitrary, although bandwidth matters, we'll transmit band-limited noise -N = 10000 # samples to transmit bandwidth = 20e6 taps = firwin(numtaps=129, cutoff=bandwidth / 2, fs=sample_rate) -tx_signal = lfilter(taps, 1.0, np.random.randn(N) + 1j * np.random.randn(N)) +tx_signal = lfilter(taps, 1.0, np.random.randn(tx_len_samples) + 1j * np.random.randn(tx_len_samples)) # Simulate what each receiver records true_distances = np.linalg.norm(rx_positions - tx_position, axis=1) true_delays = true_distances / c unknown_tx_time = 1.234e-5 # seconds. arbitray, unknown to receivers and we wont use it in any TDOA calcs +# Calc the actual TDOAs to act as ground truth +for k, (a, b) in enumerate(pairs): + true_rd = true_distances[b] - true_distances[a] + # Figure out how many samples we have to simulate total_delay_samples = (unknown_tx_time + true_delays.max()) * sample_rate -buffer_len = N + int(np.ceil(total_delay_samples)) + 10 +buffer_len = tx_len_samples + int(np.ceil(total_delay_samples)) + 10 # Taken from Synchronization chapter def frac_delay_filter(delay): # delay is in samples, but it can (and will be) not an integer @@ -49,91 +54,54 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no tau_integer_samps = int(np.round(tau_samples)) tau_frac_samps = tau_samples - tau_integer_samps rx = np.zeros(buffer_len, dtype=complex) - rx[tau_integer_samps:tau_integer_samps+N] = tx_signal + rx[tau_integer_samps:tau_integer_samps+tx_len_samples] = tx_signal frac_delay_i = frac_delay_filter(tau_frac_samps) rx = np.convolve(rx, frac_delay_i, "same") - # Each receiver adds its own thermal noise - noise_power = 0.05 + # Each receiver adds its own thermal noise, scaled to hit the SNR set at the top + signal_power = np.mean(np.abs(tx_signal)**2) + noise_power = signal_power / 10**(snr_db / 10) noise = np.sqrt(noise_power / 2) * (np.random.randn(buffer_len) + 1j * np.random.randn(buffer_len)) rx_signals[i] = rx + noise -# ============================================================================= -# METHOD 1: integer-only TDOA via a plain time-domain cross-correlation -# ============================================================================= -# 4. Estimate the time differences. np.correlate just slides Rx_b past Rx_a and -# reports how well they overlap at every integer shift -- about as simple as DSP -# gets. Two consequences: it resolves the lag only to the nearest WHOLE sample -# (6 m of range at 50 MHz), and being a direct O(N^2) correlation it is slow, -# which is exactly why N is modest and why Method 2 later switches to the FFT. -range_diff_int = np.zeros(len(pairs)) +# Estimate the TDOAs using a normal cross-correlation +range_diff = np.zeros(len(pairs)) # meters for k, (a, b) in enumerate(pairs): - xcorr = np.correlate(rx_signals[b], rx_signals[a], mode='full') - # 'full' puts zero lag at index buffer_len-1; subtract it to get the lag. - peak_lag = np.argmax(np.abs(xcorr)) - (buffer_len - 1) # +ve => Rx_b farther - range_diff_int[k] = (peak_lag / sample_rate) * c + xcorr = np.correlate(rx_signals[b], rx_signals[a], mode='full') + peak_lag = np.argmax(np.abs(xcorr)) - (buffer_len - 1) # 'full' puts zero lag at index buffer_len-1 + range_diff[k] = (peak_lag / sample_rate) * c # meters -print("METHOD 1 (integer-only, time domain)") -print(" Pair | true range diff [m] | measured range diff [m]") -for k, (a, b) in enumerate(pairs): - true_rd = true_distances[b] - true_distances[a] - print(f"Rx{b}-Rx{a} | {true_rd:9.1f} | {range_diff_int[k]:9.1f}") - -# 5. Solve for the transmitter with a grid search. Each measurement says: "for -# the true Tx location, distance to Rx_b minus distance to Rx_a should equal -# range_diff[k]." Rather than solving the hyperbola equations algebraically, we -# brute-force it: score every candidate cell by how well it matches all the -# measured range differences, and take the best. This naturally shows the cost -# surface and is dead simple to read. -# Precompute the distance from each receiver to every grid point, since the solver and the hyperbola overlays both need it. +# Precompute the distance from each receiver to every grid point, this will get used later grid_x = np.linspace(-200, 800, 400) grid_y = np.linspace(-200, 800, 400) GX, GY = np.meshgrid(grid_x, grid_y) rx_dist = [] for i in range(num_rx): rx_dist.append(np.sqrt((GX - rx_positions[i, 0])**2 + (GY - rx_positions[i, 1])**2)) -error_surface_int = np.zeros_like(GX) -for k, (a, b) in enumerate(pairs): - predicted = rx_dist[b] - rx_dist[a] - error_surface_int += (predicted - range_diff_int[k])**2 -best = np.unravel_index(np.argmin(error_surface_int), error_surface_int.shape) -est_int = np.array([GX[best], GY[best]]) -print(f"True Tx: ({tx_position[0]:.0f}, {tx_position[1]:.0f}) " - f"Estimate: ({est_int[0]:.0f}, {est_int[1]:.0f}) " - f"Error: {np.linalg.norm(est_int - tx_position):.1f} m\n") # 6. FIGURE 1: the integer-only result. fig1, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) fig1.suptitle('Method 1: integer-only TDOA (time-domain cross-correlation)') -# Left: cost surface + hyperbolas. The hyperbolas all cross at the transmitter, -# and the cost surface is darkest there. -im = ax1.pcolormesh(GX, GY, np.log10(error_surface_int + 1), shading='auto', cmap='viridis') -fig1.colorbar(im, ax=ax1, label='log10 of total squared range-difference error') +# Left: the hyperbolas, which all cross at the transmitter. hyperbola_handles = [] -pair_colors = ['white', 'orange', 'magenta'] +pair_colors = ['tab:blue', 'tab:orange', 'tab:green'] for k, (a, b) in enumerate(pairs): - ax1.contour(GX, GY, (rx_dist[b] - rx_dist[a]) - range_diff_int[k], levels=[0], - colors=pair_colors[k], linewidths=1.5, linestyles='--') - hyperbola_handles.append(Line2D([0], [0], color=pair_colors[k], - linestyle='--', label=f'Rx{a}-Rx{b}')) -ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='cyan', marker='^', - s=120, edgecolors='k', label='Receivers', zorder=5) + # the next line is what calculates the hyperbola, note levels=[0] means we're making a contour map but only one level, specifically the level where the difference is zero + ax1.contour(GX, GY, (rx_dist[b] - rx_dist[a]) - range_diff[k], levels=[0], colors=pair_colors[k], linestyles='--') + hyperbola_handles.append(Line2D([0], [0], color=pair_colors[k], linestyle='--', label=f'Rx{a}-Rx{b}')) +ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='tab:blue', marker='^', s=120, edgecolors='k', label='Receivers', zorder=5) for i in range(num_rx): - ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', - xytext=(8, 8), color='white', fontweight='bold', zorder=6) -ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', - label='True Tx', zorder=5) -ax1.scatter(*est_int, c='lime', marker='x', s=150, linewidths=3, - label='Estimated Tx', zorder=5) + ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', xytext=(8, 8), fontweight='bold', zorder=6) +ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', label='True Tx', zorder=5) +ax1.set_xlim(grid_x[0], grid_x[-1]); ax1.set_ylim(grid_y[0], grid_y[-1]) ax1.set_xlabel('x [m]'); ax1.set_ylabel('y [m]') -ax1.set_title('TDOA cost surface and hyperbolas') +ax1.set_title('TDOA hyperbolas') ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') ax1.set_aspect('equal') -# Right: the raw time-domain cross-correlation of one pair, with the integer -# (whole-sample) peak we picked. The peak sits on a sample; we can't do better. -a, b = pairs[1] # the (Rx0, Rx2) pair +# Cross-correlation of one pair, integer only +a, b = pairs[1] # the (Rx0, Rx2) pair xcorr = np.abs(np.correlate(rx_signals[b], rx_signals[a], mode='full')) lags = np.arange(xcorr.size) - (buffer_len - 1) peak = lags[np.argmax(xcorr)] @@ -143,6 +111,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.set_xlabel('lag [samples]'); ax2.set_ylabel('|cross-correlation|') ax2.set_title(f'Cross-correlation of Rx{b} vs Rx{a}') ax2.legend() +ax2.grid() fig1.tight_layout() # ============================================================================= @@ -157,7 +126,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no U = 16 # correlation upsampling factor L = buffer_len half = (L + 1) // 2 # number of DC + positive-frequency bins -range_diff = np.zeros(len(pairs)) +range_diff = np.zeros(len(pairs)) # meters for k, (a, b) in enumerate(pairs): X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) # Insert zeros in the high-frequency MIDDLE: DC + positive freqs at the @@ -171,7 +140,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no if peak_idx > U * L // 2: peak_idx -= U * L peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther - range_diff[k] = (peak_lag / sample_rate) * c + range_diff[k] = (peak_lag / sample_rate) * c # meters print("METHOD 2 (sub-sample, zero-padded FFT)") print(" Pair | true range diff [m] | measured range diff [m]") @@ -179,40 +148,27 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no true_rd = true_distances[b] - true_distances[a] print(f"Rx{b}-Rx{a} | {true_rd:9.1f} | {range_diff[k]:9.1f}") -# 8. Solve again, identical grid search but with the refined range differences. -error_surface = np.zeros_like(GX) -for k, (a, b) in enumerate(pairs): - predicted = rx_dist[b] - rx_dist[a] - error_surface += (predicted - range_diff[k])**2 -best = np.unravel_index(np.argmin(error_surface), error_surface.shape) -est_position = np.array([GX[best], GY[best]]) -print(f"True Tx: ({tx_position[0]:.0f}, {tx_position[1]:.0f}) " - f"Estimate: ({est_position[0]:.0f}, {est_position[1]:.0f}) " - f"Error: {np.linalg.norm(est_position - tx_position):.1f} m") - -# 9. FIGURE 2: the sub-sample result, same layout as Figure 1. +# 8. FIGURE 2: the sub-sample result, same layout as Figure 1. fig2, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) fig2.suptitle('Method 2: sub-sample TDOA (zero-padded FFT cross-correlation)') -im = ax1.pcolormesh(GX, GY, np.log10(error_surface + 1), shading='auto', cmap='viridis') -fig2.colorbar(im, ax=ax1, label='log10 of total squared range-difference error') +# Left: the hyperbolas from the refined range differences. hyperbola_handles = [] for k, (a, b) in enumerate(pairs): ax1.contour(GX, GY, (rx_dist[b] - rx_dist[a]) - range_diff[k], levels=[0], colors=pair_colors[k], linewidths=1.5, linestyles='--') hyperbola_handles.append(Line2D([0], [0], color=pair_colors[k], linestyle='--', label=f'Rx{a}-Rx{b}')) -ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='cyan', marker='^', +ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='tab:blue', marker='^', s=120, edgecolors='k', label='Receivers', zorder=5) for i in range(num_rx): ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', - xytext=(8, 8), color='white', fontweight='bold', zorder=6) + xytext=(8, 8), fontweight='bold', zorder=6) ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', label='True Tx', zorder=5) -ax1.scatter(*est_position, c='lime', marker='x', s=150, linewidths=3, - label='Estimated Tx', zorder=5) +ax1.set_xlim(grid_x[0], grid_x[-1]); ax1.set_ylim(grid_y[0], grid_y[-1]) ax1.set_xlabel('x [m]'); ax1.set_ylabel('y [m]') -ax1.set_title('TDOA cost surface and hyperbolas') +ax1.set_title('TDOA hyperbolas') ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') ax1.set_aspect('equal') @@ -242,6 +198,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.set_xlabel('lag [samples]'); ax2.set_ylabel('|cross-correlation|') ax2.set_title(f'Cross-correlation of Rx{b} vs Rx{a}') ax2.legend() +ax2.grid() fig2.tight_layout() plt.show() From 21c30db72c331c7a07477b774e71f03a881376f2 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 24 Jun 2026 23:44:28 -0400 Subject: [PATCH 14/27] asd --- figure-generating-scripts/tdoa.py | 57 +++++++++++++------------------ 1 file changed, 23 insertions(+), 34 deletions(-) diff --git a/figure-generating-scripts/tdoa.py b/figure-generating-scripts/tdoa.py index 01d1042b..847cdd55 100644 --- a/figure-generating-scripts/tdoa.py +++ b/figure-generating-scripts/tdoa.py @@ -11,12 +11,12 @@ snr_db = 10 # SNR of the received signal at each receiver [dB] tx_len_samples = 1000 # samples to transmit rx_positions = np.array([ - [0.0, 0.0], # Rx0 - [600.0, 100.0], # Rx1 - [150.0, 500.0], # Rx2 + [65, 229], # Rx0 + [676, 123], # Rx1 + [153, 543], # Rx2 ]) num_rx = rx_positions.shape[0] -tx_position = np.array([150.0, 350.0]) +tx_position = np.array([153, 355]) pairs = list(combinations(range(num_rx), 2)) # For 3 receivers it's (Rx0,Rx1), (Rx0,Rx2), (Rx1,Rx2) -> 3 pairs # For the tx signal itself it's arbitrary, although bandwidth matters, we'll transmit band-limited noise @@ -71,18 +71,16 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no peak_lag = np.argmax(np.abs(xcorr)) - (buffer_len - 1) # 'full' puts zero lag at index buffer_len-1 range_diff[k] = (peak_lag / sample_rate) * c # meters -# Precompute the distance from each receiver to every grid point, this will get used later +# FIGURE 1: the integer-only result. +# Precompute the distance from each receiver to every grid point, this will get used in the contour plot grid_x = np.linspace(-200, 800, 400) grid_y = np.linspace(-200, 800, 400) GX, GY = np.meshgrid(grid_x, grid_y) rx_dist = [] for i in range(num_rx): rx_dist.append(np.sqrt((GX - rx_positions[i, 0])**2 + (GY - rx_positions[i, 1])**2)) - -# 6. FIGURE 1: the integer-only result. fig1, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) fig1.suptitle('Method 1: integer-only TDOA (time-domain cross-correlation)') - # Left: the hyperbolas, which all cross at the transmitter. hyperbola_handles = [] pair_colors = ['tab:blue', 'tab:orange', 'tab:green'] @@ -99,7 +97,6 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax1.set_title('TDOA hyperbolas') ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') ax1.set_aspect('equal') - # Cross-correlation of one pair, integer only a, b = pairs[1] # the (Rx0, Rx2) pair xcorr = np.abs(np.correlate(rx_signals[b], rx_signals[a], mode='full')) @@ -114,31 +111,23 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.grid() fig1.tight_layout() -# ============================================================================= -# METHOD 2: sub-sample TDOA via a zero-padded frequency-domain correlation -# ============================================================================= -# 7. Same idea, but in the frequency domain. The cross-correlation is the IFFT -# of the cross-power spectrum conj(A)*B, which for large signals is far cheaper -# than the direct correlation above. And we get sub-sample resolution almost for -# free: ZERO-PAD the spectrum before the inverse FFT. Padding a spectrum is -# exact sinc interpolation in the time domain, so the IFFT lands on a grid U -# times finer, and we just take its argmax. -U = 16 # correlation upsampling factor -L = buffer_len -half = (L + 1) // 2 # number of DC + positive-frequency bins +# Subsample TDOA calc using a freq domain cross-correlation that was padded as a way to interpolate +U = 16 # correlation upsampling factor +half = (buffer_len + 1) // 2 # number of DC + positive-frequency bins range_diff = np.zeros(len(pairs)) # meters for k, (a, b) in enumerate(pairs): X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) - # Insert zeros in the high-frequency MIDDLE: DC + positive freqs at the - # front, negative freqs at the back, so it stays a valid FFT layout. - X_padded = np.zeros(U * L, dtype=complex) + + # Insert zeros in the high-frequency MIDDLE: DC + positive freqs at the front, negative freqs at the back, so it stays a valid FFT layout. + X_padded = np.zeros(U * buffer_len, dtype=complex) X_padded[:half] = X[:half] - X_padded[U * L - (L - half):] = X[half:] - cc = np.abs(np.fft.ifft(X_padded)) * U + X_padded[U * buffer_len - (buffer_len - half):] = X[half:] + + xcorr = np.abs(np.fft.ifft(X_padded)) * U # Peak index -> signed lag; indices past the midpoint are negative lags. - peak_idx = np.argmax(cc) - if peak_idx > U * L // 2: - peak_idx -= U * L + peak_idx = np.argmax(xcorr) + if peak_idx > U * buffer_len // 2: + peak_idx -= U * buffer_len peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther range_diff[k] = (peak_lag / sample_rate) * c # meters @@ -177,13 +166,13 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no a, b = pairs[1] # the (Rx0, Rx2) pair X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) cc_coarse = np.abs(np.fft.ifft(X)) -X_padded = np.zeros(U * L, dtype=complex) +X_padded = np.zeros(U * buffer_len, dtype=complex) X_padded[:half] = X[:half] -X_padded[U * L - (L - half):] = X[half:] +X_padded[U * buffer_len - (buffer_len - half):] = X[half:] cc_fine = np.abs(np.fft.ifft(X_padded)) * U -lags_coarse = np.where(np.arange(L) <= L // 2, np.arange(L), np.arange(L) - L) -lags_fine = np.arange(U * L) / U -lags_fine = np.where(lags_fine <= L / 2, lags_fine, lags_fine - L) +lags_coarse = np.where(np.arange(buffer_len) <= buffer_len // 2, np.arange(buffer_len), np.arange(buffer_len) - buffer_len) +lags_fine = np.arange(U * buffer_len) / U +lags_fine = np.where(lags_fine <= buffer_len / 2, lags_fine, lags_fine - buffer_len) peak = lags_coarse[np.argmax(cc_coarse)] subsample_peak = lags_fine[np.argmax(cc_fine)] # The lag axes wrap from + back to - partway through, so sort before plotting, From 6aa98e2f4b3ff23aabd41bf8a033ef7811bc59aa Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 00:23:42 -0400 Subject: [PATCH 15/27] added the code to the text --- _images/tdoa_python_integer.svg | 2078 ++++++++++++++++++++++++ _images/tdoa_python_subsample.svg | 2494 +++++++++++++++++++++++++++++ content/tdoa.rst | 147 +- figure-generating-scripts/tdoa.py | 15 +- spelling_wordlist.txt | 31 + 5 files changed, 4752 insertions(+), 13 deletions(-) create mode 100644 _images/tdoa_python_integer.svg create mode 100644 _images/tdoa_python_subsample.svg diff --git a/_images/tdoa_python_integer.svg b/_images/tdoa_python_integer.svg new file mode 100644 index 00000000..abdd2cf7 --- /dev/null +++ b/_images/tdoa_python_integer.svg @@ -0,0 +1,2078 @@ + + + + + + + + 2026-06-24T23:58:47.498877 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_images/tdoa_python_subsample.svg b/_images/tdoa_python_subsample.svg new file mode 100644 index 00000000..ecd3ceed --- /dev/null +++ b/_images/tdoa_python_subsample.svg @@ -0,0 +1,2494 @@ + + + + + + + + 2026-06-24T23:58:47.790598 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/tdoa.rst b/content/tdoa.rst index 5db22fa1..059ddf0e 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -204,6 +204,146 @@ is maximized when the shift :math:`\tau` aligns the two copies, i.e. at :math:`\ with the sample cross-correlation computed from a finite record of length :math:`T`. Under the independent-noise assumption the noise contributes no systematic peak, so the correlation peak rides on the signal alignment. In practice the correlation is computed efficiently in the frequency domain via the FFT, using the cross-power spectral density :math:`G_{x_i x_j}(f) = \mathcal{F}\{R_{x_i x_j}(\tau)\}` and an inverse transform. +Python Simulation +================== + +Enough math, let's see how this all looks with a simple Python example. First we'll set up the simulation with some high level parameters such as emitter and sensor position, and the simulated sample rate which is essentially how much spectrum the receiver's will "see". + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + from matplotlib.lines import Line2D + from itertools import combinations + from scipy.signal import firwin, lfilter + + sample_rate = 50e6 + c = 3e8 # speed of light [m/s] + snr_db = 10 # SNR of the received signal at each receiver [dB] + tx_len_samples = 1000 # samples to transmit + rx_positions = np.array([ + [65, 229], # Rx0 + [676, 123], # Rx1 + [153, 543], # Rx2 + ]) + num_rx = rx_positions.shape[0] + tx_position = np.array([153, 355]) + pairs = list(combinations(range(num_rx), 2)) # For 3 receivers it's (Rx0,Rx1), (Rx0,Rx2), (Rx1,Rx2) -> 3 pairs + +TDOA is not very dependent on the specific signal the transmitter emits, although the bandwidth of the signal does matter, so to keep this simple we'll have it transmit random noise that is band-limited to a specified bandwidth. If we were to use something like QPSK of the same bandwidth instead, nothing would really change. + +.. code-block:: python + + bandwidth = 20e6 + taps = firwin(numtaps=129, cutoff=bandwidth / 2, fs=sample_rate) + tx_signal = lfilter(taps, 1.0, np.random.randn(tx_len_samples) + 1j * np.random.randn(tx_len_samples)) + +Next we will simulate the receivers receiving the signal at a delay based on their position. We will use a fractional delay filter like we learned about in the :ref:`sync-chapter` Chapter. The rest of the code should look relatively straightforward. We make sure to apply unique AWGN per receiver. + +.. code-block:: python + + # Simulate what each receiver records + true_distances = np.linalg.norm(rx_positions - tx_position, axis=1) + true_delays = true_distances / c + unknown_tx_time = 1.234e-5 # seconds. arbitray, unknown to receivers and we wont use it in any TDOA calcs + + # Calc the actual TDOAs to act as ground truth + for k, (a, b) in enumerate(pairs): + true_rd = true_distances[b] - true_distances[a] + + # Figure out how many samples we have to simulate + total_delay_samples = (unknown_tx_time + true_delays.max()) * sample_rate + buffer_len = tx_len_samples + int(np.ceil(total_delay_samples)) + 10 + + # Taken from Synchronization chapter + def frac_delay_filter(delay): # delay is in samples, but it can (and will be) not an integer + N = 21 # number of taps, keep this odd + n = np.arange(-(N-1)//2, N//2+1) # -10,-9,...,0,...,9,10 + h = np.sinc(n - delay) # calc filter taps + h *= np.hamming(N) # window the filter to make sure it decays to 0 on both sides + h /= np.sum(h) # normalize to get unity gain, we don't want to change the amplitude/power + return h + + # Simulate the delayed signal being received by each sensor + rx_signals = np.zeros((num_rx, buffer_len), dtype=complex) + for i in range(num_rx): + tau = unknown_tx_time + true_delays[i] # absolute delay at this Rx, in seconds + tau_samples = tau * sample_rate + tau_integer_samps = int(np.round(tau_samples)) + tau_frac_samps = tau_samples - tau_integer_samps + rx = np.zeros(buffer_len, dtype=complex) + rx[tau_integer_samps:tau_integer_samps+tx_len_samples] = tx_signal + frac_delay_i = frac_delay_filter(tau_frac_samps) + rx = np.convolve(rx, frac_delay_i, "same") + + # Each receiver adds its own thermal noise, scaled to hit the SNR set at the top + signal_power = np.mean(np.abs(tx_signal)**2) + noise_power = signal_power / 10**(snr_db / 10) + noise = np.sqrt(noise_power / 2) * (np.random.randn(buffer_len) + 1j * np.random.randn(buffer_len)) + rx_signals[i] = rx + noise + +Everything so far was purely for simulation, the rest represents what you would actually do to calculate the TDOA, typically at a central location or one of the sensors, but it needs access to the samples received at all three sensors. It's not a lot of code, we simply loop through each pair of sensors, calculate the cross-correlation between their received samples, and pull out the peak. Later we will see how to do the subsample version of this for more granularity. + +.. code-block:: python + + # Estimate the TDOAs using a normal cross-correlation + range_diff = np.zeros(len(pairs)) # meters + for k, (a, b) in enumerate(pairs): + xcorr = np.correlate(rx_signals[b], rx_signals[a], mode='full') + peak_lag = np.argmax(np.abs(xcorr)) - (buffer_len - 1) # 'full' puts zero lag at index buffer_len-1 + range_diff[k] = (peak_lag / sample_rate) * c # meters + +Not much to it! + +This gives us the following results: + +.. image:: ../_images/tdoa_python_integer.svg + :align: center + :target: ../_images/tdoa_python_integer.svg + :alt: Python simulation output when doing integer correlation + +Note that this code doesn't fully "solve" the problem, even though it might seem like it does at first glance because the lines intersect exactly at the position of the emitter, but it's really your brain doing the final "solving" of the position, by looking at the intersection of the hyperbolas. Also, if there was more noise, the hyperbolas would not all intersect at one point. We will dive into automated solutions later in this chapter. + +The full Python code (including the plotting portion) can be found `here `_. + +Resolution and Sub-Sample Estimation +========================================== + +With sampling rate :math:`f_s`, the correlation is computed on a lag grid spaced :math:`1/f_s` apart, so the naive peak resolution is one sample, i.e. :math:`c/f_s` in range. This is usually far too coarse, especially if the sensors (and emitter) are close together, e.g., less than 100 meters. There are two options for sub-sample refinement, one way is to interpolate the signals as part of the cross-correlation, and another is to fit a model to the samples around the discrete peak. For the latter, parabolic interpolation through the peak and its two neighbors is the simplest, while sinc-based interpolation is more accurate because the true correlation of a band-limited signal is a sinc-like function. Good interpolation routinely yields delay estimates one to two orders of magnitude finer than the sample period. Below we show an example of doing the interpolated cross-correlation. + +.. code-block:: python + + U = 16 # correlation upsampling factor + half = (buffer_len + 1) // 2 # number of DC + positive-frequency bins + range_diff = np.zeros(len(pairs)) # meters + for k, (a, b) in enumerate(pairs): + # Cross-correlation in the frequency domain + X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) + + # Insert zeros in the high-frequency MIDDLE: DC + positive freqs at the front, negative freqs at the back, so it stays a valid FFT layout. + X_padded = np.zeros(U * buffer_len, dtype=complex) + X_padded[:half] = X[:half] + X_padded[U * buffer_len - (buffer_len - half):] = X[half:] + + # Now IFFT to finish the crosscorrelation + xcorr = np.abs(np.fft.ifft(X_padded)) * U + + # Peak index -> signed lag; indices past the midpoint are negative lags + peak_idx = np.argmax(xcorr) + if peak_idx > U * buffer_len // 2: + peak_idx -= U * buffer_len + peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther + range_diff[k] = (peak_lag / sample_rate) * c # meters + +When doing the same Python simulation as before, but with subsampling, we get the following results. You would have to zoom in on the left-hand plot to see the accuracy difference. + +.. image:: ../_images/tdoa_python_subsample.svg + :align: center + :target: ../_images/tdoa_python_subsample.svg + :alt: Python simulation output when doing subsample correlation + +Looking at the right-hand plot, we can see how the original integer-only method was off by a decent margin. + The Generalized Cross-Correlation Framework ================================================== @@ -233,11 +373,6 @@ The **GCC-PHAT** estimator deserves emphasis. By dividing out the magnitude of t Because the delay between two copies of a signal is encoded entirely in the *linear phase* term :math:`e^{-j2\pi f \tau_{ij}}`, while the magnitude carries the (often unhelpful) spectral shape and reverberant coloring, whitening to unit magnitude weights every frequency equally and produces a sharp, near-impulsive peak at the true delay. This makes PHAT strikingly robust to reverberation. Its weakness is that it also whitens noise-dominated frequencies, so at low SNR the equal weighting amplifies noise; SNR-aware variants reintroduce a coherence-based weighting to compensate. -Resolution and Sub-Sample Estimation -========================================== - -With sampling rate :math:`f_s`, the correlation is computed on a lag grid spaced :math:`1/f_s` apart, so the naive peak resolution is one sample, i.e. :math:`c/f_s` in range. This is usually far too coarse. Sub-sample refinement fits a model to the samples around the discrete peak — parabolic interpolation through the peak and its two neighbors is the simplest, while sinc-based interpolation is more accurate because the true correlation of a band-limited signal is a sinc-like function. Good interpolation routinely yields delay estimates one to two orders of magnitude finer than the sample period. - Practical Considerations ================================ @@ -499,7 +634,7 @@ Off-the-shelf SDRs that can be easily synchronized include any of the Ettus Rese Multipath and Non-Line-of-Sight Propagation =================================================== -Everything so far has assumed a single line-of-sight path between the emitter and receivers. Real environments add reflections (multipath) and can block the direct path entirely. Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. When the direct path is entirely obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying these non-line-of-sight links statistically (non-line-of-sight measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them (requires having way more sensors than three), and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators described earlier. In dense indoor multipath, which is an extremely difficult envrionment for TDOA, model-based delay estimation and machine-learning approaches increasingly outperform classical correlation. +Everything so far has assumed a single line-of-sight path between the emitter and receivers. Real environments add reflections (multipath) and can block the direct path entirely. Multipath superimposes delayed copies of the signal, which distort or split the correlation peak and bias the delay estimate; this is exactly the failure GCC-PHAT was designed to resist, since whitening sharpens the direct-path peak relative to the smeared reflections. When the direct path is entirely obstructed, the *earliest* arriving energy travels an excess distance, so the measured TDOA is biased *long* in a way no amount of averaging removes, because the error is systematic rather than random. Mitigation strategies include identifying these non-line-of-sight links statistically (non-line-of-sight measurements often show larger variance or violate geometric consistency among redundant sensors), down-weighting or discarding them (requires having way more sensors than three), and exploiting redundancy so that a few corrupted links among many can be detected and rejected by the robust estimators described earlier. In dense indoor multipath, which is an extremely difficult environment for TDOA, model-based delay estimation and machine-learning approaches increasingly outperform classical correlation. Sensor-Position Uncertainty and Calibration =================================================== diff --git a/figure-generating-scripts/tdoa.py b/figure-generating-scripts/tdoa.py index 847cdd55..d445d504 100644 --- a/figure-generating-scripts/tdoa.py +++ b/figure-generating-scripts/tdoa.py @@ -4,8 +4,6 @@ from itertools import combinations from scipy.signal import firwin, lfilter -np.random.seed(0) - sample_rate = 50e6 c = 3e8 # speed of light [m/s] snr_db = 10 # SNR of the received signal at each receiver [dB] @@ -80,7 +78,6 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no for i in range(num_rx): rx_dist.append(np.sqrt((GX - rx_positions[i, 0])**2 + (GY - rx_positions[i, 1])**2)) fig1, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) -fig1.suptitle('Method 1: integer-only TDOA (time-domain cross-correlation)') # Left: the hyperbolas, which all cross at the transmitter. hyperbola_handles = [] pair_colors = ['tab:blue', 'tab:orange', 'tab:green'] @@ -109,6 +106,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.set_title(f'Cross-correlation of Rx{b} vs Rx{a}') ax2.legend() ax2.grid() +fig1.savefig('../_images/tdoa_python_integer.svg', bbox_inches='tight') fig1.tight_layout() # Subsample TDOA calc using a freq domain cross-correlation that was padded as a way to interpolate @@ -116,6 +114,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no half = (buffer_len + 1) // 2 # number of DC + positive-frequency bins range_diff = np.zeros(len(pairs)) # meters for k, (a, b) in enumerate(pairs): + # Cross-correlation in the frequency domain X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) # Insert zeros in the high-frequency MIDDLE: DC + positive freqs at the front, negative freqs at the back, so it stays a valid FFT layout. @@ -123,12 +122,14 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no X_padded[:half] = X[:half] X_padded[U * buffer_len - (buffer_len - half):] = X[half:] + # Now IFFT to finish the crosscorrelation xcorr = np.abs(np.fft.ifft(X_padded)) * U - # Peak index -> signed lag; indices past the midpoint are negative lags. + + # Peak index -> signed lag; indices past the midpoint are negative lags peak_idx = np.argmax(xcorr) if peak_idx > U * buffer_len // 2: peak_idx -= U * buffer_len - peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther + peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther range_diff[k] = (peak_lag / sample_rate) * c # meters print("METHOD 2 (sub-sample, zero-padded FFT)") @@ -139,7 +140,6 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no # 8. FIGURE 2: the sub-sample result, same layout as Figure 1. fig2, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) -fig2.suptitle('Method 2: sub-sample TDOA (zero-padded FFT cross-correlation)') # Left: the hyperbolas from the refined range differences. hyperbola_handles = [] @@ -180,7 +180,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no order_c = np.argsort(lags_coarse) order_f = np.argsort(lags_fine) ax2.plot(lags_coarse[order_c], cc_coarse[order_c], 'o', markersize=6, label='coarse (1 sample/lag)') -ax2.plot(lags_fine[order_f], cc_fine[order_f], '-', label=f'{U}x zero-padded') +ax2.plot(lags_fine[order_f], cc_fine[order_f], '.-', label=f'{U}x interpolation') ax2.axvline(subsample_peak, color='red', linestyle='--', label=f'sub-sample peak = {subsample_peak:.3f}') ax2.set_xlim(peak - 6, peak + 6) @@ -189,5 +189,6 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.legend() ax2.grid() fig2.tight_layout() +fig2.savefig('../_images/tdoa_python_subsample.svg', bbox_inches='tight') plt.show() diff --git a/spelling_wordlist.txt b/spelling_wordlist.txt index 424acd36..58dbaf6c 100644 --- a/spelling_wordlist.txt +++ b/spelling_wordlist.txt @@ -331,3 +331,34 @@ Vandermonde eigendecomposition ULA geolocation +observability +timestamping +coplanar +collinear +GDOP +hyperbolae +Cramér +Rao +nonlinearity +Jacobians +linearizes +Foy +iteratively +minimizer +Jacobian +linearized +multimodal +overdetermined +Schau +Linearization +initializers +stationarity +reverberant +suboptimal +linearize +foci +Multilateration +subsample +subsampling +linearization +hyperboloid From 8ddb8f785f97b15fad3a1979b62730341e83627a Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 11:37:39 -0400 Subject: [PATCH 16/27] tweaks --- AGENTS.md | 7 ++++-- content/tdoa.rst | 58 ++++++++++++++++++++----------------------- spelling_wordlist.txt | 1 - 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 561a6299..1cd6f62e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,8 +52,11 @@ PySDR's prose is intentionally instructional, conversational, and example-driven - Keep technical terminology precise, but avoid sounding overly academic or formal. - When a section already has figures or code, make the surrounding prose point the reader to them and explain what they should notice. - Avoid hype, filler, and motivational fluff. - -When in doubt, read the surrounding chapter aloud mentally and aim for the same cadence and readability rather than a generic documentation voice. +- Lead with a concrete scenario before any equation. Pose a small "what if" with real numbers (e.g. "the emitter is 100 m closer to one sensor"), then generalize. Introduce the named concept (hyperbola, foci) in plain words before showing the formula. +- Read the equation back in plain English. Right after a .. math:: block, add a sentence translating it ("which reads: distance to one sensor minus distance to the other equals..."). +- Explain why, not just what. For each fact, give the intuition behind it (why a range difference can't exceed the baseline) rather than stating it as a rule. +- Define jargon inline the moment it appears ("the baseline," "ill-conditioned, meaning small errors move the estimate a lot") instead of assuming the reader knows it. +- Use second person and rhetorical questions to walk the reader through the reasoning as if thinking aloud. ## Notes diff --git a/content/tdoa.rst b/content/tdoa.rst index 059ddf0e..4bbd934f 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -4,9 +4,9 @@ TDOA #### -Time Difference of Arrival (TDOA) is a technique that localizes an emitter from differences in signal arrival time across synchronized sensors, without needing the transmitter's clock. This chapter covers the full TDOA pipeline, geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA can be used in RF, acoustic, and sonar geolocation. +Time Difference of Arrival (TDOA) is a technique that can find the position of a transmitter (a.k.a. emitter) using multiple synchronized receivers (a.k.a. sensors), by comparing differences in signal arrival time. This chapter covers the full TDOA pipeline, geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA is commonly used in both RF and acoustic/sonar applications. -Try the interactive demo below to get a quick feel for how TDOA works, it involves intersection of hyperbolas. +Before diving in, try playing with the interactive demo below to get a quick feel for how TDOA works, it involves intersection of hyperbolas. .. raw:: html @@ -19,21 +19,15 @@ Try the interactive demo below to get a quick feel for how TDOA works, it involv Introduction ************ -A recurring problem across acoustics, radio engineering, and defense systems is this: a source emits a signal, several spatially separated sensors receive it, and we wish to recover the source's position from those received signals alone. The source may be cooperative (a cell phone trying to be found) or non-cooperative (a radar emitter that would rather not be), stationary or moving, and the medium may be air, water, or free space. Despite this diversity, the geometry and estimation theory that solve the problem are remarkably uniform, and *Time Difference of Arrival* (TDOA) sits at the center of them. +A common problem across RF and acoustics/sonar is the desire to find the position of an emitter, also known as the process of geolocation. The emitter may be cooperative (a cell phone trying to be found) or non-cooperative (a radar emitter that would rather not be), stationary or moving, and the medium may be air, water, or free space. TDOA-based localization appears in cellular emergency-caller location, acoustics with microphone arrays (e.g., gunshot-detection systems mounted on city streetlights), passive sonar, passive (non-emitting) radar, electronic warfare and signals intelligence, and even wildlife tracking. In each case the engineering details differ, but the mathematical skeleton is the same. -TDOA-based localization appears in cellular emergency-caller location, acoustics with microphone arrays (e.g., gunshot-detection systems mounted on city streetlights), passive sonar, passive (non-emitting) radar, electronic warfare and signals intelligence, and even wildlife tracking. In each case the engineering details differ, but the mathematical skeleton is the same one developed in this chapter. - -****************** -TDOA in a Nutshell -****************** - -The key insight is that when the same wavefront hits two sensors, the difference in arrival times depends only on geometry — not on when the source transmitted. To see why, consider the propagation time from a source to sensor :math:`i`: :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) instant of transmission, :math:`r_i` is the source-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, +The key behind TDOA is that when the same wavefront hits two sensors, the difference in arrival times depends only on geometry, not on when the source transmitted. To see why, consider the propagation time from an emitter to sensor :math:`i`: :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) start of transmission, :math:`r_i` is the emitter-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, .. math:: \tau_{ij} = t_i - t_j = \frac{r_i - r_j}{c}, -the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on source and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters: we never need to know when the source transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. +the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on emitter and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters; we never need to know when the emitter transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. You still need to isolate the signal so that you're only observing one emitter, so signal detection and classification may be nessesary, plus filtering. Each pair of sensors yields one TDOA, and each TDOA traces out one hyperbola, so the number of hyperbolas we can draw is just the number of sensor pairs. With :math:`N` sensors that is @@ -41,13 +35,13 @@ Each pair of sensors yields one TDOA, and each TDOA traces out one hyperbola, so \binom{N}{2} = \frac{N(N-1)}{2}, -i.e. 3 sensors give 3 hyperbolas, 4 give 6, 5 give 10, and so on. Not all of these are independent — as we will see below, only :math:`N-1` carry new geometric information — but the full set is still useful for averaging down noise. +i.e. 3 sensors give 3 hyperbolas, 4 give 6, 5 give 10, and so on. Not all of these are independent, as we will see below, only :math:`N-1` carry new geometric information, but the full set is still useful for averaging out noise. -The price we pay is that the receivers must share a precise common time reference — a requirement that, as discussed later, is itself a demanding engineering problem because a timing error of one nanosecond corresponds to about 0.3 m of range error. +The price we pay is that the receivers must share a precise common time reference, a requirement that, as discussed later, is itself a demanding engineering problem because a timing error of one nanosecond corresponds to about 1 foot or 0.3 meters of range error, at least in RF applications. -************************* -Geometric Foundations -************************* +************* +TDOA Geometry +************* From Time Difference to Range Difference =============================================== @@ -58,7 +52,7 @@ Multiplying a measured time difference by the propagation speed converts it into \Delta r_{ij} = c\,\tau_{ij} = r_i - r_j . -This is the quantity we actually localize with. For acoustic problems :math:`c \approx 343` m/s in air; for radio problems :math:`c \approx 2.998\times10^8` m/s. Note immediately the consequence for accuracy: in air, a :math:`0.1` ms timing error is only :math:`\sim`\3 cm, whereas in free space the same timing error is 30 km. Radio TDOA therefore demands extraordinarily precise timing, a theme we return to repeatedly. +For acoustic problems :math:`c \approx 343` m/s in air; for radio problems :math:`c \approx 2.998\times10^8` m/s. Note immediately the consequence for accuracy: in air, a :math:`0.1` ms timing error is only :math:`\sim`\3 cm, whereas in free space the same timing error is 30 km. Radio TDOA therefore demands extraordinarily precise timing, a theme we return to repeatedly. The diagram below shows an example of an emitter and three sensors, with a time domain plot of the signal being received by each sensor at different times. @@ -70,19 +64,21 @@ The diagram below shows an example of an emitter and three sensors, with a time The Hyperbola =================== -Fix two sensors at positions :math:`\mathbf{s}_i` and :math:`\mathbf{s}_j`, the *foci*. The set of source positions :math:`\mathbf{u}` consistent with a measured range difference satisfies +Now think about what a single range difference actually tells us. Suppose we have two sensors, and we have measured that the emitter is, say, 100 meters closer to one sensor than the other. Where could the emitter be? Not at a single spot, it turns out, but anywhere along a curved line. As you slide along that line, the two distances to the sensors both change, but their *difference* stays fixed at 100 meters the whole way. + +That curve has a name: it is a **hyperbola**, with the two sensors sitting at its two focal points. (In 3D the same idea sweeps out a curved surface called a hyperboloid, but the 2D picture is easier to reason about and everything carries over). If we write the emitter position as :math:`\mathbf{u}` and the two sensor positions as :math:`\mathbf{s}_i` and :math:`\mathbf{s}_j`, the hyperbola is just the set of points obeying .. math:: - |\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_j| = \Delta r_{ij} = \text{constant}. + |\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_j| = \Delta r_{ij} = \text{constant}, -This is the defining property of a **hyperbola** (in 3D, a hyperboloid of two sheets): the locus of points whose *difference* of distances to two fixed foci is constant. Several features matter in practice: +which reads "distance to one sensor minus distance to the other equals our measured range difference". A few practical consequences fall right out of this: -* The constant equals :math:`2a`, where :math:`a` is the hyperbola's semi-transverse axis, so :math:`|\Delta r_{ij}| < |\mathbf{s}_i - \mathbf{s}_j|` always — a range difference can never exceed the baseline between the sensors. Measurements that violate this bound signal an error (noise, multipath, or a synchronization fault). -* The *sign* of :math:`\Delta r_{ij}` selects which of the two branches the source lies on (the branch nearer the closer sensor). -* As :math:`|\Delta r_{ij}| \to |\mathbf{s}_i-\mathbf{s}_j|`, the hyperbola degenerates toward the baseline ray; as :math:`\Delta r_{ij}\to 0`, it flattens into the perpendicular bisector of the baseline. Geometry near these extremes is ill-conditioned. +* **A range difference can't exceed the spacing between the two sensors.** Intuitively, the difference of two distances is largest when the emitter lies directly out beyond one sensor along the line connecting them, and even then it can only equal that sensor-to-sensor spacing (often called the *baseline*). So if you ever measure a range difference bigger than the baseline, something is wrong, most likely noise, multipath, or a timing/synchronization error. +* **The sign tells you which side you're on.** A hyperbola actually has two mirror-image branches, one curving toward each sensor. Whether your range difference came out positive or negative picks the branch nearer the closer sensor, so you don't get confused between the two halves. +* **The shape depends on the measurement.** When the range difference is close to the full baseline, the hyperbola hugs the line between the sensors. When the range difference is near zero (the emitter is roughly equidistant), the curve straightens out into the line that perpendicularly bisects the baseline. Near both of these extremes the geometry becomes "ill-conditioned," meaning small measurement errors push the estimated position around a lot, so positioning there is less reliable. -A single TDOA thus constrains the source to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line but you would need a third sensor to get more specific. +A single TDOA thus constrains the source to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line but you would need a third sensor to find where on that line it is (performing geolocation). .. image:: ../_images/tdoa_hyperbola.svg :align: center @@ -92,12 +88,12 @@ A single TDOA thus constrains the source to a curve, not a point. To fix a posit Multilateration ===================== -With :math:`N` sensors we can form pairs and intersect their hyperbolae; the source lies at (or near) their common intersection. This process is **hyperbolic multilateration**. Counting degrees of freedom tells us how many sensors we need: +With :math:`N` sensors we can form pairs and intersect their hyperbolas; the source lies at (or near) their common intersection. This process is **hyperbolic multilateration**. Counting degrees of freedom tells us how many sensors we need: -* In **2D** the source has 2 unknowns :math:`(x,y)`. Each independent TDOA gives one equation, so we need at least 2 independent TDOAs, which requires **3 sensors**. +* In **2D** the source has 2 unknowns :math:`(x,y)`. Each independent TDOA gives one equation, so we need at least 2 independent TDOAs, which requires **3 sensors**. For example, if we know the emitter is on land and we're not having to take into account curvature of the Earth, this would work. * In **3D** the source has 3 unknowns :math:`(x,y,z)`, requiring 3 independent TDOAs and therefore **4 sensors**. -In the noiseless, exactly-determined case the hyperbolae meet at a single point (with an occasional geometric ambiguity resolved by branch signs or an extra sensor). With more sensors than the minimum the system is *overdetermined*: noisy hyperbolae no longer share an exact common point, and we must solve a least-squares or maximum-likelihood problem, as described below. +In the noiseless case the hyperbolas meet at a single point (with an occasional geometric ambiguity resolved by branch signs or an extra sensor). With more sensors than the minimum the system is *overdetermined*: noisy hyperbolas no longer share an exact common point, and we must solve a least-squares or maximum-likelihood problem, as described below. Reference Sensor and Independent Pairs ============================================= @@ -489,7 +485,7 @@ The positive root is :math:`r_1 = 50.0` m (the negative root is non-physical and x = 48.54 - 0.1708(50) = 40.0, \qquad y = 45.31 - 0.3062(50) = 30.0 . -We recover the true source :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolae illustrated above represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. +We recover the true source :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolas illustrated above represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. ***************************************** Iterative and Statistical Estimation @@ -595,10 +591,10 @@ Even with perfect measurements, geometry can ruin a fix. **Geometric Dilution of GDOP is a pure number :math:`\ge 1`: it is the factor by which the underlying ranging error is magnified at a given source location. The geometric intuition follows from the Jacobian rows being differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1`: -* When the sensors surround the source so that the bearing vectors point in well-spread directions, the hyperbolae cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). -* When the source lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolae intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). +* When the sensors surround the source so that the bearing vectors point in well-spread directions, the hyperbolas cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). +* When the source lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolas intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). -This is the geometric counterpart of the observation above that hyperbolae degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. +This is the geometric counterpart of the observation above that hyperbolas degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. The figure below shows GDOP heat maps over a plane for (left) three sensors at the vertices of an equilateral triangle and (right) three nearly collinear sensors, showing a broad low-GDOP region inside the triangle versus a narrow usable corridor for the collinear array, with GDOP rising sharply outside the convex hull in both cases. diff --git a/spelling_wordlist.txt b/spelling_wordlist.txt index 58dbaf6c..b7ca8f71 100644 --- a/spelling_wordlist.txt +++ b/spelling_wordlist.txt @@ -336,7 +336,6 @@ timestamping coplanar collinear GDOP -hyperbolae Cramér Rao nonlinearity From a9bf35e4c4393604cdd91cb2a13ef46af7278abd Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 11:42:02 -0400 Subject: [PATCH 17/27] use emitter instead of source --- content/tdoa.rst | 54 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index 4bbd934f..322aa211 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -21,7 +21,7 @@ Introduction A common problem across RF and acoustics/sonar is the desire to find the position of an emitter, also known as the process of geolocation. The emitter may be cooperative (a cell phone trying to be found) or non-cooperative (a radar emitter that would rather not be), stationary or moving, and the medium may be air, water, or free space. TDOA-based localization appears in cellular emergency-caller location, acoustics with microphone arrays (e.g., gunshot-detection systems mounted on city streetlights), passive sonar, passive (non-emitting) radar, electronic warfare and signals intelligence, and even wildlife tracking. In each case the engineering details differ, but the mathematical skeleton is the same. -The key behind TDOA is that when the same wavefront hits two sensors, the difference in arrival times depends only on geometry, not on when the source transmitted. To see why, consider the propagation time from an emitter to sensor :math:`i`: :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) start of transmission, :math:`r_i` is the emitter-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, +The key behind TDOA is that when the same wavefront hits two sensors, the difference in arrival times depends only on geometry, not on when the emitter transmitted. To see why, consider the propagation time from an emitter to sensor :math:`i`: :math:`t_i = t_0 + r_i / c`, where :math:`t_0` is the (unknown) start of transmission, :math:`r_i` is the emitter-to-sensor distance, and :math:`c` is the propagation speed. If we subtract the arrival times at two sensors, .. math:: @@ -78,7 +78,7 @@ which reads "distance to one sensor minus distance to the other equals our measu * **The sign tells you which side you're on.** A hyperbola actually has two mirror-image branches, one curving toward each sensor. Whether your range difference came out positive or negative picks the branch nearer the closer sensor, so you don't get confused between the two halves. * **The shape depends on the measurement.** When the range difference is close to the full baseline, the hyperbola hugs the line between the sensors. When the range difference is near zero (the emitter is roughly equidistant), the curve straightens out into the line that perpendicularly bisects the baseline. Near both of these extremes the geometry becomes "ill-conditioned," meaning small measurement errors push the estimated position around a lot, so positioning there is less reliable. -A single TDOA thus constrains the source to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line but you would need a third sensor to find where on that line it is (performing geolocation). +A single TDOA thus constrains the emitter's position to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line but you would need a third sensor to find where on that line it is (performing geolocation). .. image:: ../_images/tdoa_hyperbola.svg :align: center @@ -88,10 +88,10 @@ A single TDOA thus constrains the source to a curve, not a point. To fix a posit Multilateration ===================== -With :math:`N` sensors we can form pairs and intersect their hyperbolas; the source lies at (or near) their common intersection. This process is **hyperbolic multilateration**. Counting degrees of freedom tells us how many sensors we need: +With :math:`N` sensors we can form pairs and intersect their hyperbolas; the emitter lies at (or near) their common intersection. This process is **hyperbolic multilateration**. Counting degrees of freedom tells us how many sensors we need: -* In **2D** the source has 2 unknowns :math:`(x,y)`. Each independent TDOA gives one equation, so we need at least 2 independent TDOAs, which requires **3 sensors**. For example, if we know the emitter is on land and we're not having to take into account curvature of the Earth, this would work. -* In **3D** the source has 3 unknowns :math:`(x,y,z)`, requiring 3 independent TDOAs and therefore **4 sensors**. +* In **2D** the emitter position has 2 unknowns :math:`(x,y)`. Each independent TDOA gives one equation, so we need at least 2 independent TDOAs, which requires **3 sensors**. For example, if we know the emitter is on land and we're not having to take into account curvature of the Earth, this would work. +* In **3D** the emitter position has 3 unknowns :math:`(x,y,z)`, requiring 3 independent TDOAs and therefore **4 sensors**. In the noiseless case the hyperbolas meet at a single point (with an occasional geometric ambiguity resolved by branch signs or an extra sensor). With more sensors than the minimum the system is *overdetermined*: noisy hyperbolas no longer share an exact common point, and we must solve a least-squares or maximum-likelihood problem, as described below. @@ -100,10 +100,10 @@ Reference Sensor and Independent Pairs From :math:`N` sensors one can form :math:`\binom{N}{2}` pairwise TDOAs, but they are not all independent. Choosing one sensor as a **reference** (say sensor 1) and forming :math:`\tau_{i1}` for :math:`i = 2,\dots,N` yields :math:`N-1` TDOAs from which every other pairwise difference can be reconstructed, since :math:`\tau_{ij} = \tau_{i1} - \tau_{j1}`. These :math:`N-1` are the *independent* measurements that carry all the geometric information. -The redundant pairs are not worthless, however. Because each measured TDOA carries independent *noise*, using all :math:`\binom{N}{2}` pairs (with a correctly modeled, correlated noise covariance — the reference sensor's noise is common to every :math:`\tau_{i1}`) can improve the estimate. For clarity of exposition we develop the algorithms with the reference-sensor formulation and note where the full covariance enters. +The redundant pairs are not worthless, however. Because each measured TDOA carries independent *noise*, using all :math:`\binom{N}{2}` pairs (with a correctly modeled, correlated noise covariance — the reference sensor's noise is common to every :math:`\tau_{i1}`) can improve the estimate. Example: A Three-Sensor 2D Fix -============================================ +============================== Place three sensors at @@ -111,7 +111,7 @@ Place three sensors at \mathbf{s}_1=(0,0),\quad \mathbf{s}_2=(100,0),\quad \mathbf{s}_3=(0,100)\ \text{(meters)}, -and suppose the true source is at :math:`\mathbf{u}=(40,30)`. The source-to-sensor distances are +and suppose the true emitter is at :math:`\mathbf{u}=(40,30)`. The emitter-to-sensor distances are .. math:: @@ -126,7 +126,7 @@ Taking sensor 1 as reference, the range-difference measurements are \Delta r_{21}=r_2-r_1\approx 17.08\ \text{m},\qquad \Delta r_{31}=r_3-r_1\approx 30.62\ \text{m}. -Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the source. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization developed below — where we will recover :math:`(40,30)` from exactly these numbers in closed form. +Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the emitter's position. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization developed below — where we will recover :math:`(40,30)` from exactly these numbers in closed form. ************************************* The Signal and Measurement Model @@ -135,7 +135,7 @@ The Signal and Measurement Model Received-Signal Model ============================ -Each sensor receives a time-delayed, scaled, noisy copy of whatever the source is transmitting. Specifically, let :math:`s(t)` be the source waveform. Sensor :math:`i` receives: +Each sensor receives a time-delayed, scaled, noisy copy of whatever the emitter is transmitting. Specifically, let :math:`s(t)` be the transmit waveform. Sensor :math:`i` receives: .. math:: @@ -152,12 +152,12 @@ The pairwise TDOA is the difference of arrival times, \tau_{ij} = t_i - t_j = \frac{r_i - r_j}{c} = \frac{|\mathbf{u}-\mathbf{s}_i| - |\mathbf{u}-\mathbf{s}_j|}{c}. -The right-hand side makes explicit that the TDOA is a nonlinear function of the source coordinates :math:`\mathbf{u}`. The *measurement* problem is to estimate :math:`\tau_{ij}` from the waveforms :math:`x_i, x_j`; the *localization* problem is to invert the nonlinear map from :math:`\mathbf{u}` to the collection of TDOAs. +The right-hand side makes explicit that the TDOA is a nonlinear function of the emitter coordinates :math:`\mathbf{u}`. The *measurement* problem is to estimate :math:`\tau_{ij}` from the waveforms :math:`x_i, x_j`; the *localization* problem is to invert the nonlinear map from :math:`\mathbf{u}` to the collection of TDOAs. Noise Assumptions ======================== -We assume each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and independent of the source signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is +We assume each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and independent of the trasnmitted signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is .. math:: @@ -343,7 +343,7 @@ Looking at the right-hand plot, we can see how the original integer-only method The Generalized Cross-Correlation Framework ================================================== -Plain cross-correlation is fragile: if the source spectrum is concentrated or the channel is reverberant, the correlation peak is broad and easily shifted by noise. Knapp and Carter's *Generalized Cross-Correlation* (GCC) addresses this by inserting a frequency weighting :math:`\Psi(f)` before transforming back to the lag domain: +Plain cross-correlation is fragile: if the transmitted signal is narrow in bandwidth or the channel contains multipath, the correlation peak is broad and easily shifted by noise. Knapp and Carter's *Generalized Cross-Correlation* (GCC) addresses this by inserting a frequency weighting :math:`\Psi(f)` before transforming back to the lag domain: .. math:: @@ -374,7 +374,7 @@ Practical Considerations Several effects govern real performance: -* The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the stationarity assumption and, for moving sources, against blurring of the delay over the window. +* The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the stationarity assumption and, for moving emitters, against blurring of the delay over the window. * **Coherence bandwidth** limits which frequencies actually carry usable phase. * **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. @@ -400,7 +400,7 @@ The measurement equations above are nonlinear and, taken directly, require itera The Linearization Strategy ================================== -The trick is to square the range equations and subtract pairs, which cancels the nonlinear :math:`x^2+y^2` term and introduces :math:`r_1`, the range to the reference sensor, as a single auxiliary unknown. Starting with the squared range from the source :math:`\mathbf{u}=(x,y)` to sensor :math:`i` at :math:`\mathbf{s}_i=(x_i,y_i)`: +The trick is to square the range equations and subtract pairs, which cancels the nonlinear :math:`x^2+y^2` term and introduces :math:`r_1`, the range to the reference sensor, as a single auxiliary unknown. Starting with the squared range from the emitter :math:`\mathbf{u}=(x,y)` to sensor :math:`i` at :math:`\mathbf{s}_i=(x_i,y_i)`: .. math:: @@ -485,7 +485,7 @@ The positive root is :math:`r_1 = 50.0` m (the negative root is non-physical and x = 48.54 - 0.1708(50) = 40.0, \qquad y = 45.31 - 0.3062(50) = 30.0 . -We recover the true source :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolas illustrated above represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. +We recover the true emitter position :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolas illustrated above represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. ***************************************** Iterative and Statistical Estimation @@ -514,7 +514,7 @@ Foy's classical approach linearizes :math:`\mathbf{h}` about the current estimat \frac{\partial h_i}{\partial \mathbf{u}} = \frac{\mathbf{u}-\mathbf{s}_i}{|\mathbf{u}-\mathbf{s}_i|} - \frac{\mathbf{u}-\mathbf{s}_1}{|\mathbf{u}-\mathbf{s}_1|} = \hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1, -a difference of *unit vectors* pointing from the candidate source toward sensor :math:`i` and the reference. The Gauss-Newton update is +a difference of *unit vectors* pointing from the candidate emitter toward sensor :math:`i` and the reference. The Gauss-Newton update is .. math:: @@ -532,13 +532,13 @@ Robust, Recursive, and Bayesian Extensions Real measurements contain outliers — a multipath-corrupted TDOA can be wildly wrong while the rest are fine. Plain least squares, which squares residuals, is badly distorted by such outliers. *Robust* estimators replace the squared loss with one that grows more slowly (e.g. Huber's), or explicitly detect and discard inconsistent TDOAs via residual tests or RANSAC-style consensus. -When the source *moves*, we want to fuse measurements over time rather than localize each instant independently. State-space filtering does this by modeling the source's position (and velocity) as an evolving state. The **Kalman filter** is optimal for linear-Gaussian dynamics, but the TDOA measurement is nonlinear, so practitioners use the **Extended Kalman Filter** (which linearizes the measurement with the same Jacobian as above), the **Unscented Kalman Filter** (which propagates a deterministic set of sigma points through the nonlinearity, avoiding explicit Jacobians and handling stronger nonlinearity better), or, for multimodal or heavily non-Gaussian problems, the **particle filter** (which represents the posterior by a weighted sample cloud). These trackers also naturally enforce motion continuity, which suppresses the per-snapshot ambiguities of static localization. +When the emitter *moves*, we want to fuse measurements over time rather than localize each instant independently. State-space filtering does this by modeling the emitter's position (and velocity) as an evolving state. The **Kalman filter** is optimal for linear-Gaussian dynamics, but the TDOA measurement is nonlinear, so practitioners use the **Extended Kalman Filter** (which linearizes the measurement with the same Jacobian as above), the **Unscented Kalman Filter** (which propagates a deterministic set of sigma points through the nonlinearity, avoiding explicit Jacobians and handling stronger nonlinearity better), or, for multimodal or heavily non-Gaussian problems, the **particle filter** (which represents the posterior by a weighted sample cloud). These trackers also naturally enforce motion continuity, which suppresses the per-snapshot ambiguities of static localization. *********************************************** Performance Analysis and Fundamental Bounds *********************************************** -Having estimators in hand, we ask: how accurate *can* a TDOA system be, and what governs that accuracy? Two ideas answer this — the Cramér-Rao bound, which sets a noise floor from the signals, and geometric dilution of precision, which describes how sensor-source geometry amplifies that floor. +Having estimators in hand, we ask: how accurate *can* a TDOA system be, and what governs that accuracy? Two ideas answer this — the Cramér-Rao bound, which sets a noise floor from the signals, and geometric dilution of precision, which describes how sensor-emitter geometry amplifies that floor. Error Propagation ======================== @@ -565,7 +565,7 @@ where :math:`\beta` is the *RMS (Gabor) bandwidth* of the signal and :math:`\gam The Localization Cramér-Rao Lower Bound ============================================== -Combining measurement quality and geometry, the Fisher information matrix for the source position is +Combining measurement quality and geometry, the Fisher information matrix for the emitter position is .. math:: @@ -582,17 +582,17 @@ The bound is the benchmark against which estimators are judged: a method that at Geometric Dilution of Precision ======================================= -Even with perfect measurements, geometry can ruin a fix. **Geometric Dilution of Precision** (GDOP) quantifies how the sensor-source configuration amplifies measurement error into position error. If the range-difference errors are independent with common standard deviation :math:`\sigma`, so :math:`\mathbf{C}=\sigma^2\mathbf{I}`, then +Even with perfect measurements, geometry can ruin a fix. **Geometric Dilution of Precision** (GDOP) quantifies how the sensor-emitter configuration amplifies measurement error into position error. If the range-difference errors are independent with common standard deviation :math:`\sigma`, so :math:`\mathbf{C}=\sigma^2\mathbf{I}`, then .. math:: \mathrm{GDOP} = \sqrt{\mathrm{tr}\bigl[(\mathbf{J}^\top\mathbf{J})^{-1}\bigr]}, \qquad \sigma_{\text{position}} = \mathrm{GDOP}\cdot \sigma . -GDOP is a pure number :math:`\ge 1`: it is the factor by which the underlying ranging error is magnified at a given source location. The geometric intuition follows from the Jacobian rows being differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1`: +GDOP is a pure number :math:`\ge 1`: it is the factor by which the underlying ranging error is magnified at a given emitter location. The geometric intuition follows from the Jacobian rows being differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1`: -* When the sensors surround the source so that the bearing vectors point in well-spread directions, the hyperbolas cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). -* When the source lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolas intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). +* When the sensors surround the emitter so that the bearing vectors point in well-spread directions, the hyperbolas cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). +* When the emitter lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolas intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). This is the geometric counterpart of the observation above that hyperbolas degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. @@ -606,7 +606,7 @@ The figure below shows GDOP heat maps over a plane for (left) three sensors at t Sensor-Placement Optimization ===================================== -Because geometry is often a *design* variable, we can place sensors to minimize error. Common objectives minimize a scalar derived from :math:`\mathbf{F}^{-1}` — its trace (equivalent to GDOP), its determinant (the confidence-ellipse volume), or its largest eigenvalue (worst-case error). The qualitative results are intuitive: spread the sensors widely so long baselines sharpen angular resolution, surround the region of interest so sources fall inside the convex hull, avoid collinear or coplanar layouts that create ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or large coverage area, placement is optimized over the whole region — minimizing average or worst-case GDOP — usually by numerical search. +Because geometry is often a *design* variable, we can place sensors to minimize error. Common objectives minimize a scalar derived from :math:`\mathbf{F}^{-1}` — its trace (equivalent to GDOP), its determinant (the confidence-ellipse volume), or its largest eigenvalue (worst-case error). The qualitative results are intuitive: spread the sensors widely so long baselines sharpen angular resolution, surround the region of interest so emitters fall inside the convex hull, avoid collinear or coplanar layouts that create ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or large coverage area, placement is optimized over the whole region — minimizing average or worst-case GDOP — usually by numerical search. ***************************************** Practical Challenges in Real Systems @@ -635,7 +635,7 @@ Everything so far has assumed a single line-of-sight path between the emitter an Sensor-Position Uncertainty and Calibration =================================================== -The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s}_i`. Errors in those coordinates propagate into the position estimate just as measurement errors do, and for distant sources can be amplified by the same poor geometry that inflates GDOP. Careful survey of fixed installations, GPS positioning of mobile sensors, and *self-calibration* — jointly estimating sensor positions and source locations from sources of opportunity at known or constrained locations — are the standard responses. A full error budget must include sensor-position uncertainty alongside timing and TDE error; in well-synchronized systems it is often the next-largest term. +The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s}_i`. Errors in those coordinates propagate into the position estimate just as measurement errors do, and for distant emitters can be amplified by the same poor geometry that inflates GDOP. Careful survey of fixed installations, GPS positioning of mobile sensors, and *self-calibration* — jointly estimating sensor positions and emitter locations from emitters of opportunity at known or constrained locations — are the standard responses. A full error budget must include sensor-position uncertainty alongside timing and TDE error; in well-synchronized systems it is often the next-largest term. Bandwidth and SNR Limits ================================ @@ -649,7 +649,7 @@ Advanced Topics Joint TDOA/FDOA Estimation ================================== -When the source, the sensors, or both are *moving*, the relative motion imparts a Doppler shift that differs between sensors — a **Frequency Difference of Arrival** (FDOA). FDOA carries information about the source's *velocity* and, crucially, adds an independent geometric constraint that improves position observability, especially for the difficult far-field and few-sensor cases where TDOA alone is poorly conditioned. TDOA and FDOA are estimated jointly by maximizing the **Complex Ambiguity Function** (CAF) over both delay and frequency offset: +When the emitter, the sensors, or both are *moving*, the relative motion imparts a Doppler shift that differs between sensors — a **Frequency Difference of Arrival** (FDOA). FDOA carries information about the emitter's *velocity* and, crucially, adds an independent geometric constraint that improves position observability, especially for the difficult far-field and few-sensor cases where TDOA alone is poorly conditioned. TDOA and FDOA are estimated jointly by maximizing the **Complex Ambiguity Function** (CAF) over both delay and frequency offset: .. math:: From 5d3a9473e8855c6d7d43f4c176cddb26e0bd66a4 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 12:01:21 -0400 Subject: [PATCH 18/27] asd --- _images/tdoa_hyperbola.svg | 32 +++++++++++--------- content/tdoa.rst | 62 +++++++++++++++++++++++--------------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/_images/tdoa_hyperbola.svg b/_images/tdoa_hyperbola.svg index 22329c3f..282affa7 100644 --- a/_images/tdoa_hyperbola.svg +++ b/_images/tdoa_hyperbola.svg @@ -5,7 +5,7 @@ viewBox="0 0 418.31515 361.95105" version="1.1" id="svg300" - sodipodi:docname="doa_hyperbola.svg" + sodipodi:docname="tdoa_hyperbola.svg" inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" @@ -26,9 +26,9 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="pt" showgrid="false" - inkscape:zoom="3.4509409" - inkscape:cx="260.94333" - inkscape:cy="221.67867" + inkscape:zoom="19.52147" + inkscape:cx="261.17398" + inkscape:cy="222.473" inkscape:window-width="3840" inkscape:window-height="2054" inkscape:window-x="-11" @@ -65,15 +65,6 @@ style="fill:#ffffff" id="path11" /> - - - @@ -664,9 +655,10 @@ + transform="translate(-81.263091,-22.408021)" + style="fill:#00af00;fill-opacity:1;stroke:none;stroke-opacity:1"> baseline @@ -763,4 +755,14 @@ id="rect295" /> + + + diff --git a/content/tdoa.rst b/content/tdoa.rst index 322aa211..5dbbf17a 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -126,7 +126,7 @@ Taking sensor 1 as reference, the range-difference measurements are \Delta r_{21}=r_2-r_1\approx 17.08\ \text{m},\qquad \Delta r_{31}=r_3-r_1\approx 30.62\ \text{m}. -Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the emitter's position. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization developed below — where we will recover :math:`(40,30)` from exactly these numbers in closed form. +Each defines a hyperbola with foci :math:`\{\mathbf{s}_2,\mathbf{s}_1\}` and :math:`\{\mathbf{s}_3,\mathbf{s}_1\}` respectively; their intersection is the emitter's position. Solving the two hyperbola equations by hand is awkward, which is precisely the motivation for the algebraic linearization developed below, where we will recover :math:`(40,30)` from exactly these numbers in closed form. ************************************* The Signal and Measurement Model @@ -163,7 +163,7 @@ We assume each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and \mathrm{SNR}_i = \frac{a_i^2 \sigma_s^2}{\sigma_{n_i}^2}, -with :math:`\sigma_s^2` and :math:`\sigma_{n_i}^2` the signal and noise powers. These are idealizations — real noise is often colored and partially correlated across sensors — but they lead to estimators and bounds that perform well in practice, and the framework extends to a general noise covariance when needed. +with :math:`\sigma_s^2` and :math:`\sigma_{n_i}^2` the signal and noise powers. These are idealizations; real noise is often colored and partially correlated across sensors, but they lead to estimators and bounds that perform well in practice, and the framework extends to a general noise covariance when needed. The Nonlinear Measurement Equations ========================================== @@ -198,7 +198,7 @@ is maximized when the shift :math:`\tau` aligns the two copies, i.e. at :math:`\ \hat{\tau}_{ij} = \arg\max_{\tau} \, \hat{R}_{x_i x_j}(\tau), -with the sample cross-correlation computed from a finite record of length :math:`T`. Under the independent-noise assumption the noise contributes no systematic peak, so the correlation peak rides on the signal alignment. In practice the correlation is computed efficiently in the frequency domain via the FFT, using the cross-power spectral density :math:`G_{x_i x_j}(f) = \mathcal{F}\{R_{x_i x_j}(\tau)\}` and an inverse transform. +In practice the correlation is usually computed efficiently in the frequency domain via the FFT (just like how large convolutions typically use an FFT), using the cross-power spectral density :math:`G_{x_i x_j}(f) = \mathcal{F}\{R_{x_i x_j}(\tau)\}` and an inverse transform. Python Simulation ================== @@ -289,9 +289,7 @@ Everything so far was purely for simulation, the rest represents what you would peak_lag = np.argmax(np.abs(xcorr)) - (buffer_len - 1) # 'full' puts zero lag at index buffer_len-1 range_diff[k] = (peak_lag / sample_rate) * c # meters -Not much to it! - -This gives us the following results: +Not much to it! This gives us the following results: .. image:: ../_images/tdoa_python_integer.svg :align: center @@ -349,53 +347,69 @@ Plain cross-correlation is fragile: if the transmitted signal is narrow in bandw R^{\mathrm{GCC}}_{x_i x_j}(\tau) = \int_{-\infty}^{\infty} \Psi(f)\, G_{x_i x_j}(f)\, e^{j 2\pi f \tau}\, df . -The weighting reshapes the spectrum to sharpen and stabilize the peak. Different choices of :math:`\Psi(f)` correspond to different classical estimators, and selecting it well is the heart of robust TDE. - -Weighting Functions -========================== - -Common weightings include: +The weighting reshapes the spectrum to sharpen and stabilize the peak. Different choices of :math:`\Psi(f)` correspond to different classical estimators, and selecting it well is the heart of robust TDE. Common weightings include: * **Cross-correlation** (:math:`\Psi = 1`): the maximum-likelihood choice only in the high-SNR, broadband-flat limit; otherwise suboptimal. * **Roth** (:math:`\Psi = 1/G_{x_i x_i}(f)`): suppresses frequencies where one sensor is noisy. * **SCOT** (Smoothed Coherence Transform, :math:`\Psi = 1/\sqrt{G_{x_i x_i}G_{x_j x_j}}`): symmetric whitening of both channels. * **PHAT** (Phase Transform, :math:`\Psi = 1/|G_{x_i x_j}(f)|`): the most widely used choice in acoustics. -The **GCC-PHAT** estimator deserves emphasis. By dividing out the magnitude of the cross-spectrum it retains *only the phase*: +Diving deeper into the **GCC-PHAT** estimator- by dividing out the magnitude of the cross-spectrum it retains *only the phase*: .. math:: R^{\mathrm{PHAT}}_{x_i x_j}(\tau) = \int \frac{G_{x_i x_j}(f)}{\bigl|G_{x_i x_j}(f)\bigr|} e^{j2\pi f \tau} df . -Because the delay between two copies of a signal is encoded entirely in the *linear phase* term :math:`e^{-j2\pi f \tau_{ij}}`, while the magnitude carries the (often unhelpful) spectral shape and reverberant coloring, whitening to unit magnitude weights every frequency equally and produces a sharp, near-impulsive peak at the true delay. This makes PHAT strikingly robust to reverberation. Its weakness is that it also whitens noise-dominated frequencies, so at low SNR the equal weighting amplifies noise; SNR-aware variants reintroduce a coherence-based weighting to compensate. +Because the delay between two copies of a signal is encoded entirely in the *linear phase* term :math:`e^{-j2\pi f \tau_{ij}}`, while the magnitude carries the (often unhelpful) spectral shape and reverberant coloring, whitening to unit magnitude weights every frequency equally and produces a sharp, near-impulsive peak at the true delay. This makes PHAT strikingly robust to multipath. Its weakness is that it also whitens noise-dominated frequencies, so at low SNR the equal weighting amplifies noise; SNR-aware variants reintroduce a coherence-based weighting to compensate. In real systems, because most signals are not always on, you typically have to determine the time-frequency bounding box of the target signal before performing TDOA, so unless there is a lot of interference you can estimate the SNR pretty easily. Practical Considerations ================================ -Several effects govern real performance: +Several effects govern the accuracy of the TDOA results: -* The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the stationarity assumption and, for moving emitters, against blurring of the delay over the window. +* The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the stationarity assumption and, for moving emitters, against blurring of the delay over the window. Many times this value is determined by the signal itself, e.g. you might perform TDOA on a per-packet basis. * **Coherence bandwidth** limits which frequencies actually carry usable phase. -* **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the RMS bandwidth, so wideband signals localize far better than narrowband ones. +* **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the bandwidth, so wideband signals localize far better than narrowband ones, unlike DOA where we didn't care about bandwidth (in fact, many of the DOA concepts used a narrowband assumption). -Finally, the entire computation is dominated by FFTs and is :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large microphone arrays and dense sensor networks tractable. +From a compute perspective, the TDOA computation is dominated by FFTs and is :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large sensor networks tractable. -Worked Example: GCC-PHAT in Practice +GCC-PHAT Python Example ============================================ -Suppose two microphones sample at :math:`f_s = 48` kHz a transient whose true inter-microphone delay is :math:`\tau_{12} = 0.521` ms. In samples this is :math:`0.521\times10^{-3}\times 48000 \approx 25.0` samples. The processing chain is: (1) take an :math:`M`-point FFT of each record; (2) form the cross-spectrum :math:`X_1(f)\,X_2^*(f)`; (3) divide by its magnitude to apply PHAT; (4) inverse-FFT to obtain the lag-domain function; (5) locate the peak near lag 25 and refine by parabolic interpolation. If the discrete peak sits at lag 25 with neighbors at 24 and 26 having correlation values :math:`y_{-},y_0,y_{+}`, the sub-sample offset is +The nice thing about PHAT is that we can fold it into the simulation we already built with almost no new code. Recall that the sub-sample estimator above already worked in the frequency domain: it formed the cross-spectrum :math:`X_a^*(f)\,X_b(f)`, zero-padded it to interpolate, and inverse-FFT'd to recover the lag-domain correlation. PHAT is just one extra line: before transforming back to the lag domain, we divide the cross-spectrum by its own magnitude, so that every frequency bin contributes with unit weight and only the phase, which is where the delay lives, survives. -.. math:: +.. code-block:: python + + U = 16 # correlation upsampling factor + half = (buffer_len + 1) // 2 # number of DC + positive-frequency bins + range_diff = np.zeros(len(pairs)) # meters + for k, (a, b) in enumerate(pairs): + # Cross-spectrum, same as the sub-sample example + X = np.conj(np.fft.fft(rx_signals[a])) * np.fft.fft(rx_signals[b]) - \delta = \frac{1}{2}\,\frac{y_{-}-y_{+}}{y_{-}-2y_0+y_{+}}, + # PHAT weighting: divide out the magnitude so only the phase remains + X = X / (np.abs(X) + 1e-12) # small epsilon avoids divide-by-zero + + # Zero-pad in the high-frequency middle to interpolate, then IFFT + X_padded = np.zeros(U * buffer_len, dtype=complex) + X_padded[:half] = X[:half] + X_padded[U * buffer_len - (buffer_len - half):] = X[half:] + xcorr = np.abs(np.fft.ifft(X_padded)) * U + + # Peak index -> signed lag; indices past the midpoint are negative lags + peak_idx = np.argmax(xcorr) + if peak_idx > U * buffer_len // 2: + peak_idx -= U * buffer_len + peak_lag = peak_idx / U # sub-sample lag, +ve => Rx_b farther + range_diff[k] = (peak_lag / sample_rate) * c # meters -so a refined estimate of, say, lag :math:`25.02` corresponds to :math:`\hat\tau_{12} = 25.02/48000 \approx 0.5213` ms, and a range difference :math:`c\,\hat\tau_{12}\approx 0.179` m in air. The same code path, with :math:`c = 3\times10^8` m/s, serves a radio system — only the timing precision demanded of the hardware changes. +The only change from the sub-sample code is the single ``X = X / (np.abs(X) + 1e-12)`` line; the small epsilon in the denominator keeps frequency bins that hold almost no energy from blowing up when we divide. With our wideband, high-SNR simulated signal the result barely differs from plain cross-correlation, because PHAT and cross-correlation coincide in exactly that broadband, high-SNR limit. The payoff shows up in harder conditions: when the spectrum is colored or multipath smears the peak, whitening to unit magnitude collapses the correlation back to a sharp, near-impulsive spike at the true delay, which is why PHAT is the default in acoustics. ************************************* Closed-Form Localization Algorithms ************************************* -The measurement equations above are nonlinear and, taken directly, require iterative solution with a good starting point. *Closed-form* (non-iterative) estimators sidestep this by an algebraic trick: introduce an auxiliary variable that absorbs the nonlinearity and renders the system linear. They are fast, need no initial guess, and cannot get stuck in local minima — making them invaluable both on their own and as initializers for the iterative methods described below. +The measurement equations above are nonlinear and, taken directly, require iterative solution with a good starting point. *Closed-form* (non-iterative) estimators sidestep this by an algebraic trick: introduce an auxiliary variable that absorbs the nonlinearity and renders the system linear. They are fast, need no initial guess, and cannot get stuck in local minima, making them invaluable both on their own and as initializers for the iterative methods described below. The Linearization Strategy ================================== From 581aa2732c6ae7d3d823f8f011d4f900ebf57850 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 12:17:44 -0400 Subject: [PATCH 19/27] asd --- content/tdoa.rst | 62 +++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index 5dbbf17a..94b28281 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -445,6 +445,14 @@ Fang's Method Fang's algorithm provides an exact algebraic solution for the *minimum* configuration (3 sensors in 2D, 4 in 3D), giving a determined system rather than an overdetermined one. It is elegant and computationally trivial but does not use redundant sensors, so it cannot average down measurement noise and is sensitive to geometry. It is best viewed as the exact-determined special case that the least-squares methods generalize. +To see how it works, look again at the boxed linear equation above. With three sensors there are exactly two such equations (:math:`i=2,3`) but three unknowns :math:`(x,y,r_1)`. That looks underdetermined, but :math:`r_1` is not free: it is glued to the position by :math:`r_1^2=(x-x_1)^2+(y-y_1)^2`. Fang's trick is to *defer* that constraint, treat :math:`r_1` as a known constant, and solve the two linear equations for :math:`x` and :math:`y`. Because :math:`r_1` enters linearly, inverting the :math:`2\times2` matrix (well-conditioned as long as the sensors are not collinear) gives both coordinates as straight-line functions of the still-unknown range, + +.. math:: + + x = g_x + h_x\,r_1, \qquad y = g_y + h_y\,r_1 , + +with constants :math:`g_x,h_x,g_y,h_y` from the matrix inverse. Now cash in the deferred constraint: substituting these into :math:`r_1^2=(x-x_1)^2+(y-y_1)^2` collapses everything to a single scalar quadratic :math:`a\,r_1^2 + b\,r_1 + c = 0`. Solve it, keep the physical root (a range must be positive; the other root typically lands on the wrong hyperbola branch), and back-substitute to read off :math:`(x,y)`. That is the whole method: one :math:`2\times2` solve and one quadratic, no iteration and no initial guess. The worked example in the next subsection is precisely this procedure carried out with numbers. + Chan's Method (Two-Step Weighted Least Squares) ====================================================== @@ -465,41 +473,45 @@ The method returns a position directly, with computational cost dominated by inv Example, Continued: Solving the Three-Sensor Fix in Closed Form ================================================================ -Return to the three-sensor geometry from the example above: :math:`\mathbf{s}_1=(0,0)`, :math:`\mathbf{s}_2=(100,0)`, :math:`\mathbf{s}_3=(0,100)`, with measured range differences :math:`r_{21}=17.08` m and :math:`r_{31}=30.62` m. Here :math:`K_1=0`, :math:`K_2=K_3=10{,}000`. The boxed linear equations become, for :math:`i=2` and :math:`i=3`, - -.. math:: - - 200\,x + 34.16\,r_1 = 10{,}000 - (17.08)^2 = 9708.3, - -.. math:: +Let's pick up right where the Python simulation left off. At that point we had the ``range_diff`` array holding one measured range difference per sensor pair, and earlier we let our brain do the final step by eyeballing where the hyperbolas crossed. Now we'll replace that eyeballing with the closed-form algebra developed above, recovering the emitter position directly from ``range_diff`` and ``rx_positions``. Because we have exactly three sensors in 2D, this is Fang's minimum-configuration case: two boxed linear equations and one quadratic, no iteration and no initial guess. - 200\,y + 61.24\,r_1 = 10{,}000 - (30.62)^2 = 9062.5 . +We take ``Rx0`` as the reference sensor. The pairs were built as ``(0,1)``, ``(0,2)``, ``(1,2)``, and recall that ``range_diff[k]`` for pair ``(a,b)`` is :math:`r_b - r_a`, so the two pairs that include the reference, ``(0,1)`` and ``(0,2)``, hand us exactly the reference-based range differences :math:`r_{i0}=r_i-r_0` that the boxed equation needs. -Solving each for the position coordinate in terms of :math:`r_1`: - -.. math:: - - x = 48.54 - 0.1708\,r_1, \qquad y = 45.31 - 0.3062\,r_1 . - -Now impose the constraint :math:`r_1^2 = x^2 + y^2` (since :math:`\mathbf{s}_1` is at the origin). Substituting, - -.. math:: +.. code-block:: python - r_1^2 = (48.54 - 0.1708\,r_1)^2 + (45.31 - 0.3062\,r_1)^2, + # Solve for the emitter position in closed form (Fang's method, 3 sensors in 2D) + ref = 0 # use Rx0 as the reference sensor + s = rx_positions.astype(float) + K = np.sum(s**2, axis=1) # K_i = x_i^2 + y_i^2 for each sensor -which expands to the scalar quadratic + # Reference-based range differences r_i0 = r_i - r_ref for the two non-reference sensors + others = [i for i in range(num_rx) if i != ref] + r_i0 = np.array([range_diff[pairs.index((ref, i))] for i in others]) # pair (ref,i) holds r_i - r_ref -.. math:: + # Build the 2x2 linear system that gives (x, y) as a function of the unknown range r_ref + M = 2 * (s[others] - s[ref]) # rows: [2(x_i - x_ref), 2(y_i - y_ref)] + d = K[others] - K[ref] - r_i0**2 # right-hand side constants + Minv = np.linalg.inv(M) # well-conditioned as long as the sensors aren't collinear + g = Minv @ d # part of (x, y) that doesn't depend on r_ref + h = -2 * (Minv @ r_i0) # how (x, y) slide with r_ref: [x, y] = g + h * r_ref - 0.8771\,r_1^2 + 44.33\,r_1 - 4409.1 = 0 . + # Cash in the deferred constraint r_ref^2 = (x - x_ref)^2 + (y - y_ref)^2 -> scalar quadratic in r_ref + p = g - s[ref] # constant part of (x - x_ref, y - y_ref) + a_q = h[0]**2 + h[1]**2 - 1 + b_q = 2 * (p[0]*h[0] + p[1]*h[1]) + c_q = p[0]**2 + p[1]**2 + roots = np.roots([a_q, b_q, c_q]) -The positive root is :math:`r_1 = 50.0` m (the negative root is non-physical and is discarded). Back-substituting, + # Keep the physical root (a range must be positive and real), then back-substitute + r_ref = roots[(roots.real > 0) & (np.abs(roots.imag) < 1e-6)].real.max() + emitter_est = g + h * r_ref -.. math:: + print("Estimated emitter position:", emitter_est) # ~[153, 355] + print("True emitter position: ", tx_position) - x = 48.54 - 0.1708(50) = 40.0, \qquad y = 45.31 - 0.3062(50) = 30.0 . +The structure mirrors the math exactly: ``M`` and ``d`` are the two boxed linear equations, ``g`` and ``h`` express :math:`x` and :math:`y` as straight-line functions of the still-unknown reference range :math:`r_1` (called ``r_ref`` here), and substituting those into :math:`r_1^2=(x-x_1)^2+(y-y_1)^2` collapses everything to the scalar quadratic that ``np.roots`` solves. We discard the non-physical (negative or complex) root, keep the positive real one, and back-substitute to read off the position. With our high-SNR, wideband simulation the estimate lands right on top of the true emitter at :math:`(153, 355)`, with no human in the loop reading off a hyperbola intersection. -We recover the true emitter position :math:`\mathbf{u}=(40,30)` exactly, as we must in the noiseless case. This is the same fix that the intersecting hyperbolas illustrated above represented geometrically — now obtained by pure algebra, with no iteration and no initial guess. With noisy measurements the two equations would not be perfectly consistent, the quadratic root would be perturbed, and the weighting and second step of Chan's method would govern how gracefully the estimate degrades. +With noisier measurements the two linear equations would no longer be perfectly consistent, the quadratic root would be perturbed, and — because three sensors give us no redundancy to average over — the error would pass straight through. That is exactly where the redundant pairs and the weighting and second step of Chan's method earn their keep, governing how gracefully the estimate degrades. ***************************************** Iterative and Statistical Estimation From 27540b5f71aabedb351ba84e0537165ef2497de5 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 12:20:29 -0400 Subject: [PATCH 20/27] asd --- content/tdoa.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/content/tdoa.rst b/content/tdoa.rst index 94b28281..89dddfe8 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -553,6 +553,33 @@ Maximum-Likelihood Estimation Under Gaussian noise, the negative log-likelihood is, up to constants, exactly the weighted squared residual above. So **the maximum-likelihood estimator coincides with weighted nonlinear least squares** — the Gauss-Newton iteration is not a heuristic, it is the statistically optimal estimator under the assumed model. This is also the estimator whose covariance the Cramér-Rao bound below predicts. +Let's put that to work by continuing the Python example one more time. We already have a position from the closed-form solver, ``emitter_est``, and the theory tells us two things: the maximum-likelihood estimate is just the Gauss-Newton iteration above, and the closed-form fix is the ideal seed for it because it drops us right inside the basin of the true minimum. So we'll start at ``emitter_est`` and take a few Gauss-Newton steps, each one re-linearizing the range-difference model at the current guess and solving a tiny least-squares problem for the correction. Unlike Fang's solver, which used only the two pairs touching the reference sensor, this one uses *all three* pairs in ``range_diff``, so the extra pair acts as redundancy that the iteration averages over. + +.. code-block:: python + + # Refine the closed-form fix with Gauss-Newton (= maximum likelihood under Gaussian noise) + u = emitter_est.copy() # seed the iteration with the closed-form estimate + for _ in range(10): + h = np.zeros(len(pairs)) # predicted range differences at the current guess + J = np.zeros((len(pairs), 2)) # Jacobian, one row per pair + for k, (a, b) in enumerate(pairs): + e_a = (u - s[a]) / np.linalg.norm(u - s[a]) # unit vector from Rx_a toward the guess + e_b = (u - s[b]) / np.linalg.norm(u - s[b]) # unit vector from Rx_b toward the guess + h[k] = np.linalg.norm(u - s[b]) - np.linalg.norm(u - s[a]) # predicted r_b - r_a + J[k] = e_b - e_a # row of the Jacobian is a difference of unit bearing vectors + + residual = range_diff - h # measured minus predicted range differences + delta, *_ = np.linalg.lstsq(J, residual, rcond=None) # Gauss-Newton step (J^T J)^-1 J^T residual + u = u + delta + if np.linalg.norm(delta) < 1e-9: # stop once the update stops moving the estimate + break + + emitter_ml = u + print("ML (Gauss-Newton) estimate:", emitter_ml) # ~[153, 355] + print("True emitter position: ", tx_position) + +A couple of details worth pointing out. Because we assumed the range-difference errors are independent with equal variance, the weight :math:`\mathbf{C}^{-1}=\sigma^{-2}\mathbf{I}` is a scalar that cancels out of the update, which is why a plain ``np.linalg.lstsq`` (no weight matrix) computes the step exactly; if the pairs had unequal quality we would fold their inverse variances in here. The Jacobian rows are literally the ``e_b - e_a`` differences of unit bearing vectors from the math above, so you can watch the geometry enter the estimator directly. Starting from the already-good closed-form seed, the iteration converges in just a handful of steps and lands on the true emitter at :math:`(153, 355)`. In our high-SNR simulation it barely moves off the closed-form answer, but with noisier measurements this is where the extra pair and the iterative refinement pay off, and it is this same :math:`\mathbf{J}^\top\mathbf{C}^{-1}\mathbf{J}` that reappears in the Cramér-Rao bound below as the estimator's covariance. + Robust, Recursive, and Bayesian Extensions ================================================== From cc31fa1543c4d38402dbbb590cc29d6806b32ebf Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 12:59:52 -0400 Subject: [PATCH 21/27] cramer rao plot --- _images/tdoa_cramer_rao.svg | 1468 ++++++++++++++++++ content/tdoa.rst | 8 +- figure-generating-scripts/tdoa_cramer_rao.py | 38 + 3 files changed, 1513 insertions(+), 1 deletion(-) create mode 100644 _images/tdoa_cramer_rao.svg create mode 100644 figure-generating-scripts/tdoa_cramer_rao.py diff --git a/_images/tdoa_cramer_rao.svg b/_images/tdoa_cramer_rao.svg new file mode 100644 index 00000000..90e2e616 --- /dev/null +++ b/_images/tdoa_cramer_rao.svg @@ -0,0 +1,1468 @@ + + + + + + + + 2026-06-25T12:54:18.794956 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/tdoa.rst b/content/tdoa.rst index 89dddfe8..301ffd31 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -630,7 +630,13 @@ and the Cramér-Rao Lower Bound states that *any* unbiased estimator has covaria \mathrm{Cov}(\hat{\mathbf{u}}) \succeq \mathbf{F}^{-1} = (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}. -The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise — which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. +The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise — which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. The plot below shows a few example bandwidths and the lower bound over SNR, to give you a feel for how much error you should expect, or at least the floor. The y-axis is the 1-σ value (one standard deviation). + +.. image:: ../_images/tdoa_cramer_rao.svg + :align: center + :target: ../_images/tdoa_cramer_rao.svg + :alt: Plot of cramer rao lower bound + Geometric Dilution of Precision ======================================= diff --git a/figure-generating-scripts/tdoa_cramer_rao.py b/figure-generating-scripts/tdoa_cramer_rao.py new file mode 100644 index 00000000..3f2390a6 --- /dev/null +++ b/figure-generating-scripts/tdoa_cramer_rao.py @@ -0,0 +1,38 @@ +import numpy as np +import matplotlib.pyplot as plt + +# This script generates a figure showing the Cramer-Rao Lower Bound (CRLB) on +# time-delay estimation accuracy as a function of SNR, for three different +# signal bandwidths. The bound on the variance of any unbiased delay estimate is +# +# var(tau_hat) >= 1 / (8 * pi^2 * beta^2 * T * gamma) +# +# where beta is the RMS (Gabor) bandwidth of the signal, T is the integration +# time, and gamma is an effective SNR factor. We convert the resulting delay +# standard deviation into a ranging error in meters (multiplying by the speed of +# light) since that is the more intuitive quantity for localization. + +c = 3e8 # speed of light [m/s] +T = 100e-6 # integration time [s] + +snr_db = np.linspace(0, 30, 200) # SNR sweep [dB] +gamma = 10 ** (snr_db / 10) # effective SNR factor (linear) + +# For band-limited noise that is flat from -B/2 to +B/2, the RMS bandwidth is +# beta = B / sqrt(12). We show three signal bandwidths. +bandwidths = [1e6, 10e6, 50e6] # signal bandwidths [Hz] + +fig, ax = plt.subplots(figsize=(8, 5)) +for B in bandwidths: + beta = B / np.sqrt(12) # RMS bandwidth [Hz] + var_tau = 1.0 / (8 * np.pi**2 * beta**2 * T * gamma) # delay variance [s^2] + range_std = c * np.sqrt(var_tau) # ranging error [m] + ax.semilogy(snr_db, range_std, label=f'{B/1e6:.0f} MHz bandwidth') + +ax.set_xlabel('SNR [dB]') +ax.set_ylabel('Ranging error (CRLB) [m]') +ax.grid(True, which='both', alpha=0.4) +ax.legend() +ax.set_xlim(snr_db[0], snr_db[-1]) +fig.savefig('../_images/tdoa_cramer_rao.svg', bbox_inches='tight') +plt.show() From 6e5c8a5e4651cb5eadacb494e0c4acaa368f1b0f Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 13:05:18 -0400 Subject: [PATCH 22/27] asd --- _images/tdoa_gdop.svg | 68 +++++++++++++++++++------------------------ content/tdoa.rst | 11 +++---- 2 files changed, 36 insertions(+), 43 deletions(-) diff --git a/_images/tdoa_gdop.svg b/_images/tdoa_gdop.svg index 7f7b32ca..eb79e378 100644 --- a/_images/tdoa_gdop.svg +++ b/_images/tdoa_gdop.svg @@ -5,7 +5,7 @@ viewBox="0 0 810.43059 351.69705" version="1.1" id="svg1163" - sodipodi:docname="doa_gdop.svg" + sodipodi:docname="tdoa_gdop.svg" inkscape:version="1.2.2 (b0a8486541, 2022-12-01)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" @@ -26,15 +26,15 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="pt" showgrid="false" - inkscape:zoom="3.8327016" - inkscape:cx="529.78296" - inkscape:cy="176.11598" + inkscape:zoom="1.9163508" + inkscape:cx="453.72695" + inkscape:cy="168.54952" inkscape:window-width="3840" inkscape:window-height="2054" inkscape:window-x="-11" inkscape:window-y="-11" inkscape:window-maximized="1" - inkscape:current-layer="svg1163" /> + inkscape:current-layer="matplotlib.axis_6" /> @@ -1518,23 +1518,7 @@ x="0" y="-0.171875" style="font-size:12px;font-family:'DejaVu Sans'" - id="tspan1094">4×100 + id="tspan1094">4 @@ -1563,23 +1547,31 @@ x="0" y="-0.171875" style="font-size:12px;font-family:'DejaVu Sans'" - id="tspan1115">6×16 + + + + + + + + 00 + id="tspan1115-2">7 diff --git a/content/tdoa.rst b/content/tdoa.rst index 301ffd31..a6db64ab 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -641,19 +641,20 @@ The bound is the benchmark against which estimators are judged: a method that at Geometric Dilution of Precision ======================================= -Even with perfect measurements, geometry can ruin a fix. **Geometric Dilution of Precision** (GDOP) quantifies how the sensor-emitter configuration amplifies measurement error into position error. If the range-difference errors are independent with common standard deviation :math:`\sigma`, so :math:`\mathbf{C}=\sigma^2\mathbf{I}`, then +Suppose your sensors can measure range differences to about 1 m of accuracy — a respectable number for a well-synchronized radio system. You might expect to then pin down the emitter to roughly 1 m as well. But where is the emitter? Picture it sitting comfortably inside a triangle of three sensors: the hyperbolas from each sensor pair slice across one another at steep, nearly right angles, and where they cross is pinned down tightly, so your 1 m of ranging error turns into maybe 1.5 m of position error. Now slide that same emitter far off to one side, well outside the cluster. The hyperbolas now graze each other at a shallow angle, like two gently curving lines that nearly overlap, and the crossing point smears out along the direction they share. The very same 1 m of ranging error can now balloon into tens of meters of position error. Nothing about your hardware changed — only the geometry did. + +That blow-up factor has a name: **Geometric Dilution of Precision** (GDOP). It captures how much the sensor-emitter layout magnifies measurement error into position error. If the range-difference errors are independent and each has the same standard deviation :math:`\sigma`, so the covariance is :math:`\mathbf{C}=\sigma^2\mathbf{I}` (a diagonal matrix with :math:`\sigma^2` on the diagonal), then .. math:: \mathrm{GDOP} = \sqrt{\mathrm{tr}\bigl[(\mathbf{J}^\top\mathbf{J})^{-1}\bigr]}, \qquad \sigma_{\text{position}} = \mathrm{GDOP}\cdot \sigma . -GDOP is a pure number :math:`\ge 1`: it is the factor by which the underlying ranging error is magnified at a given emitter location. The geometric intuition follows from the Jacobian rows being differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1`: +Your position error is just your ranging error multiplied by GDOP, so GDOP is a unitless number, always :math:`\ge 1`, telling you the factor by which ranging error gets magnified at a given emitter location. -* When the sensors surround the emitter so that the bearing vectors point in well-spread directions, the hyperbolas cross at large angles, :math:`\mathbf{J}^\top\mathbf{J}` is well-conditioned, and GDOP is small (good). -* When the emitter lies far outside the sensor cluster, or the sensors are nearly collinear, the bearing vectors become nearly parallel, the hyperbolas intersect at shallow angles, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular, and GDOP explodes (bad). +Where does the magnification come from? It is baked into the Jacobian :math:`\mathbf{J}`, whose rows are differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1` (the direction to one sensor minus the direction to another). When those directions point all over the place, :math:`\mathbf{J}^\top\mathbf{J}` is *well-conditioned* (far from singular, so its inverse stays small) and GDOP is small. When they nearly line up, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular and GDOP blows up. So an emitter surrounded by the sensors, with bearing vectors well-spread and hyperbolas crossing at large angles, gets a small GDOP (good), while an emitter far outside the cluster, or sensors nearly collinear (almost in a straight line), leaves the bearing vectors nearly parallel and the hyperbolas grazing at shallow angles, giving a huge GDOP (bad). -This is the geometric counterpart of the observation above that hyperbolas degenerate near the baseline extremes. A practical TDOA system can be limited far more by where its sensors sit than by how well it measures time. +This is the same effect we saw when hyperbolas degenerate near the ends of the baseline. The takeaway: a TDOA system can be limited far more by *where its sensors sit* than by *how well it measures time* — all the nanosecond synchronization and wide bandwidth in the world won't save a fix in a high-GDOP region of the map. The figure below shows GDOP heat maps over a plane for (left) three sensors at the vertices of an equilateral triangle and (right) three nearly collinear sensors, showing a broad low-GDOP region inside the triangle versus a narrow usable corridor for the collinear array, with GDOP rising sharply outside the convex hull in both cases. From ae6d06a42159fe4343ba3b36fe90bc15e26cca6a Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 13:08:47 -0400 Subject: [PATCH 23/27] asd --- content/tdoa.rst | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index a6db64ab..b8903c34 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -369,7 +369,7 @@ Several effects govern the accuracy of the TDOA results: * The **integration window** :math:`T` trades estimator variance (longer is better, since variance falls roughly as :math:`1/T`) against the stationarity assumption and, for moving emitters, against blurring of the delay over the window. Many times this value is determined by the signal itself, e.g. you might perform TDOA on a per-packet basis. * **Coherence bandwidth** limits which frequencies actually carry usable phase. -* **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the bandwidth, so wideband signals localize far better than narrowband ones, unlike DOA where we didn't care about bandwidth (in fact, many of the DOA concepts used a narrowband assumption). +* **Signal bandwidth** is decisive: as the Cramér-Rao analysis below shows, delay variance falls as the *square* of the bandwidth, so wideband signals localize far better than narrowband ones, unlike DOA where we didn't care about bandwidth (in fact, many of the DOA concepts used a narrowband assumption). That being said, you don't need to capture the entire signal (from a frequency domain perspective) to perform TDOA, if you are limited by your SDR's maximum sample rate, and you can only receive a portion of the signal bandwidth, you can still do TDOA! From a compute perspective, the TDOA computation is dominated by FFTs and is :math:`O(M\log M)` per sensor pair for records of :math:`M` samples, which is what makes large sensor networks tractable. @@ -672,7 +672,7 @@ Because geometry is often a *design* variable, we can place sensors to minimize Practical Challenges in Real Systems ***************************************** -The clean model developed above omits the effects that, in deployment, usually dominate the error budget. Three deserve detailed treatment. +The model used in this chapter so far omits a few effects that usually dominate the error budget in actual TDOA deployments. Three deserve detailed treatment: Receiver Synchronization ================================ @@ -697,11 +697,6 @@ Sensor-Position Uncertainty and Calibration The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s}_i`. Errors in those coordinates propagate into the position estimate just as measurement errors do, and for distant emitters can be amplified by the same poor geometry that inflates GDOP. Careful survey of fixed installations, GPS positioning of mobile sensors, and *self-calibration* — jointly estimating sensor positions and emitter locations from emitters of opportunity at known or constrained locations — are the standard responses. A full error budget must include sensor-position uncertainty alongside timing and TDE error; in well-synchronized systems it is often the next-largest term. -Bandwidth and SNR Limits -================================ - -The bound derived above already quantified the dependence: delay variance scales as :math:`1/(\beta^2 T\,\gamma)`. The practical reading is that the most effective levers on accuracy are usually *more bandwidth* (quadratic payoff) and *more integration time* or *power* (linear payoff). A system designer who cannot move the sensors and cannot improve the clocks can still often improve the fix by capturing a wider slice of the emitter's spectrum. That being said, you don't need to capture the entire signal (from a frequency domain perspective) to perform TDOA, if you are limited by your SDR's maximum sample rate, and you can only receive a portion of the signal bandwidth, you can still do TDOA! - ******************* Advanced Topics ******************* From bc78745a1d94b3ca9ae17d27b0ccb30e59a6c2d6 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 13:18:53 -0400 Subject: [PATCH 24/27] editing pass --- content/tdoa.rst | 56 ++++++++++++++++++++++++------------------- spelling_wordlist.txt | 3 +++ 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index b8903c34..5109b1dd 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -4,9 +4,9 @@ TDOA #### -Time Difference of Arrival (TDOA) is a technique that can find the position of a transmitter (a.k.a. emitter) using multiple synchronized receivers (a.k.a. sensors), by comparing differences in signal arrival time. This chapter covers the full TDOA pipeline, geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA is commonly used in both RF and acoustic/sonar applications. +Time Difference of Arrival (TDOA) is a technique that can find the position of a transmitter (a.k.a. emitter) using multiple synchronized receivers (a.k.a. sensors), by comparing differences in signal arrival time. This chapter covers the full TDOA pipeline: geometry, GCC-PHAT time-delay estimation, closed-form and maximum-likelihood localization, accuracy bounds (CRLB and GDOP), and challenges like synchronization and multipath. TDOA is commonly used in both RF and acoustic/sonar applications. -Before diving in, try playing with the interactive demo below to get a quick feel for how TDOA works, it involves intersection of hyperbolas. +Before diving in, try playing with the interactive demo below to get a quick feel for how TDOA works, which involves the intersection of hyperbolas. .. raw:: html @@ -27,7 +27,7 @@ The key behind TDOA is that when the same wavefront hits two sensors, the differ \tau_{ij} = t_i - t_j = \frac{r_i - r_j}{c}, -the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on emitter and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters; we never need to know when the emitter transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. You still need to isolate the signal so that you're only observing one emitter, so signal detection and classification may be nessesary, plus filtering. +the unknown :math:`t_0` vanishes, which is good because we will likely never know :math:`t_0`. The TDOA depends only on the *difference* of ranges, which depends only on emitter and sensor geometry. This single fact is why TDOA dominates for non-cooperative emitters; we never need to know when the emitter transmitted, only that the same wavefront reached our synchronized receivers at measurable relative delays. You still need to isolate the signal so that you're only observing one emitter, so signal detection and classification may be necessary, plus filtering. Each pair of sensors yields one TDOA, and each TDOA traces out one hyperbola, so the number of hyperbolas we can draw is just the number of sensor pairs. With :math:`N` sensors that is @@ -78,7 +78,7 @@ which reads "distance to one sensor minus distance to the other equals our measu * **The sign tells you which side you're on.** A hyperbola actually has two mirror-image branches, one curving toward each sensor. Whether your range difference came out positive or negative picks the branch nearer the closer sensor, so you don't get confused between the two halves. * **The shape depends on the measurement.** When the range difference is close to the full baseline, the hyperbola hugs the line between the sensors. When the range difference is near zero (the emitter is roughly equidistant), the curve straightens out into the line that perpendicularly bisects the baseline. Near both of these extremes the geometry becomes "ill-conditioned," meaning small measurement errors push the estimated position around a lot, so positioning there is less reliable. -A single TDOA thus constrains the emitter's position to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line but you would need a third sensor to find where on that line it is (performing geolocation). +A single TDOA thus constrains the emitter's position to a curve, not a point. To fix a position we intersect several such curves. Below we plot two sensors, and several hyperbola branches drawn for :math:`\Delta r < 0`, :math:`\Delta r = 0` (the perpendicular bisector), and :math:`\Delta r > 0`. On each hyperbola, the TDOA between the two sensors is constant. If you calculated the TDOA with just two sensors, you would know it is somewhere on that line, but you would need a third sensor to find where on that line it is (performing geolocation). .. image:: ../_images/tdoa_hyperbola.svg :align: center @@ -100,7 +100,7 @@ Reference Sensor and Independent Pairs From :math:`N` sensors one can form :math:`\binom{N}{2}` pairwise TDOAs, but they are not all independent. Choosing one sensor as a **reference** (say sensor 1) and forming :math:`\tau_{i1}` for :math:`i = 2,\dots,N` yields :math:`N-1` TDOAs from which every other pairwise difference can be reconstructed, since :math:`\tau_{ij} = \tau_{i1} - \tau_{j1}`. These :math:`N-1` are the *independent* measurements that carry all the geometric information. -The redundant pairs are not worthless, however. Because each measured TDOA carries independent *noise*, using all :math:`\binom{N}{2}` pairs (with a correctly modeled, correlated noise covariance — the reference sensor's noise is common to every :math:`\tau_{i1}`) can improve the estimate. +The redundant pairs are not worthless, however. Because each measured TDOA carries independent *noise*, using all :math:`\binom{N}{2}` pairs (with a correctly modeled, correlated noise covariance, where the reference sensor's noise is common to every :math:`\tau_{i1}`) can improve the estimate. Example: A Three-Sensor 2D Fix ============================== @@ -157,7 +157,7 @@ The right-hand side makes explicit that the TDOA is a nonlinear function of the Noise Assumptions ======================== -We assume each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and independent of the trasnmitted signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is +We assume each :math:`n_i(t)` is zero-mean, wide-sense stationary, Gaussian, and independent of the transmitted signal and of the noise at other sensors. The per-sensor signal-to-noise ratio is .. math:: @@ -196,14 +196,14 @@ is maximized when the shift :math:`\tau` aligns the two copies, i.e. at :math:`\ .. math:: - \hat{\tau}_{ij} = \arg\max_{\tau} \, \hat{R}_{x_i x_j}(\tau), + \hat{\tau}_{ij} = \arg\max_{\tau} \, \hat{R}_{x_i x_j}(\tau). In practice the correlation is usually computed efficiently in the frequency domain via the FFT (just like how large convolutions typically use an FFT), using the cross-power spectral density :math:`G_{x_i x_j}(f) = \mathcal{F}\{R_{x_i x_j}(\tau)\}` and an inverse transform. Python Simulation ================== -Enough math, let's see how this all looks with a simple Python example. First we'll set up the simulation with some high level parameters such as emitter and sensor position, and the simulated sample rate which is essentially how much spectrum the receiver's will "see". +Enough math, let's see how this all looks with a simple Python example. First we'll set up the simulation with some high level parameters such as emitter and sensor position, and the simulated sample rate, which is essentially how much spectrum the receivers will "see". .. code-block:: python @@ -241,7 +241,7 @@ Next we will simulate the receivers receiving the signal at a delay based on the # Simulate what each receiver records true_distances = np.linalg.norm(rx_positions - tx_position, axis=1) true_delays = true_distances / c - unknown_tx_time = 1.234e-5 # seconds. arbitray, unknown to receivers and we wont use it in any TDOA calcs + unknown_tx_time = 1.234e-5 # seconds. arbitrary, unknown to receivers and we won't use it in any TDOA calcs # Calc the actual TDOAs to act as ground truth for k, (a, b) in enumerate(pairs): @@ -376,7 +376,7 @@ From a compute perspective, the TDOA computation is dominated by FFTs and is :ma GCC-PHAT Python Example ============================================ -The nice thing about PHAT is that we can fold it into the simulation we already built with almost no new code. Recall that the sub-sample estimator above already worked in the frequency domain: it formed the cross-spectrum :math:`X_a^*(f)\,X_b(f)`, zero-padded it to interpolate, and inverse-FFT'd to recover the lag-domain correlation. PHAT is just one extra line: before transforming back to the lag domain, we divide the cross-spectrum by its own magnitude, so that every frequency bin contributes with unit weight and only the phase, which is where the delay lives, survives. +The nice thing about PHAT is that we can fold it into the simulation we already built with almost no new code. Recall that the sub-sample estimator above already worked in the frequency domain: it formed the cross-spectrum :math:`X_a^*(f)\,X_b(f)`, zero-padded it to interpolate, and inverse-FFT to recover the lag-domain correlation. PHAT is just one extra line: before transforming back to the lag domain, we divide the cross-spectrum by its own magnitude, so that every frequency bin contributes with unit weight and only the phase, which is where the delay lives, survives. .. code-block:: python @@ -438,7 +438,7 @@ This equation is **linear** in the unknowns :math:`(x, y, r_1)`, where the range Spherical Interpolation and Spherical Intersection ========================================================= -The earliest closed-form estimators, Spherical Interpolation (SI) and Spherical Intersection (SX) of Schau and Robinson, exploit exactly this structure. They first solve the linear system for :math:`(x,y)` as a function of :math:`r_1`, then impose the constraint that ties them together — namely :math:`r_1^2 = (x-x_1)^2+(y-y_1)^2` — to pin down :math:`r_1`. SI obtains :math:`r_1` by a least-squares projection; SX substitutes the linear solution into the quadratic constraint and solves the resulting scalar quadratic. They are simple and fast but treat the auxiliary variable somewhat crudely, leaving accuracy on the table at higher noise. +The earliest closed-form estimators, Spherical Interpolation (SI) and Spherical Intersection (SX) of Schau and Robinson, exploit exactly this structure. They first solve the linear system for :math:`(x,y)` as a function of :math:`r_1`, then impose the constraint that ties them together, namely :math:`r_1^2 = (x-x_1)^2+(y-y_1)^2`, to pin down :math:`r_1`. SI obtains :math:`r_1` by a least-squares projection; SX substitutes the linear solution into the quadratic constraint and solves the resulting scalar quadratic. They are simple and fast but treat the auxiliary variable somewhat crudely, leaving accuracy on the table at higher noise. Fang's Method ==================== @@ -464,11 +464,11 @@ The estimator that became the practical standard is Chan and Ho's two-step weigh \hat{\boldsymbol{\theta}} = (\mathbf{A}^\top \mathbf{W}\mathbf{A})^{-1}\mathbf{A}^\top \mathbf{W}\,\mathbf{b}, -with the weight :math:`\mathbf{W}` chosen as the inverse covariance of the equation errors. Because that covariance itself depends on the unknown ranges, in practice one first solves with :math:`\mathbf{W}=\mathbf{I}` (or the raw TDOA noise covariance), then recomputes :math:`\mathbf{W}` from the resulting range estimates and re-solves — a one- or two-pass refinement. +with the weight :math:`\mathbf{W}` chosen as the inverse covariance of the equation errors. Because that covariance itself depends on the unknown ranges, in practice one first solves with :math:`\mathbf{W}=\mathbf{I}` (or the raw TDOA noise covariance), then recomputes :math:`\mathbf{W}` from the resulting range estimates and re-solves, a one- or two-pass refinement. **Second step.** The first step ignored the known relationship :math:`r_1^2 = (x-x_1)^2+(y-y_1)^2` that couples the auxiliary variable to the position. The second step restores it: form a new small least-squares problem in the squared quantities :math:`[(x-x_1)^2,(y-y_1)^2,r_1^2]`, using the first-step covariance to weight it, and solve for a corrected position. This second WLS removes much of the bias of the naive linear solution and is what brings Chan's estimator close to optimal. -The method returns a position directly, with computational cost dominated by inverting small :math:`3\times3` matrices — negligible compared with the FFTs of the front end. Its limitations appear at high noise or unfavorable geometry, where the squared-range manipulation amplifies errors and the second step can pick the wrong root; there, the iterative refinement described below, seeded by Chan's output, is the standard remedy. +The method returns a position directly, with computational cost dominated by inverting small :math:`3\times3` matrices, negligible compared with the FFTs of the front end. Its limitations appear at high noise or unfavorable geometry, where the squared-range manipulation amplifies errors and the second step can pick the wrong root; there, the iterative refinement described below, seeded by Chan's output, is the standard remedy. Example, Continued: Solving the Three-Sensor Fix in Closed Form ================================================================ @@ -511,7 +511,7 @@ We take ``Rx0`` as the reference sensor. The pairs were built as ``(0,1)``, ``(0 The structure mirrors the math exactly: ``M`` and ``d`` are the two boxed linear equations, ``g`` and ``h`` express :math:`x` and :math:`y` as straight-line functions of the still-unknown reference range :math:`r_1` (called ``r_ref`` here), and substituting those into :math:`r_1^2=(x-x_1)^2+(y-y_1)^2` collapses everything to the scalar quadratic that ``np.roots`` solves. We discard the non-physical (negative or complex) root, keep the positive real one, and back-substitute to read off the position. With our high-SNR, wideband simulation the estimate lands right on top of the true emitter at :math:`(153, 355)`, with no human in the loop reading off a hyperbola intersection. -With noisier measurements the two linear equations would no longer be perfectly consistent, the quadratic root would be perturbed, and — because three sensors give us no redundancy to average over — the error would pass straight through. That is exactly where the redundant pairs and the weighting and second step of Chan's method earn their keep, governing how gracefully the estimate degrades. +With noisier measurements the two linear equations would no longer be perfectly consistent, the quadratic root would be perturbed, and, because three sensors give us no redundancy to average over, the error would pass straight through. That is exactly where the redundant pairs and the weighting and second step of Chan's method earn their keep, governing how gracefully the estimate degrades. ***************************************** Iterative and Statistical Estimation @@ -551,7 +551,7 @@ iterated to convergence. Each step solves a small linear system. The method conv Maximum-Likelihood Estimation ===================================== -Under Gaussian noise, the negative log-likelihood is, up to constants, exactly the weighted squared residual above. So **the maximum-likelihood estimator coincides with weighted nonlinear least squares** — the Gauss-Newton iteration is not a heuristic, it is the statistically optimal estimator under the assumed model. This is also the estimator whose covariance the Cramér-Rao bound below predicts. +Under Gaussian noise, the negative log-likelihood is, up to constants, exactly the weighted squared residual above. So **the maximum-likelihood estimator coincides with weighted nonlinear least squares**, the Gauss-Newton iteration is not a heuristic, it is the statistically optimal estimator under the assumed model. This is also the estimator whose covariance the Cramér-Rao bound below predicts. Let's put that to work by continuing the Python example one more time. We already have a position from the closed-form solver, ``emitter_est``, and the theory tells us two things: the maximum-likelihood estimate is just the Gauss-Newton iteration above, and the closed-form fix is the ideal seed for it because it drops us right inside the basin of the true minimum. So we'll start at ``emitter_est`` and take a few Gauss-Newton steps, each one re-linearizing the range-difference model at the current guess and solving a tiny least-squares problem for the correction. Unlike Fang's solver, which used only the two pairs touching the reference sensor, this one uses *all three* pairs in ``range_diff``, so the extra pair acts as redundancy that the iteration averages over. @@ -583,15 +583,21 @@ A couple of details worth pointing out. Because we assumed the range-difference Robust, Recursive, and Bayesian Extensions ================================================== -Real measurements contain outliers — a multipath-corrupted TDOA can be wildly wrong while the rest are fine. Plain least squares, which squares residuals, is badly distorted by such outliers. *Robust* estimators replace the squared loss with one that grows more slowly (e.g. Huber's), or explicitly detect and discard inconsistent TDOAs via residual tests or RANSAC-style consensus. +Real measurements contain outliers, a multipath-corrupted TDOA can be wildly wrong while the rest are fine. Plain least squares, which squares residuals, is badly distorted by such outliers. *Robust* estimators replace the squared loss with one that grows more slowly (e.g. Huber's), or explicitly detect and discard inconsistent TDOAs via residual tests or RANSAC-style consensus. When the emitter *moves*, we want to fuse measurements over time rather than localize each instant independently. State-space filtering does this by modeling the emitter's position (and velocity) as an evolving state. The **Kalman filter** is optimal for linear-Gaussian dynamics, but the TDOA measurement is nonlinear, so practitioners use the **Extended Kalman Filter** (which linearizes the measurement with the same Jacobian as above), the **Unscented Kalman Filter** (which propagates a deterministic set of sigma points through the nonlinearity, avoiding explicit Jacobians and handling stronger nonlinearity better), or, for multimodal or heavily non-Gaussian problems, the **particle filter** (which represents the posterior by a weighted sample cloud). These trackers also naturally enforce motion continuity, which suppresses the per-snapshot ambiguities of static localization. +**************************** +Brute-Force Heatmap Approach +**************************** + + + *********************************************** Performance Analysis and Fundamental Bounds *********************************************** -Having estimators in hand, we ask: how accurate *can* a TDOA system be, and what governs that accuracy? Two ideas answer this — the Cramér-Rao bound, which sets a noise floor from the signals, and geometric dilution of precision, which describes how sensor-emitter geometry amplifies that floor. +Having estimators in hand, we ask: how accurate *can* a TDOA system be, and what governs that accuracy? Two ideas answer this: the Cramér-Rao bound, which sets a noise floor from the signals, and geometric dilution of precision, which describes how sensor-emitter geometry amplifies that floor. Error Propagation ======================== @@ -613,7 +619,7 @@ We can bound how well any estimator can measure a single delay. For a signal of \mathrm{var}(\hat\tau_{ij}) \gtrsim \frac{1}{8\pi^2 \beta^2 T \gamma}, -where :math:`\beta` is the *RMS (Gabor) bandwidth* of the signal and :math:`\gamma` is an effective SNR factor combining the two sensors' SNRs. Three design lessons fall straight out: variance improves with **integration time** :math:`T`, with **effective SNR** :math:`\gamma`, and — most strikingly — with the **square of bandwidth** :math:`\beta^2`. Doubling the bandwidth quarters the delay variance. This is why wideband and spread-spectrum waveforms are so prized for ranging, and why narrowband emitters are intrinsically hard to localize by TDOA alone. +where :math:`\beta` is the *RMS (Gabor) bandwidth* of the signal and :math:`\gamma` is an effective SNR factor combining the two sensors' SNRs. Three design lessons fall straight out: variance improves with **integration time** :math:`T`, with **effective SNR** :math:`\gamma`, and, most strikingly, with the **square of bandwidth** :math:`\beta^2`. Doubling the bandwidth quarters the delay variance. This is why wideband and spread-spectrum waveforms are so prized for ranging, and why narrowband emitters are intrinsically hard to localize by TDOA alone. The Localization Cramér-Rao Lower Bound ============================================== @@ -630,7 +636,7 @@ and the Cramér-Rao Lower Bound states that *any* unbiased estimator has covaria \mathrm{Cov}(\hat{\mathbf{u}}) \succeq \mathbf{F}^{-1} = (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}. -The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise — which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. The plot below shows a few example bandwidths and the lower bound over SNR, to give you a feel for how much error you should expect, or at least the floor. The y-axis is the 1-σ value (one standard deviation). +The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise, which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. The plot below shows a few example bandwidths and the lower bound over SNR, to give you a feel for how much error you should expect, or at least the floor. The y-axis is the 1-σ value (one standard deviation). .. image:: ../_images/tdoa_cramer_rao.svg :align: center @@ -641,7 +647,7 @@ The bound is the benchmark against which estimators are judged: a method that at Geometric Dilution of Precision ======================================= -Suppose your sensors can measure range differences to about 1 m of accuracy — a respectable number for a well-synchronized radio system. You might expect to then pin down the emitter to roughly 1 m as well. But where is the emitter? Picture it sitting comfortably inside a triangle of three sensors: the hyperbolas from each sensor pair slice across one another at steep, nearly right angles, and where they cross is pinned down tightly, so your 1 m of ranging error turns into maybe 1.5 m of position error. Now slide that same emitter far off to one side, well outside the cluster. The hyperbolas now graze each other at a shallow angle, like two gently curving lines that nearly overlap, and the crossing point smears out along the direction they share. The very same 1 m of ranging error can now balloon into tens of meters of position error. Nothing about your hardware changed — only the geometry did. +Suppose your sensors can measure range differences to about 1 m of accuracy, a respectable number for a well-synchronized radio system. You might expect to then pin down the emitter to roughly 1 m as well. But where is the emitter? Picture it sitting comfortably inside a triangle of three sensors: the hyperbolas from each sensor pair slice across one another at steep, nearly right angles, and where they cross is pinned down tightly, so your 1 m of ranging error turns into maybe 1.5 m of position error. Now slide that same emitter far off to one side, well outside the cluster. The hyperbolas now graze each other at a shallow angle, like two gently curving lines that nearly overlap, and the crossing point smears out along the direction they share. The very same 1 m of ranging error can now balloon into tens of meters of position error. Nothing about your hardware changed, only the geometry did. That blow-up factor has a name: **Geometric Dilution of Precision** (GDOP). It captures how much the sensor-emitter layout magnifies measurement error into position error. If the range-difference errors are independent and each has the same standard deviation :math:`\sigma`, so the covariance is :math:`\mathbf{C}=\sigma^2\mathbf{I}` (a diagonal matrix with :math:`\sigma^2` on the diagonal), then @@ -654,7 +660,7 @@ Your position error is just your ranging error multiplied by GDOP, so GDOP is a Where does the magnification come from? It is baked into the Jacobian :math:`\mathbf{J}`, whose rows are differences of unit bearing vectors :math:`\hat{\mathbf{e}}_i - \hat{\mathbf{e}}_1` (the direction to one sensor minus the direction to another). When those directions point all over the place, :math:`\mathbf{J}^\top\mathbf{J}` is *well-conditioned* (far from singular, so its inverse stays small) and GDOP is small. When they nearly line up, :math:`\mathbf{J}^\top\mathbf{J}` becomes nearly singular and GDOP blows up. So an emitter surrounded by the sensors, with bearing vectors well-spread and hyperbolas crossing at large angles, gets a small GDOP (good), while an emitter far outside the cluster, or sensors nearly collinear (almost in a straight line), leaves the bearing vectors nearly parallel and the hyperbolas grazing at shallow angles, giving a huge GDOP (bad). -This is the same effect we saw when hyperbolas degenerate near the ends of the baseline. The takeaway: a TDOA system can be limited far more by *where its sensors sit* than by *how well it measures time* — all the nanosecond synchronization and wide bandwidth in the world won't save a fix in a high-GDOP region of the map. +This is the same effect we saw when hyperbolas degenerate near the ends of the baseline. The takeaway: a TDOA system can be limited far more by *where its sensors sit* than by *how well it measures time*, all the nanosecond synchronization and wide bandwidth in the world won't save a fix in a high-GDOP region of the map. The figure below shows GDOP heat maps over a plane for (left) three sensors at the vertices of an equilateral triangle and (right) three nearly collinear sensors, showing a broad low-GDOP region inside the triangle versus a narrow usable corridor for the collinear array, with GDOP rising sharply outside the convex hull in both cases. @@ -666,7 +672,7 @@ The figure below shows GDOP heat maps over a plane for (left) three sensors at t Sensor-Placement Optimization ===================================== -Because geometry is often a *design* variable, we can place sensors to minimize error. Common objectives minimize a scalar derived from :math:`\mathbf{F}^{-1}` — its trace (equivalent to GDOP), its determinant (the confidence-ellipse volume), or its largest eigenvalue (worst-case error). The qualitative results are intuitive: spread the sensors widely so long baselines sharpen angular resolution, surround the region of interest so emitters fall inside the convex hull, avoid collinear or coplanar layouts that create ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or large coverage area, placement is optimized over the whole region — minimizing average or worst-case GDOP — usually by numerical search. +Because geometry is often a *design* variable, we can place sensors to minimize error. Common objectives minimize a scalar derived from :math:`\mathbf{F}^{-1}`, such as its trace (equivalent to GDOP), its determinant (the confidence-ellipse volume), or its largest eigenvalue (worst-case error). The qualitative results are intuitive: spread the sensors widely so long baselines sharpen angular resolution, surround the region of interest so emitters fall inside the convex hull, avoid collinear or coplanar layouts that create ill-conditioned directions, and add sensors where redundancy both lowers variance and guards against outliers. For a moving target or large coverage area, placement is optimized over the whole region, minimizing average or worst-case GDOP, usually by numerical search. ***************************************** Practical Challenges in Real Systems @@ -677,7 +683,7 @@ The model used in this chapter so far omits a few effects that usually dominate Receiver Synchronization ================================ -TDOA's defining advantage — that it needs no synchronized transmitter — comes paired with its defining burden: the *receivers* must share a common time reference, and any error in that reference enters the measurement directly. If sensor :math:`i`'s clock is offset from truth by :math:`\delta t_i`, the measured TDOA is corrupted by :math:`\delta t_i - \delta t_j`, an error multiplied by :math:`c` in range. The scale is unforgiving for radio systems: +TDOA's defining advantage, that it needs no synchronized transmitter, comes paired with its defining burden: the *receivers* must share a common time reference, and any error in that reference enters the measurement directly. If sensor :math:`i`'s clock is offset from truth by :math:`\delta t_i`, the measured TDOA is corrupted by :math:`\delta t_i - \delta t_j`, an error multiplied by :math:`c` in range. The scale is unforgiving for radio systems: .. math:: @@ -695,7 +701,7 @@ Everything so far has assumed a single line-of-sight path between the emitter an Sensor-Position Uncertainty and Calibration =================================================== -The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s}_i`. Errors in those coordinates propagate into the position estimate just as measurement errors do, and for distant emitters can be amplified by the same poor geometry that inflates GDOP. Careful survey of fixed installations, GPS positioning of mobile sensors, and *self-calibration* — jointly estimating sensor positions and emitter locations from emitters of opportunity at known or constrained locations — are the standard responses. A full error budget must include sensor-position uncertainty alongside timing and TDE error; in well-synchronized systems it is often the next-largest term. +The geometry assumed exact knowledge of the sensor coordinates :math:`\mathbf{s}_i`. Errors in those coordinates propagate into the position estimate just as measurement errors do, and for distant emitters can be amplified by the same poor geometry that inflates GDOP. Careful survey of fixed installations, GPS positioning of mobile sensors, and *self-calibration*, jointly estimating sensor positions and emitter locations from emitters of opportunity at known or constrained locations, are the standard responses. A full error budget must include sensor-position uncertainty alongside timing and TDE error; in well-synchronized systems it is often the next-largest term. ******************* Advanced Topics @@ -704,7 +710,7 @@ Advanced Topics Joint TDOA/FDOA Estimation ================================== -When the emitter, the sensors, or both are *moving*, the relative motion imparts a Doppler shift that differs between sensors — a **Frequency Difference of Arrival** (FDOA). FDOA carries information about the emitter's *velocity* and, crucially, adds an independent geometric constraint that improves position observability, especially for the difficult far-field and few-sensor cases where TDOA alone is poorly conditioned. TDOA and FDOA are estimated jointly by maximizing the **Complex Ambiguity Function** (CAF) over both delay and frequency offset: +When the emitter, the sensors, or both are *moving*, the relative motion imparts a Doppler shift that differs between sensors, a **Frequency Difference of Arrival** (FDOA). FDOA carries information about the emitter's *velocity* and, crucially, adds an independent geometric constraint that improves position observability, especially for the difficult far-field and few-sensor cases where TDOA alone is poorly conditioned. TDOA and FDOA are estimated jointly by maximizing the **Complex Ambiguity Function** (CAF) over both delay and frequency offset: .. math:: diff --git a/spelling_wordlist.txt b/spelling_wordlist.txt index b7ca8f71..74b4023c 100644 --- a/spelling_wordlist.txt +++ b/spelling_wordlist.txt @@ -361,3 +361,6 @@ subsample subsampling linearization hyperboloid +underdetermined +unitless +linearizing From 9c1ad00527bd8650cf4763b7fb186dee2d5802e8 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 13:25:54 -0400 Subject: [PATCH 25/27] heatmap --- _images/tdoa_python_heatmap.svg | 1766 +++++++++++++++++++++++++++++ content/tdoa.rst | 32 + figure-generating-scripts/tdoa.py | 40 +- 3 files changed, 1836 insertions(+), 2 deletions(-) create mode 100644 _images/tdoa_python_heatmap.svg diff --git a/_images/tdoa_python_heatmap.svg b/_images/tdoa_python_heatmap.svg new file mode 100644 index 00000000..02467509 --- /dev/null +++ b/_images/tdoa_python_heatmap.svg @@ -0,0 +1,1766 @@ + + + + + + + + 2026-06-25T13:23:29.991363 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/tdoa.rst b/content/tdoa.rst index 5109b1dd..fb77c8a2 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -591,7 +591,39 @@ When the emitter *moves*, we want to fuse measurements over time rather than loc Brute-Force Heatmap Approach **************************** +Every method so far has been algebraic or iterative: we manipulated equations or descended a gradient. But there is a refreshingly simple alternative that needs neither. Lay a grid over the search area, and at every candidate position ask a single question: *if the emitter were here, what range differences would the sensors see, and how far off are those from what we actually measured?* Squaring and summing those mismatches gives a cost at each grid point, and the emitter is wherever that cost is smallest. The result is a heatmap of the same cost surface the Gauss-Newton iteration was quietly walking down, except now we can see all of it at once. +We can do this with the variables we already have, ``range_diff``, ``rx_positions``, and ``pairs``: + +.. code-block:: python + + # Evaluate the TDOA cost on a grid of candidate emitter positions + gx = np.linspace(0, 700, 400) + gy = np.linspace(0, 700, 400) + GX, GY = np.meshgrid(gx, gy) + + cost = np.zeros_like(GX) + for k, (a, b) in enumerate(pairs): + r_a = np.hypot(GX - rx_positions[a, 0], GY - rx_positions[a, 1]) # range to Rx_a + r_b = np.hypot(GX - rx_positions[b, 0], GY - rx_positions[b, 1]) # range to Rx_b + cost += ((r_b - r_a) - range_diff[k])**2 # squared mismatch for this pair, summed over pairs + + # The best estimate is simply the grid cell with the lowest cost + iy, ix = np.unravel_index(np.argmin(cost), cost.shape) + emitter_grid = np.array([gx[ix], gy[iy]]) + print("Grid estimate:", emitter_grid) # ~[153, 355] + + # Invert the cost into a likelihood-style surface so higher = more likely emitter location + likelihood = -np.log10(cost + 1e-9) + +Plotting ``likelihood`` as an image reveals the geometry directly: the bright ridges trace out the hyperbolas from earlier, and they all funnel into one bright peak at the true emitter. We take the negative log of the cost so that the most likely location is the maximum rather than a minimum, which is easier to read off visually. The trade-offs are exactly what you'd expect. The method is dead simple, needs no initial guess, and cannot diverge or land on the wrong root, so it is a great sanity check and a robust way to *seed* the iterative refiner. It also handles multimodal cost surfaces gracefully, since it sees every minimum, not just the nearest one. The price is resolution and speed: accuracy is limited by the grid spacing, and cost grows with the number of grid cells, so for a fine answer over a large area you would localize coarsely first and then refine, either by zooming the grid or by handing the result to Gauss-Newton. Below shows the heatmap approach applied to our Python example. + +.. image:: ../_images/tdoa_python_heatmap.svg + :align: center + :target: ../_images/tdoa_python_heatmap.svg + :alt: Adding heatmap to the tdoa plot shown earlier + +One nice part about the heatmap approach is if there is a lot of error, or sensors with low SNR without realizing it, there may be multiple hot spots on the heatmap, which your brain can notice. The heatmap can even be overlaid on top of a satellite view of the area! *********************************************** Performance Analysis and Fundamental Bounds diff --git a/figure-generating-scripts/tdoa.py b/figure-generating-scripts/tdoa.py index d445d504..f00768bc 100644 --- a/figure-generating-scripts/tdoa.py +++ b/figure-generating-scripts/tdoa.py @@ -106,7 +106,7 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.set_title(f'Cross-correlation of Rx{b} vs Rx{a}') ax2.legend() ax2.grid() -fig1.savefig('../_images/tdoa_python_integer.svg', bbox_inches='tight') +#fig1.savefig('../_images/tdoa_python_integer.svg', bbox_inches='tight') fig1.tight_layout() # Subsample TDOA calc using a freq domain cross-correlation that was padded as a way to interpolate @@ -189,6 +189,42 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no ax2.legend() ax2.grid() fig2.tight_layout() -fig2.savefig('../_images/tdoa_python_subsample.svg', bbox_inches='tight') +#fig2.savefig('../_images/tdoa_python_subsample.svg', bbox_inches='tight') + + +# Heatmap portion: evaluate the TDOA cost on the grid and display it under the ax1 map +cost = np.zeros_like(GX) +for k, (a, b) in enumerate(pairs): + cost += ((rx_dist[b] - rx_dist[a]) - range_diff[k])**2 # squared mismatch for this pair, summed over pairs + +# The best estimate is simply the grid cell with the lowest cost +iy, ix = np.unravel_index(np.argmin(cost), cost.shape) +emitter_grid = np.array([grid_x[ix], grid_y[iy]]) +print("Grid estimate:", emitter_grid) # ~[153, 355] + +fig3, ax1 = plt.subplots(1, 1, figsize=(7, 6)) +# Invert the cost into a likelihood-style surface so higher (brighter) = more likely emitter location +likelihood = -np.log10(cost + 1e-9) +im = ax1.imshow(likelihood, origin='lower', cmap='viridis', + extent=[grid_x[0], grid_x[-1], grid_y[0], grid_y[-1]]) +fig3.colorbar(im, ax=ax1, label='likelihood (higher = more likely)') +# Overlay the hyperbolas, receivers, true Tx, and the grid-search estimate +hyperbola_handles = [] +for k, (a, b) in enumerate(pairs): + ax1.contour(GX, GY, (rx_dist[b] - rx_dist[a]) - range_diff[k], levels=[0], + colors=pair_colors[k], linewidths=1.5, linestyles='--') + hyperbola_handles.append(Line2D([0], [0], color=pair_colors[k], linestyle='--', label=f'Rx{a}-Rx{b}')) +ax1.scatter(rx_positions[:, 0], rx_positions[:, 1], c='tab:cyan', marker='^', s=120, edgecolors='k', label='Receivers', zorder=5) +for i in range(num_rx): + ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', xytext=(8, 8), color='w', fontweight='bold', zorder=6) +ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', label='True Tx', zorder=5) +ax1.scatter(*emitter_grid, c='white', marker='x', s=120, linewidths=2, label='Grid estimate', zorder=6) +ax1.set_xlim(grid_x[0], grid_x[-1]); ax1.set_ylim(grid_y[0], grid_y[-1]) +ax1.set_xlabel('x [m]'); ax1.set_ylabel('y [m]') +ax1.set_title('Brute-force TDOA cost heatmap') +ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') +ax1.set_aspect('equal') +fig3.tight_layout() +fig3.savefig('../_images/tdoa_python_heatmap.svg', bbox_inches='tight') plt.show() From 76be1428cce51503e99608e8fb3a4a135fd81e93 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 13:28:33 -0400 Subject: [PATCH 26/27] asd --- _images/tdoa_python_heatmap.svg | 1226 +++++++++++------------------ figure-generating-scripts/tdoa.py | 7 +- 2 files changed, 460 insertions(+), 773 deletions(-) diff --git a/_images/tdoa_python_heatmap.svg b/_images/tdoa_python_heatmap.svg index 02467509..e8909cb8 100644 --- a/_images/tdoa_python_heatmap.svg +++ b/_images/tdoa_python_heatmap.svg @@ -1,12 +1,12 @@ - + - 2026-06-25T13:23:29.991363 + 2026-06-25T13:28:25.517802 image/svg+xml @@ -21,41 +21,41 @@ - - - + +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" id="image5b95d5f61d" transform="scale(1 -1) translate(0 -344.88)" x="55.345313" y="-29.2672" width="344.88" height="344.88"/> - - + - + - + - + @@ -133,12 +133,12 @@ z - + - + @@ -148,12 +148,12 @@ z - + - + - + - + - + - + - + - - + - + @@ -390,12 +390,12 @@ L -3.5 0 - + - + @@ -403,12 +403,12 @@ L -3.5 0 - + - + @@ -418,12 +418,12 @@ L -3.5 0 - + - + @@ -433,12 +433,12 @@ L -3.5 0 - + - + @@ -448,12 +448,12 @@ L -3.5 0 - + - + @@ -462,7 +462,7 @@ L -3.5 0 - + - + - + - + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1141,114 +836,73 @@ z - + - + - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - + + + + + + + - - + - + + - - + - + @@ -1292,14 +946,14 @@ L 317.418125 97.050937 - - + - + @@ -1310,9 +964,9 @@ L 317.418125 111.729062 - + - + - + - + - + - + - - - - - - +iVBORw0KGgoAAAANSUhEUgAAABsAAAIdCAYAAADMEgZhAAACkUlEQVR4nO2cwW0EMRDD7F2XlhLSfyl36SAvYgATZAOCVpqx4eCyf/bvdw3xTAmttdZZe05v1tl+9phYmd0nVkEQhp2JM7M624+2jdrMVpkBtBvvEzu7wxOgoUYwO+uIAcgZgtmZdetXEIReCxBqI0LOEJozBHFmX6uz6erPiVV9hJwhtK4Q2iAIOUPwzllDjVD1EaYzmxOrjQjn2wYBmN4gc2Jldp+YeBGLD0/tIhZnJp6zObGOGAR1ZnNi5muB9u9nVZ+gzBA6YhCaMwR1ZtI5qyAI5qHWvhaIM9M60y5idWZzYh2eCBUEocwQygxBnZn1WmDObE5MfC3wFsTrTFv9MkMoM4QyQ8jZfWJVH2E4s0GxMkMQO2uDEJQZgjezPiNC1UfIGUJtRCgzBHFmWmdVH6GC3CdWGxFyhlAbEcrsPjFxQdaee2yv+gjqNs6J1UYEcWZmZ9Kt31An9i/ioda20buuxG3MGUFzdp+YuCAdMQS1EeEM/tatNjKI2yh2tqRtrCAIZ7euAMoMQZ3ZnFgXHgTxnGnbWEEQ1IvYmtlgZGXGoHY2Jzbr7BFnJnXWZ0RoqBFqI8J5rI+bzRlCc4bgzayCIHgLoj5irM7EbcwZQM7uExMvYm1BcoaQM4Q2CMK0s8+YWJndJ9ZQIzTUCOf1ZiaeM2sbvRvkrY0A5muBt/paZ8+SVt/cxjIDmN4g2sy01wJtZt2uELoRI5QZgjozqzPxhUebWdcCgu6NCL0WIIhfeLyLuAsPQXOG4N2NHZ4I4qHuKkfQOwiCdxHXRoReeO4TEx+eVZ+gzBDEzh7tr/vFmQ2KTV/l5sTU14K571j1Ec47+H9WpjOzOjPPmdWZec7m9GrjfWLn3d6CSJ39AWezjzw9CSNsAAAAAElFTkSuQmCC" id="imagec22f57cbb9" transform="scale(1 -1) translate(0 -389.52)" x="421.2" y="-6.48" width="19.44" height="389.52"/> - - + - - - + + + + + + + + - + - - - + + + + + - + - - - + + + - + + + - + - - - - - - + + + - + + + - + - - - - - - - - - - - - - - - - - + + + - + + + - + - + + + + - - - + + diff --git a/figure-generating-scripts/tdoa.py b/figure-generating-scripts/tdoa.py index f00768bc..47012146 100644 --- a/figure-generating-scripts/tdoa.py +++ b/figure-generating-scripts/tdoa.py @@ -205,7 +205,9 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no fig3, ax1 = plt.subplots(1, 1, figsize=(7, 6)) # Invert the cost into a likelihood-style surface so higher (brighter) = more likely emitter location likelihood = -np.log10(cost + 1e-9) -im = ax1.imshow(likelihood, origin='lower', cmap='viridis', +# Cap the colormap a bit below the peak so the bright region around the emitter spreads out and is easier to see +vmax = likelihood.min() + 0.5 * (likelihood.max() - likelihood.min()) # 0.5 was adjusted manually to look good +im = ax1.imshow(likelihood, origin='lower', cmap='viridis', vmax=vmax, extent=[grid_x[0], grid_x[-1], grid_y[0], grid_y[-1]]) fig3.colorbar(im, ax=ax1, label='likelihood (higher = more likely)') # Overlay the hyperbolas, receivers, true Tx, and the grid-search estimate @@ -218,10 +220,9 @@ def frac_delay_filter(delay): # delay is in samples, but it can (and will be) no for i in range(num_rx): ax1.annotate(f'Rx{i}', rx_positions[i], textcoords='offset points', xytext=(8, 8), color='w', fontweight='bold', zorder=6) ax1.scatter(*tx_position, c='red', marker='*', s=300, edgecolors='k', label='True Tx', zorder=5) -ax1.scatter(*emitter_grid, c='white', marker='x', s=120, linewidths=2, label='Grid estimate', zorder=6) +#ax1.scatter(*emitter_grid, c='white', marker='x', s=120, linewidths=2, label='Grid estimate', zorder=6) ax1.set_xlim(grid_x[0], grid_x[-1]); ax1.set_ylim(grid_y[0], grid_y[-1]) ax1.set_xlabel('x [m]'); ax1.set_ylabel('y [m]') -ax1.set_title('Brute-force TDOA cost heatmap') ax1.legend(handles=ax1.get_legend_handles_labels()[0] + hyperbola_handles, loc='upper right') ax1.set_aspect('equal') fig3.tight_layout() From efc4b6536fe2cc13438094a602ec506b3e964b3c Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Thu, 25 Jun 2026 15:04:49 -0400 Subject: [PATCH 27/27] asd --- content/tdoa.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/content/tdoa.rst b/content/tdoa.rst index fb77c8a2..9145667d 100644 --- a/content/tdoa.rst +++ b/content/tdoa.rst @@ -593,7 +593,7 @@ Brute-Force Heatmap Approach Every method so far has been algebraic or iterative: we manipulated equations or descended a gradient. But there is a refreshingly simple alternative that needs neither. Lay a grid over the search area, and at every candidate position ask a single question: *if the emitter were here, what range differences would the sensors see, and how far off are those from what we actually measured?* Squaring and summing those mismatches gives a cost at each grid point, and the emitter is wherever that cost is smallest. The result is a heatmap of the same cost surface the Gauss-Newton iteration was quietly walking down, except now we can see all of it at once. -We can do this with the variables we already have, ``range_diff``, ``rx_positions``, and ``pairs``: +Back to the Python example, we can do this with the variables we already have, ``range_diff``, ``rx_positions``, and ``pairs``: .. code-block:: python @@ -625,6 +625,8 @@ Plotting ``likelihood`` as an image reveals the geometry directly: the bright ri One nice part about the heatmap approach is if there is a lot of error, or sensors with low SNR without realizing it, there may be multiple hot spots on the heatmap, which your brain can notice. The heatmap can even be overlaid on top of a satellite view of the area! +This brute-force approach is not very computationally efficient, and it's not really an option at all for 3D TDOA. One alternative to calculating every grid point but still brute-forcing it is to draw all of the hyperbolas in 2D but with "width" applied to each one, e.g. by applying a lobe shaped function along the hyperbola so it tapers off. + *********************************************** Performance Analysis and Fundamental Bounds *********************************************** @@ -668,7 +670,7 @@ and the Cramér-Rao Lower Bound states that *any* unbiased estimator has covaria \mathrm{Cov}(\hat{\mathbf{u}}) \succeq \mathbf{F}^{-1} = (\mathbf{J}^\top \mathbf{C}^{-1}\mathbf{J})^{-1}. -The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise, which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. The plot below shows a few example bandwidths and the lower bound over SNR, to give you a feel for how much error you should expect, or at least the floor. The y-axis is the 1-σ value (one standard deviation). +The bound is the benchmark against which estimators are judged: a method that attains it is *efficient*. The maximum-likelihood estimator above attains it asymptotically (large :math:`T`, high SNR), and Chan's closed-form method attains it at small noise, which is exactly why both are used. The CRLB also cleanly separates the two influences on accuracy: :math:`\mathbf{C}` (signal-and-noise quality, improvable by more bandwidth, power, or integration) and :math:`\mathbf{J}` (geometry, improvable by sensor placement), studied next. The plot below shows a few example bandwidths and the lower bound over SNR, to give you a feel for how much error you should expect, or at least the floor. The y-axis is the 1-:math:`\mathrm{\sigma}` value (one standard deviation). .. image:: ../_images/tdoa_cramer_rao.svg :align: center