Kalman Filter GPS Smoothing for Bin Coordinates
Resolve a jittering GPS cloud into one stable coordinate per static waste bin.
This page solves one specific problem: a static waste bin reports dozens of slightly different GPS fixes over its lifetime, and you need each bin to resolve to one stable coordinate instead of a jittering cloud that smears the bin across the map.
Unlike a moving refuse vehicle, a bin does not travel. Every variation between its fixes is measurement noise — multipath off the container’s own steel walls, satellite geometry changes across the day, receiver drift while the asset sits perfectly still. Applying the constant-velocity smoother from the parent Kalman Filtering & GPS Smoothing guide here would be a mistake: that model carries a velocity state, and on a stationary target the velocity drifts on pure noise and slowly walks the estimate away from the truth. The correct tool for a static asset is a position-only estimator — a Kalman filter whose state is just the coordinate, mathematically equivalent to a recursive inverse-variance weighted mean — which folds each fix in by its reported accuracy and shrinks its uncertainty toward a single point. Each fix has already passed the kinematic checks in Validating GPS Coordinates in Python, so this stage only reconciles the survivors.
Environment & Data Prerequisites
The estimator needs only NumPy plus the standard library. pytest is used solely for the verification step.
python -m pip install numpy==2.1.1 pytest==8.2.0
Each inbound record is one GPS fix reported for one bin. The estimator reasons over exactly these fields:
| Field | Unit / type | Rule |
|---|---|---|
bin_id |
str | grouping key; fixes must be partitioned by it |
lat |
float, degrees | −90.0 … 90.0 (WGS84) |
lon |
float, degrees | −180.0 … 180.0 (WGS84) |
accuracy_m |
float, metres (CEP) | > 0; drives the per-fix weight |
ts |
ISO 8601 string | tz-aware UTC; used only for ordering and provenance |
The single most important rule is the grouping key. Every fix must be partitioned by bin_id before any estimation runs — a coordinate averaged across two different bins resolves to a phantom point between them that belongs to neither. Accuracy is the second: because it drives each fix’s weight, a value of zero or negative is rejected outright rather than silently producing an infinite weight.
Step-by-Step Implementation
Step 1 — Type and group the fixes per bin
Model the inbound fix, then partition the stream by bin_id so each bin is estimated in isolation. Grouping first is what prevents the most common corruption — mixing coordinates across bins.
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class BinFix(BaseModel):
bin_id: str = Field(min_length=1)
lat: float = Field(ge=-90.0, le=90.0)
lon: float = Field(ge=-180.0, le=180.0)
accuracy_m: float = Field(gt=0.0) # CEP; zero would create an infinite weight
ts: datetime
@field_validator("ts")
@classmethod
def require_utc(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("ts must be timezone-aware UTC")
return v
def group_by_bin(fixes: list[BinFix]) -> dict[str, list[BinFix]]:
grouped: dict[str, list[BinFix]] = defaultdict(list)
for f in fixes:
grouped[f.bin_id].append(f)
return dict(grouped)
Step 2 — Run a position-only Kalman estimator over one bin’s fixes
The state is the 2-D position alone. Because the bin does not move, there is no transition step and no process noise — each fix is a direct measurement of the same fixed coordinate. Folding fix into the running estimate uses the scalar-per-axis Kalman update, which for a static target reduces to inverse-variance weighting: the posterior variance is
where is the measurement variance derived from fix ’s reported accuracy. Each fix can only shrink , so the estimate provably converges toward one coordinate.
import numpy as np
class StaticBinEstimator:
"""Position-only recursive estimator: collapses a bin's fixes to one coordinate + covariance."""
def __init__(self) -> None:
self._x: np.ndarray | None = None # [lat, lon] posterior mean
self._P: np.ndarray | None = None # 2-vector of per-axis posterior variance
self._n = 0
@staticmethod
def _meters_to_deg2(accuracy_m: float) -> float:
deg = accuracy_m / 111_000.0 # ~111 km per degree
return deg ** 2
def add(self, lat: float, lon: float, accuracy_m: float) -> None:
z = np.array([lat, lon], dtype=np.float64)
r = self._meters_to_deg2(accuracy_m)
R = np.array([r, r], dtype=np.float64)
if self._x is None: # first fix seeds the estimate
self._x, self._P = z.copy(), R.copy()
self._n = 1
return
gain = self._P / (self._P + R) # scalar Kalman gain per axis
self._x = self._x + gain * (z - self._x)
self._P = (1.0 - gain) * self._P # variance strictly shrinks
self._n += 1
@property
def coordinate(self) -> tuple[float, float]:
if self._x is None:
raise ValueError("no fixes ingested for this bin")
return float(self._x[0]), float(self._x[1])
@property
def variance_deg2(self) -> tuple[float, float]:
return float(self._P[0]), float(self._P[1])
@property
def count(self) -> int:
return self._n
Step 3 — Emit one resolved coordinate per bin
Drive the estimator over each group and serialize a per-bin record. The circular error at roughly 95% confidence (2-sigma) is derived from the posterior variance and reported in metres so downstream consumers can judge how tight the resolution is.
import math
def resolve_bins(fixes: list[BinFix]) -> list[dict]:
resolved: list[dict] = []
for bin_id, group in group_by_bin(fixes).items():
est = StaticBinEstimator()
for f in sorted(group, key=lambda x: x.ts): # deterministic ingest order
est.add(f.lat, f.lon, f.accuracy_m)
lat, lon = est.coordinate
var_lat, var_lon = est.variance_deg2
# 2-sigma radius in metres from the larger per-axis std deviation.
cep95_m = 2.0 * math.sqrt(max(var_lat, var_lon)) * 111_000.0
resolved.append({
"bin_id": bin_id,
"resolved_lat": lat,
"resolved_lon": lon,
"cep95_m": round(cep95_m, 2),
"fix_count": est.count,
"raw_fixes": [{"lat": f.lat, "lon": f.lon, "accuracy_m": f.accuracy_m,
"ts": f.ts.isoformat()} for f in group],
})
return resolved
Serializing raw_fixes beside the resolved coordinate is deliberate: the original readings are preserved, never discarded, so the resolution is reproducible. That resolved coordinate is what franchise-boundary and service-verification reporting consume, and it feeds the Distance Matrix Construction stage as a stable bin location. The fill-level telemetry keyed to the same bin_id is decoded in Parsing IoT Fill-Level Sensor Payloads.
Compliance Output
Each resolved bin serializes to a single JSON record. From a three-fix run for one bin, the output is:
{
"bin_id": "BIN-4417",
"resolved_lat": 40.70235,
"resolved_lon": -74.01482,
"cep95_m": 6.44,
"fix_count": 3,
"raw_fixes": [
{"lat": 40.7024, "lon": -74.0149, "accuracy_m": 9.0, "ts": "2026-07-14T09:12:00+00:00"},
{"lat": 40.7023, "lon": -74.0147, "accuracy_m": 6.0, "ts": "2026-07-14T13:40:00+00:00"},
{"lat": 40.7024, "lon": -74.0149, "accuracy_m": 11.0, "ts": "2026-07-15T08:05:00+00:00"}
]
}
Each field earns its place:
bin_id— the grouping key the coordinate was resolved under; proves no cross-bin averaging occurred.resolved_lat/resolved_lon— the single smoothed coordinate. Because municipal collection ordinances tie franchise-boundary and diversion reporting to bin location, this must be a defensible reconciliation of real fixes, never a fabricated point.cep95_m— the 2-sigma circular error in metres; states how tightly the fixes agree so a reviewer can judge confidence rather than trust a bare coordinate.fix_count— how many measurements informed the estimate; a coordinate resolved from one fix is weaker than one from thirty, and this makes that visible.raw_fixes— the preserved originals. Retaining them keeps the record tamper-evident: the resolved coordinate can be independently recomputed, and no original reading was clamped or thrown away.
Verification
The tests use pytest to prove the two properties that make the estimator trustworthy: the resolved coordinate lies inside the cloud of input fixes, and the variance shrinks with every fix.
import numpy as np
from datetime import datetime, timezone
def _fix(bin_id, lat, lon, acc, minute):
return BinFix(bin_id=bin_id, lat=lat, lon=lon, accuracy_m=acc,
ts=datetime(2026, 7, 14, 9, minute, tzinfo=timezone.utc))
def test_resolved_coordinate_lies_within_fix_cloud() -> None:
fixes = [
_fix("BIN-1", 40.7024, -74.0149, 9.0, 0),
_fix("BIN-1", 40.7023, -74.0147, 6.0, 5),
_fix("BIN-1", 40.7025, -74.0150, 11.0, 10),
]
(rec,) = resolve_bins(fixes)
lats = [f.lat for f in fixes]
lons = [f.lon for f in fixes]
assert min(lats) <= rec["resolved_lat"] <= max(lats)
assert min(lons) <= rec["resolved_lon"] <= max(lons)
def test_variance_shrinks_monotonically() -> None:
est = StaticBinEstimator()
prev = float("inf")
for _ in range(10):
est.add(40.7024, -74.0149, accuracy_m=8.0)
var_lat, _ = est.variance_deg2
assert var_lat <= prev, "each fix must shrink or hold the variance"
prev = var_lat
assert est.variance_deg2[0] < 1.0e-9 # converged tightly (~r/10)
def test_grouping_keeps_bins_separate() -> None:
fixes = [
_fix("BIN-A", 40.7000, -74.0000, 5.0, 0),
_fix("BIN-B", 41.9000, -87.6000, 5.0, 1),
]
records = {r["bin_id"]: r for r in resolve_bins(fixes)}
assert abs(records["BIN-A"]["resolved_lat"] - 40.7000) < 1e-6
assert abs(records["BIN-B"]["resolved_lat"] - 41.9000) < 1e-6
Common Errors
Resolved coordinates slowly drift away from the bin over months of fixes. Root cause: applying the constant-velocity vehicle filter to a static bin. Its velocity state accumulates on measurement noise and walks the position estimate off the true location. Fix: use the position-only StaticBinEstimator above — no transition matrix, no process noise, so the estimate can only converge, never wander.
numpy.linalg.LinAlgError or a nan coordinate after ingesting a fix. Root cause: a fix with accuracy_m of 0, which makes the measurement variance zero and the Kalman gain 1/0. The BinFix schema’s Field(gt=0.0) rejects it at the boundary, so the error means a fix bypassed validation. Fix: never construct a fix from a raw dict without the Pydantic model, and treat a zero-accuracy reading as invalid rather than infinitely precise.
Two nearby bins collapse into one coordinate midway between them. Root cause: estimating before grouping, so fixes from different bin_ids were folded into one estimator. Fix: always call group_by_bin first and run a fresh StaticBinEstimator per group, exactly as resolve_bins does — one estimator instance must never see two bins.
FAQ
Is a position-only Kalman filter really just a weighted average?
For a static target with no process noise, yes — the recursive update is algebraically identical to an inverse-variance weighted mean, where each fix contributes in proportion to the inverse of its measurement variance. Framing it as a Kalman filter is still useful: it gives you a running posterior variance you can report as CEP and a natural place to add a small process noise later if a bin is ever relocated.
What if a bin is physically moved to a new location?
A pure position-only estimator assumes the coordinate is truly fixed, so a relocation looks like a burst of large residuals it will resist. Detect a relocation as a sustained run of fixes far outside the current CEP and reset the estimator for that bin_id, seeding a fresh coordinate. Do not widen the variance to swallow the move, or you will blend the old and new locations.
Up: Kalman Filtering & GPS Smoothing
Related
- Kalman Filtering & GPS Smoothing — the constant-velocity filter for moving vehicles and the theory behind the position-only variant here.
- Validating GPS Coordinates in Python — the kinematic pre-filter that cleans each fix before it reaches this estimator.
- Parsing IoT Fill-Level Sensor Payloads — decoding the fill-level telemetry keyed to the same bin_id.
- Distance Matrix Construction — the routing stage that consumes each resolved bin coordinate.