From a7abcddfa9182acd25cefab5bfd9fa9f089278a0 Mon Sep 17 00:00:00 2001 From: Mikko Koskinen Date: Sun, 5 Jul 2026 14:48:26 +0300 Subject: [PATCH] Add GPS filter drive diagnostics --- README.md | 35 +++++- custom_components/gps_filter/config_flow.py | 17 +++ custom_components/gps_filter/const.py | 2 + custom_components/gps_filter/coordinator.py | 52 ++++++++- .../gps_filter/device_tracker.py | 11 ++ custom_components/gps_filter/diagnostics.py | 34 ++++++ custom_components/gps_filter/filter_engine.py | 25 +++- custom_components/gps_filter/helpers.py | 19 +++- custom_components/gps_filter/models.py | 7 ++ custom_components/gps_filter/sensor.py | 107 +++++++++++++++++- custom_components/gps_filter/strings.json | 39 ++++++- .../gps_filter/translations/en.json | 39 ++++++- tests/test_config_flow.py | 37 ++++++ tests/test_coordinator.py | 94 ++++++++++++++- tests/test_diagnostics.py | 29 ++++- tests/test_filter_engine.py | 61 +++++++++- tests/test_sensor.py | 50 ++++++++ 17 files changed, 643 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2e66b52..be2b0b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ The current filter accepts the first valid point, then rejects updates when: - GPS accuracy exceeds the configured maximum accuracy - The incoming point is an exact duplicate of the last accepted point - The calculated movement speed exceeds the configured maximum speed +- The calculated movement speed and reported source speed differ by more than + the configured maximum speed difference The integration tracks both calculated speed and reported source speed. Reported speed is converted from m/s to km/h when present. @@ -61,14 +63,25 @@ the exact entity IDs from the configured GPS Filter device name. - `sensor.gps_filter_last_received_timestamp` - `sensor.gps_filter_last_accepted_timestamp` - `sensor.gps_filter_acceptance_rate` +- `sensor.gps_filter_seconds_since_last_accepted` +- `sensor.gps_filter_max_speed_threshold` +- `sensor.gps_filter_max_speed_difference_threshold` +- `sensor.gps_filter_max_accuracy_threshold` +- `sensor.gps_filter_total_received_count` +- `sensor.gps_filter_total_rejected_count` - `sensor.gps_filter_max_distance` - `sensor.gps_filter_max_calculated_speed` - `sensor.gps_filter_max_reported_speed` - `sensor.gps_filter_max_accuracy` +- `sensor.gps_filter_max_rejected_distance` +- `sensor.gps_filter_max_rejected_calculated_speed` +- `sensor.gps_filter_max_rejected_reported_speed` +- `sensor.gps_filter_max_rejected_accuracy` - `sensor.gps_filter_accepted_count` - `sensor.gps_filter_duplicate_count` - `sensor.gps_filter_accuracy_rejections` - `sensor.gps_filter_speed_rejections` +- `sensor.gps_filter_speed_consistency_rejections` These sensors are intended for a developer/debug dashboard during drive testing. @@ -87,14 +100,25 @@ For one configured tracker, the default generated names are expected to be: - `sensor.gps_filter_last_received_timestamp` - `sensor.gps_filter_last_accepted_timestamp` - `sensor.gps_filter_acceptance_rate` +- `sensor.gps_filter_seconds_since_last_accepted` +- `sensor.gps_filter_max_speed_threshold` +- `sensor.gps_filter_max_speed_difference_threshold` +- `sensor.gps_filter_max_accuracy_threshold` +- `sensor.gps_filter_total_received_count` +- `sensor.gps_filter_total_rejected_count` - `sensor.gps_filter_max_distance` - `sensor.gps_filter_max_calculated_speed` - `sensor.gps_filter_max_reported_speed` - `sensor.gps_filter_max_accuracy` +- `sensor.gps_filter_max_rejected_distance` +- `sensor.gps_filter_max_rejected_calculated_speed` +- `sensor.gps_filter_max_rejected_reported_speed` +- `sensor.gps_filter_max_rejected_accuracy` - `sensor.gps_filter_accepted_count` - `sensor.gps_filter_duplicate_count` - `sensor.gps_filter_accuracy_rejections` - `sensor.gps_filter_speed_rejections` +- `sensor.gps_filter_speed_consistency_rejections` When multiple GPS Filter entries are configured, each entry uses its config entry title as the Home Assistant device name, for example @@ -116,8 +140,9 @@ use `TOTAL_INCREASING`. 6. Set: - Maximum speed in km/h - Maximum GPS accuracy in meters + - Maximum speed difference in km/h -Both threshold values must be greater than zero. +All threshold values must be greater than zero. ## Options @@ -125,6 +150,7 @@ After setup, thresholds can be edited from the integration options: - Maximum speed - Maximum GPS accuracy +- Maximum speed difference Changing options reloads the config entry so the coordinator and filter engine use the updated values. @@ -172,6 +198,7 @@ decisions. Each timeline entry contains: - distance_m - calculated_speed_kmh - reported_speed_kmh +- seconds_since_last_accepted The timeline is not persisted and is reset when Home Assistant restarts or when `gps_filter.reset_filter` is called. @@ -179,15 +206,21 @@ The timeline is not persisted and is reset when Home Assistant restarts or when The diagnostics summary includes: - total_received_count +- total_rejected_count - accepted_count - duplicate_count - accuracy_rejections - speed_rejections +- speed_consistency_rejections - acceptance_rate_percent - max_distance_m - max_calculated_speed_kmh - max_reported_speed_kmh - max_accuracy_m +- max_rejected_distance_m +- max_rejected_calculated_speed_kmh +- max_rejected_reported_speed_kmh +- max_rejected_accuracy_m Summary statistics are in-memory only. They reset when `gps_filter.reset_statistics` or `gps_filter.reset_filter` is called. diff --git a/custom_components/gps_filter/config_flow.py b/custom_components/gps_filter/config_flow.py index 972da9c..42f0c26 100644 --- a/custom_components/gps_filter/config_flow.py +++ b/custom_components/gps_filter/config_flow.py @@ -13,9 +13,11 @@ from .const import ( CONF_MAX_ACCURACY, CONF_MAX_SPEED, + CONF_MAX_SPEED_DIFFERENCE, CONF_SOURCE, DEFAULT_MAX_ACCURACY, DEFAULT_MAX_SPEED, + DEFAULT_MAX_SPEED_DIFFERENCE, DOMAIN, ) @@ -36,6 +38,13 @@ def _options_defaults(entry: ConfigEntry) -> dict[str, float]: CONF_MAX_ACCURACY, entry.data.get(CONF_MAX_ACCURACY, DEFAULT_MAX_ACCURACY), ), + CONF_MAX_SPEED_DIFFERENCE: entry.options.get( + CONF_MAX_SPEED_DIFFERENCE, + entry.data.get( + CONF_MAX_SPEED_DIFFERENCE, + DEFAULT_MAX_SPEED_DIFFERENCE, + ), + ), } @@ -56,6 +65,10 @@ def _get_user_data_schema() -> vol.Schema: CONF_MAX_ACCURACY, default=DEFAULT_MAX_ACCURACY, ): POSITIVE_FLOAT, + vol.Required( + CONF_MAX_SPEED_DIFFERENCE, + default=DEFAULT_MAX_SPEED_DIFFERENCE, + ): POSITIVE_FLOAT, } ) @@ -72,6 +85,10 @@ def _get_options_schema(defaults: dict[str, float]) -> vol.Schema: CONF_MAX_ACCURACY, default=defaults[CONF_MAX_ACCURACY], ): POSITIVE_FLOAT, + vol.Required( + CONF_MAX_SPEED_DIFFERENCE, + default=defaults[CONF_MAX_SPEED_DIFFERENCE], + ): POSITIVE_FLOAT, } ) diff --git a/custom_components/gps_filter/const.py b/custom_components/gps_filter/const.py index bdcec94..6ff7209 100644 --- a/custom_components/gps_filter/const.py +++ b/custom_components/gps_filter/const.py @@ -8,8 +8,10 @@ CONF_SOURCE: Final = "source" CONF_MAX_SPEED: Final = "max_speed" CONF_MAX_ACCURACY: Final = "max_accuracy" +CONF_MAX_SPEED_DIFFERENCE: Final = "max_speed_difference_kmh" DEFAULT_MAX_SPEED: Final = 220.0 DEFAULT_MAX_ACCURACY: Final = 30.0 +DEFAULT_MAX_SPEED_DIFFERENCE: Final = 40.0 PLATFORMS: Final = ["button", "device_tracker", "sensor"] diff --git a/custom_components/gps_filter/coordinator.py b/custom_components/gps_filter/coordinator.py index 511c287..87b87c1 100644 --- a/custom_components/gps_filter/coordinator.py +++ b/custom_components/gps_filter/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_MAX_ACCURACY, CONF_MAX_SPEED +from .const import CONF_MAX_ACCURACY, CONF_MAX_SPEED, CONF_MAX_SPEED_DIFFERENCE from .filter_engine import GPSFilterEngine from .helpers import get_config_value from .models import ( @@ -59,6 +59,10 @@ def __init__( self.engine = GPSFilterEngine( max_speed=get_config_value(entry, CONF_MAX_SPEED), max_accuracy=get_config_value(entry, CONF_MAX_ACCURACY), + max_speed_difference_kmh=get_config_value( + entry, + CONF_MAX_SPEED_DIFFERENCE, + ), ) self._remove_listener = None @@ -103,6 +107,16 @@ def acceptance_rate_percent(self) -> float: * 100 ) + @property + def total_rejected_count(self) -> int: + """Return the total number of rejected points.""" + return ( + self.data.engine_stats.duplicate + + self.data.engine_stats.accuracy_rejections + + self.data.engine_stats.speed_rejections + + self.data.engine_stats.speed_consistency_rejections + ) + async def async_start(self) -> None: """Start listening for GPS updates.""" @@ -164,6 +178,7 @@ def _state_changed(self, event: Event) -> None: distance_m=result.distance_m, calculated_speed_kmh=result.calculated_speed_kmh, reported_speed_kmh=result.reported_speed_kmh, + seconds_since_last_accepted=result.seconds_since_last_accepted, ) ) @@ -231,6 +246,10 @@ def reset_filter(self) -> None: self.engine = GPSFilterEngine( max_speed=get_config_value(self.entry, CONF_MAX_SPEED), max_accuracy=get_config_value(self.entry, CONF_MAX_ACCURACY), + max_speed_difference_kmh=get_config_value( + self.entry, + CONF_MAX_SPEED_DIFFERENCE, + ), ) self.summary_stats = SummaryStats() self.filter_timeline.clear() @@ -239,6 +258,37 @@ def reset_filter(self) -> None: def _update_summary_stats(self, point: GPSPoint, result: FilterResult) -> None: """Update post-drive summary statistics from a filter decision.""" self.summary_stats.total_received_count += 1 + + if not result.accepted: + self.summary_stats.max_rejected_accuracy_m = max( + self.summary_stats.max_rejected_accuracy_m, + point.accuracy, + ) + + if result.distance_m is not None: + self.summary_stats.max_rejected_distance_m = max( + self.summary_stats.max_rejected_distance_m, + result.distance_m, + ) + + if result.calculated_speed_kmh is not None: + self.summary_stats.max_rejected_calculated_speed_kmh = max( + self.summary_stats.max_rejected_calculated_speed_kmh, + result.calculated_speed_kmh, + ) + + reported_speed_kmh = result.reported_speed_kmh + if reported_speed_kmh is None and point.speed is not None: + reported_speed_kmh = point.speed * 3.6 + + if reported_speed_kmh is not None: + self.summary_stats.max_rejected_reported_speed_kmh = max( + self.summary_stats.max_rejected_reported_speed_kmh, + reported_speed_kmh, + ) + + return + self.summary_stats.max_accuracy_m = max( self.summary_stats.max_accuracy_m, point.accuracy, diff --git a/custom_components/gps_filter/device_tracker.py b/custom_components/gps_filter/device_tracker.py index dfe41fb..9a44f0a 100644 --- a/custom_components/gps_filter/device_tracker.py +++ b/custom_components/gps_filter/device_tracker.py @@ -58,6 +58,9 @@ def extra_state_attributes(self): attributes["duplicate_count"] = stats.duplicate attributes["accuracy_rejections"] = stats.accuracy_rejections attributes["speed_rejections"] = stats.speed_rejections + attributes["speed_consistency_rejections"] = ( + stats.speed_consistency_rejections + ) attributes["last_received_accuracy"] = None attributes["last_received_timestamp"] = None @@ -66,6 +69,7 @@ def extra_state_attributes(self): attributes["last_distance_m"] = None attributes["last_calculated_speed_kmh"] = None attributes["last_reported_speed_kmh"] = None + attributes["last_seconds_since_accepted"] = None if self.coordinator.last_received_point is not None: attributes["last_received_accuracy"] = ( @@ -94,6 +98,13 @@ def extra_state_attributes(self): attributes["last_reported_speed_kmh"] = ( self.coordinator.last_result.reported_speed_kmh ) + attributes["last_seconds_since_accepted"] = ( + getattr( + self.coordinator.last_result, + "seconds_since_last_accepted", + None, + ) + ) return attributes diff --git a/custom_components/gps_filter/diagnostics.py b/custom_components/gps_filter/diagnostics.py index a607861..d9f6337 100644 --- a/custom_components/gps_filter/diagnostics.py +++ b/custom_components/gps_filter/diagnostics.py @@ -46,6 +46,7 @@ def _serialize_filter_result(result: FilterResult | None) -> dict[str, Any] | No "distance_m": result.distance_m, "calculated_speed_kmh": result.calculated_speed_kmh, "reported_speed_kmh": result.reported_speed_kmh, + "seconds_since_last_accepted": result.seconds_since_last_accepted, } @@ -61,6 +62,7 @@ def _serialize_timeline_entry(entry: FilterTimelineEntry) -> dict[str, Any]: "distance_m": entry.distance_m, "calculated_speed_kmh": entry.calculated_speed_kmh, "reported_speed_kmh": entry.reported_speed_kmh, + "seconds_since_last_accepted": entry.seconds_since_last_accepted, } @@ -69,10 +71,16 @@ def _serialize_summary(coordinator: Any, stats: Any) -> dict[str, Any]: summary_stats = getattr(coordinator, "summary_stats", None) return { "total_received_count": getattr(summary_stats, "total_received_count", 0), + "total_rejected_count": getattr(coordinator, "total_rejected_count", 0), "accepted_count": getattr(stats, "accepted", 0), "duplicate_count": getattr(stats, "duplicate", 0), "accuracy_rejections": getattr(stats, "accuracy_rejections", 0), "speed_rejections": getattr(stats, "speed_rejections", 0), + "speed_consistency_rejections": getattr( + stats, + "speed_consistency_rejections", + 0, + ), "acceptance_rate_percent": getattr( coordinator, "acceptance_rate_percent", @@ -90,6 +98,26 @@ def _serialize_summary(coordinator: Any, stats: Any) -> dict[str, Any]: 0.0, ), "max_accuracy_m": getattr(summary_stats, "max_accuracy_m", 0.0), + "max_rejected_distance_m": getattr( + summary_stats, + "max_rejected_distance_m", + 0.0, + ), + "max_rejected_calculated_speed_kmh": getattr( + summary_stats, + "max_rejected_calculated_speed_kmh", + 0.0, + ), + "max_rejected_reported_speed_kmh": getattr( + summary_stats, + "max_rejected_reported_speed_kmh", + 0.0, + ), + "max_rejected_accuracy_m": getattr( + summary_stats, + "max_rejected_accuracy_m", + 0.0, + ), } @@ -121,6 +149,7 @@ async def async_get_config_entry_diagnostics( "duplicate_count": 0, "accuracy_rejections": 0, "speed_rejections": 0, + "speed_consistency_rejections": 0, "summary": _serialize_summary(coordinator, None), "last_received_point": None, "last_accepted_point": None, @@ -138,6 +167,11 @@ async def async_get_config_entry_diagnostics( "duplicate_count": getattr(stats, "duplicate", 0), "accuracy_rejections": getattr(stats, "accuracy_rejections", 0), "speed_rejections": getattr(stats, "speed_rejections", 0), + "speed_consistency_rejections": getattr( + stats, + "speed_consistency_rejections", + 0, + ), "summary": _serialize_summary(coordinator, stats), "last_received_point": _serialize_gps_point(data.last_received_point), "last_accepted_point": _serialize_gps_point(data.last_accepted_point), diff --git a/custom_components/gps_filter/filter_engine.py b/custom_components/gps_filter/filter_engine.py index 52c7830..5d5e3fc 100644 --- a/custom_components/gps_filter/filter_engine.py +++ b/custom_components/gps_filter/filter_engine.py @@ -38,10 +38,12 @@ def __init__( *, max_speed: float = 220.0, max_accuracy: float = 30.0, + max_speed_difference_kmh: float = 40.0, ) -> None: self._max_speed = max_speed self._max_accuracy = max_accuracy + self._max_speed_difference_kmh = max_speed_difference_kmh self._last_point: GPSPoint | None = None self.stats = EngineStats() @@ -83,9 +85,7 @@ def process( seconds = 1 calculated_speed_kmh = distance / seconds * 3.6 - reported_speed_kmh = ( - point.speed * 3.6 if point.speed is not None else None - ) + reported_speed_kmh = point.speed * 3.6 if point.speed is not None else None if ( point.latitude == self._last_point.latitude @@ -99,6 +99,7 @@ def process( distance_m=0.0, calculated_speed_kmh=0.0, reported_speed_kmh=reported_speed_kmh, + seconds_since_last_accepted=seconds, ) if calculated_speed_kmh > self._max_speed: @@ -110,6 +111,23 @@ def process( distance_m=distance, calculated_speed_kmh=calculated_speed_kmh, reported_speed_kmh=reported_speed_kmh, + seconds_since_last_accepted=seconds, + ) + + if ( + reported_speed_kmh is not None + and abs(calculated_speed_kmh - reported_speed_kmh) + > self._max_speed_difference_kmh + ): + self.stats.speed_consistency_rejections += 1 + return FilterResult( + accepted=False, + reason="speed_consistency", + point=None, + distance_m=distance, + calculated_speed_kmh=calculated_speed_kmh, + reported_speed_kmh=reported_speed_kmh, + seconds_since_last_accepted=seconds, ) self._last_point = point @@ -122,4 +140,5 @@ def process( distance_m=distance, calculated_speed_kmh=calculated_speed_kmh, reported_speed_kmh=reported_speed_kmh, + seconds_since_last_accepted=seconds, ) diff --git a/custom_components/gps_filter/helpers.py b/custom_components/gps_filter/helpers.py index bcf4d08..925c04e 100644 --- a/custom_components/gps_filter/helpers.py +++ b/custom_components/gps_filter/helpers.py @@ -7,8 +7,10 @@ from .const import ( CONF_MAX_ACCURACY, CONF_MAX_SPEED, + CONF_MAX_SPEED_DIFFERENCE, DEFAULT_MAX_ACCURACY, DEFAULT_MAX_SPEED, + DEFAULT_MAX_SPEED_DIFFERENCE, ) @@ -19,7 +21,15 @@ def get_device_name(entry: ConfigEntry) -> str: def get_config_value(entry: ConfigEntry, key: str): """Return an option value, falling back to config entry data.""" - return getattr(entry, "options", {}).get(key, entry.data[key]) + defaults = { + CONF_MAX_SPEED: DEFAULT_MAX_SPEED, + CONF_MAX_ACCURACY: DEFAULT_MAX_ACCURACY, + CONF_MAX_SPEED_DIFFERENCE: DEFAULT_MAX_SPEED_DIFFERENCE, + } + return getattr(entry, "options", {}).get( + key, + entry.data.get(key, defaults[key]), + ) def get_effective_filter_config(entry: ConfigEntry) -> dict[str, float]: @@ -33,4 +43,11 @@ def get_effective_filter_config(entry: ConfigEntry) -> dict[str, float]: CONF_MAX_ACCURACY, entry.data.get(CONF_MAX_ACCURACY, DEFAULT_MAX_ACCURACY), ), + CONF_MAX_SPEED_DIFFERENCE: getattr(entry, "options", {}).get( + CONF_MAX_SPEED_DIFFERENCE, + entry.data.get( + CONF_MAX_SPEED_DIFFERENCE, + DEFAULT_MAX_SPEED_DIFFERENCE, + ), + ), } diff --git a/custom_components/gps_filter/models.py b/custom_components/gps_filter/models.py index a123b98..d475ce2 100644 --- a/custom_components/gps_filter/models.py +++ b/custom_components/gps_filter/models.py @@ -28,6 +28,7 @@ class FilterResult: distance_m: float | None = None calculated_speed_kmh: float | None = None reported_speed_kmh: float | None = None + seconds_since_last_accepted: float | None = None @dataclass(slots=True) @@ -43,6 +44,7 @@ class FilterTimelineEntry: distance_m: float | None = None calculated_speed_kmh: float | None = None reported_speed_kmh: float | None = None + seconds_since_last_accepted: float | None = None @dataclass(slots=True) @@ -53,6 +55,7 @@ class EngineStats: duplicate: int = 0 accuracy_rejections: int = 0 speed_rejections: int = 0 + speed_consistency_rejections: int = 0 @dataclass(slots=True) @@ -64,6 +67,10 @@ class SummaryStats: max_calculated_speed_kmh: float = 0.0 max_reported_speed_kmh: float = 0.0 max_accuracy_m: float = 0.0 + max_rejected_distance_m: float = 0.0 + max_rejected_calculated_speed_kmh: float = 0.0 + max_rejected_reported_speed_kmh: float = 0.0 + max_rejected_accuracy_m: float = 0.0 @dataclass(slots=True) diff --git a/custom_components/gps_filter/sensor.py b/custom_components/gps_filter/sensor.py index 65789f4..1a76dd4 100644 --- a/custom_components/gps_filter/sensor.py +++ b/custom_components/gps_filter/sensor.py @@ -18,9 +18,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import CONF_MAX_ACCURACY, CONF_MAX_SPEED, CONF_MAX_SPEED_DIFFERENCE, DOMAIN from .coordinator import GPSFilterCoordinator -from .helpers import get_device_name +from .helpers import get_config_value, get_device_name @dataclass(frozen=True, kw_only=True) @@ -149,6 +149,64 @@ def _last_accepted_timestamp(coordinator: GPSFilterCoordinator): native_unit_of_measurement=PERCENTAGE, value_fn=lambda coordinator: coordinator.acceptance_rate_percent, ), + GPSFilterSensorEntityDescription( + key="seconds_since_last_accepted", + translation_key="seconds_since_last_accepted", + icon="mdi:timer-outline", + value_fn=lambda coordinator: ( + 0.0 + if coordinator.last_result is None + or coordinator.last_result.seconds_since_last_accepted is None + else coordinator.last_result.seconds_since_last_accepted + ), + ), + GPSFilterSensorEntityDescription( + key="max_speed_threshold", + translation_key="max_speed_threshold", + icon="mdi:speedometer", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + value_fn=lambda coordinator: get_config_value( + coordinator.entry, + CONF_MAX_SPEED, + ), + ), + GPSFilterSensorEntityDescription( + key="max_speed_difference_threshold", + translation_key="max_speed_difference_threshold", + icon="mdi:speedometer-medium", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + value_fn=lambda coordinator: get_config_value( + coordinator.entry, + CONF_MAX_SPEED_DIFFERENCE, + ), + ), + GPSFilterSensorEntityDescription( + key="max_accuracy_threshold", + translation_key="max_accuracy_threshold", + icon="mdi:crosshairs-gps", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + value_fn=lambda coordinator: get_config_value( + coordinator.entry, + CONF_MAX_ACCURACY, + ), + ), + GPSFilterSensorEntityDescription( + key="total_received_count", + translation_key="total_received_count", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda coordinator: coordinator.summary_stats.total_received_count, + ), + GPSFilterSensorEntityDescription( + key="total_rejected_count", + translation_key="total_rejected_count", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda coordinator: coordinator.total_rejected_count, + ), GPSFilterSensorEntityDescription( key="max_distance", translation_key="max_distance", @@ -183,6 +241,42 @@ def _last_accepted_timestamp(coordinator: GPSFilterCoordinator): device_class=SensorDeviceClass.DISTANCE, value_fn=lambda coordinator: coordinator.summary_stats.max_accuracy_m, ), + GPSFilterSensorEntityDescription( + key="max_rejected_distance", + translation_key="max_rejected_distance", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + value_fn=lambda coordinator: coordinator.summary_stats.max_rejected_distance_m, + ), + GPSFilterSensorEntityDescription( + key="max_rejected_calculated_speed", + translation_key="max_rejected_calculated_speed", + icon="mdi:speedometer", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + value_fn=lambda coordinator: ( + coordinator.summary_stats.max_rejected_calculated_speed_kmh + ), + ), + GPSFilterSensorEntityDescription( + key="max_rejected_reported_speed", + translation_key="max_rejected_reported_speed", + icon="mdi:speedometer", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + value_fn=lambda coordinator: ( + coordinator.summary_stats.max_rejected_reported_speed_kmh + ), + ), + GPSFilterSensorEntityDescription( + key="max_rejected_accuracy", + translation_key="max_rejected_accuracy", + icon="mdi:crosshairs-gps", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + value_fn=lambda coordinator: coordinator.summary_stats.max_rejected_accuracy_m, + ), GPSFilterSensorEntityDescription( key="accepted_count", translation_key="accepted_count", @@ -211,6 +305,15 @@ def _last_accepted_timestamp(coordinator: GPSFilterCoordinator): state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda coordinator: coordinator.data.engine_stats.speed_rejections, ), + GPSFilterSensorEntityDescription( + key="speed_consistency_rejections", + translation_key="speed_consistency_rejections", + icon="mdi:alert-outline", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda coordinator: ( + coordinator.data.engine_stats.speed_consistency_rejections + ), + ), ) diff --git a/custom_components/gps_filter/strings.json b/custom_components/gps_filter/strings.json index e110575..2504c5e 100644 --- a/custom_components/gps_filter/strings.json +++ b/custom_components/gps_filter/strings.json @@ -7,7 +7,8 @@ "data": { "source": "Source tracker", "max_speed": "Maximum speed (km/h)", - "max_accuracy": "Maximum GPS accuracy (m)" + "max_accuracy": "Maximum GPS accuracy (m)", + "max_speed_difference_kmh": "Maximum speed difference (km/h)" } } } @@ -19,7 +20,8 @@ "description": "Update GPS filter thresholds.", "data": { "max_speed": "Maximum speed (km/h)", - "max_accuracy": "Maximum GPS accuracy (m)" + "max_accuracy": "Maximum GPS accuracy (m)", + "max_speed_difference_kmh": "Maximum speed difference (km/h)" } } } @@ -61,6 +63,24 @@ "acceptance_rate": { "name": "Acceptance Rate" }, + "seconds_since_last_accepted": { + "name": "Seconds Since Last Accepted" + }, + "max_speed_threshold": { + "name": "Max Speed Threshold" + }, + "max_speed_difference_threshold": { + "name": "Max Speed Difference Threshold" + }, + "max_accuracy_threshold": { + "name": "Max Accuracy Threshold" + }, + "total_received_count": { + "name": "Total Received Count" + }, + "total_rejected_count": { + "name": "Total Rejected Count" + }, "max_distance": { "name": "Max Distance" }, @@ -73,6 +93,18 @@ "max_accuracy": { "name": "Max Accuracy" }, + "max_rejected_distance": { + "name": "Max Rejected Distance" + }, + "max_rejected_calculated_speed": { + "name": "Max Rejected Calculated Speed" + }, + "max_rejected_reported_speed": { + "name": "Max Rejected Reported Speed" + }, + "max_rejected_accuracy": { + "name": "Max Rejected Accuracy" + }, "accepted_count": { "name": "Accepted Count" }, @@ -84,6 +116,9 @@ }, "speed_rejections": { "name": "Speed Rejections" + }, + "speed_consistency_rejections": { + "name": "Speed Consistency Rejections" } } }, diff --git a/custom_components/gps_filter/translations/en.json b/custom_components/gps_filter/translations/en.json index b3fc739..7a410d9 100644 --- a/custom_components/gps_filter/translations/en.json +++ b/custom_components/gps_filter/translations/en.json @@ -8,7 +8,8 @@ "data": { "source": "Source tracker", "max_speed": "Maximum speed (km/h)", - "max_accuracy": "Maximum GPS accuracy (m)" + "max_accuracy": "Maximum GPS accuracy (m)", + "max_speed_difference_kmh": "Maximum speed difference (km/h)" } } } @@ -20,7 +21,8 @@ "description": "Update GPS filter thresholds.", "data": { "max_speed": "Maximum speed (km/h)", - "max_accuracy": "Maximum GPS accuracy (m)" + "max_accuracy": "Maximum GPS accuracy (m)", + "max_speed_difference_kmh": "Maximum speed difference (km/h)" } } } @@ -62,6 +64,24 @@ "acceptance_rate": { "name": "Acceptance Rate" }, + "seconds_since_last_accepted": { + "name": "Seconds Since Last Accepted" + }, + "max_speed_threshold": { + "name": "Max Speed Threshold" + }, + "max_speed_difference_threshold": { + "name": "Max Speed Difference Threshold" + }, + "max_accuracy_threshold": { + "name": "Max Accuracy Threshold" + }, + "total_received_count": { + "name": "Total Received Count" + }, + "total_rejected_count": { + "name": "Total Rejected Count" + }, "max_distance": { "name": "Max Distance" }, @@ -74,6 +94,18 @@ "max_accuracy": { "name": "Max Accuracy" }, + "max_rejected_distance": { + "name": "Max Rejected Distance" + }, + "max_rejected_calculated_speed": { + "name": "Max Rejected Calculated Speed" + }, + "max_rejected_reported_speed": { + "name": "Max Rejected Reported Speed" + }, + "max_rejected_accuracy": { + "name": "Max Rejected Accuracy" + }, "accepted_count": { "name": "Accepted Count" }, @@ -85,6 +117,9 @@ }, "speed_rejections": { "name": "Speed Rejections" + }, + "speed_consistency_rejections": { + "name": "Speed Consistency Rejections" } } }, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 633b901..1355aad 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -12,6 +12,7 @@ from custom_components.gps_filter.const import ( CONF_MAX_ACCURACY, CONF_MAX_SPEED, + CONF_MAX_SPEED_DIFFERENCE, CONF_SOURCE, DOMAIN, ) @@ -23,6 +24,7 @@ def __init__(self, options=None) -> None: CONF_SOURCE: "device_tracker.test", CONF_MAX_SPEED: 220.0, CONF_MAX_ACCURACY: 30.0, + CONF_MAX_SPEED_DIFFERENCE: 40.0, } self.options = options or {} @@ -36,6 +38,7 @@ def test_config_schema_rejects_zero_or_negative_thresholds(): CONF_SOURCE: "device_tracker.test", CONF_MAX_SPEED: 0, CONF_MAX_ACCURACY: 30, + CONF_MAX_SPEED_DIFFERENCE: 40, } ) @@ -45,6 +48,17 @@ def test_config_schema_rejects_zero_or_negative_thresholds(): CONF_SOURCE: "device_tracker.test", CONF_MAX_SPEED: 220, CONF_MAX_ACCURACY: -1, + CONF_MAX_SPEED_DIFFERENCE: 40, + } + ) + + with pytest.raises(vol.Invalid): + schema( + { + CONF_SOURCE: "device_tracker.test", + CONF_MAX_SPEED: 220, + CONF_MAX_ACCURACY: 30, + CONF_MAX_SPEED_DIFFERENCE: 0, } ) @@ -57,11 +71,13 @@ def test_config_schema_accepts_positive_thresholds(): CONF_SOURCE: "device_tracker.test", CONF_MAX_SPEED: "120.5", CONF_MAX_ACCURACY: "15", + CONF_MAX_SPEED_DIFFERENCE: "35", } ) assert result[CONF_MAX_SPEED] == 120.5 assert result[CONF_MAX_ACCURACY] == 15.0 + assert result[CONF_MAX_SPEED_DIFFERENCE] == 35.0 def test_options_schema_uses_existing_options_as_defaults(): @@ -69,6 +85,7 @@ def test_options_schema_uses_existing_options_as_defaults(): { CONF_MAX_SPEED: 100.0, CONF_MAX_ACCURACY: 10.0, + CONF_MAX_SPEED_DIFFERENCE: 25.0, } ) @@ -82,9 +99,15 @@ def test_options_schema_uses_existing_options_as_defaults(): for marker in schema.schema if marker.schema == CONF_MAX_ACCURACY ) + max_speed_difference_marker = next( + marker + for marker in schema.schema + if marker.schema == CONF_MAX_SPEED_DIFFERENCE + ) assert max_speed_marker.default() == 100.0 assert max_accuracy_marker.default() == 10.0 + assert max_speed_difference_marker.default() == 25.0 def test_options_schema_rejects_zero_or_negative_thresholds(): @@ -92,6 +115,7 @@ def test_options_schema_rejects_zero_or_negative_thresholds(): { CONF_MAX_SPEED: 100.0, CONF_MAX_ACCURACY: 10.0, + CONF_MAX_SPEED_DIFFERENCE: 40.0, } ) @@ -100,6 +124,7 @@ def test_options_schema_rejects_zero_or_negative_thresholds(): { CONF_MAX_SPEED: 100.0, CONF_MAX_ACCURACY: 0, + CONF_MAX_SPEED_DIFFERENCE: 40.0, } ) @@ -108,6 +133,16 @@ def test_options_schema_rejects_zero_or_negative_thresholds(): { CONF_MAX_SPEED: -1, CONF_MAX_ACCURACY: 10.0, + CONF_MAX_SPEED_DIFFERENCE: 40.0, + } + ) + + with pytest.raises(vol.Invalid): + schema( + { + CONF_MAX_SPEED: 100.0, + CONF_MAX_ACCURACY: 10.0, + CONF_MAX_SPEED_DIFFERENCE: -1, } ) @@ -129,6 +164,7 @@ def test_options_flow_saves_positive_thresholds(): { CONF_MAX_SPEED: 130.0, CONF_MAX_ACCURACY: 12.0, + CONF_MAX_SPEED_DIFFERENCE: 30.0, } ) ) @@ -137,6 +173,7 @@ def test_options_flow_saves_positive_thresholds(): assert result["data"] == { CONF_MAX_SPEED: 130.0, CONF_MAX_ACCURACY: 12.0, + CONF_MAX_SPEED_DIFFERENCE: 30.0, } diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index a2203c9..78622ef 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -21,6 +21,7 @@ def __init__(self, options=None) -> None: "source": "device_tracker.test", "max_speed": 220.0, "max_accuracy": 30.0, + "max_speed_difference_kmh": 40.0, } self.options = options or {} @@ -120,12 +121,14 @@ def test_coordinator_uses_options_for_thresholds(): options={ "max_speed": 80.0, "max_accuracy": 12.0, + "max_speed_difference_kmh": 25.0, } ), ) assert coordinator.engine._max_speed == 80.0 assert coordinator.engine._max_accuracy == 12.0 + assert coordinator.engine._max_speed_difference_kmh == 25.0 def test_coordinator_logs_point_diagnostics_at_debug(caplog): @@ -270,11 +273,78 @@ def test_coordinator_tracks_post_drive_summary_statistics(): assert coordinator.data.engine_stats.duplicate == 1 assert coordinator.data.engine_stats.accuracy_rejections == 1 assert coordinator.data.engine_stats.speed_rejections == 0 + assert coordinator.data.engine_stats.speed_consistency_rejections == 0 assert coordinator.acceptance_rate_percent == 50.0 assert coordinator.summary_stats.max_distance_m > 0 assert coordinator.summary_stats.max_calculated_speed_kmh > 0 assert coordinator.summary_stats.max_reported_speed_kmh == 36.0 - assert coordinator.summary_stats.max_accuracy_m == 100.0 + assert coordinator.summary_stats.max_accuracy_m == 8.0 + + +def test_rejected_points_do_not_update_summary_max_values(): + coordinator = GPSFilterCoordinator( + hass=Mock(), + entry=DummyEntry( + options={ + "max_speed": 50.0, + "max_accuracy": 30.0, + } + ), + ) + + states = [ + SimpleNamespace( + attributes={ + "latitude": 60.0, + "longitude": 25.0, + "gps_accuracy": 5.0, + "speed": 5.0, + }, + last_updated=datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC), + ), + SimpleNamespace( + attributes={ + "latitude": 60.0, + "longitude": 25.001, + "gps_accuracy": 6.0, + "speed": 8.0, + }, + last_updated=datetime(2024, 1, 1, 0, 1, 0, tzinfo=UTC), + ), + SimpleNamespace( + attributes={ + "latitude": 61.0, + "longitude": 26.0, + "gps_accuracy": 98.0, + "speed": 27.0, + }, + last_updated=datetime(2024, 1, 1, 0, 2, 0, tzinfo=UTC), + ), + SimpleNamespace( + attributes={ + "latitude": 61.0, + "longitude": 26.0, + "gps_accuracy": 1.0, + "speed": 40.0, + }, + last_updated=datetime(2024, 1, 1, 0, 2, 1, tzinfo=UTC), + ), + ] + + for state in states: + coordinator._state_changed(SimpleNamespace(data={"new_state": state})) + + assert coordinator.summary_stats.total_received_count == 4 + assert coordinator.total_rejected_count == 2 + assert coordinator.acceptance_rate_percent == 50.0 + assert coordinator.summary_stats.max_accuracy_m == 6.0 + assert coordinator.summary_stats.max_reported_speed_kmh == 28.8 + assert coordinator.summary_stats.max_calculated_speed_kmh < 10.0 + assert coordinator.summary_stats.max_distance_m < 100.0 + assert coordinator.summary_stats.max_rejected_accuracy_m == 98.0 + assert coordinator.summary_stats.max_rejected_reported_speed_kmh == 144.0 + assert coordinator.summary_stats.max_rejected_calculated_speed_kmh > 50.0 + assert coordinator.summary_stats.max_rejected_distance_m > 100.0 def test_reset_filter_clears_filter_timeline(): @@ -303,6 +373,7 @@ def test_filtered_tracker_exposes_filter_metrics_as_attributes(): coordinator.data.engine_stats.duplicate = 1 coordinator.data.engine_stats.accuracy_rejections = 3 coordinator.data.engine_stats.speed_rejections = 4 + coordinator.data.engine_stats.speed_consistency_rejections = 5 coordinator.data.last_result = type( "Result", @@ -322,6 +393,7 @@ def test_filtered_tracker_exposes_filter_metrics_as_attributes(): assert attributes["duplicate_count"] == 1 assert attributes["accuracy_rejections"] == 3 assert attributes["speed_rejections"] == 4 + assert attributes["speed_consistency_rejections"] == 5 assert attributes["last_result_reason"] == "accepted" assert attributes["last_result_accepted"] is True assert attributes["last_distance_m"] == 42.0 @@ -352,11 +424,16 @@ def test_coordinator_reset_statistics_and_filter_state(): coordinator.data.engine_stats.duplicate = 1 coordinator.data.engine_stats.accuracy_rejections = 1 coordinator.data.engine_stats.speed_rejections = 1 + coordinator.data.engine_stats.speed_consistency_rejections = 1 coordinator.summary_stats.total_received_count = 4 coordinator.summary_stats.max_distance_m = 42.0 coordinator.summary_stats.max_calculated_speed_kmh = 12.5 coordinator.summary_stats.max_reported_speed_kmh = 10.0 coordinator.summary_stats.max_accuracy_m = 30.0 + coordinator.summary_stats.max_rejected_distance_m = 100.0 + coordinator.summary_stats.max_rejected_calculated_speed_kmh = 300.0 + coordinator.summary_stats.max_rejected_reported_speed_kmh = 97.2 + coordinator.summary_stats.max_rejected_accuracy_m = 98.0 coordinator.reset_statistics() @@ -364,18 +441,28 @@ def test_coordinator_reset_statistics_and_filter_state(): assert coordinator.data.engine_stats.duplicate == 0 assert coordinator.data.engine_stats.accuracy_rejections == 0 assert coordinator.data.engine_stats.speed_rejections == 0 + assert coordinator.data.engine_stats.speed_consistency_rejections == 0 assert coordinator.summary_stats.total_received_count == 0 assert coordinator.acceptance_rate_percent == 0.0 + assert coordinator.total_rejected_count == 0 assert coordinator.summary_stats.max_distance_m == 0.0 assert coordinator.summary_stats.max_calculated_speed_kmh == 0.0 assert coordinator.summary_stats.max_reported_speed_kmh == 0.0 assert coordinator.summary_stats.max_accuracy_m == 0.0 + assert coordinator.summary_stats.max_rejected_distance_m == 0.0 + assert coordinator.summary_stats.max_rejected_calculated_speed_kmh == 0.0 + assert coordinator.summary_stats.max_rejected_reported_speed_kmh == 0.0 + assert coordinator.summary_stats.max_rejected_accuracy_m == 0.0 coordinator.summary_stats.total_received_count = 2 coordinator.summary_stats.max_distance_m = 24.0 coordinator.summary_stats.max_calculated_speed_kmh = 20.0 coordinator.summary_stats.max_reported_speed_kmh = 9.0 coordinator.summary_stats.max_accuracy_m = 15.0 + coordinator.summary_stats.max_rejected_distance_m = 100.0 + coordinator.summary_stats.max_rejected_calculated_speed_kmh = 300.0 + coordinator.summary_stats.max_rejected_reported_speed_kmh = 97.2 + coordinator.summary_stats.max_rejected_accuracy_m = 98.0 coordinator.reset_filter() @@ -386,9 +473,14 @@ def test_coordinator_reset_statistics_and_filter_state(): assert coordinator.data.engine_stats.duplicate == 0 assert coordinator.data.engine_stats.accuracy_rejections == 0 assert coordinator.data.engine_stats.speed_rejections == 0 + assert coordinator.data.engine_stats.speed_consistency_rejections == 0 assert coordinator.summary_stats.total_received_count == 0 assert coordinator.acceptance_rate_percent == 0.0 assert coordinator.summary_stats.max_distance_m == 0.0 assert coordinator.summary_stats.max_calculated_speed_kmh == 0.0 assert coordinator.summary_stats.max_reported_speed_kmh == 0.0 assert coordinator.summary_stats.max_accuracy_m == 0.0 + assert coordinator.summary_stats.max_rejected_distance_m == 0.0 + assert coordinator.summary_stats.max_rejected_calculated_speed_kmh == 0.0 + assert coordinator.summary_stats.max_rejected_reported_speed_kmh == 0.0 + assert coordinator.summary_stats.max_rejected_accuracy_m == 0.0 diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 731d4ee..adf3443 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -33,6 +33,7 @@ def __init__(self, options=None) -> None: "source": "device_tracker.test", "max_speed": 220.0, "max_accuracy": 30.0, + "max_speed_difference_kmh": 40.0, } self.options = options or {} @@ -52,12 +53,17 @@ def test_diagnostics_report_contains_expected_fields(): accuracy=5.0, timestamp=datetime(2024, 1, 1, tzinfo=UTC), ), - last_result=FilterResult(accepted=True, reason="accepted"), + last_result=FilterResult( + accepted=True, + reason="accepted", + seconds_since_last_accepted=5.0, + ), engine_stats=EngineStats( accepted=2, duplicate=1, accuracy_rejections=3, speed_rejections=4, + speed_consistency_rejections=5, ), ) coordinator.summary_stats.total_received_count = 10 @@ -65,6 +71,10 @@ def test_diagnostics_report_contains_expected_fields(): coordinator.summary_stats.max_calculated_speed_kmh = 40.0 coordinator.summary_stats.max_reported_speed_kmh = 36.0 coordinator.summary_stats.max_accuracy_m = 25.0 + coordinator.summary_stats.max_rejected_distance_m = 100.0 + coordinator.summary_stats.max_rejected_calculated_speed_kmh = 300.0 + coordinator.summary_stats.max_rejected_reported_speed_kmh = 97.2 + coordinator.summary_stats.max_rejected_accuracy_m = 98.0 hass = SimpleNamespace(data={"gps_filter": {"entry-1": coordinator}}) entry = DummyEntry() @@ -76,26 +86,35 @@ def test_diagnostics_report_contains_expected_fields(): assert result["effective_filter_config"] == { "max_speed": 220.0, "max_accuracy": 30.0, + "max_speed_difference_kmh": 40.0, } assert result["accepted_count"] == 2 assert result["duplicate_count"] == 1 assert result["accuracy_rejections"] == 3 assert result["speed_rejections"] == 4 + assert result["speed_consistency_rejections"] == 5 assert result["summary"] == { "total_received_count": 10, + "total_rejected_count": 13, "accepted_count": 2, "duplicate_count": 1, "accuracy_rejections": 3, "speed_rejections": 4, + "speed_consistency_rejections": 5, "acceptance_rate_percent": 20.0, "max_distance_m": 88.0, "max_calculated_speed_kmh": 40.0, "max_reported_speed_kmh": 36.0, "max_accuracy_m": 25.0, + "max_rejected_distance_m": 100.0, + "max_rejected_calculated_speed_kmh": 300.0, + "max_rejected_reported_speed_kmh": 97.2, + "max_rejected_accuracy_m": 98.0, } assert result["last_received_point"]["latitude"] == 60.0 assert result["last_accepted_point"]["latitude"] == 60.0 assert result["last_filter_result"]["reason"] == "accepted" + assert result["last_filter_result"]["seconds_since_last_accepted"] == 5.0 assert result["filter_timeline"] == [] @@ -106,6 +125,7 @@ def test_diagnostics_report_uses_effective_options(): options={ "max_speed": 120.0, "max_accuracy": 15.0, + "max_speed_difference_kmh": 30.0, } ), ) @@ -115,6 +135,7 @@ def test_diagnostics_report_uses_effective_options(): options={ "max_speed": 120.0, "max_accuracy": 15.0, + "max_speed_difference_kmh": 30.0, } ) @@ -123,6 +144,7 @@ def test_diagnostics_report_uses_effective_options(): assert result["effective_filter_config"] == { "max_speed": 120.0, "max_accuracy": 15.0, + "max_speed_difference_kmh": 30.0, } @@ -165,6 +187,7 @@ def test_diagnostics_report_contains_filter_timeline(): "distance_m": None, "calculated_speed_kmh": None, "reported_speed_kmh": None, + "seconds_since_last_accepted": None, }, { "timestamp": "2024-01-01T00:00:01+00:00", @@ -176,13 +199,17 @@ def test_diagnostics_report_contains_filter_timeline(): "distance_m": 0.0, "calculated_speed_kmh": 0.0, "reported_speed_kmh": 0.0, + "seconds_since_last_accepted": 1.0, }, ] assert result["summary"]["total_received_count"] == 2 + assert result["summary"]["total_rejected_count"] == 1 assert result["summary"]["accepted_count"] == 1 assert result["summary"]["duplicate_count"] == 1 + assert result["summary"]["speed_consistency_rejections"] == 0 assert result["summary"]["acceptance_rate_percent"] == 50.0 assert result["summary"]["max_accuracy_m"] == 5.0 + assert result["summary"]["max_rejected_accuracy_m"] == 5.0 def test_manifest_version_matches_const_version(): diff --git a/tests/test_filter_engine.py b/tests/test_filter_engine.py index bdf8a03..0413d9c 100644 --- a/tests/test_filter_engine.py +++ b/tests/test_filter_engine.py @@ -109,7 +109,7 @@ def test_calculated_speed_and_reported_speed_are_added_to_result(): longitude=25.0, accuracy=5, speed=5.0, - timestamp=datetime(2024, 1, 1, 0, 0, 1, tzinfo=UTC), + timestamp=datetime(2024, 1, 1, 0, 0, 10, tzinfo=UTC), ) engine.process(first_point) @@ -120,6 +120,7 @@ def test_calculated_speed_and_reported_speed_are_added_to_result(): assert result.distance_m is not None assert result.calculated_speed_kmh is not None assert result.reported_speed_kmh == 18.0 + assert result.seconds_since_last_accepted == 10.0 assert result.calculated_speed_kmh > 0 @@ -161,3 +162,61 @@ def test_engine_stats_track_decisions(): assert engine.stats.duplicate == 1 assert engine.stats.accuracy_rejections == 1 assert engine.stats.speed_rejections == 1 + assert engine.stats.speed_consistency_rejections == 0 + + +def test_speed_consistency_rejects_large_calculated_reported_difference(): + engine = GPSFilterEngine(max_speed=220.0, max_speed_difference_kmh=40.0) + + engine.process( + GPSPoint( + latitude=60.0, + longitude=25.0, + accuracy=5.0, + timestamp=datetime(2024, 1, 1, tzinfo=UTC), + ) + ) + + result = engine.process( + GPSPoint( + latitude=60.001, + longitude=25.0, + accuracy=5.0, + speed=1.0, + timestamp=datetime(2024, 1, 1, 0, 0, 5, tzinfo=UTC), + ) + ) + + assert not result.accepted + assert result.reason == "speed_consistency" + assert result.calculated_speed_kmh is not None + assert result.reported_speed_kmh == 3.6 + assert result.seconds_since_last_accepted == 5.0 + assert engine.stats.speed_consistency_rejections == 1 + assert engine.stats.speed_rejections == 0 + + +def test_speed_consistency_requires_reported_speed(): + engine = GPSFilterEngine(max_speed=220.0, max_speed_difference_kmh=40.0) + + engine.process( + GPSPoint( + latitude=60.0, + longitude=25.0, + accuracy=5.0, + timestamp=datetime(2024, 1, 1, tzinfo=UTC), + ) + ) + + result = engine.process( + GPSPoint( + latitude=60.001, + longitude=25.0, + accuracy=5.0, + speed=None, + timestamp=datetime(2024, 1, 1, 0, 0, 5, tzinfo=UTC), + ) + ) + + assert result.accepted + assert engine.stats.speed_consistency_rejections == 0 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b64bc3c..6bad5f8 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -24,14 +24,25 @@ "sensor.gps_filter_last_received_timestamp", "sensor.gps_filter_last_accepted_timestamp", "sensor.gps_filter_acceptance_rate", + "sensor.gps_filter_seconds_since_last_accepted", + "sensor.gps_filter_max_speed_threshold", + "sensor.gps_filter_max_speed_difference_threshold", + "sensor.gps_filter_max_accuracy_threshold", + "sensor.gps_filter_total_received_count", + "sensor.gps_filter_total_rejected_count", "sensor.gps_filter_max_distance", "sensor.gps_filter_max_calculated_speed", "sensor.gps_filter_max_reported_speed", "sensor.gps_filter_max_accuracy", + "sensor.gps_filter_max_rejected_distance", + "sensor.gps_filter_max_rejected_calculated_speed", + "sensor.gps_filter_max_rejected_reported_speed", + "sensor.gps_filter_max_rejected_accuracy", "sensor.gps_filter_accepted_count", "sensor.gps_filter_duplicate_count", "sensor.gps_filter_accuracy_rejections", "sensor.gps_filter_speed_rejections", + "sensor.gps_filter_speed_consistency_rejections", ] @@ -75,12 +86,14 @@ def test_sensor_entities_expose_coordinator_state(): distance_m=42.0, calculated_speed_kmh=12.5, reported_speed_kmh=10.0, + seconds_since_last_accepted=5.0, ), engine_stats=EngineStats( accepted=2, duplicate=1, accuracy_rejections=3, speed_rejections=4, + speed_consistency_rejections=5, ), ) coordinator.summary_stats.total_received_count = 4 @@ -88,6 +101,10 @@ def test_sensor_entities_expose_coordinator_state(): coordinator.summary_stats.max_calculated_speed_kmh = 40.0 coordinator.summary_stats.max_reported_speed_kmh = 36.0 coordinator.summary_stats.max_accuracy_m = 25.0 + coordinator.summary_stats.max_rejected_distance_m = 100.0 + coordinator.summary_stats.max_rejected_calculated_speed_kmh = 300.0 + coordinator.summary_stats.max_rejected_reported_speed_kmh = 97.2 + coordinator.summary_stats.max_rejected_accuracy_m = 98.0 coordinator.data.last_received_point.timestamp = datetime( 2024, 1, @@ -119,14 +136,25 @@ def test_sensor_entities_expose_coordinator_state(): tzinfo=UTC, ) assert sensors["acceptance_rate"].native_value == 50.0 + assert sensors["seconds_since_last_accepted"].native_value == 5.0 + assert sensors["max_speed_threshold"].native_value == 220.0 + assert sensors["max_speed_difference_threshold"].native_value == 40.0 + assert sensors["max_accuracy_threshold"].native_value == 30.0 + assert sensors["total_received_count"].native_value == 4 + assert sensors["total_rejected_count"].native_value == 13 assert sensors["max_distance"].native_value == 88.0 assert sensors["max_calculated_speed"].native_value == 40.0 assert sensors["max_reported_speed"].native_value == 36.0 assert sensors["max_accuracy"].native_value == 25.0 + assert sensors["max_rejected_distance"].native_value == 100.0 + assert sensors["max_rejected_calculated_speed"].native_value == 300.0 + assert sensors["max_rejected_reported_speed"].native_value == 97.2 + assert sensors["max_rejected_accuracy"].native_value == 98.0 assert sensors["accepted_count"].native_value == 2 assert sensors["duplicate_count"].native_value == 1 assert sensors["accuracy_rejections"].native_value == 3 assert sensors["speed_rejections"].native_value == 4 + assert sensors["speed_consistency_rejections"].native_value == 5 def test_sensor_entities_default_to_valid_dashboard_states(): @@ -144,14 +172,25 @@ def test_sensor_entities_default_to_valid_dashboard_states(): assert sensors["last_received_timestamp"].native_value is None assert sensors["last_accepted_timestamp"].native_value is None assert sensors["acceptance_rate"].native_value == 0.0 + assert sensors["seconds_since_last_accepted"].native_value == 0.0 + assert sensors["max_speed_threshold"].native_value == 220.0 + assert sensors["max_speed_difference_threshold"].native_value == 40.0 + assert sensors["max_accuracy_threshold"].native_value == 30.0 + assert sensors["total_received_count"].native_value == 0 + assert sensors["total_rejected_count"].native_value == 0 assert sensors["max_distance"].native_value == 0.0 assert sensors["max_calculated_speed"].native_value == 0.0 assert sensors["max_reported_speed"].native_value == 0.0 assert sensors["max_accuracy"].native_value == 0.0 + assert sensors["max_rejected_distance"].native_value == 0.0 + assert sensors["max_rejected_calculated_speed"].native_value == 0.0 + assert sensors["max_rejected_reported_speed"].native_value == 0.0 + assert sensors["max_rejected_accuracy"].native_value == 0.0 assert sensors["accepted_count"].native_value == 0 assert sensors["duplicate_count"].native_value == 0 assert sensors["accuracy_rejections"].native_value == 0 assert sensors["speed_rejections"].native_value == 0 + assert sensors["speed_consistency_rejections"].native_value == 0 def test_sensor_descriptions_follow_home_assistant_entity_id_conventions(): @@ -205,12 +244,23 @@ def test_only_counter_sensors_compile_long_term_statistics(): "last_received_timestamp": None, "last_accepted_timestamp": None, "acceptance_rate": None, + "seconds_since_last_accepted": None, + "max_speed_threshold": None, + "max_speed_difference_threshold": None, + "max_accuracy_threshold": None, + "total_received_count": SensorStateClass.TOTAL_INCREASING, + "total_rejected_count": SensorStateClass.TOTAL_INCREASING, "max_distance": None, "max_calculated_speed": None, "max_reported_speed": None, "max_accuracy": None, + "max_rejected_distance": None, + "max_rejected_calculated_speed": None, + "max_rejected_reported_speed": None, + "max_rejected_accuracy": None, "accepted_count": SensorStateClass.TOTAL_INCREASING, "duplicate_count": SensorStateClass.TOTAL_INCREASING, "accuracy_rejections": SensorStateClass.TOTAL_INCREASING, "speed_rejections": SensorStateClass.TOTAL_INCREASING, + "speed_consistency_rejections": SensorStateClass.TOTAL_INCREASING, }