From 1965a8beb5e0d7fbd391a062fed4836562b636f6 Mon Sep 17 00:00:00 2001 From: Peter Date: Sat, 15 Nov 2025 09:48:45 -0300 Subject: [PATCH 1/3] Add hysteresis to route deviation tracking to prevent oscillation Adds `on_route_threshold` parameter to `StaticThreshold` route deviation tracking. When set lower than `max_acceptable_deviation`, this creates a buffer zone that prevents users from oscillating between on-route and off-route states near the threshold boundary. For example, with max_acceptable_deviation=50m and on_route_threshold=40m: - User goes off-route when deviation exceeds 50m - User returns on-route only when deviation drops to 40m or less - This 10m buffer prevents rapid state changes when GPS accuracy fluctuates Includes comprehensive test coverage for the hysteresis behavior. --- .../FFIWrappers/RouteDeviationWrapper.swift | 12 +- apple/Sources/UniFFI/ferrostar.swift | 18 +- common/ferrostar/src/deviation_detection.rs | 160 +++++++++++++++++- .../src/navigation_controller/test_helpers.rs | 1 + ...rding__tests__recording_serialization.snap | 1 + 5 files changed, 183 insertions(+), 9 deletions(-) diff --git a/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift b/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift index 965b0f4f..645e79e3 100644 --- a/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift +++ b/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift @@ -18,7 +18,11 @@ private final class DetectorImpl: RouteDeviationDetector { public enum SwiftRouteDeviationTracking { case none - case staticThreshold(minimumHorizontalAccuracy: UInt16, maxAcceptableDeviation: Double) + case staticThreshold( + minimumHorizontalAccuracy: UInt16, + maxAcceptableDeviation: Double, + onRouteThreshold: Double? = nil + ) case custom(detector: @Sendable (Route, TripState) -> RouteDeviation) @@ -28,11 +32,13 @@ public enum SwiftRouteDeviationTracking { .none case let .staticThreshold( minimumHorizontalAccuracy: minimumHorizontalAccuracy, - maxAcceptableDeviation: maxAcceptableDeviation + maxAcceptableDeviation: maxAcceptableDeviation, + onRouteThreshold: onRouteThreshold ): .staticThreshold( minimumHorizontalAccuracy: minimumHorizontalAccuracy, - maxAcceptableDeviation: maxAcceptableDeviation + maxAcceptableDeviation: maxAcceptableDeviation, + onRouteThreshold: onRouteThreshold ?? maxAcceptableDeviation ) case let .custom(detector: detectorFunc): .custom(detector: DetectorImpl(detectorFunc: detectorFunc)) diff --git a/apple/Sources/UniFFI/ferrostar.swift b/apple/Sources/UniFFI/ferrostar.swift index 4a68f2a8..00a096f1 100644 --- a/apple/Sources/UniFFI/ferrostar.swift +++ b/apple/Sources/UniFFI/ferrostar.swift @@ -7926,7 +7926,18 @@ public enum RouteDeviationTracking { * * If the distance between the reported location and the expected route line * is greater than this threshold, it will be flagged as an off route condition. - */maxAcceptableDeviation: Double + */maxAcceptableDeviation: Double, + /** + * The threshold for returning to on-route state, in meters. + * + * Must be less than or equal to `max_acceptable_deviation`. + * This creates hysteresis to prevent oscillation between on/off route states. + * For example, if `max_acceptable_deviation` is 50m and `on_route_threshold` is 40m, + * the user must deviate more than 50m to trigger off-route, but must return within + * 40m to be considered back on route. + * + * If not specified or equal to `max_acceptable_deviation`, no hysteresis is applied. + */onRouteThreshold: Double ) /** * An arbitrary user-defined implementation. @@ -7955,7 +7966,7 @@ public struct FfiConverterTypeRouteDeviationTracking: FfiConverterRustBuffer { case 1: return .none - case 2: return .staticThreshold(minimumHorizontalAccuracy: try FfiConverterUInt16.read(from: &buf), maxAcceptableDeviation: try FfiConverterDouble.read(from: &buf) + case 2: return .staticThreshold(minimumHorizontalAccuracy: try FfiConverterUInt16.read(from: &buf), maxAcceptableDeviation: try FfiConverterDouble.read(from: &buf), onRouteThreshold: try FfiConverterDouble.read(from: &buf) ) case 3: return .custom(detector: try FfiConverterTypeRouteDeviationDetector.read(from: &buf) @@ -7973,10 +7984,11 @@ public struct FfiConverterTypeRouteDeviationTracking: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) - case let .staticThreshold(minimumHorizontalAccuracy,maxAcceptableDeviation): + case let .staticThreshold(minimumHorizontalAccuracy,maxAcceptableDeviation,onRouteThreshold): writeInt(&buf, Int32(2)) FfiConverterUInt16.write(minimumHorizontalAccuracy, into: &buf) FfiConverterDouble.write(maxAcceptableDeviation, into: &buf) + FfiConverterDouble.write(onRouteThreshold, into: &buf) case let .custom(detector): diff --git a/common/ferrostar/src/deviation_detection.rs b/common/ferrostar/src/deviation_detection.rs index 99cd81f9..e7372ea1 100644 --- a/common/ferrostar/src/deviation_detection.rs +++ b/common/ferrostar/src/deviation_detection.rs @@ -58,6 +58,16 @@ pub enum RouteDeviationTracking { /// If the distance between the reported location and the expected route line /// is greater than this threshold, it will be flagged as an off route condition. max_acceptable_deviation: f64, + /// The threshold for returning to on-route state, in meters. + /// + /// Must be less than or equal to `max_acceptable_deviation`. + /// This creates hysteresis to prevent oscillation between on/off route states. + /// For example, if `max_acceptable_deviation` is 50m and `on_route_threshold` is 40m, + /// the user must deviate more than 50m to trigger off-route, but must return within + /// 40m to be considered back on route. + /// + /// If not specified or equal to `max_acceptable_deviation`, no hysteresis is applied. + on_route_threshold: f64, }, // TODO: Standard variants that account for mode of travel. For example, `DefaultFor(modeOfTravel: ModeOfTravel)` with sensible defaults for walking, driving, cycling, etc. /// An arbitrary user-defined implementation. @@ -80,24 +90,32 @@ impl RouteDeviationTracking { RouteDeviationTracking::StaticThreshold { minimum_horizontal_accuracy, max_acceptable_deviation, + on_route_threshold, } => match trip_state { TripState::Idle { .. } | TripState::Complete { .. } => RouteDeviation::NoDeviation, TripState::Navigating { user_location, remaining_steps, + deviation, .. } => { if user_location.horizontal_accuracy > f64::from(*minimum_horizontal_accuracy) { return RouteDeviation::NoDeviation; } + // Choose threshold based on current state (hysteresis) + let threshold = match deviation { + RouteDeviation::NoDeviation => *max_acceptable_deviation, + RouteDeviation::OffRoute { .. } => *on_route_threshold, + }; + let mut first_step_deviation = None; for (index, step) in remaining_steps.iter().enumerate() { let step_deviation = self.static_threshold_deviation_from_line( &Point::from(*user_location), &step.get_linestring(), - max_acceptable_deviation.clone(), + threshold, ); if index == 0 { @@ -402,7 +420,8 @@ proptest! { ) { let tracking = RouteDeviationTracking::StaticThreshold { minimum_horizontal_accuracy, - max_acceptable_deviation + max_acceptable_deviation, + on_route_threshold: max_acceptable_deviation }; let current_route_step = gen_dummy_route_step(x1, y1, x2, y2); let route = gen_route_from_steps(vec![current_route_step.clone()]); @@ -478,7 +497,8 @@ proptest! { ) { let tracking = RouteDeviationTracking::StaticThreshold { minimum_horizontal_accuracy: horizontal_accuracy - 1, - max_acceptable_deviation + max_acceptable_deviation, + on_route_threshold: max_acceptable_deviation }; let current_route_step = gen_dummy_route_step(x1, y1, x2, y2); let route = gen_route_from_steps(vec![current_route_step.clone()]); @@ -506,3 +526,137 @@ proptest! { ); } } + +/// Tests that hysteresis prevents oscillation between on-route and off-route states. +/// This test verifies that: +/// 1. User goes off-route when deviation > max_acceptable_deviation +/// 2. User stays off-route until deviation <= on_route_threshold +/// 3. User stays on-route until deviation > max_acceptable_deviation again +#[cfg(test)] +#[test] +fn static_threshold_hysteresis_prevents_oscillation() { + use crate::{ + models::{GeographicCoordinate, UserLocation}, + navigation_controller::test_helpers::{gen_dummy_route_step, gen_route_from_steps, get_navigating_trip_state}, + }; + + #[cfg(feature = "std")] + use std::time::SystemTime; + #[cfg(feature = "web-time")] + use web_time::SystemTime; + + let max_deviation = 50.0; + let on_route_threshold = 40.0; + + let tracking = RouteDeviationTracking::StaticThreshold { + minimum_horizontal_accuracy: 100, + max_acceptable_deviation: max_deviation, + on_route_threshold, + }; + + // Create a simple route step from (0, 0) to (0, 0.001) (~111 meters north) + let current_route_step = gen_dummy_route_step(0.0, 0.0, 0.0, 0.001); + let route = gen_route_from_steps(vec![current_route_step.clone()]); + + // Start on route + let user_location_on_route = UserLocation { + coordinates: GeographicCoordinate { lng: 0.0, lat: 0.0 }, + horizontal_accuracy: 5.0, + course_over_ground: None, + timestamp: SystemTime::now(), + speed: None, + }; + + // Initially on route + let trip_state_on_route = get_navigating_trip_state( + user_location_on_route.clone(), + vec![current_route_step.clone()], + vec![], + RouteDeviation::NoDeviation, + ); + + assert_eq!( + tracking.check_route_deviation(&route, &trip_state_on_route), + RouteDeviation::NoDeviation + ); + + // Move 45m away from route (between on_route_threshold and max_deviation) + // At this distance, should still be on-route since we started on-route + let user_location_45m = UserLocation { + coordinates: GeographicCoordinate { lng: 0.0004, lat: 0.0 }, // ~45m east + horizontal_accuracy: 5.0, + course_over_ground: None, + timestamp: SystemTime::now(), + speed: None, + }; + + let trip_state_45m_from_onroute = get_navigating_trip_state( + user_location_45m.clone(), + vec![current_route_step.clone()], + vec![], + RouteDeviation::NoDeviation, // Still on-route from previous state + ); + + // Should remain on-route because 45m < max_deviation (50m) + assert_eq!( + tracking.check_route_deviation(&route, &trip_state_45m_from_onroute), + RouteDeviation::NoDeviation + ); + + // Move 55m away from route (beyond max_deviation) + // Should trigger off-route + let user_location_55m = UserLocation { + coordinates: GeographicCoordinate { lng: 0.0005, lat: 0.0 }, // ~55m east + horizontal_accuracy: 5.0, + course_over_ground: None, + timestamp: SystemTime::now(), + speed: None, + }; + + let trip_state_55m = get_navigating_trip_state( + user_location_55m.clone(), + vec![current_route_step.clone()], + vec![], + RouteDeviation::NoDeviation, // Was on-route + ); + + // Should be off-route now + let deviation_result = tracking.check_route_deviation(&route, &trip_state_55m); + assert!(matches!(deviation_result, RouteDeviation::OffRoute { .. })); + + // Move back to 45m (between thresholds) + // Should STAY off-route because 45m > on_route_threshold (40m) + let trip_state_45m_from_offroute = get_navigating_trip_state( + user_location_45m.clone(), + vec![current_route_step.clone()], + vec![], + RouteDeviation::OffRoute { deviation_from_route_line: 55.0 }, // Was off-route + ); + + // Should remain off-route because 45m > on_route_threshold (40m) + let deviation_result_2 = tracking.check_route_deviation(&route, &trip_state_45m_from_offroute); + assert!(matches!(deviation_result_2, RouteDeviation::OffRoute { .. })); + + // Move to 35m (below on_route_threshold) + // Should return to on-route + let user_location_35m = UserLocation { + coordinates: GeographicCoordinate { lng: 0.00031, lat: 0.0 }, // ~35m east + horizontal_accuracy: 5.0, + course_over_ground: None, + timestamp: SystemTime::now(), + speed: None, + }; + + let trip_state_35m = get_navigating_trip_state( + user_location_35m.clone(), + vec![current_route_step.clone()], + vec![], + RouteDeviation::OffRoute { deviation_from_route_line: 45.0 }, // Was off-route + ); + + // Should be back on-route because 35m <= on_route_threshold (40m) + assert_eq!( + tracking.check_route_deviation(&route, &trip_state_35m), + RouteDeviation::NoDeviation + ); +} diff --git a/common/ferrostar/src/navigation_controller/test_helpers.rs b/common/ferrostar/src/navigation_controller/test_helpers.rs index 7246d338..325ccc78 100644 --- a/common/ferrostar/src/navigation_controller/test_helpers.rs +++ b/common/ferrostar/src/navigation_controller/test_helpers.rs @@ -29,6 +29,7 @@ pub fn get_test_navigation_controller_config( route_deviation_tracking: RouteDeviationTracking::StaticThreshold { minimum_horizontal_accuracy: 0, max_acceptable_deviation: 0.0, + on_route_threshold: 0.0, }, snapped_location_course_filtering: CourseFiltering::Raw, step_advance_condition, diff --git a/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap b/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap index 99c81fd5..6ee2ae21 100644 --- a/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap +++ b/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap @@ -11,6 +11,7 @@ config: StaticThreshold: max_acceptable_deviation: 0 minimum_horizontal_accuracy: 0 + on_route_threshold: 0 snapped_location_course_filtering: Raw step_advance_condition: DistanceToEndOfStep: From a75b90c21a88e2d5bed0c34420cf939020eca693 Mon Sep 17 00:00:00 2001 From: devnull133 Date: Tue, 18 Nov 2025 13:44:12 +0100 Subject: [PATCH 2/3] Update common/ferrostar/src/deviation_detection.rs Co-authored-by: Ian Wagner --- common/ferrostar/src/deviation_detection.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/common/ferrostar/src/deviation_detection.rs b/common/ferrostar/src/deviation_detection.rs index e7372ea1..a2c72fee 100644 --- a/common/ferrostar/src/deviation_detection.rs +++ b/common/ferrostar/src/deviation_detection.rs @@ -115,6 +115,7 @@ impl RouteDeviationTracking { let step_deviation = self.static_threshold_deviation_from_line( &Point::from(*user_location), &step.get_linestring(), + // Note: This is always <= max_acceptable_deviation threshold, ); From 4350cd6fbfea3ecaea6e3a82507db8cbaa594fdc Mon Sep 17 00:00:00 2001 From: Peter Date: Wed, 19 Nov 2025 10:44:25 -0300 Subject: [PATCH 3/3] Refactor StaticThreshold to use validated config struct with return_buffer Replace inline enum fields with StaticThresholdConfig struct that validates invariants at construction time, making invalid states impossible to create. - Add StaticThresholdConfig with failable constructor - Change on_route_threshold parameter to return_buffer for clearer semantics - Add StaticThresholdError for validation failures - Update Swift FFI wrapper and Kotlin demo app --- .../com/stadiamaps/ferrostar/DemoConfigs.kt | 9 +- .../FFIWrappers/RouteDeviationWrapper.swift | 19 +- apple/Sources/UniFFI/ferrostar.swift | 243 ++++++++++++++++-- common/ferrostar/src/deviation_detection.rs | 215 ++++++++++++---- .../src/navigation_controller/test_helpers.rs | 14 +- ...rding__tests__recording_serialization.snap | 2 +- 6 files changed, 412 insertions(+), 90 deletions(-) diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoConfigs.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoConfigs.kt index 5b807a32..4d55816f 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoConfigs.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoConfigs.kt @@ -4,6 +4,7 @@ import uniffi.ferrostar.CourseFiltering import uniffi.ferrostar.NavigationCachingConfig import uniffi.ferrostar.NavigationControllerConfig import uniffi.ferrostar.RouteDeviationTracking +import uniffi.ferrostar.StaticThresholdConfig import uniffi.ferrostar.WaypointAdvanceMode import uniffi.ferrostar.stepAdvanceDistanceEntryAndExit import uniffi.ferrostar.stepAdvanceDistanceToEndOfStep @@ -13,7 +14,13 @@ fun NavigationControllerConfig.Companion.demoConfig(): NavigationControllerConfi WaypointAdvanceMode.WaypointWithinRange(100.0), stepAdvanceDistanceEntryAndExit(30u, 5u, 32u), stepAdvanceDistanceToEndOfStep(10u, 32u), - RouteDeviationTracking.StaticThreshold(15U, 50.0), + RouteDeviationTracking.StaticThreshold( + StaticThresholdConfig( + minimumHorizontalAccuracy = 15U, + maxAcceptableDeviation = 50.0, + returnBuffer = 0.0 + ) + ), CourseFiltering.SNAP_TO_ROUTE) } diff --git a/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift b/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift index 645e79e3..f1a9d743 100644 --- a/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift +++ b/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift @@ -18,10 +18,17 @@ private final class DetectorImpl: RouteDeviationDetector { public enum SwiftRouteDeviationTracking { case none + /// Static threshold deviation tracking with hysteresis support. + /// + /// - Parameters: + /// - minimumHorizontalAccuracy: Minimum GPS accuracy required to trigger deviation checks + /// - maxAcceptableDeviation: Maximum distance from route before going off-route (must be >= 0) + /// - returnBuffer: Buffer distance for hysteresis (must be >= 0 and <= maxAcceptableDeviation). + /// If nil, defaults to 0 (no hysteresis). case staticThreshold( minimumHorizontalAccuracy: UInt16, maxAcceptableDeviation: Double, - onRouteThreshold: Double? = nil + returnBuffer: Double? = nil ) case custom(detector: @Sendable (Route, TripState) -> RouteDeviation) @@ -33,12 +40,14 @@ public enum SwiftRouteDeviationTracking { case let .staticThreshold( minimumHorizontalAccuracy: minimumHorizontalAccuracy, maxAcceptableDeviation: maxAcceptableDeviation, - onRouteThreshold: onRouteThreshold + returnBuffer: returnBuffer ): .staticThreshold( - minimumHorizontalAccuracy: minimumHorizontalAccuracy, - maxAcceptableDeviation: maxAcceptableDeviation, - onRouteThreshold: onRouteThreshold ?? maxAcceptableDeviation + StaticThresholdConfig( + minimumHorizontalAccuracy: minimumHorizontalAccuracy, + maxAcceptableDeviation: maxAcceptableDeviation, + returnBuffer: returnBuffer ?? 0.0 + ) ) case let .custom(detector: detectorFunc): .custom(detector: DetectorImpl(detectorFunc: detectorFunc)) diff --git a/apple/Sources/UniFFI/ferrostar.swift b/apple/Sources/UniFFI/ferrostar.swift index 00a096f1..3cf6497c 100644 --- a/apple/Sources/UniFFI/ferrostar.swift +++ b/apple/Sources/UniFFI/ferrostar.swift @@ -5489,6 +5489,112 @@ public func FfiConverterTypeSpokenInstruction_lower(_ value: SpokenInstruction) } +/** + * Configuration for static threshold route deviation detection with hysteresis support. + * + * This struct ensures valid configuration through a failable constructor, + * making it impossible to create invalid threshold configurations. + */ +public struct StaticThresholdConfig: Equatable, Hashable, Codable { + /** + * The minimum required horizontal accuracy of the user location, in meters. + * Values larger than this will not trigger route deviation warnings. + */ + public var minimumHorizontalAccuracy: UInt16 + /** + * The maximum acceptable deviation from the route line, in meters. + * + * If the distance between the reported location and the expected route line + * is greater than this threshold, it will be flagged as an off route condition. + */ + public var maxAcceptableDeviation: Double + /** + * The buffer distance used for hysteresis when returning to on-route state, in meters. + * + * The actual threshold for returning to on-route is calculated as: + * `max_acceptable_deviation - return_buffer` + * + * For example, if `max_acceptable_deviation` is 50m and `return_buffer` is 10m, + * the user must deviate more than 50m to trigger off-route, but must return within + * 40m to be considered back on route. + * + * Set to 0 for no hysteresis (same threshold for going off-route and returning). + */ + public var returnBuffer: Double + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * The minimum required horizontal accuracy of the user location, in meters. + * Values larger than this will not trigger route deviation warnings. + */minimumHorizontalAccuracy: UInt16, + /** + * The maximum acceptable deviation from the route line, in meters. + * + * If the distance between the reported location and the expected route line + * is greater than this threshold, it will be flagged as an off route condition. + */maxAcceptableDeviation: Double, + /** + * The buffer distance used for hysteresis when returning to on-route state, in meters. + * + * The actual threshold for returning to on-route is calculated as: + * `max_acceptable_deviation - return_buffer` + * + * For example, if `max_acceptable_deviation` is 50m and `return_buffer` is 10m, + * the user must deviate more than 50m to trigger off-route, but must return within + * 40m to be considered back on route. + * + * Set to 0 for no hysteresis (same threshold for going off-route and returning). + */returnBuffer: Double) { + self.minimumHorizontalAccuracy = minimumHorizontalAccuracy + self.maxAcceptableDeviation = maxAcceptableDeviation + self.returnBuffer = returnBuffer + } + + +} + +#if compiler(>=6) +extension StaticThresholdConfig: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeStaticThresholdConfig: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StaticThresholdConfig { + return + try StaticThresholdConfig( + minimumHorizontalAccuracy: FfiConverterUInt16.read(from: &buf), + maxAcceptableDeviation: FfiConverterDouble.read(from: &buf), + returnBuffer: FfiConverterDouble.read(from: &buf) + ) + } + + public static func write(_ value: StaticThresholdConfig, into buf: inout [UInt8]) { + FfiConverterUInt16.write(value.minimumHorizontalAccuracy, into: &buf) + FfiConverterDouble.write(value.maxAcceptableDeviation, into: &buf) + FfiConverterDouble.write(value.returnBuffer, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStaticThresholdConfig_lift(_ buf: RustBuffer) throws -> StaticThresholdConfig { + return try FfiConverterTypeStaticThresholdConfig.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStaticThresholdConfig_lower(_ value: StaticThresholdConfig) -> RustBuffer { + return FfiConverterTypeStaticThresholdConfig.lower(value) +} + + /** * The step advance result is produced on every iteration of the navigation state machine and * used by the navigation to build a new [`NavState`](super::NavState) instance for that update. @@ -7916,28 +8022,7 @@ public enum RouteDeviationTracking { /** * Detects deviation from the route using a configurable static distance threshold from the route line. */ - case staticThreshold( - /** - * The minimum required horizontal accuracy of the user location, in meters. - * Values larger than this will not trigger route deviation warnings. - */minimumHorizontalAccuracy: UInt16, - /** - * The maximum acceptable deviation from the route line, in meters. - * - * If the distance between the reported location and the expected route line - * is greater than this threshold, it will be flagged as an off route condition. - */maxAcceptableDeviation: Double, - /** - * The threshold for returning to on-route state, in meters. - * - * Must be less than or equal to `max_acceptable_deviation`. - * This creates hysteresis to prevent oscillation between on/off route states. - * For example, if `max_acceptable_deviation` is 50m and `on_route_threshold` is 40m, - * the user must deviate more than 50m to trigger off-route, but must return within - * 40m to be considered back on route. - * - * If not specified or equal to `max_acceptable_deviation`, no hysteresis is applied. - */onRouteThreshold: Double + case staticThreshold(StaticThresholdConfig ) /** * An arbitrary user-defined implementation. @@ -7966,7 +8051,7 @@ public struct FfiConverterTypeRouteDeviationTracking: FfiConverterRustBuffer { case 1: return .none - case 2: return .staticThreshold(minimumHorizontalAccuracy: try FfiConverterUInt16.read(from: &buf), maxAcceptableDeviation: try FfiConverterDouble.read(from: &buf), onRouteThreshold: try FfiConverterDouble.read(from: &buf) + case 2: return .staticThreshold(try FfiConverterTypeStaticThresholdConfig.read(from: &buf) ) case 3: return .custom(detector: try FfiConverterTypeRouteDeviationDetector.read(from: &buf) @@ -7984,11 +8069,9 @@ public struct FfiConverterTypeRouteDeviationTracking: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) - case let .staticThreshold(minimumHorizontalAccuracy,maxAcceptableDeviation,onRouteThreshold): + case let .staticThreshold(v1): writeInt(&buf, Int32(2)) - FfiConverterUInt16.write(minimumHorizontalAccuracy, into: &buf) - FfiConverterDouble.write(maxAcceptableDeviation, into: &buf) - FfiConverterDouble.write(onRouteThreshold, into: &buf) + FfiConverterTypeStaticThresholdConfig.write(v1, into: &buf) case let .custom(detector): @@ -8383,6 +8466,112 @@ public func FfiConverterTypeSimulationError_lower(_ value: SimulationError) -> R return FfiConverterTypeSimulationError.lower(value) } + +/** + * Errors that can occur when creating a [`StaticThresholdConfig`]. + */ +public enum StaticThresholdError: Swift.Error, Equatable, Hashable, Codable, Foundation.LocalizedError { + + + + /** + * The maximum acceptable deviation must be non-negative. + */ + case NegativeMaxDeviation(value: Double + ) + /** + * The return buffer must be non-negative. + */ + case NegativeReturnBuffer(value: Double + ) + /** + * The return buffer must not exceed the maximum acceptable deviation. + */ + case ReturnBufferTooLarge(returnBuffer: Double, maxAcceptableDeviation: Double + ) + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension StaticThresholdError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeStaticThresholdError: FfiConverterRustBuffer { + typealias SwiftType = StaticThresholdError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> StaticThresholdError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .NegativeMaxDeviation( + value: try FfiConverterDouble.read(from: &buf) + ) + case 2: return .NegativeReturnBuffer( + value: try FfiConverterDouble.read(from: &buf) + ) + case 3: return .ReturnBufferTooLarge( + returnBuffer: try FfiConverterDouble.read(from: &buf), + maxAcceptableDeviation: try FfiConverterDouble.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: StaticThresholdError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .NegativeMaxDeviation(value): + writeInt(&buf, Int32(1)) + FfiConverterDouble.write(value, into: &buf) + + + case let .NegativeReturnBuffer(value): + writeInt(&buf, Int32(2)) + FfiConverterDouble.write(value, into: &buf) + + + case let .ReturnBufferTooLarge(returnBuffer,maxAcceptableDeviation): + writeInt(&buf, Int32(3)) + FfiConverterDouble.write(returnBuffer, into: &buf) + FfiConverterDouble.write(maxAcceptableDeviation, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStaticThresholdError_lift(_ buf: RustBuffer) throws -> StaticThresholdError { + return try FfiConverterTypeStaticThresholdError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeStaticThresholdError_lower(_ value: StaticThresholdError) -> RustBuffer { + return FfiConverterTypeStaticThresholdError.lower(value) +} + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. /** diff --git a/common/ferrostar/src/deviation_detection.rs b/common/ferrostar/src/deviation_detection.rs index a2c72fee..cc09db67 100644 --- a/common/ferrostar/src/deviation_detection.rs +++ b/common/ferrostar/src/deviation_detection.rs @@ -24,6 +24,120 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "wasm-bindgen")] use tsify::Tsify; +/// Errors that can occur when creating a [`StaticThresholdConfig`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "std", derive(thiserror::Error))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] +pub enum StaticThresholdError { + /// The maximum acceptable deviation must be non-negative. + #[cfg_attr( + feature = "std", + error("max_acceptable_deviation must be non-negative, got {value}") + )] + NegativeMaxDeviation { value: f64 }, + /// The return buffer must be non-negative. + #[cfg_attr( + feature = "std", + error("return_buffer must be non-negative, got {value}") + )] + NegativeReturnBuffer { value: f64 }, + /// The return buffer must not exceed the maximum acceptable deviation. + #[cfg_attr( + feature = "std", + error( + "return_buffer ({return_buffer}) must not exceed max_acceptable_deviation ({max_acceptable_deviation})" + ) + )] + ReturnBufferTooLarge { + return_buffer: f64, + max_acceptable_deviation: f64, + }, +} + +/// Configuration for static threshold route deviation detection with hysteresis support. +/// +/// This struct ensures valid configuration through a failable constructor, +/// making it impossible to create invalid threshold configurations. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))] +#[cfg_attr(feature = "wasm-bindgen", tsify(from_wasm_abi))] +pub struct StaticThresholdConfig { + /// The minimum required horizontal accuracy of the user location, in meters. + /// Values larger than this will not trigger route deviation warnings. + pub minimum_horizontal_accuracy: u16, + /// The maximum acceptable deviation from the route line, in meters. + /// + /// If the distance between the reported location and the expected route line + /// is greater than this threshold, it will be flagged as an off route condition. + pub max_acceptable_deviation: f64, + /// The buffer distance used for hysteresis when returning to on-route state, in meters. + /// + /// The actual threshold for returning to on-route is calculated as: + /// `max_acceptable_deviation - return_buffer` + /// + /// For example, if `max_acceptable_deviation` is 50m and `return_buffer` is 10m, + /// the user must deviate more than 50m to trigger off-route, but must return within + /// 40m to be considered back on route. + /// + /// Set to 0 for no hysteresis (same threshold for going off-route and returning). + pub return_buffer: f64, +} + +impl StaticThresholdConfig { + /// Creates a new static threshold configuration with validation. + /// + /// # Arguments + /// + /// * `minimum_horizontal_accuracy` - Minimum GPS accuracy required to trigger deviation checks + /// * `max_acceptable_deviation` - Maximum distance from route before going off-route (must be >= 0) + /// * `return_buffer` - Buffer distance for hysteresis (must be >= 0 and <= max_acceptable_deviation) + /// + /// # Errors + /// + /// Returns an error if: + /// - `max_acceptable_deviation` is < 0 + /// - `return_buffer` is < 0 + /// - `return_buffer` > `max_acceptable_deviation` + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn new( + minimum_horizontal_accuracy: u16, + max_acceptable_deviation: f64, + return_buffer: f64, + ) -> Result { + if max_acceptable_deviation < 0.0 { + return Err(StaticThresholdError::NegativeMaxDeviation { + value: max_acceptable_deviation, + }); + } + if return_buffer < 0.0 { + return Err(StaticThresholdError::NegativeReturnBuffer { + value: return_buffer, + }); + } + if return_buffer > max_acceptable_deviation { + return Err(StaticThresholdError::ReturnBufferTooLarge { + return_buffer, + max_acceptable_deviation, + }); + } + + Ok(Self { + minimum_horizontal_accuracy, + max_acceptable_deviation, + return_buffer, + }) + } + + /// Returns the threshold distance for returning to on-route state. + /// + /// This is calculated as `max_acceptable_deviation - return_buffer`. + #[must_use] + pub fn on_route_threshold(&self) -> f64 { + self.max_acceptable_deviation - self.return_buffer + } +} + #[cfg(test)] use { crate::{ @@ -48,27 +162,7 @@ pub enum RouteDeviationTracking { /// No checks will be done, and we assume the user is always following the route. None, /// Detects deviation from the route using a configurable static distance threshold from the route line. - #[cfg_attr(feature = "wasm-bindgen", serde(rename_all = "camelCase"))] - StaticThreshold { - /// The minimum required horizontal accuracy of the user location, in meters. - /// Values larger than this will not trigger route deviation warnings. - minimum_horizontal_accuracy: u16, - /// The maximum acceptable deviation from the route line, in meters. - /// - /// If the distance between the reported location and the expected route line - /// is greater than this threshold, it will be flagged as an off route condition. - max_acceptable_deviation: f64, - /// The threshold for returning to on-route state, in meters. - /// - /// Must be less than or equal to `max_acceptable_deviation`. - /// This creates hysteresis to prevent oscillation between on/off route states. - /// For example, if `max_acceptable_deviation` is 50m and `on_route_threshold` is 40m, - /// the user must deviate more than 50m to trigger off-route, but must return within - /// 40m to be considered back on route. - /// - /// If not specified or equal to `max_acceptable_deviation`, no hysteresis is applied. - on_route_threshold: f64, - }, + StaticThreshold(StaticThresholdConfig), // TODO: Standard variants that account for mode of travel. For example, `DefaultFor(modeOfTravel: ModeOfTravel)` with sensible defaults for walking, driving, cycling, etc. /// An arbitrary user-defined implementation. /// You decide with your own [`RouteDeviationDetector`] implementation! @@ -87,11 +181,7 @@ impl RouteDeviationTracking { ) -> RouteDeviation { match self { RouteDeviationTracking::None => RouteDeviation::NoDeviation, - RouteDeviationTracking::StaticThreshold { - minimum_horizontal_accuracy, - max_acceptable_deviation, - on_route_threshold, - } => match trip_state { + RouteDeviationTracking::StaticThreshold(config) => match trip_state { TripState::Idle { .. } | TripState::Complete { .. } => RouteDeviation::NoDeviation, TripState::Navigating { user_location, @@ -99,14 +189,16 @@ impl RouteDeviationTracking { deviation, .. } => { - if user_location.horizontal_accuracy > f64::from(*minimum_horizontal_accuracy) { + if user_location.horizontal_accuracy + > f64::from(config.minimum_horizontal_accuracy) + { return RouteDeviation::NoDeviation; } // Choose threshold based on current state (hysteresis) let threshold = match deviation { - RouteDeviation::NoDeviation => *max_acceptable_deviation, - RouteDeviation::OffRoute { .. } => *on_route_threshold, + RouteDeviation::NoDeviation => config.max_acceptable_deviation, + RouteDeviation::OffRoute { .. } => config.on_route_threshold(), }; let mut first_step_deviation = None; @@ -115,7 +207,6 @@ impl RouteDeviationTracking { let step_deviation = self.static_threshold_deviation_from_line( &Point::from(*user_location), &step.get_linestring(), - // Note: This is always <= max_acceptable_deviation threshold, ); @@ -419,11 +510,12 @@ proptest! { horizontal_accuracy: f64, max_acceptable_deviation in 0f64.., ) { - let tracking = RouteDeviationTracking::StaticThreshold { + let config = StaticThresholdConfig::new( minimum_horizontal_accuracy, max_acceptable_deviation, - on_route_threshold: max_acceptable_deviation - }; + 0.0, // no hysteresis for this test + ).unwrap(); + let tracking = RouteDeviationTracking::StaticThreshold(config); let current_route_step = gen_dummy_route_step(x1, y1, x2, y2); let route = gen_route_from_steps(vec![current_route_step.clone()]); @@ -494,13 +586,14 @@ proptest! { x2 in -180f64..=180f64, y2 in -90f64..=90f64, x3 in -180f64..=180f64, y3 in -90f64..=90f64, horizontal_accuracy in 1u16.., - max_acceptable_deviation: f64, + max_acceptable_deviation in 0f64.., ) { - let tracking = RouteDeviationTracking::StaticThreshold { - minimum_horizontal_accuracy: horizontal_accuracy - 1, + let config = StaticThresholdConfig::new( + horizontal_accuracy - 1, max_acceptable_deviation, - on_route_threshold: max_acceptable_deviation - }; + 0.0, // no hysteresis for this test + ).unwrap(); + let tracking = RouteDeviationTracking::StaticThreshold(config); let current_route_step = gen_dummy_route_step(x1, y1, x2, y2); let route = gen_route_from_steps(vec![current_route_step.clone()]); @@ -538,7 +631,9 @@ proptest! { fn static_threshold_hysteresis_prevents_oscillation() { use crate::{ models::{GeographicCoordinate, UserLocation}, - navigation_controller::test_helpers::{gen_dummy_route_step, gen_route_from_steps, get_navigating_trip_state}, + navigation_controller::test_helpers::{ + gen_dummy_route_step, gen_route_from_steps, get_navigating_trip_state, + }, }; #[cfg(feature = "std")] @@ -547,13 +642,15 @@ fn static_threshold_hysteresis_prevents_oscillation() { use web_time::SystemTime; let max_deviation = 50.0; - let on_route_threshold = 40.0; + let return_buffer = 10.0; // on_route_threshold will be 50 - 10 = 40m - let tracking = RouteDeviationTracking::StaticThreshold { - minimum_horizontal_accuracy: 100, - max_acceptable_deviation: max_deviation, - on_route_threshold, - }; + let config = StaticThresholdConfig::new( + 100, // minimum_horizontal_accuracy + max_deviation, + return_buffer, + ) + .unwrap(); + let tracking = RouteDeviationTracking::StaticThreshold(config); // Create a simple route step from (0, 0) to (0, 0.001) (~111 meters north) let current_route_step = gen_dummy_route_step(0.0, 0.0, 0.0, 0.001); @@ -584,7 +681,10 @@ fn static_threshold_hysteresis_prevents_oscillation() { // Move 45m away from route (between on_route_threshold and max_deviation) // At this distance, should still be on-route since we started on-route let user_location_45m = UserLocation { - coordinates: GeographicCoordinate { lng: 0.0004, lat: 0.0 }, // ~45m east + coordinates: GeographicCoordinate { + lng: 0.0004, + lat: 0.0, + }, // ~45m east horizontal_accuracy: 5.0, course_over_ground: None, timestamp: SystemTime::now(), @@ -607,7 +707,10 @@ fn static_threshold_hysteresis_prevents_oscillation() { // Move 55m away from route (beyond max_deviation) // Should trigger off-route let user_location_55m = UserLocation { - coordinates: GeographicCoordinate { lng: 0.0005, lat: 0.0 }, // ~55m east + coordinates: GeographicCoordinate { + lng: 0.0005, + lat: 0.0, + }, // ~55m east horizontal_accuracy: 5.0, course_over_ground: None, timestamp: SystemTime::now(), @@ -631,17 +734,25 @@ fn static_threshold_hysteresis_prevents_oscillation() { user_location_45m.clone(), vec![current_route_step.clone()], vec![], - RouteDeviation::OffRoute { deviation_from_route_line: 55.0 }, // Was off-route + RouteDeviation::OffRoute { + deviation_from_route_line: 55.0, + }, // Was off-route ); // Should remain off-route because 45m > on_route_threshold (40m) let deviation_result_2 = tracking.check_route_deviation(&route, &trip_state_45m_from_offroute); - assert!(matches!(deviation_result_2, RouteDeviation::OffRoute { .. })); + assert!(matches!( + deviation_result_2, + RouteDeviation::OffRoute { .. } + )); // Move to 35m (below on_route_threshold) // Should return to on-route let user_location_35m = UserLocation { - coordinates: GeographicCoordinate { lng: 0.00031, lat: 0.0 }, // ~35m east + coordinates: GeographicCoordinate { + lng: 0.00031, + lat: 0.0, + }, // ~35m east horizontal_accuracy: 5.0, course_over_ground: None, timestamp: SystemTime::now(), @@ -652,7 +763,9 @@ fn static_threshold_hysteresis_prevents_oscillation() { user_location_35m.clone(), vec![current_route_step.clone()], vec![], - RouteDeviation::OffRoute { deviation_from_route_line: 45.0 }, // Was off-route + RouteDeviation::OffRoute { + deviation_from_route_line: 45.0, + }, // Was off-route ); // Should be back on-route because 35m <= on_route_threshold (40m) diff --git a/common/ferrostar/src/navigation_controller/test_helpers.rs b/common/ferrostar/src/navigation_controller/test_helpers.rs index 325ccc78..39776398 100644 --- a/common/ferrostar/src/navigation_controller/test_helpers.rs +++ b/common/ferrostar/src/navigation_controller/test_helpers.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use crate::deviation_detection::RouteDeviation; use crate::deviation_detection::RouteDeviationTracking; +use crate::deviation_detection::StaticThresholdConfig; use crate::models::{ BoundingBox, GeographicCoordinate, Route, RouteStep, UserLocation, Waypoint, WaypointKind, }; @@ -26,11 +27,14 @@ pub fn get_test_navigation_controller_config( // Careful setup: if the user is ever off the route // (ex: because of an improper automatic step advance), // we want to know about it. - route_deviation_tracking: RouteDeviationTracking::StaticThreshold { - minimum_horizontal_accuracy: 0, - max_acceptable_deviation: 0.0, - on_route_threshold: 0.0, - }, + route_deviation_tracking: RouteDeviationTracking::StaticThreshold( + StaticThresholdConfig::new( + 0, // minimum_horizontal_accuracy + 0.0, // max_acceptable_deviation + 0.0, // return_buffer (no hysteresis) + ) + .expect("Valid static threshold config for tests"), + ), snapped_location_course_filtering: CourseFiltering::Raw, step_advance_condition, arrival_step_advance_condition: Arc::new(DistanceToEndOfStepCondition { diff --git a/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap b/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap index 6ee2ae21..b57a6368 100644 --- a/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap +++ b/common/ferrostar/src/navigation_session/recording/snapshots/ferrostar__navigation_session__recording__tests__recording_serialization.snap @@ -11,7 +11,7 @@ config: StaticThreshold: max_acceptable_deviation: 0 minimum_horizontal_accuracy: 0 - on_route_threshold: 0 + return_buffer: 0 snapped_location_course_filtering: Raw step_advance_condition: DistanceToEndOfStep: