Files
2026-02-13 16:14:14 +08:00

424 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Copyright (c) 2026, Rick Lan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, and/or sublicense,
for non-commercial purposes only, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
- Commercial use (e.g. use in a product, service, or activity intended to
generate revenue) is prohibited without explicit written permission from
the copyright holder.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
GPS Location Service - GPS + livePose fusion with Kalman filter.
- Position: 2D Kalman filter fusing GPS with livePose velocity
- Bearing: livePose yaw + GPS-calibrated offset (with slow drift correction)
"""
import numpy as np
from cereal import messaging, custom
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.common.transformations.coordinates import LocalCoord
from openpilot.common.swaglog import cloudlog
from openpilot.common.gps import get_gps_location_service
def wrap_angle(x):
return np.arctan2(np.sin(x), np.cos(x))
class GPSKalman:
"""
3D Kalman filter for GPS fusion.
State: [north, east, yaw_offset] where yaw_offset calibrates livePose to true north.
Adapts automatically to GPS accuracy (ublox vs qcom).
"""
# Process noise
POS_NOISE = 0.5 # m²/s - position uncertainty growth
YAW_NOISE = 0.0001 # rad²/s - yaw offset drift (~0.6°/min)
def __init__(self):
self.x = np.zeros(3) # [north, east, yaw_offset]
self.P = np.diag([100.0, 100.0, 1.0]) # uncertainty (position high, yaw moderate)
def get_yaw(self, pose_yaw):
"""Get calibrated yaw from pose yaw + estimated offset."""
return wrap_angle(pose_yaw + self.x[2])
def predict(self, vel_ned, dt):
"""Predict state using velocity from livePose."""
# Position prediction
self.x[0] += vel_ned[0] * dt
self.x[1] += vel_ned[1] * dt
# yaw_offset: no change (constant), just add process noise
# Process noise - more yaw drift when stopped (gyro drift accumulates)
speed = np.linalg.norm(vel_ned[:2])
yaw_noise = self.YAW_NOISE if speed > 1.0 else self.YAW_NOISE * 10
Q = np.diag([self.POS_NOISE * dt, self.POS_NOISE * dt, yaw_noise * dt])
self.P += Q
def update_position(self, gps_ned, gps_accuracy):
"""Update position with GPS measurement."""
# Observation matrix: observe [north, east], not yaw_offset
H = np.array([[1, 0, 0],
[0, 1, 0]])
# Measurement noise from GPS accuracy
R = np.eye(2) * (gps_accuracy ** 2)
# Innovation
z = gps_ned[:2]
y = z - H @ self.x
# Kalman gain
S = H @ self.P @ H.T + R
det = S[0, 0] * S[1, 1] - S[0, 1] * S[1, 0]
if abs(det) < 1e-10:
return
S_inv = np.array([[S[1, 1], -S[0, 1]],
[-S[1, 0], S[0, 0]]]) / det
K = self.P @ H.T @ S_inv
# Update
self.x += K @ y
self.P = (np.eye(3) - K @ H) @ self.P
self._ensure_positive_definite()
def update_yaw(self, gps_bearing, pose_yaw, bearing_std):
"""Update yaw_offset with GPS bearing measurement."""
# Observation: yaw_offset = gps_bearing - pose_yaw
# H = [0, 0, 1] - we observe yaw_offset directly
H = np.array([[0, 0, 1]])
# Measurement noise from GPS bearing uncertainty
R = np.array([[bearing_std ** 2]])
# Expected yaw_offset from GPS
observed_offset = wrap_angle(gps_bearing - pose_yaw)
# Innovation (handle angle wrapping)
predicted_offset = self.x[2]
y = np.array([wrap_angle(observed_offset - predicted_offset)])
# Kalman gain
S = H @ self.P @ H.T + R
K = self.P @ H.T / S[0, 0]
# Update
self.x += (K @ y).flatten()
self.x[2] = wrap_angle(self.x[2]) # keep yaw_offset wrapped
self.P = (np.eye(3) - K @ H) @ self.P
self._ensure_positive_definite()
def _ensure_positive_definite(self):
"""Ensure covariance stays positive definite. Reinit if corrupted."""
self.P = (self.P + self.P.T) / 2
if np.any(np.diag(self.P) < 0):
cloudlog.warning("gpsd: negative covariance detected, reinitializing filter")
self.P = np.diag([100.0, 100.0, 1.0])
return
min_var = np.array([0.01, 0.01, 0.0001]) # minimum variances
for i in range(3):
self.P[i, i] = max(self.P[i, i], min_var[i])
def reset(self, pos, yaw_offset=None):
"""Reset to known position, optionally with yaw offset."""
self.x[0] = pos[0]
self.x[1] = pos[1]
if yaw_offset is not None:
self.x[2] = yaw_offset
self.P = np.diag([1.0, 1.0, 0.1]) # low uncertainty
else:
self.P = np.diag([1.0, 1.0, self.P[2, 2]]) # keep yaw uncertainty
@property
def pos(self):
"""Position [north, east]."""
return self.x[:2]
@property
def yaw_offset(self):
"""Yaw offset estimate."""
return self.x[2]
@property
def pos_uncertainty(self):
"""Position uncertainty (meters)."""
return np.sqrt(max(0.0, (self.P[0, 0] + self.P[1, 1]) / 2))
@property
def yaw_uncertainty(self):
"""Yaw offset uncertainty (radians)."""
return np.sqrt(max(0.0, self.P[2, 2]))
class LiveGPS:
"""
GPS + livePose fusion with 3D Kalman filter.
- Position: Kalman filter fusing GPS with livePose velocity
- Bearing: Kalman-estimated yaw_offset + livePose yaw
"""
GPS_MIN_SPEED = 5.0 # m/s (18 km/h) - need speed for reliable GPS bearing
GPS_MAX_ACCURACY = 50.0 # m - reject very bad GPS
BEARING_STD_BASE = 0.1 # rad (~6°) - base GPS bearing uncertainty
BEARING_STD_PER_ACC = 0.02 # rad per meter of GPS accuracy
def __init__(self):
# pose inputs
self.orientation_ned = np.zeros(3)
self.vel_device = np.zeros(3)
# gps inputs
self.gps = None
self.last_gps_t = 0.0
self.unix_timestamp_millis = 0
# Kalman filter: [north, east, yaw_offset]
self.origin = None # LocalCoord of first GPS fix
self.kf = GPSKalman() # 3D Kalman filter
self.altitude = 0.0 # altitude tracked separately (1D)
self.last_gps_update_t = 0.0 # track when we last updated Kalman with GPS
# timing
self.last_t = None
self.last_pose_yaw = None # for yaw rate calculation
self.live_pose_ok = False # for monitoring
# -----------------------------
# inputs
# -----------------------------
def handle_pose(self, pose):
if pose.orientationNED.valid:
self.orientation_ned[:] = [
pose.orientationNED.x,
pose.orientationNED.y,
pose.orientationNED.z,
]
if pose.velocityDevice.valid:
self.vel_device[:] = [
pose.velocityDevice.x,
pose.velocityDevice.y,
pose.velocityDevice.z,
]
# For monitoring
self.live_pose_ok = pose.orientationNED.valid and pose.velocityDevice.valid
def handle_gps(self, t, gps):
if gps.horizontalAccuracy > 0 and gps.horizontalAccuracy > self.GPS_MAX_ACCURACY:
return
if abs(gps.latitude) < 0.1 or abs(gps.longitude) < 0.1:
return
self.gps = gps
self.last_gps_t = t
self.unix_timestamp_millis = gps.unixTimestampMillis
# -----------------------------
# core update
# -----------------------------
def update(self, t):
dt = (t - self.last_t) if self.last_t else 0.05
self.last_t = t
if self.gps is None:
return
# initialize origin on first GPS
if self.origin is None:
self.origin = LocalCoord.from_geodetic([self.gps.latitude, self.gps.longitude, self.gps.altitude])
self.kf.reset(np.zeros(2))
self.altitude = self.gps.altitude
cloudlog.info(f"gpsd: origin set at {self.gps.latitude:.6f}, {self.gps.longitude:.6f}")
return
# get current yaw from Kalman (pose_yaw + estimated yaw_offset)
pose_yaw = self.orientation_ned[2]
yaw = self.kf.get_yaw(pose_yaw)
# transform velocity from device frame to NED
cos_yaw, sin_yaw = np.cos(yaw), np.sin(yaw)
vel_ned = np.array([
self.vel_device[0] * cos_yaw - self.vel_device[1] * sin_yaw,
self.vel_device[0] * sin_yaw + self.vel_device[1] * cos_yaw,
-self.vel_device[2]
])
# Kalman predict: propagate position using livePose velocity
# Skip prediction when stationary (GPS wanders, IMU drifts)
# Threshold 0.1 m/s - above noise (~0.04) but catches actual stops
speed = np.linalg.norm(self.vel_device[:2])
is_moving = speed > 0.1
if is_moving:
self.kf.predict(vel_ned, dt)
# Kalman update: only on NEW GPS data (not stale)
# Skip position update when stopped - prevents GPS wander from moving position
new_gps = self.last_gps_t > self.last_gps_update_t
if new_gps and is_moving:
self.last_gps_update_t = self.last_gps_t
gps_ned = self.origin.geodetic2ned([self.gps.latitude, self.gps.longitude, self.gps.altitude])
gps_accuracy = self.gps.horizontalAccuracy if self.gps.horizontalAccuracy > 0 else 15.0
# Check for hard reset (large error after tunnel/GPS loss)
error = np.linalg.norm(gps_ned[:2] - self.kf.pos)
if error > 100.0 and gps_accuracy < 20.0:
# GPS is good but we're far off - full reset (new origin, fresh Kalman)
yaw_err_deg = np.degrees(self.kf.yaw_uncertainty)
cloudlog.warning(f"gpsd: hard reset, error={error:.1f}m, yaw_unc={yaw_err_deg:.1f}°, gps_acc={gps_accuracy:.1f}m, livePoseOk={self.live_pose_ok}")
# Reset origin to current GPS - starts fresh
self.origin = LocalCoord.from_geodetic([self.gps.latitude, self.gps.longitude, self.gps.altitude])
self.kf = GPSKalman() # fresh Kalman filter
self.kf.reset(np.zeros(2)) # position (0,0) at new origin
self.altitude = self.gps.altitude
return # skip normal update this frame
else:
# Position update - adapts to GPS accuracy:
# - ublox (2-5m): high gain, trusts GPS
# - qcom (10-30m): low gain, trusts IMU more
self.kf.update_position(gps_ned, gps_accuracy)
# simple altitude tracking (no Kalman needed)
self.altitude = 0.9 * self.altitude + 0.1 * self.gps.altitude
# Yaw update (need speed for reliable GPS bearing)
if self.gps.speed > self.GPS_MIN_SPEED:
# compute yaw rate to check if driving straight
yaw_rate = 0.0
if self.last_pose_yaw is not None and dt > 0:
yaw_rate = abs(wrap_angle(pose_yaw - self.last_pose_yaw)) / dt
# GPS bearing is unreliable during turns - increase uncertainty
gps_bearing = np.radians(self.gps.bearingDeg)
bearing_std = self.BEARING_STD_BASE + self.BEARING_STD_PER_ACC * gps_accuracy
if yaw_rate > 0.1: # turning - GPS bearing lags
bearing_std *= 3.0
self.kf.update_yaw(gps_bearing, pose_yaw, bearing_std)
self.last_pose_yaw = pose_yaw
# -----------------------------
# output
# -----------------------------
def get_msg(self, log_mono_time):
msg = messaging.new_message("liveGPS", valid=True)
msg.logMonoTime = log_mono_time
out = msg.liveGPS
t = log_mono_time * 1e-9
gps_fresh = self.gps is not None and (t - self.last_gps_t) < 5.0
pos_initialized = self.origin is not None
# yaw is calibrated when uncertainty < 0.5 rad (~30°)
yaw_calibrated = self.kf.yaw_uncertainty < 0.5
if pos_initialized:
# position from Kalman filter (NED -> geodetic)
pos_ned = np.array([self.kf.pos[0], self.kf.pos[1], self.altitude])
geodetic = self.origin.ned2geodetic(pos_ned)
out.latitude = float(geodetic[0])
out.longitude = float(geodetic[1])
out.altitude = float(geodetic[2])
out.speed = float(np.linalg.norm(self.vel_device[:2]))
# horizontalAccuracy from Kalman uncertainty
out.horizontalAccuracy = float(self.kf.pos_uncertainty)
out.verticalAccuracy = float(self.gps.verticalAccuracy) if hasattr(self.gps, 'verticalAccuracy') and self.gps.verticalAccuracy > 0 else 15.0
# bearing from Kalman (pose_yaw + estimated yaw_offset)
has_livePose = np.any(self.orientation_ned != 0)
if yaw_calibrated and has_livePose and gps_fresh:
yaw = self.kf.get_yaw(self.orientation_ned[2])
out.bearingDeg = float(np.degrees(yaw) % 360)
out.status = custom.LiveGPS.Status.valid
else:
# fallback to raw GPS bearing
out.bearingDeg = float(self.gps.bearingDeg)
out.status = custom.LiveGPS.Status.uncalibrated
elif self.gps is not None:
# have GPS but not initialized yet - pass through raw
out.latitude = float(self.gps.latitude)
out.longitude = float(self.gps.longitude)
out.altitude = float(self.gps.altitude)
out.speed = float(self.gps.speed)
out.bearingDeg = float(self.gps.bearingDeg)
out.horizontalAccuracy = float(self.gps.horizontalAccuracy) if self.gps.horizontalAccuracy > 0 else 20.0
out.status = custom.LiveGPS.Status.uncalibrated
else:
out.status = custom.LiveGPS.Status.uninitialized
# gpsOK = position is usable (bearing calibration tracked separately via status)
out.gpsOK = gps_fresh and pos_initialized
out.unixTimestampMillis = self.unix_timestamp_millis
out.lastGpsTimestamp = int(self.last_gps_t * 1e9)
# livePose health - for monitoring
out.livePoseOk = self.live_pose_ok
return msg
def main():
import os
params = Params()
# EXT=1 forces gpsLocationExternal (ublox), EXT=0 forces gpsLocation (qcom)
ext_override = os.environ.get("EXT")
if ext_override == "1":
gps_service = "gpsLocationExternal"
cloudlog.info("gpsd: EXT=1, using gpsLocationExternal (ublox)")
elif ext_override == "0":
gps_service = "gpsLocation"
cloudlog.info("gpsd: EXT=0, using gpsLocation (qcom)")
else:
gps_service = get_gps_location_service(params)
pm = messaging.PubMaster(["liveGPS"])
sm = messaging.SubMaster([gps_service, "livePose"], ignore_alive=[gps_service])
gps = LiveGPS()
rk = Ratekeeper(20)
while True:
try:
sm.update(0)
if sm.logMonoTime["livePose"] > 0:
t = sm.logMonoTime["livePose"] * 1e-9
log_mono_time = sm.logMonoTime["livePose"]
else:
log_mono_time = int(rk.frame * 1e9 / 20)
t = log_mono_time * 1e-9
if sm.updated[gps_service]:
gps.handle_gps(t, sm[gps_service])
if sm.updated["livePose"] and sm.valid["livePose"]:
gps.handle_pose(sm["livePose"])
gps.update(t)
pm.send("liveGPS", gps.get_msg(log_mono_time))
except Exception:
cloudlog.exception("gpsd: error in main loop")
rk.keep_time()
if __name__ == "__main__":
main()