Common Use Cases & Recipes
This guide provides ready-to-use code for common tracking and estimation scenarios. Each recipe is self-contained and can be adapted to your needs.
See Library Architecture for module organization and API Navigation Guide for function discovery.
A note on return types: the filter functions return named tuples, not plain
pairs. kf_predict/ekf_predict/ukf_predict return a
KalmanPrediction with fields x and P;
kf_update/ekf_update/ukf_update return a KalmanUpdate with
fields x, P, y, S, K, and likelihood. Unpack fields
by name.
Basic Single-Target Kalman Filtering
Problem: Track a single object with position and velocity in 2D, given range/bearing measurements.
Recipe:
import numpy as np
from pytcl.dynamic_estimation.kalman import kf_predict, kf_update
from pytcl.dynamic_models import f_constant_velocity, q_constant_velocity
from pytcl.coordinate_systems.conversions import sphere2cart
class SimpleTracker:
def __init__(self, T=0.1, sigma_a=0.1, meas_std=0.5):
"""
T: time step (seconds)
sigma_a: acceleration standard deviation (m/s^2)
meas_std: measurement standard deviation (meters)
"""
self.T = T
# State: [x, vx, y, vy]
self.x = np.array([100.0, 0.0, 0.0, 0.0])
self.P = np.eye(4) * 10.0 # Initial uncertainty
# Motion model (constant velocity)
self.F = f_constant_velocity(T, num_dims=2)
self.Q = q_constant_velocity(T, sigma_a, num_dims=2)
# Measurement model (observe position only)
self.H = np.array([[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0]])
self.R = np.eye(2) * meas_std**2
def process_measurement(self, range_az_el):
"""
Process a [range, azimuth, elevation] measurement (radians):
convert to Cartesian and update the filter.
"""
# Predict
pred = kf_predict(self.x, self.P, self.F, self.Q)
# Convert measurement from spherical to Cartesian; keep x, y.
# 'az-el' puts az=0, el=0 on the +x axis (radar convention).
r, az, el = range_az_el
cart_meas = sphere2cart(r, az, el, "az-el")[:2]
# Update
upd = kf_update(pred.x, pred.P, cart_meas, self.H, self.R)
self.x, self.P = upd.x, upd.P
return self.x[[0, 2]], np.sqrt(np.diag(self.P)[[0, 2]])
# Usage
tracker = SimpleTracker()
# Process measurements over time
measurements = [
np.array([100.0, 0.0, 0.0]), # range=100, az=0, el=0
np.array([100.5, 0.01, 0.0]),
np.array([101.2, 0.02, 0.0]),
]
for meas in measurements:
pos, std = tracker.process_measurement(meas)
print(f"Position: {np.round(pos, 2)}, Std: {np.round(std, 3)}")
Output:
Position: [100. 0.], Std: [0.494 0.494]
Position: [100.29 0.58], Std: [0.381 0.381]
Position: [100.82 1.46], Std: [0.373 0.373]
Multi-Target Tracking with Assignment
Problem: Track multiple targets with ambiguous measurements using Global Nearest Neighbor (GNN).
Recipe: MultiTargetTracker takes the motion and measurement models up
front, manages track initiation/confirmation/deletion internally, and is
driven with process(measurements, dt):
import numpy as np
from pytcl.dynamic_models import f_constant_velocity, q_constant_velocity
from pytcl.trackers import MultiTargetTracker
T = 1.0
# State [x, vx, y, vy]; measurements are [x, y] positions
F = f_constant_velocity(T, num_dims=2)
Q = q_constant_velocity(T, sigma_a=0.5, num_dims=2)
H = np.array([[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0]])
R = np.eye(2)
tracker = MultiTargetTracker(
state_dim=4,
meas_dim=2,
F=F,
H=H,
Q=Q,
R=R,
gate_probability=0.99,
confirm_hits=3, # hits needed to confirm a track...
confirm_window=5, # ...within this many updates
max_misses=5, # delete after this many consecutive misses
)
np.random.seed(0)
for frame in range(10):
# Two real targets plus one clutter point per frame
measurements = [
np.array([100.0 + 5.0 * frame, 50.0]) + np.random.randn(2),
np.array([200.0 - 3.0 * frame, 80.0 + 2.0 * frame]) + np.random.randn(2),
np.random.uniform(0.0, 300.0, size=2), # clutter
]
tracker.process(measurements, dt=T)
# Confirmed tracks survive; clutter-born tracks never confirm
for track in tracker.confirmed_tracks:
pos = track.state[[0, 2]]
vel = track.state[[1, 3]]
print(f"Track {track.id}: pos={np.round(pos, 1)}, "
f"vel={np.round(vel, 1)}, hits={track.hits}")
Output:
Track 0: pos=[144.2 50. ], vel=[5. 0.3], hits=10
Track 1: pos=[172. 97.3], vel=[-3.2 1.7], hits=10
process returns the full track list (Track named tuples with
id, state, covariance, status, hits, misses, and
time); the confirmed_tracks property filters it to confirmed ones.
Extended Kalman Filter with Nonlinear Dynamics
Problem: Track with nonlinear motion (e.g., coordinated turn model).
Recipe:
import numpy as np
from pytcl.dynamic_estimation.kalman import ekf_predict, ekf_update
def coordinated_turn(x, T):
"""Coordinated turn transition; state [x, vx, y, vy, omega]."""
px, vx, py, vy, omega = x
if abs(omega) > 1e-6:
s, c = np.sin(omega * T), np.cos(omega * T)
return np.array([
px + vx / omega * s - vy / omega * (1 - c),
vx * c - vy * s,
py + vx / omega * (1 - c) + vy / omega * s,
vx * s + vy * c,
omega,
])
# Straight line when omega is approximately 0
x_new = x.copy()
x_new[0] += vx * T
x_new[2] += vy * T
return x_new
def coordinated_turn_jacobian(x, T):
"""Jacobian of the coordinated turn model."""
px, vx, py, vy, omega = x
F = np.eye(5)
if abs(omega) > 1e-6:
s, c = np.sin(omega * T), np.cos(omega * T)
F[0, 1] = s / omega
F[0, 3] = -(1 - c) / omega
F[1, 1] = c
F[1, 3] = -s
F[2, 1] = (1 - c) / omega
F[2, 3] = s / omega
F[3, 1] = s
F[3, 3] = c
# Sensitivity to the turn rate
F[0, 4] = vx / omega * (T * c - s / omega) - vy / omega * (T * s - (1 - c) / omega)
F[1, 4] = -T * (vx * s + vy * c)
F[2, 4] = vx / omega * (T * s - (1 - c) / omega) + vy / omega * (T * c - s / omega)
F[3, 4] = T * (vx * c - vy * s)
else:
F[0, 1] = T
F[2, 3] = T
return F
class EKFTracker:
def __init__(self, T=0.1):
self.T = T
# State: [x, vx, y, vy, omega]
self.x = np.array([0.0, 10.0, 0.0, 5.0, 0.05])
self.P = np.diag([1.0, 1.0, 1.0, 1.0, 0.01])
self.Q = np.diag([0.01, 0.1, 0.01, 0.1, 1e-4])
self.H = np.array([[1.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0]]) # measure [x, y]
self.R = np.eye(2) * 0.5**2
def step(self, z):
F = coordinated_turn_jacobian(self.x, self.T)
pred = ekf_predict(
self.x, self.P,
lambda x: coordinated_turn(x, self.T),
F, self.Q,
)
upd = ekf_update(
pred.x, pred.P, z,
lambda x: x[[0, 2]],
self.H, self.R,
)
self.x, self.P = upd.x, upd.P
return self.x
# Usage: simulate a turning target and track it
np.random.seed(1)
ekf = EKFTracker(T=0.1)
truth = np.array([0.0, 10.0, 0.0, 5.0, 0.1])
for k in range(100):
truth = coordinated_turn(truth, 0.1)
z = truth[[0, 2]] + np.random.randn(2) * 0.5
est = ekf.step(z)
print(f"True position: ({truth[0]:.1f}, {truth[2]:.1f})")
print(f"Estimated position: ({est[0]:.1f}, {est[2]:.1f})")
print(f"Estimated turn rate: {est[4]:.3f} (true 0.100)")
Output:
True position: (61.2, 88.0)
Estimated position: (61.6, 88.2)
Estimated turn rate: 0.096 (true 0.100)
Unscented Kalman Filter
Problem: Track a nonlinear system without computing Jacobians.
Recipe:
import numpy as np
from pytcl.dynamic_estimation.kalman import ukf_predict, ukf_update
class UKFTracker:
def __init__(self, T=0.1, alpha=1e-3, beta=2.0, kappa=0.0):
"""
Unscented Kalman Filter (no Jacobians needed!)
alpha, beta, kappa: sigma-point parameters
"""
self.T = T
self.alpha = alpha
self.beta = beta
self.kappa = kappa
# State: [x, vx, y, vy]
self.x = np.array([0.0, 1.0, 0.0, 0.0])
self.P = np.eye(4) * 0.1
self.Q = np.eye(4) * 0.01
self.R = np.eye(2) * 0.5**2
def motion_model(self, x, dt):
"""Nonlinear motion: could be any complex model"""
x_new = x.copy()
x_new[0] += x[1] * dt
x_new[2] += x[3] * dt
return x_new
def measurement_model(self, x):
"""Measure position and speed"""
return np.array([x[0], np.sqrt(x[1]**2 + x[3]**2)])
def update(self, measurement):
def f(x):
return self.motion_model(x, self.T)
pred = ukf_predict(
self.x, self.P, f, self.Q,
alpha=self.alpha, beta=self.beta, kappa=self.kappa,
)
upd = ukf_update(
pred.x, pred.P, measurement, self.measurement_model, self.R,
alpha=self.alpha, beta=self.beta, kappa=self.kappa,
)
self.x = upd.x
self.P = upd.P
return self.x[[0, 2]]
# Usage: measurements are [position, speed]
np.random.seed(2)
ukf = UKFTracker()
for k in range(5):
z = np.array([0.1 * (k + 1), 1.0]) + np.random.randn(2) * 0.1
pos = ukf.update(z)
print(f"Position estimate: {np.round(pos, 3)}")
Output:
Position estimate: [0.086 0. ]
Position estimate: [0.136 0. ]
Position estimate: [ 0.205 -0. ]
Position estimate: [ 0.329 -0. ]
Position estimate: [ 0.413 -0. ]
Particle Filter for Nonlinear Non-Gaussian Systems
Problem: Track a system with non-Gaussian noise or highly nonlinear dynamics.
Recipe: bootstrap_pf_step performs predict + weight update +
adaptive resampling in one call. The process noise is specified as a
sampler, so any distribution works – here heavy-tailed Cauchy noise,
which no Kalman filter can represent:
import numpy as np
from pytcl.dynamic_estimation.particle_filters import (
bootstrap_pf_step,
initialize_particles,
particle_mean,
)
rng = np.random.default_rng(42)
# State [x, vx, y, vy]; measurement is the range from the origin
x0 = np.array([10.0, 1.0, 5.0, 0.0])
P0 = np.eye(4)
state = initialize_particles(x0, P0, N=2000, rng=rng)
dt = 0.1
def f(x):
"""Constant-velocity motion (per particle)."""
return np.array([x[0] + x[1] * dt, x[1], x[2] + x[3] * dt, x[3]])
def h(x):
"""Nonlinear measurement: distance from origin."""
return np.array([np.hypot(x[0], x[2])])
def q_sample(n, rng):
"""Heavy-tailed (Cauchy) process noise."""
return rng.standard_cauchy((n, 4)) * 0.05
R = np.array([[0.5**2]])
true_x = x0.copy()
for k in range(10):
true_x = f(true_x)
z = h(true_x) + rng.normal(0.0, 0.5, 1)
state = bootstrap_pf_step(
state.particles, state.weights, z, f, h, q_sample, R, rng=rng,
)
estimate = particle_mean(state.particles, state.weights)
print(f"True range: {h(true_x)[0]:.2f}, estimated range: {h(estimate)[0]:.2f}")
Output:
True range: 12.08, estimated range: 11.65
Resampling happens automatically when the effective sample size drops below
resample_threshold * N; particle_covariance and
effective_sample_size are available from the same module.
Adaptive Kalman Filtering
Problem: Track when the measurement noise varies over time.
Recipe:
import numpy as np
from pytcl.dynamic_estimation.kalman import kf_predict, kf_update
class AdaptiveKalmanFilter:
def __init__(self, T=0.1, sigma_a=0.1):
# State: [x, vx, y, vy]
self.x = np.array([0.0, 1.0, 0.0, 1.0])
self.P = np.eye(4) * 0.1
self.F = np.array([
[1, T, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, T],
[0, 0, 0, 1],
], dtype=float)
self.Q = np.eye(4) * sigma_a**2
self.H = np.array([[1.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0]]) # measure [x, y]
self.R = np.eye(2) * 0.5**2
# Measurement noise adaptation
self.innovation_history = []
self.max_history = 30
def update(self, measurement):
# Predict
pred = kf_predict(self.x, self.P, self.F, self.Q)
# Compute innovation
innovation = measurement - self.H @ pred.x
self.innovation_history.append(np.linalg.norm(innovation))
# Keep history bounded
if len(self.innovation_history) > self.max_history:
self.innovation_history = self.innovation_history[1:]
# Adapt measurement noise based on innovation statistics
if len(self.innovation_history) >= 10:
mean_innovation = np.mean(self.innovation_history)
std_innovation = np.std(self.innovation_history)
# Inflate R if innovations get erratic (measurement unreliable)
if std_innovation > mean_innovation * 2:
self.R = np.eye(2) * mean_innovation**2
else:
self.R = np.eye(2) * 0.5**2 # Default
# Update with adapted R
upd = kf_update(pred.x, pred.P, measurement, self.H, self.R)
self.x, self.P = upd.x, upd.P
return self.x[[0, 2]]
# Usage
np.random.seed(3)
adaptive_kf = AdaptiveKalmanFilter()
for k in range(20):
z = np.array([0.1 * (k + 1), 0.1 * (k + 1)]) + np.random.randn(2) * 0.5
pos = adaptive_kf.update(z)
print(f"Final position estimate: {np.round(pos, 2)}")
Output:
Final position estimate: [1.91 1.72]
Batch Processing / Smoothing
Problem: Process all data offline to get the best parameter estimates.
Recipe: fit a constant-velocity trajectory to all measurements at once
with ordinary_least_squares:
import numpy as np
from pytcl.static_estimation import ordinary_least_squares
# Truth: [x0, vx, y0, vy]
truth = np.array([100.0, 5.0, 50.0, -2.0])
np.random.seed(4)
T = 0.1
times = np.arange(20) * T
measurements = np.column_stack([
truth[0] + truth[1] * times,
truth[2] + truth[3] * times,
]) + np.random.randn(20, 2) * 0.5
# Linear model: z = A @ [x0, vx, y0, vy]
A = np.zeros((2 * len(times), 4))
A[0::2, 0] = 1.0
A[0::2, 1] = times
A[1::2, 2] = 1.0
A[1::2, 3] = times
b = measurements.ravel()
result = ordinary_least_squares(A, b)
print(f"Estimated [x0, vx, y0, vy]: {np.round(result.x, 2)}")
Output:
Estimated [x0, vx, y0, vy]: [99.83 5.26 49.98 -2.01]
ordinary_least_squares returns an LSResult with fields x,
residuals, rank, and singular_values. For recursive smoothing
of dynamic systems (RTS and two-filter smoothers), see Smoothing Algorithms & Offline Estimation.
Data Association with Gating
Problem: Only associate measurements within a statistical gate.
Recipe: pytcl ships the gating utilities – chi2_gate_threshold
for the gate size, mahalanobis_distance for the squared statistical
distance, and gnn_association to solve the gated assignment:
import numpy as np
from pytcl.assignment_algorithms import (
chi2_gate_threshold,
gnn_association,
mahalanobis_distance,
)
# Two predicted track positions with innovation covariances
track_positions = [np.array([10.0, 0.0]), np.array([0.0, 20.0])]
S_list = [np.eye(2) * 2.0, np.eye(2) * 2.0]
measurements = [
np.array([10.5, 0.3]), # near track 0
np.array([-0.2, 19.5]), # near track 1
np.array([50.0, 50.0]), # clutter
]
# 99% gate for a 2-dimensional measurement
gate = chi2_gate_threshold(0.99, num_dimensions=2)
print(f"Gate threshold: {gate:.2f}")
# Cost matrix of squared Mahalanobis distances
cost = np.zeros((len(track_positions), len(measurements)))
for i, (pred, S) in enumerate(zip(track_positions, S_list)):
for j, z in enumerate(measurements):
cost[i, j] = mahalanobis_distance(z - pred, S)
# Gated global-nearest-neighbor assignment
assoc = gnn_association(cost, gate_threshold=gate)
print(f"Track-to-measurement assignment: {assoc.track_to_measurement}")
Output:
Gate threshold: 9.21
Track-to-measurement assignment: [0 1]
The clutter point exceeds the gate for both tracks, so it is left
unassigned. For k-best assignments use kbest_assign2d (Murty’s
algorithm), and for higher-dimensional (multi-scan) assignment problems see
assign3d and assignment_nd.
See Also
Library Architecture - Module organization
API Navigation Guide - Function discovery
Kalman Filter Tuning Guide - Parameter tuning
Performance Optimization Guide - Performance tips
examples/- More complete examples