#!/usr/bin/env python3 """ Kalman Filter for Angular Position/Velocity/Acceleration ========================================================= 1D Kalman filter for tracking door angle from noisy encoder readings. State vector: [angle, velocity, acceleration] Measurement: angle (from 14-bit I2C encoder) Author: Kynsight Version: 1.0.0 """ import numpy as np from typing import Tuple, Optional class AngleKalmanFilter: """ Kalman filter for angular position tracking. Tracks angle (deg), angular velocity (deg/s), and angular acceleration (deg/s²). Designed for 14-bit encoder with 0.022° quantization noise. """ def __init__( self, process_noise: float = 0.1, measurement_noise: float = 0.022, initial_angle: float = 0.0 ): """ Initialize Kalman filter. Args: process_noise: Process noise covariance (system uncertainty) measurement_noise: Measurement noise (encoder quantization = 0.022°) initial_angle: Initial angle estimate (degrees) """ # State vector: [angle, velocity, acceleration] self.x = np.array([initial_angle, 0.0, 0.0]) # State covariance matrix (uncertainty in estimates) self.P = np.eye(3) * 1.0 # Process noise covariance self.Q = np.array([ [process_noise, 0, 0], [0, process_noise * 10, 0], # Velocity more uncertain [0, 0, process_noise * 100] # Acceleration even more uncertain ]) # Measurement noise covariance (14-bit encoder = 360/16384 = 0.022°) self.R = np.array([[measurement_noise ** 2]]) # Measurement matrix (we only measure angle) self.H = np.array([[1.0, 0.0, 0.0]]) # Previous timestamp for dt calculation self.prev_time_ns: Optional[int] = None # Filter initialized flag self.initialized = False def predict(self, dt: float): """ Predict step: propagate state forward in time. State transition model: angle(k+1) = angle(k) + velocity(k)*dt + 0.5*accel(k)*dt² velocity(k+1) = velocity(k) + accel(k)*dt accel(k+1) = accel(k) (constant acceleration model) Args: dt: Time step in seconds """ # State transition matrix F = np.array([ [1.0, dt, 0.5 * dt**2], [0.0, 1.0, dt], [0.0, 0.0, 1.0] ]) # Predict state self.x = F @ self.x # Predict covariance self.P = F @ self.P @ F.T + self.Q def update(self, measurement: float): """ Update step: correct prediction with measurement. Args: measurement: Measured angle in degrees """ # Measurement residual z = np.array([[measurement]]) y = z - self.H @ self.x.reshape(-1, 1) # Residual covariance S = self.H @ self.P @ self.H.T + self.R # Kalman gain K = self.P @ self.H.T @ np.linalg.inv(S) # Update state self.x = self.x + (K @ y).flatten() # Update covariance I = np.eye(3) self.P = (I - K @ self.H) @ self.P def process( self, angle_measurement: float, timestamp_ns: int ) -> Tuple[float, float, float]: """ Process one measurement (predict + update). Args: angle_measurement: Raw angle measurement (degrees) timestamp_ns: Timestamp in nanoseconds Returns: (filtered_angle, filtered_velocity, filtered_acceleration) """ if not self.initialized: # First measurement - initialize state self.x[0] = angle_measurement self.x[1] = 0.0 self.x[2] = 0.0 self.prev_time_ns = timestamp_ns self.initialized = True return (angle_measurement, 0.0, 0.0) # Calculate time step dt = (timestamp_ns - self.prev_time_ns) / 1_000_000_000.0 self.prev_time_ns = timestamp_ns if dt <= 0: # Duplicate timestamp - return previous estimate return (self.x[0], self.x[1], self.x[2]) # Predict self.predict(dt) # Update with measurement self.update(angle_measurement) return (self.x[0], self.x[1], self.x[2]) def reset(self, angle: float = 0.0): """ Reset filter to initial state. Args: angle: Initial angle estimate """ self.x = np.array([angle, 0.0, 0.0]) self.P = np.eye(3) * 1.0 self.prev_time_ns = None self.initialized = False def get_state(self) -> Tuple[float, float, float]: """ Get current state estimate. Returns: (angle, velocity, acceleration) """ return (self.x[0], self.x[1], self.x[2]) # ============================================================================= # Demo # ============================================================================= if __name__ == "__main__": print("Kalman Filter Demo") print("=" * 60) # Create filter kf = AngleKalmanFilter( process_noise=0.1, measurement_noise=0.022, # 14-bit encoder quantization initial_angle=-84.0 ) # Simulate noisy measurements import random print("\nSimulating noisy angle measurements:") print(f"{'Time (ms)':<12} {'Raw Angle':<12} {'Filtered':<12} {'Velocity':<12} {'Accel':<12}") print("-" * 60) true_angle = -84.0 time_ns = 0 for i in range(20): time_ns += 35_000_000 # ~35ms intervals (typical packet rate) # Add quantization noise (0.022° steps) noise = random.choice([-0.022, 0, 0.022]) measurement = true_angle + noise # Process measurement angle, vel, accel = kf.process(measurement, time_ns) print(f"{time_ns/1e6:<12.1f} {measurement:<12.4f} {angle:<12.4f} {vel:<12.4f} {accel:<12.4f}") # Slowly increase angle (simulate door opening) true_angle += 0.02 print("\n✓ Filter smooths quantization noise while tracking motion")