SevIQ
/ home / radar / math

Radar math — from two echoes to a moving target

The live radar never measures position, bearing, or velocity directly. It measures exactly two things — the echo time of each HC-SR04 sensor — and derives everything else. This chapter walks the full pipeline: trilateration to \((x, y)\), polar conversion to range and bearing, finite differences to the velocity vector \((v_x, v_y)\), and constant-velocity extrapolation for the prediction dot.

trilateration • two circles, one fix velocity from finite differences matches radar.js field-for-field

1) The coordinate frame (get this wrong and nothing else matters)

Two sensors sit on a fixed baseline of length \(b\) (10 cm on the live rig), facing forward. We put the origin midway between them:

  • Left sensor at \(S_L = (-b/2,\ 0)\)
  • Right sensor at \(S_R = (+b/2,\ 0)\)
  • \(y\) points forward, away from the rig — always positive for a valid target
  • \(x\) points right along the baseline — negative is left of centre
y (forward) x S_L (−b/2, 0) S_R (+b/2, 0) target (x, y) r₁ r₂ θ
Each sensor only knows a distance, which constrains the target to a circle. Two circles from two known centres intersect at (at most) two points — and since the target must be in front (\(y > 0\)), the fix is unique.
Bearing convention. Bearing is measured from the forward axis, not the x-axis: \(\theta = \operatorname{atan2}(x, y)\) — note the swapped arguments compared to the usual \(\operatorname{atan2}(y, x)\). That makes 0° = dead ahead, positive = right, negative = left, which is what a radar display wants. This is exactly what radar.js does.

2) From echo time to range

An HC-SR04 emits a 40 kHz burst and reports the time until the echo returns. Sound travels out and back, so:

\[ r = \frac{t_\text{echo} \cdot v_\text{sound}}{2}, \qquad v_\text{sound} \approx 343\ \text{m/s at } 20^\circ\text{C} \]

The popular Arduino shortcut distance_cm = duration_us / 58 is this same formula: \(343\ \text{m/s} = 0.0343\ \text{cm/µs}\), halved for the round trip gives \(\approx 1/58.3\ \text{cm per µs}\).

Temperature matters more than people expect: \(v_\text{sound} \approx 331 + 0.6\,T\) m/s. A 10 °C swing shifts every range by ~1.7%. At 2 m that's 3.5 cm of systematic error — larger than the sensor's quoted resolution.

3) Trilateration: two circles → one point

Each range constrains the target to a circle around its sensor:

\[ r_1^2 = \left(x + \tfrac{b}{2}\right)^2 + y^2 \qquad\text{(left sensor)} \]

\[ r_2^2 = \left(x - \tfrac{b}{2}\right)^2 + y^2 \qquad\text{(right sensor)} \]

Subtract the second from the first. The \(x^2\), \(y^2\), and \(b^2/4\) terms all cancel, leaving something beautifully linear:

\[ r_1^2 - r_2^2 = 2bx \quad\Longrightarrow\quad \boxed{\,x = \frac{r_1^2 - r_2^2}{2b}\,} \]

Then substitute \(x\) back into either circle to get the forward distance:

\[ \boxed{\,y = \sqrt{r_1^2 - \left(x + \tfrac{b}{2}\right)^2}\,} \]

Before trusting the result, check it's geometrically possible. The circles only intersect when:

  • \(|r_1 - r_2| \le b\) — otherwise one circle is inside the other (the sensors are seeing different objects, the most common failure)
  • the expression under the square root is \(\ge 0\) (same condition, caught numerically)
Why the difference of squares is the whole trick. Each individual range tells you nothing about direction. But the difference between the two ranges is a pure function of \(x\): a target dead ahead gives \(r_1 = r_2\); drift right and \(r_1\) grows while \(r_2\) shrinks. The baseline converts that range difference into lateral position.

4) Polar components: range and bearing

The display wants polar coordinates, which is one line each:

\[ R = \sqrt{x^2 + y^2} \qquad \theta = \operatorname{atan2}(x,\ y) \]

Always use atan2, never atan(x/y)atan2 handles \(y \to 0\) and gets the sign of the quadrant right without special cases.

5) The velocity vector: differentiate the track, component by component

With a position fix \((x_k, y_k)\) at time \(t_k\) and the previous fix \((x_{k-1}, y_{k-1})\) at \(t_{k-1}\), the velocity components are finite differences:

\[ v_x = \frac{x_k - x_{k-1}}{t_k - t_{k-1}} \qquad v_y = \frac{y_k - y_{k-1}}{t_k - t_{k-1}} \]

From the components you recover the scalar quantities:

\[ \text{speed} = \sqrt{v_x^2 + v_y^2} \qquad \text{heading} = \operatorname{atan2}(v_x,\ v_y) \]

Heading uses the same swapped-argument convention as bearing: 0° = moving straight away from the rig, ±180° = straight at it. A useful derived signal is the radial speed (closing rate) — the component of velocity along the line of sight:

\[ v_\text{radial} = \frac{x\,v_x + y\,v_y}{R} \qquad \begin{cases} v_\text{radial} < 0 & \text{approaching} \\ v_\text{radial} > 0 & \text{receding} \end{cases} \]

That's the dot product \(\vec{v} \cdot \hat{r}\) — project the velocity vector onto the unit vector pointing at the target. It's what a Doppler radar would measure directly; we get it for free from the track.

Differentiation amplifies noise — always smooth

This is the step where naive implementations fall apart. If each position fix carries ±1 cm of noise and fixes arrive 60 ms apart, raw differencing turns that into ±0.33 m/s of velocity noise — easily bigger than the signal. Two standard fixes:

  • Difference over a longer window: compare against the fix from ~0.5 s ago instead of the immediately previous one. Noise stays constant while the denominator grows.
  • Exponential moving average (EMA): blend each raw estimate into a running value: \(\hat{v} \leftarrow \alpha\, v_\text{raw} + (1-\alpha)\,\hat{v}\) with \(\alpha \approx 0.2\text{–}0.4\). One line of code, dramatic improvement. (A Kalman filter is the principled version of this — overkill at this baseline, but the natural next step.)

6) Prediction: dead reckoning with a constant-velocity model

The hollow blue dot on the radar is the simplest possible motion model — assume the velocity vector stays constant and extrapolate:

\[ x_\text{pred} = x + v_x\,\Delta t \qquad y_\text{pred} = y + v_y\,\Delta t \qquad (\Delta t = 0.5\ \text{s on the live rig}) \]

Each component extrapolates independently — that's the payoff of working in Cartesian components rather than range/bearing. (Extrapolating \(R\) and \(\theta\) directly is wrong for any target not moving radially: a target crossing in front of you at constant velocity has wildly non-constant \(\dot\theta\).)

Constant-velocity prediction degrades gracefully: perfect for straight-line motion, decent for slow curves over 0.5 s, useless past a second or two. Pick \(\Delta t\) to match how far you actually trust the model.

7) The error budget: why a 10 cm baseline gives coarse bearing

Propagate range noise through \(x = (r_1^2 - r_2^2)/2b\). For a target near centreline at range \(R\), a small error \(\delta r\) in one range produces:

\[ \delta x \approx \frac{r\,\delta r}{b} \approx \frac{R}{b}\,\delta r \]

The baseline is the lever arm, and it's working against you: at \(R = 2\) m with \(b = 0.1\) m, lateral error is 20× the range error. With ±3 mm of HC-SR04 jitter, that's ±6 cm of \(x\) wobble — about ±1.7° of bearing jitter at that range. Meanwhile \(y\) (forward range) stays nearly as accurate as the raw measurement.

  • Bearing precision scales with \(b/R\) — double the baseline, halve the jitter. This is the same reason long-baseline interferometry uses continent-sized baselines.
  • The forward cone is the sweet spot: off to the side, one sensor sees the target at a grazing angle or not at all, and the common-target assumption collapses.
  • Both sensors must see the same object — the validity checks in §3 are the only defence against fusing two different echoes into one phantom fix.

8) The whole pipeline in code

Everything above, in the exact field names the live radar's bridge emits (radar.js consumes these verbatim):

// inputs: r1, r2 (metres), b = baseline (metres), dt since last fix (seconds)
function solveFix(r1, r2, b, prev, dt) {
  // --- validity: are these two echoes the same object? ---
  if (Math.abs(r1 - r2) > b) return null;        // circles don't intersect

  // --- trilateration (§3) ---
  const x = (r1 * r1 - r2 * r2) / (2 * b);
  const y2 = r1 * r1 - (x + b / 2) ** 2;
  if (y2 < 0) return null;                       // numerically impossible
  const y = Math.sqrt(y2);

  // --- polar components (§4) ---
  const range_m = Math.hypot(x, y);
  const bearing_deg = Math.atan2(x, y) * 180 / Math.PI;  // 0 deg = forward

  // --- velocity vector via finite differences + EMA smoothing (§5) ---
  let vx = 0, vy = 0;
  if (prev && dt > 1e-3) {
    const a = 0.3;                               // EMA factor
    vx = a * (x - prev.x) / dt + (1 - a) * prev.vx;
    vy = a * (y - prev.y) / dt + (1 - a) * prev.vy;
  }
  const speed_mps = Math.hypot(vx, vy);

  // --- constant-velocity prediction, 0.5 s ahead (§6) ---
  const pred_x_m = x + vx * 0.5;
  const pred_y_m = y + vy * 0.5;

  return { x_m: x, y_m: y, range_m, bearing_deg,
           vx_mps: vx, vy_mps: vy, speed_mps,
           pred_x_m, pred_y_m, valid: true };
}

Cross-check against the synthetic target in radar.js (demoFrame()): it runs this identical math in reverse — picks an \((x, y)\), then computes r1_cm = hypot(x + b/2, y) and r2_cm = hypot(x − b/2, y) so the demo's range arcs land exactly on the demo blip.

9) Where to go from here

  • Kalman filter: replace the EMA with a constant-velocity Kalman filter — it weighs each measurement by its expected noise and gives you confidence estimates for free.
  • Wider baseline: §7 says bearing jitter is \(\propto 1/b\). Even 30 cm transforms the fix quality.
  • Three sensors: a third range over-determines the fix, letting you reject phantom fixes by residual instead of by triangle inequality alone.
  • The phase relationships behind atan2 and rotating vectors are the same machinery as AC analysis — see the phasors chapter.

← back to the live radar · home

SevIQ — systems • tooling • infrastructure. Math as implemented; no hand-waving.