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 965b0f4f..f1a9d743 100644 --- a/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift +++ b/apple/Sources/FerrostarCore/FFIWrappers/RouteDeviationWrapper.swift @@ -18,7 +18,18 @@ private final class DetectorImpl: RouteDeviationDetector { public enum SwiftRouteDeviationTracking { case none - case staticThreshold(minimumHorizontalAccuracy: UInt16, maxAcceptableDeviation: Double) + /// 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, + returnBuffer: Double? = nil + ) case custom(detector: @Sendable (Route, TripState) -> RouteDeviation) @@ -28,11 +39,15 @@ public enum SwiftRouteDeviationTracking { .none case let .staticThreshold( minimumHorizontalAccuracy: minimumHorizontalAccuracy, - maxAcceptableDeviation: maxAcceptableDeviation + maxAcceptableDeviation: maxAcceptableDeviation, + returnBuffer: returnBuffer ): .staticThreshold( - minimumHorizontalAccuracy: minimumHorizontalAccuracy, - maxAcceptableDeviation: 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 4a68f2a8..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,17 +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 + case staticThreshold(StaticThresholdConfig ) /** * An arbitrary user-defined implementation. @@ -7955,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) + case 2: return .staticThreshold(try FfiConverterTypeStaticThresholdConfig.read(from: &buf) ) case 3: return .custom(detector: try FfiConverterTypeRouteDeviationDetector.read(from: &buf) @@ -7973,10 +8069,9 @@ public struct FfiConverterTypeRouteDeviationTracking: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) - case let .staticThreshold(minimumHorizontalAccuracy,maxAcceptableDeviation): + case let .staticThreshold(v1): writeInt(&buf, Int32(2)) - FfiConverterUInt16.write(minimumHorizontalAccuracy, into: &buf) - FfiConverterDouble.write(maxAcceptableDeviation, into: &buf) + FfiConverterTypeStaticThresholdConfig.write(v1, into: &buf) case let .custom(detector): @@ -8371,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 99cd81f9..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,17 +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, - }, + 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! @@ -77,27 +181,33 @@ impl RouteDeviationTracking { ) -> RouteDeviation { match self { RouteDeviationTracking::None => RouteDeviation::NoDeviation, - RouteDeviationTracking::StaticThreshold { - minimum_horizontal_accuracy, - max_acceptable_deviation, - } => match trip_state { + RouteDeviationTracking::StaticThreshold(config) => 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) { + 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 => config.max_acceptable_deviation, + RouteDeviation::OffRoute { .. } => config.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 { @@ -400,10 +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 - }; + 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()]); @@ -474,12 +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, - max_acceptable_deviation - }; + let config = StaticThresholdConfig::new( + horizontal_accuracy - 1, + 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()]); @@ -506,3 +620,157 @@ 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 return_buffer = 10.0; // on_route_threshold will be 50 - 10 = 40m + + 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); + 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..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,10 +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, - }, + 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 99c81fd5..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,6 +11,7 @@ config: StaticThreshold: max_acceptable_deviation: 0 minimum_horizontal_accuracy: 0 + return_buffer: 0 snapped_location_course_filtering: Raw step_advance_condition: DistanceToEndOfStep: