# 5-State EKF — Tight-Coupled GPS Pseudoranges

This demo runs a tightly-coupled 2D Extended Kalman Filter that consumes raw GPS pseudoranges directly, one satellite at a time. It is the structural extension of the 4D linear KF from Block 5: same predict-update rhythm, same per-state error panels, same animated trajectory with a 95% covariance ellipse — the **only** new ingredient is that the measurement Jacobian is recomputed at every pseudorange update from the current receiver-to-satellite geometry.

The headline contrast with the GPS demo from Block 6 is that the receiver here is **not a black box**. There, an NLLS solver pre-processed all the pseudoranges into a single position estimate, which a downstream filter would then ingest as a position measurement (loose coupling). Here, every individual pseudorange is its own scalar measurement and the filter processes them one at a time. The advantages are operational, not just theoretical:

- **The filter still works with 1–3 satellites.** The Block 6 black-box solver collapses below 4 satellites in 3D (3 in 2D); the EKF degrades gracefully.
- **Each pseudorange brings its own innovation $\nu_i = \tilde{\rho}_i - h_i(\hat{\mathbf{x}}^-)$.** That residual is a per-sat indicator the filter can monitor — the foundation of fault detection in Block 8.
- **The receiver clock bias becomes a state**, learned over time alongside position and velocity instead of re-solved from scratch every update.
- **Sensor cadences mix freely.** Each satellite can deliver pseudoranges at its own rate; the filter handles them in the order they arrive.

## The 5-state model

The state vector adds the receiver clock bias $b$ to the constant-velocity 4D model:

$$
\mathbf{x} = \begin{bmatrix} p_x & p_y & v_x & v_y & b \end{bmatrix}^{\top}.
$$

The transition matrix is the constant-velocity block plus a random-walk row for $b$:

$$
\mathbf{F} = \begin{bmatrix}
1 & 0 & \Delta t & 0 & 0 \\
0 & 1 & 0 & \Delta t & 0 \\
0 & 0 & 1 & 0 & 0 \\
0 & 0 & 0 & 1 & 0 \\
0 & 0 & 0 & 0 & 1
\end{bmatrix}, \qquad
\mathbf{Q}_d = \begin{bmatrix} \mathbf{Q}_{\text{nav}} & \mathbf{0} \\ \mathbf{0}^{\top} & \sigma_b^2\,\Delta t \end{bmatrix}.
$$

$\mathbf{Q}_{\text{nav}}$ is the 4D constant-velocity process-noise matrix from Block 5; the clock-bias channel evolves as a random walk with rate $\sigma_b$.

The pseudorange measurement function for satellite $i$ is the same nonlinear $h_i$ from the Block 6 reading:

$$
h_i(\mathbf{x}) = \lVert \mathbf{p} - \mathbf{s}^{(i)} \rVert + b.
$$

Linearizing at the current prior $\hat{\mathbf{x}}^-$ gives the $1 \times 5$ Jacobian

$$
\mathbf{H}_i = \begin{bmatrix} \hat{e}_x & \hat{e}_y & 0 & 0 & 1 \end{bmatrix}, \qquad
\hat{e} = \frac{\hat{\mathbf{p}}^- - \mathbf{s}^{(i)}}{\lVert \hat{\mathbf{p}}^- - \mathbf{s}^{(i)} \rVert}.
$$

Three things to read off this row:

- **Position entries** are the unit vector from satellite $i$ toward the receiver — same line-of-sight direction the Block 6 reading derived.
- **Velocity entries are zero.** Pseudoranges don't depend instantaneously on velocity, so a single pseudorange update touches velocity only through the cross-covariance.
- **Clock-bias entry is exactly 1.** $b$ adds linearly to every pseudorange, so a unit change in $b$ produces a unit change in the predicted measurement.

## Interactive demo

<a class="demo-fullscreen" href="../_static/demos/ExtendedKalmanFilter5D.html" target="_blank" rel="noopener">Open in full screen</a>

<div class="demo-wrap">
<iframe src="../_static/demos/ExtendedKalmanFilter5D.html"
        title="Interactive 5-state EKF (tight-coupled GPS) demo"
        width="100%"
        loading="lazy">
</iframe>
</div>

The layout matches the multi-state KF demos: live 2D trajectory with the 95% position-covariance ellipse, five stacked per-state error panels with $\pm 95\%$ bounds, animation control bar with scrubber, and synchronized "now" cursors across all panels. The new UI element is the **sky compass** in the top-right fieldset — a small unit-direction circle showing each satellite's bearing from the receiver. Drag any satellite around the compass to change geometry. Each satellite has its own color, on/off toggle, and update-interval slider so you can mix sensor cadences freely.

## Walkthrough

The demo opens at a default scenario: 4 satellites evenly spread, $\sigma_\rho = 10$ m, all sats updating at 1 Hz, $\sigma_a = 2$ m/s², $\sigma_b = 3$ m/√s, $T = 300$ s. The filter starts with deliberate position uncertainty ($\sigma_p = 10$ m), velocity uncertainty ($\sigma_v = 1$ m/s), and clock uncertainty ($\sigma_b = 50$ m); truth is sampled around that prior. Try the following:

1. **Read the headline metrics.** Position estimate within a few meters of truth, velocity within a fraction of a m/s, clock bias estimate tracking truth within a couple of meters. The cov ellipse on the trajectory plot stabilizes into a small near-circular shape; all five error panels stay inside their bounds. **The filter has learned the clock bias as a state, alongside position and velocity, from purely scalar pseudorange measurements.**
2. **Watch the per-satellite update flashes.** Four colored dots in the top-left of the trajectory panel — they fade in when their satellite's measurement is processed and decay over a couple of seconds. Each flash corresponds to one scalar EKF update with a 1×5 Jacobian, applied in sequence inside the same time step.
3. **Toggle off SAT 2, 3, and 4. Leave only SAT 1.** This is the headline contrast with the Block 6 demo. The black-box NLLS solver would refuse to converge with one satellite. The EKF keeps producing an estimate — bounds on the components perpendicular to SAT 1's line of sight widen visibly, but the filter never breaks. With time, even one sat at 1 Hz keeps the position channel observable through the cross-covariance and the prior's continuing predictions.
4. **Re-enable a second satellite at 90° from the first.** Position bounds collapse along that newly-observed direction. **Two complementary lines of sight are enough to constrain 2D position firmly** — and the filter never had to know that ahead of time, it just used the measurements as they arrived.
5. **Slide individual satellite intervals.** Pull SAT 1 to 0.1 s and SAT 2–4 to 5 s. SAT 1 dominates the update cadence; the cov ellipse tightens along its LoS direction much faster than along the others. **Tight coupling lets sensors at different cadences contribute proportional amounts of information** without any pre-aggregation step.
6. **Click "Clustered (bad)" preset.** All four satellites collapse into a 30° arc. The cov ellipse balloons along the cluster's LoS direction (same anisotropic stretch as the GPS demo from Block 6 — geometry still matters in the EKF). HDOP isn't displayed numerically, but you can see it visually in the ellipse's elongation. Drag a single satellite away from the cluster: the ellipse snaps tighter immediately.
7. **Slide $\sigma_\rho$ up to 30 m.** Bounds widen across all states; the per-step Kalman gain on each scalar update shrinks (filter trusts the prior more when the measurement is noisy). The geometric ellipse anisotropy stays unchanged — that's structurally separate from per-sat noise.
8. **Slide $\sigma_b$ up to 10 m/√s.** The clock-bias error panel shows wider bounds; the bias channel converges less tightly because the filter expects more drift between updates. **The clock-bias state's $\mathbf{Q}$ entry is a tuning knob just like any other process noise.**
9. **Pause and scrub.** Drag the time slider; the cov ellipse, all five error-panel cursors, sky compass, and stats card all snap to that time so you can inspect filter state at any moment.

## Key observations

- **Same recursion as the linear KF.** The predict-update equations are unchanged from Block 5; only the measurement model gets re-linearized at every update. Predict moves the state forward; update fuses the measurement with the prior using inverse-variance weighting; covariance shrinks under each update and grows under each predict.
- **The 1×5 Jacobian carries everything.** Position entries point along the line of sight; velocity entries are zero (no instantaneous velocity sensitivity in pseudoranges); clock-bias entry is unity. The Jacobian re-evaluates as $\hat{\mathbf{x}}^-$ moves, but its structure stays the same.
- **Cross-covariance picks up velocity.** Pseudorange Jacobians have zero velocity entries, but the filter still produces a velocity estimate because $\mathbf{P}$ has non-zero $p$-$v$ off-diagonals from $\mathbf{F}$'s $\Delta t$ couplings. Watch the velocity bounds in the per-state panel: they tighten over a few cycles even though no measurement directly observes velocity.
- **Tight coupling separates sensor cadence from filter cadence.** The filter runs at $\Delta t = 0.1$ s; each satellite delivers its measurement on its own schedule (1 Hz, 5 Hz, 10 s, whatever). The filter handles them as they arrive without any pre-aggregation.
- **Geometry still controls observability.** Cluster the satellites and the cov ellipse balloons along the cluster's LoS, exactly as in the Block 6 black-box demo. The EKF formulation does not magic away DOP; it just makes every other operational property of the receiver more graceful.
- **Per-satellite innovations are now available.** Each scalar update produces an innovation $\nu_i$ that the filter could test for outliers (does it exceed some multiple of its expected $\sigma$?). Block 8 builds exactly that test, and the per-sat structure of the EKF is the reason it can be done at all.

## Source

<a class="matlab-link" href="../_static/downloads/SY6301%20Navigation%20and%20State%20Estimation%20%E2%80%93%20Code.zip#code/ExtendedKalmanFilter5D.m" download><svg viewBox="0 0 22 22" width="14" height="14" aria-hidden="true" style="vertical-align:-2px;margin-right:6px;"><rect width="22" height="22" rx="3" fill="#e87722"/><text x="11" y="15.5" text-anchor="middle" font-family="'Inter',sans-serif" font-size="9" font-weight="800" fill="#fff" letter-spacing="-0.04em">MAT</text></svg><span class="ml-text">MATLAB · code/ExtendedKalmanFilter5D.m</span><span class="ml-arrow">↓</span></a>

With the 2D covariance ellipse helper in `code/navutils/Draw2DErrorBounds.m`.
