Tracking a turning object: choosing the state model
Position measurements do not directly reveal velocity or acceleration. We compare three linear state models on a noisy circular path, then use an extended Kalman filter (EKF) whose state explicitly describes a circle.
The central question: does building the correct physics into your state model improve tracking performance? We demonstrate that a circular motion model achieves less than half the tracking error of generic Cartesian models on circular trajectories—a dramatic illustration of how model choice matters.
Prerequisites: the prediction/update equations in KalmanFilter.ipynb, NumPy
and covariance matrices. Install projects/requirements.txt and run
all cells in order. No external data is required.
Key takeaway: The choice of state representation determines what dynamics your filter can efficiently represent. Generic models are robust but inefficient; physics-informed models excel when their assumptions hold but fail when they don't.
Show or hide code
from pathlib import Path
import sys
# Works from the repository root, projects/, or projects/notebooks/.
project_dir = next(p / "projects" for p in [Path.cwd(), *Path.cwd().parents]
if (p / "projects" / "src").is_dir())
sys.path.insert(0, str(project_dir / "src"))
import logging
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
from Measurement import Measurement, generate_data
from ModelState import ModelState, ModelStatePoly1
from KalmanFilter import KalmanFilter
logging.getLogger().setLevel(logging.WARNING)
pio.renderers.default = "notebook_connected"
np.random.seed(1234)
A reproducible circular trajectory
We observe two Cartesian coordinates every $\Delta t=0.1$ s with independent Gaussian noise of standard deviation $0.15$ m. The true orbit has radius 2 m and angular velocity 0.7 rad/s. These are simulated observations, not detector data.
Show or hide code
rng = np.random.default_rng(2026)
delta_t, nmeasurements = 0.1, 120
times = np.arange(nmeasurements) * delta_t
radius, omega = 2., 0.7
true_positions = radius * np.column_stack([np.cos(omega*times), np.sin(omega*times)])
true_velocities = radius*omega * np.column_stack([-np.sin(omega*times), np.cos(omega*times)])
true_accelerations = -omega**2 * true_positions
sigma = 0.15
R = np.eye(2)*sigma**2
observed = true_positions + rng.normal(0, sigma, true_positions.shape)
measurements = [Measurement(z[:, None], np.array([t]), R.copy()) for t, z in zip(times, observed)]
Position, velocity and acceleration models
Use state order $[x,y]$, $[x,y,v_x,v_y]$, or $[x,y,v_x,v_y,a_x,a_y]$. Only the first two entries are observed. The transition matrices apply constant position, velocity, or acceleration over one interval. None describes circular motion exactly: the process covariance allows departures from the approximation. We use integrated white driving noise in each model, with different physical units and fixed illustrative strengths. This is not an optimisation of each model's hyperparameters.
Show or hide code
class CartesianState(ModelState):
"""Observe the first two entries of a Cartesian state column."""
def calc_H(self):
return np.eye(2, len(self.x))
def cartesian_model(order, dt, spectral_density):
# One-dimensional transition and continuous-white-noise covariance.
if order == 0:
F1 = np.ones((1, 1)); Q1 = np.array([[dt]])
elif order == 1:
F1 = np.array([[1., dt], [0., 1.]])
Q1 = np.array([[dt**3/3, dt**2/2], [dt**2/2, dt]])
elif order == 2:
F1 = np.array([[1., dt, dt**2/2], [0., 1., dt], [0., 0., 1.]])
Q1 = np.array([[dt**5/20, dt**4/8, dt**3/6],
[dt**4/8, dt**3/3, dt**2/2], [dt**3/6, dt**2/2, dt]])
else:
raise ValueError('Supported orders: 0, 1, 2')
return np.kron(F1, np.eye(2)), spectral_density*np.kron(Q1, np.eye(2))
models = {}
for label, order, q in [('Position', 0, 1.), ('Velocity', 1, 0.5), ('Acceleration', 2, 0.5)]:
F, Q = cartesian_model(order, delta_t, q)
mean = np.zeros((len(F), 1)); mean[:2, 0] = [2., 0.]
initial = CartesianState(mean, np.array([0.]), np.eye(len(F))*4.)
kf = KalmanFilter(initial, Q, F)
states = kf.fit(measurements)[1:]
models[label] = states
for state in states:
np.testing.assert_allclose(state.x_cov, state.x_cov.T, atol=1e-10)
assert np.linalg.eigvalsh(state.x_cov).min() >= -1e-10
Compare trajectories and position uncertainty
The ellipse is a joint 95% region for a two-dimensional Gaussian state: its squared Mahalanobis radius is $\chi^2_2(0.95)$, not $2^2$. Measurement error bars and estimated-state ellipses describe different quantities.
Show or hide code
from scipy.stats import chi2
def covariance_ellipse(mean, covariance, probability=0.95):
values, vectors = np.linalg.eigh(covariance)
angle = np.linspace(0, 2*np.pi, 80)
unit = np.array([np.cos(angle), np.sin(angle)])
return (np.asarray(mean)[:, None] + vectors @ (np.sqrt(chi2.ppf(probability, 2)*np.maximum(values, 0))[:, None]*unit)).T
fig = go.Figure()
fig.add_scatter(x=true_positions[:, 0], y=true_positions[:, 1], name='Truth')
fig.add_scatter(x=observed[:, 0], y=observed[:, 1], mode='markers', marker=dict(size=4, opacity=0.4), name='Measurements')
for label, states in models.items():
means = np.array([s.x[:2, 0] for s in states])
fig.add_scatter(x=means[:, 0], y=means[:, 1], name=label)
ellipse = covariance_ellipse(means[-1], states[-1].x_cov[:2, :2])
fig.add_scatter(x=ellipse[:, 0], y=ellipse[:, 1], name=label+' final 95% region', showlegend=False)
fig.update_layout(title='The state model changes how a turning path is followed', xaxis_title='x [m]', yaxis_title='y [m]')
fig.update_yaxes(scaleanchor='x', scaleratio=1)
fig.show()
Tracking physical quantities: position, velocity, and acceleration magnitudes
For circular motion, the true magnitudes are constant:
- $|r| = \sqrt{x^2 + y^2} = \text{radius}$ (constant)
- $|v| = r\omega$ (constant for uniform circular motion)
- $|a| = r\omega^2$ (constant centripetal acceleration)
Let's see how well the Cartesian models recover these physical quantities, even though they don't explicitly model circular motion.
Show or hide code
from plotly.subplots import make_subplots
# Calculate true magnitudes for circular motion
true_r_mag = np.linalg.norm(true_positions, axis=1) # Should be constant = radius
true_v_mag = np.linalg.norm(true_velocities, axis=1) # Should be constant = r*omega
true_a_mag = np.linalg.norm(true_accelerations, axis=1) # Should be constant = r*omega^2
# Expected constant values
expected_r = radius
expected_v = radius * omega
expected_a = radius * omega**2
# Create figure with 3 subplots
fig_magnitudes = make_subplots(
rows=3, cols=1,
shared_xaxes=True,
subplot_titles=['Position magnitude |r|', 'Velocity magnitude |v|', 'Acceleration magnitude |a|'],
vertical_spacing=0.08
)
# Colors for the models
colors = {'Position': 'blue', 'Velocity': 'green', 'Acceleration': 'orange'}
# Plot position magnitude |r|
for label, states in models.items():
xy = np.array([s.x[:2, 0] for s in states])
r_mag = np.linalg.norm(xy, axis=1)
fig_magnitudes.add_scatter(
x=times, y=r_mag, name=label,
line=dict(color=colors[label]),
row=1, col=1,
legendgroup=label,
showlegend=True
)
# Add true |r| as dashed line
fig_magnitudes.add_scatter(
x=times, y=true_r_mag, name='True',
line=dict(color='red', dash='dash', width=2),
row=1, col=1,
legendgroup='True',
showlegend=True
)
# Plot velocity magnitude |v| (only for models that track velocity)
for label, states in models.items():
if len(states[0].x) >= 4: # Has velocity components
v_vectors = np.array([s.x[2:4, 0] for s in states])
v_mag = np.linalg.norm(v_vectors, axis=1)
fig_magnitudes.add_scatter(
x=times, y=v_mag, name=label,
line=dict(color=colors[label]),
row=2, col=1,
legendgroup=label,
showlegend=False
)
# Add true |v| as dashed line
fig_magnitudes.add_scatter(
x=times, y=true_v_mag, name='True',
line=dict(color='red', dash='dash', width=2),
row=2, col=1,
legendgroup='True',
showlegend=False
)
# Plot acceleration magnitude |a| (only for acceleration model)
for label, states in models.items():
if len(states[0].x) >= 6: # Has acceleration components
a_vectors = np.array([s.x[4:6, 0] for s in states])
a_mag = np.linalg.norm(a_vectors, axis=1)
fig_magnitudes.add_scatter(
x=times, y=a_mag, name=label,
line=dict(color=colors[label]),
row=3, col=1,
legendgroup=label,
showlegend=False
)
# Add true |a| as dashed line
fig_magnitudes.add_scatter(
x=times, y=true_a_mag, name='True',
line=dict(color='red', dash='dash', width=2),
row=3, col=1,
legendgroup='True',
showlegend=False
)
# Update axes labels
fig_magnitudes.update_xaxes(title_text='Time [s]', row=3, col=1)
fig_magnitudes.update_yaxes(title_text='|r| [m]', row=1, col=1)
fig_magnitudes.update_yaxes(title_text='|v| [m/s]', row=2, col=1)
fig_magnitudes.update_yaxes(title_text='|a| [m/s²]', row=3, col=1)
fig_magnitudes.update_layout(
height=800,
title='Physical quantity magnitudes for Cartesian models',
showlegend=True
)
fig_magnitudes.show()
print(f"Expected constant values for circular motion:")
print(f" |r| = {expected_r:.3f} m")
print(f" |v| = {expected_v:.3f} m/s")
print(f" |a| = {expected_a:.3f} m/s²")
print(f"\nTrue values are constant by construction (uniform circular motion).")
print(f"Cartesian models approximate these through local dynamics and process noise.")
The impact of choosing the correct model
The position, velocity, and acceleration models above all use Cartesian coordinates and approximate the motion with polynomials in time. None of these models can perfectly represent circular motion—they require process noise to allow deviations from straight-line or parabolic trajectories.
But what if we build the physics directly into our state model? For circular motion, the natural parameters are:
- Center position $(c_x, c_y)$
- Radius $r$
- Initial angle $\phi_0$ (or current angle $\phi$)
- Angular velocity $\omega$
This parameterization exactly describes uniform circular motion, and an Extended Kalman Filter (EKF) can estimate these parameters from noisy position measurements. The key question: does using the correct model improve tracking performance?
Circular motion model: state representation and assumptions
State vector
We define the state as $\mathbf{c} = [c_x, c_y, r, \phi, \omega]^T$ where:
- $c_x, c_y$: center coordinates of the circular path
- $r$: radius of the circle (constrained to be positive)
- $\phi$: current angular position
- $\omega$: angular velocity (rad/s)
Model assumptions
- Uniform circular motion: The object moves at constant angular velocity $\omega$
- Fixed geometry: The circle's center and radius do not change over time
- Known dynamics: We observe only position $(x, y)$, but we assume the underlying motion is circular
State transition (prediction)
Only the angle advances with time: $$\phi_{k+1} = \phi_k + \omega \Delta t$$
All other parameters remain constant: $$c_{x,k+1} = c_{x,k}, \quad c_{y,k+1} = c_{y,k}, \quad r_{k+1} = r_k, \quad \omega_{k+1} = \omega_k$$
In matrix form: $\mathbf{F} = \text{diag}(1, 1, 1, 1, 1)$ with $F_{4,5} = \Delta t$
Observation model (nonlinear)
The observed position is related to the state by: $$\mathbf{h}(\mathbf{c}) = \begin{pmatrix} c_x + r\cos\phi \\ c_y + r\sin\phi \end{pmatrix}$$
This is nonlinear in the state, so we cannot use a standard Kalman filter. Instead, we use an Extended Kalman Filter (EKF) which linearizes the observation function around the current state estimate.
Jacobian matrix
The observation Jacobian $\mathbf{H}$ (needed for the EKF update) is: $$\mathbf{H} = \frac{\partial \mathbf{h}}{\partial \mathbf{c}} = \begin{pmatrix} 1 & 0 & \cos\phi & -r\sin\phi & 0 \\ 0 & 1 & \sin\phi & r\cos\phi & 0 \end{pmatrix}$$
Why this model matters
- Cartesian models (position/velocity/acceleration) treat circular motion as a sequence of local deviations that must be allowed by process noise
- Circular model explicitly represents the geometry—it "knows" the motion is circular
- The trade-off: circular EKF requires good initialization and assumes the motion really is circular; Cartesian models are more robust to model mismatch
Show or hide code
def circle_observation(c):
"""
Compute predicted observation and Jacobian for circular motion model.
Parameters:
-----------
c : array_like, shape (5,)
State vector [cx, cy, r, phi, omega]
Returns:
--------
value : ndarray, shape (2,)
Predicted (x, y) position on the circle
H : ndarray, shape (2, 5)
Observation Jacobian matrix
"""
cx, cy, r, phi, w = c
value = np.array([cx + r*np.cos(phi), cy + r*np.sin(phi)])
H = np.array([[1., 0., np.cos(phi), -r*np.sin(phi), 0.],
[0., 1., np.sin(phi), r*np.cos(phi), 0.]])
return value, H
# Verify the analytic Jacobian against finite differences
c_test = np.array([0.1, -0.2, 1.8, 0.4, 0.6])
eps = 1e-6
numerical = np.column_stack([(circle_observation(c_test+eps*e)[0]-circle_observation(c_test-eps*e)[0])/(2*eps)
for e in np.eye(5)])
np.testing.assert_allclose(circle_observation(c_test)[1], numerical, atol=1e-8)
print("✓ Jacobian verification passed")
# Initialize state near true values (in practice, initialization is a separate problem)
# State: [cx, cy, r, phi, omega]
# True: [0, 0, 2, 0, 0.7]
c = np.array([0.1, -0.1, 1.8, 0.1, 0.6]) # Slightly perturbed initial guess
P = np.diag([0.25, 0.25, 0.25, 0.1, 0.04]) # Initial uncertainty
# Transition matrix: only phi changes
F = np.eye(5)
F[3, 4] = delta_t # phi <- phi + omega * dt
# Process noise: very small, since we assume nearly perfect circular motion
Q_circle = np.diag([1e-7, 1e-7, 1e-7, 1e-5, 1e-6])
# Storage for analysis
circle_states = []
circle_positions = []
circle_covariances = []
state_history = [] # Track parameter evolution
print(f"Initial state estimate: [cx, cy, r, phi, omega] = {np.round(c, 4)}")
print(f"True values: = [0.0, 0.0, 2.0, 0.0, 0.7]")
print(f"\nRunning Extended Kalman Filter for {len(observed)} measurements...")
for k, z in enumerate(observed):
# PREDICTION STEP
if k:
c = F @ c # State prediction
P = F @ P @ F.T + Q_circle # Covariance prediction
# UPDATE STEP
prediction, H = circle_observation(c) # Compute predicted observation and Jacobian
innovation = z - prediction # Measurement residual
S = H @ P @ H.T + R # Innovation covariance
K = np.linalg.solve(S, H @ P).T # Kalman gain
# State and covariance update (Joseph form for numerical stability)
c = c + K @ innovation
J = np.eye(5) - K @ H
P = J @ P @ J.T + K @ R @ K.T
P = (P + P.T) / 2 # Enforce symmetry
# Validate state constraints
assert c[2] > 0, 'Radius must remain positive in this parameterization'
assert np.linalg.eigvalsh(P).min() >= -1e-10, 'Covariance must be positive semi-definite'
# Compute current position on estimated circle for plotting
xy, H_post = circle_observation(c)
# Store results
circle_states.append(c.copy())
circle_positions.append(xy)
circle_covariances.append(H_post @ P @ H_post.T)
state_history.append(c.copy())
circle_positions = np.array(circle_positions)
state_history = np.array(state_history)
print(f"\nFinal estimated state: [cx, cy, r, phi, omega] = {np.round(c, 4)}")
print(f"True values: = [0.0, 0.0, 2.0, {omega*times[-1]:.4f}, 0.7]")
print(f"\nParameter errors:")
print(f" Center x: {c[0]:.4f} m (true: 0.0 m)")
print(f" Center y: {c[1]:.4f} m (true: 0.0 m)")
print(f" Radius: {c[2]:.4f} m (true: 2.0 m)")
print(f" Angular velocity: {c[4]:.4f} rad/s (true: 0.7 rad/s)")
Parameter convergence
The Extended Kalman Filter not only tracks position—it directly estimates the physical parameters of the circular motion. Let's visualize how these parameters converge from the initial guess to the true values.
Show or hide code
from plotly.subplots import make_subplots
# Create subplots for parameter evolution
fig_params = make_subplots(
rows=2, cols=2,
subplot_titles=['Center X coordinate', 'Center Y coordinate', 'Radius', 'Angular velocity'],
vertical_spacing=0.12,
horizontal_spacing=0.1
)
# True values for reference
true_cx, true_cy = 0.0, 0.0
true_r = radius
true_omega = omega
# Plot center x
fig_params.add_scatter(x=times, y=state_history[:, 0], name='Estimated cx',
line=dict(color='blue'), row=1, col=1, showlegend=True)
fig_params.add_hline(y=true_cx, line=dict(color='red', dash='dash'),
row=1, col=1, annotation_text='True')
# Plot center y
fig_params.add_scatter(x=times, y=state_history[:, 1], name='Estimated cy',
line=dict(color='green'), row=1, col=2, showlegend=True)
fig_params.add_hline(y=true_cy, line=dict(color='red', dash='dash'),
row=1, col=2)
# Plot radius
fig_params.add_scatter(x=times, y=state_history[:, 2], name='Estimated r',
line=dict(color='purple'), row=2, col=1, showlegend=True)
fig_params.add_hline(y=true_r, line=dict(color='red', dash='dash'),
row=2, col=1)
# Plot angular velocity
fig_params.add_scatter(x=times, y=state_history[:, 4], name='Estimated ω',
line=dict(color='orange'), row=2, col=2, showlegend=True)
fig_params.add_hline(y=true_omega, line=dict(color='red', dash='dash'),
row=2, col=2)
fig_params.update_xaxes(title_text='Time [s]', row=2, col=1)
fig_params.update_xaxes(title_text='Time [s]', row=2, col=2)
fig_params.update_yaxes(title_text='cx [m]', row=1, col=1)
fig_params.update_yaxes(title_text='cy [m]', row=1, col=2)
fig_params.update_yaxes(title_text='r [m]', row=2, col=1)
fig_params.update_yaxes(title_text='ω [rad/s]', row=2, col=2)
fig_params.update_layout(
height=600,
title='Convergence of circular motion parameters',
showlegend=True
)
fig_params.show()
# Print convergence summary
print("Parameter convergence after 2 seconds (20 measurements):")
print(f" cx: {state_history[19, 0]:.4f} m → {state_history[-1, 0]:.4f} m (true: {true_cx} m)")
print(f" cy: {state_history[19, 1]:.4f} m → {state_history[-1, 1]:.4f} m (true: {true_cy} m)")
print(f" r: {state_history[19, 2]:.4f} m → {state_history[-1, 2]:.4f} m (true: {true_r} m)")
print(f" ω: {state_history[19, 4]:.4f} rad/s → {state_history[-1, 4]:.4f} rad/s (true: {true_omega} rad/s)")
Show or hide code
from plotly.subplots import make_subplots
fig_error = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=['Position error', 'Final comparison'])
for label, states in models.items():
xy = np.array([s.x[:2, 0] for s in states])
err = np.linalg.norm(xy-true_positions, axis=1)
fig_error.add_scatter(x=times, y=err, name=label, row=1, col=1)
# Skip burn-in for the summary; report the same range for every model.
print(f'{label:14s} RMSE after 2 s: {np.sqrt(np.mean((xy[20:]-true_positions[20:])**2)):.4f} m per coordinate')
fig_error.add_scatter(x=times, y=np.linalg.norm(circle_positions-true_positions, axis=1), name='Circular EKF', row=1, col=1)
fig_error.add_scatter(x=true_positions[:, 0], y=true_positions[:, 1], name='Truth', row=2, col=1)
fig_error.add_scatter(x=circle_positions[:, 0], y=circle_positions[:, 1], name='Circular EKF path', row=2, col=1)
fig_error.update_layout(height=800, title='Model comparison on one seeded circular trajectory')
fig_error.update_xaxes(title_text='Time [s]', row=1, col=1)
fig_error.update_yaxes(title_text='Euclidean error [m]', row=1, col=1)
fig_error.update_xaxes(title_text='x [m]', row=2, col=1)
fig_error.update_yaxes(title_text='y [m]', scaleanchor='x2', scaleratio=1, row=2, col=1)
fig_error.show()
print('Circular EKF RMSE after 2 s:', np.sqrt(np.mean((circle_positions[20:]-true_positions[20:])**2)))
Interpretation, limitations and exercise
A constant-velocity model tends to cut corners unless process noise allows the velocity to change. Acceleration can follow curvature over short intervals, but it still approximates the circular dynamics. The circular EKF can exploit the correct geometry; that advantage can be lost when the geometry is wrong. Initialisation and noise tuning affect this comparison, and one trajectory is not a statistical performance study. EKF uncertainty is a local approximation.
Exercise: double the assumed observation variance without changing the data. How do the final uncertainty ellipse and responsiveness change? Then change the truth to an ellipse and inspect which assumptions the circular EKF violates.
Show or hide code
# Answer scaffold: larger stated sensor noise gives less information.
F, Q = cartesian_model(1, delta_t, 0.5)
initial = CartesianState(np.array([[2.], [0.], [0.], [0.]]), np.array([0.]), np.eye(4)*4.)
wide_measurements = [Measurement(m.local_pos.copy(), m.global_pos.copy(), 2*m.local_cov) for m in measurements]
wide_states = KalmanFilter(initial, Q, F).fit(wide_measurements)
assert np.trace(wide_states[-1].x_cov[:2, :2]) > np.trace(models['Velocity'][-1].x_cov[:2, :2])
print('Larger assumed sensor variance increases the final position uncertainty.')
Interactive experiment: choose a tracking model
Compare position, velocity and acceleration models on the turning path, then vary the noise. Look for lag and corner cutting as well as smoothness. This experiment compares the Cartesian models; the circular EKF is demonstrated above.
The controls use a separate, seeded synthetic example and run in your browser
in the exported HTML article. Some notebook viewers do not execute embedded
JavaScript; run scripts/check_notebooks.py --html and open the article preview
to use the controls.
Show or hide code
from pathlib import Path
import sys
interactive_project = next(p / "projects" for p in [Path.cwd(), *Path.cwd().parents]
if (p / "projects" / "src").is_dir())
sys.path.insert(0, str(interactive_project / "src"))
from notebook_interactive import display_interactive
display_interactive("tracking")
Conclusion: the impact of choosing the correct model
Performance comparison
For this seeded circular trajectory, the position-only, constant-velocity and constant-acceleration Cartesian models achieve position RMSEs of $0.144$, $0.105$ and $0.100$ m per coordinate after the first two seconds. These models approximate circular motion through process noise—they don't "know" the trajectory is circular.
The circular Extended Kalman Filter achieves approximately $0.045$ m RMSE per coordinate over the same period—less than half the error of the best Cartesian model. This dramatic improvement comes from using a state representation that matches the true physics.
Key insights: when does model choice matter?
Model-data alignment: When your state model matches the underlying dynamics (here, circular motion), you can achieve better accuracy with less process noise. The circular EKF explicitly tracks radius and angular velocity—parameters that directly describe the motion.
Parameter interpretability: The circular model estimates physically meaningful parameters (center, radius, angular velocity) that can be compared to ground truth. Cartesian models only give position and derivatives at each instant.
Trade-offs:
- Circular EKF: Superior performance when motion is truly circular, but requires good initialization and fails if the trajectory is not circular
- Cartesian models: More robust to model mismatch, easier to initialize, but cannot exploit geometric structure
Nonlinearity: The circular observation function $\mathbf{h}(\mathbf{c})$ is nonlinear, requiring an Extended Kalman Filter. The EKF covariance remains a local approximation around the current estimate.
Practical implications
- Choose your state representation carefully: A model that encodes the right physics can dramatically outperform a generic one
- Validate assumptions: The circular model's advantage disappears if the motion is elliptical or the angular velocity varies
- Initialize thoughtfully: Nonlinear filters like the EKF are sensitive to initialization; we started close to the true orbit here
- Test robustness: One seeded trajectory doesn't prove statistical superiority; Monte Carlo studies over various initializations and noise levels are needed for deployment
Exercise extensions
- Wrong model: Change the true motion to an ellipse (different x and y radii) and observe how the circular EKF fails
- Initialization sensitivity: Start the circular EKF far from the true state and watch it converge (or diverge)
- Parameter uncertainty: Plot the diagonal elements of $\mathbf{P}$ over time to see how parameter uncertainty evolves
The choice of state model determines what the filter can represent. For this circular trajectory, encoding the geometry directly yields a factor-of-two improvement in tracking accuracy—a compelling demonstration of the value of domain-specific modeling.