ferrostar/
simulation.rs

1//! Tools for simulating progress along a route.
2//!
3//! # Example
4//!
5//! Here's an example usage with the polyline constructor.
6//! This can serve as a template for writing your own test code.
7//! You may also get some inspiration from the [Swift](https://github.com/stadiamaps/ferrostar/blob/main/apple/Sources/FerrostarCore/Location.swift)
8//! or [Kotlin](https://github.com/stadiamaps/ferrostar/blob/main/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt)
9//! `SimulatedLocationProvider` implementations which wrap this.
10//!
11//! ```
12//! use ferrostar::simulation::{advance_location_simulation, location_simulation_from_polyline, LocationBias};
13//! # use std::error::Error;
14//! # fn main() -> Result<(), Box<dyn Error>> {
15//!
16//! let polyline_precision = 6;
17//! // Build the initial state from an encoded polyline.
18//! // You can create a simulation from coordinates or even a [Route] as well.
19//! let mut state = location_simulation_from_polyline(
20//!     "wzvmrBxalf|GcCrX}A|Nu@jI}@pMkBtZ{@x^_Afj@Inn@`@veB",
21//!     polyline_precision,
22//!     // Passing `Some(number)` will resample your polyline at uniform distances.
23//!     // This is often desirable to create a smooth simulated movement when you don't have a GPS trace.
24//!     None,
25//!     LocationBias::None,
26//! )?;
27//!
28//! loop {
29//!     let mut new_state = advance_location_simulation(&state);
30//!     if new_state == state {
31//!         // When the simulation reaches the end, it keeps yielding the input state.
32//!         break;
33//!     }
34//!     state = new_state;
35//!     // Do something; maybe sleep for some period of time until the next timestamp?
36//! }
37//! #
38//! # Ok(())
39//! # }
40//! ```
41
42use crate::algorithms::trunc_float;
43use crate::models::{CourseOverGround, GeographicCoordinate, Route, UserLocation};
44use geo::{coord, Bearing, Densify, Geodesic, Haversine, LineString, Point};
45use polyline::decode_polyline;
46
47#[cfg(any(test, feature = "wasm-bindgen"))]
48use serde::{Deserialize, Serialize};
49
50#[cfg(feature = "wasm-bindgen")]
51use wasm_bindgen::{prelude::*, JsValue};
52
53#[cfg(feature = "wasm-bindgen")]
54use tsify::Tsify;
55
56#[cfg(all(feature = "std", not(feature = "web-time")))]
57use std::time::SystemTime;
58
59#[cfg(feature = "web-time")]
60use web_time::SystemTime;
61
62#[cfg(feature = "alloc")]
63use alloc::{
64    string::{String, ToString},
65    vec::Vec,
66};
67
68#[derive(Debug)]
69#[cfg_attr(feature = "std", derive(thiserror::Error))]
70#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
71#[cfg_attr(feature = "wasm-bindgen", derive(Serialize, Deserialize, Tsify))]
72#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
73pub enum SimulationError {
74    #[cfg_attr(feature = "std", error("Failed to parse polyline: {error}."))]
75    /// Errors decoding the polyline string.
76    PolylineError { error: String },
77    #[cfg_attr(feature = "std", error("Not enough points (expected at least two)."))]
78    /// Not enough points in the input.
79    NotEnoughPoints,
80}
81
82/// Controls how simulated locations deviate from the actual route line.
83/// This simulates real-world GPS behavior where readings often have systematic bias.
84#[derive(Clone, PartialEq, Debug)]
85#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
86#[cfg_attr(any(feature = "wasm-bindgen", test), derive(Serialize, Deserialize))]
87#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
88#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
89pub enum LocationBias {
90    /// Simulates GPS bias by offsetting locations to the left of the route direction.
91    /// The f64 parameter specifies the offset distance in meters.
92    Left(f64),
93
94    /// Simulates GPS bias by offsetting locations to the right of the route direction.
95    /// The f64 parameter specifies the offset distance in meters.
96    Right(f64),
97
98    /// Simulates GPS bias by randomly choosing left or right offset on initialization
99    /// and maintaining that bias throughout the route.
100    /// The f64 parameter specifies the offset distance in meters.
101    ///
102    /// This mimics real-world GPS behavior where bias direction is random but typically
103    /// remains consistent during a trip.
104    Random(f64),
105
106    /// No position bias - locations follow the route line exactly.
107    ///
108    /// This provides "perfect" GPS behavior, useful for testing basic route following
109    /// without position uncertainty.
110    None,
111}
112
113/// The current state of the simulation.
114#[derive(Clone, PartialEq)]
115#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
116#[cfg_attr(any(feature = "wasm-bindgen", test), derive(Serialize, Deserialize))]
117#[cfg_attr(feature = "wasm-bindgen", derive(Tsify))]
118#[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))]
119pub struct LocationSimulationState {
120    pub current_location: UserLocation,
121    remaining_locations: Vec<GeographicCoordinate>,
122    bias: LocationBias,
123}
124
125/// Creates a location simulation from a set of coordinates.
126///
127/// Optionally resamples the input line so that there is a maximum distance between points.
128#[cfg_attr(feature = "uniffi", uniffi::export)]
129pub fn location_simulation_from_coordinates(
130    coordinates: &[GeographicCoordinate],
131    resample_distance: Option<f64>,
132    bias: LocationBias,
133) -> Result<LocationSimulationState, SimulationError> {
134    if let Some((current, rest)) = coordinates.split_first() {
135        if let Some(next) = rest.first() {
136            let (jittered_current, bearing) = add_lateral_offset(*current, *next, &bias);
137
138            let accuracy = match bias {
139                LocationBias::None => 0.0,
140                LocationBias::Left(m) | LocationBias::Right(m) | LocationBias::Random(m) => m,
141            };
142
143            let current_location = UserLocation {
144                coordinates: jittered_current,
145                horizontal_accuracy: accuracy,
146                course_over_ground: Some(CourseOverGround::new(bearing, Some(5))),
147                timestamp: SystemTime::now(),
148                speed: None,
149            };
150            let remaining_locations = if let Some(distance) = resample_distance {
151                // Interpolate so that there are no points further apart than the resample distance.
152                let coords: Vec<_> = rest
153                    .iter()
154                    .map(|coord| {
155                        coord! {
156                            x: coord.lng,
157                            y: coord.lat
158                        }
159                    })
160                    .collect();
161                let linestring: LineString = coords.into();
162                let densified_linestring = Haversine.densify(&linestring, distance);
163                densified_linestring
164                    .points()
165                    .map(|point| GeographicCoordinate {
166                        // We truncate the value to 6 digits of precision
167                        // in line with standard navigation API practice.
168                        // Nobody needs precision beyond this point,
169                        // and it makes testing very annoying.
170                        lat: trunc_float(point.y(), 6),
171                        lng: trunc_float(point.x(), 6),
172                    })
173                    .collect()
174            } else {
175                Vec::from(rest)
176            };
177
178            Ok(LocationSimulationState {
179                current_location,
180                remaining_locations,
181                bias,
182            })
183        } else {
184            Err(SimulationError::NotEnoughPoints)
185        }
186    } else {
187        Err(SimulationError::NotEnoughPoints)
188    }
189}
190
191/// Creates a location simulation from a route.
192///
193/// Optionally resamples the route geometry so that there is no more than the specified maximum distance between points.
194#[cfg_attr(feature = "uniffi", uniffi::export)]
195pub fn location_simulation_from_route(
196    route: &Route,
197    resample_distance: Option<f64>,
198    bias: LocationBias,
199) -> Result<LocationSimulationState, SimulationError> {
200    // This function is purely a convenience for now,
201    // but we eventually expand the simulation to be aware of route timing
202    location_simulation_from_coordinates(&route.geometry, resample_distance, bias)
203}
204
205/// Creates a location simulation from a polyline.
206///
207/// Optionally resamples the input line so that there is no more than the specified maximum distance between points.
208#[cfg_attr(feature = "uniffi", uniffi::export)]
209pub fn location_simulation_from_polyline(
210    polyline: &str,
211    precision: u32,
212    resample_distance: Option<f64>,
213    bias: LocationBias,
214) -> Result<LocationSimulationState, SimulationError> {
215    let linestring =
216        decode_polyline(polyline, precision).map_err(|error| SimulationError::PolylineError {
217            error: error.to_string(),
218        })?;
219    let coordinates: Vec<_> = linestring
220        .coords()
221        .map(|c| GeographicCoordinate::from(*c))
222        .collect();
223    location_simulation_from_coordinates(&coordinates, resample_distance, bias)
224}
225
226fn add_lateral_offset(
227    current: GeographicCoordinate,
228    next: GeographicCoordinate,
229    bias: &LocationBias,
230) -> (GeographicCoordinate, f64) {
231    let current_point = Point::from(current);
232    let next_point = Point::from(next);
233    let bearing = Geodesic.bearing(current_point, next_point);
234
235    match bias {
236        LocationBias::None => (current, bearing),
237        LocationBias::Left(meters) | LocationBias::Right(meters) | LocationBias::Random(meters) => {
238            let sign = match bias {
239                LocationBias::Left(_) => -1.0,
240                LocationBias::Right(_) => 1.0,
241                LocationBias::Random(_) => {
242                    if rand::random() {
243                        1.0
244                    } else {
245                        -1.0
246                    }
247                }
248                LocationBias::None => unreachable!(),
249            };
250
251            // calculate perpendicular bearing (±90° from bearing)
252            let lateral_bearing_rad = (bearing + sign * 90.0).to_radians();
253
254            // offset to approximate degrees
255            let offset_deg = meters / 111_111.0;
256
257            let lat_offset = offset_deg * lateral_bearing_rad.cos();
258            let lng_offset = offset_deg * lateral_bearing_rad.sin();
259
260            (
261                GeographicCoordinate {
262                    lat: current.lat + lat_offset,
263                    lng: current.lng + lng_offset,
264                },
265                bearing,
266            )
267        }
268    }
269}
270
271/// Returns the next simulation state based on the desired strategy.
272/// Results of this can be thought of like a stream from a generator function.
273///
274/// This function is intended to be called once/second.
275/// However, the caller may vary speed to purposefully replay at a faster rate
276/// (ex: calling 3x per second will be a triple speed simulation).
277///
278/// When there are now more locations to visit, returns the same state forever.
279#[cfg_attr(feature = "uniffi", uniffi::export)]
280pub fn advance_location_simulation(state: &LocationSimulationState) -> LocationSimulationState {
281    if let Some((next_coordinate, rest)) = state.remaining_locations.split_first() {
282        let (jittered_next, bearing) = add_lateral_offset(
283            *next_coordinate,
284            if let Some(future) = rest.first() {
285                *future
286            } else {
287                *next_coordinate
288            },
289            &state.bias,
290        );
291
292        let accuracy = match state.bias {
293            LocationBias::None => 0.0,
294            LocationBias::Left(m) | LocationBias::Right(m) | LocationBias::Random(m) => m,
295        };
296
297        let next_location = UserLocation {
298            coordinates: jittered_next,
299            horizontal_accuracy: accuracy,
300            course_over_ground: Some(CourseOverGround::new(bearing, Some(5))),
301            timestamp: SystemTime::now(),
302            speed: None,
303        };
304
305        LocationSimulationState {
306            current_location: next_location,
307            remaining_locations: Vec::from(rest),
308            bias: state.bias.clone(),
309        }
310    } else {
311        state.clone()
312    }
313}
314
315/// JavaScript wrapper for `location_simulation_from_coordinates`.
316#[cfg(feature = "wasm-bindgen")]
317#[wasm_bindgen(js_name = locationSimulationFromCoordinates)]
318pub fn js_location_simulation_from_coordinates(
319    coordinates: JsValue,
320    resample_distance: Option<f64>,
321    bias: LocationBias,
322) -> Result<JsValue, JsValue> {
323    let coordinates: Vec<GeographicCoordinate> = serde_wasm_bindgen::from_value(coordinates)
324        .map_err(|error| JsValue::from_str(&error.to_string()))?;
325
326    location_simulation_from_coordinates(&coordinates, resample_distance, bias)
327        .map(|state| serde_wasm_bindgen::to_value(&state).unwrap())
328        .map_err(|error| JsValue::from_str(&error.to_string()))
329}
330
331/// JavaScript wrapper for `location_simulation_from_route`.
332#[cfg(feature = "wasm-bindgen")]
333#[wasm_bindgen(js_name = locationSimulationFromRoute)]
334pub fn js_location_simulation_from_route(
335    route: JsValue,
336    resample_distance: Option<f64>,
337    bias: LocationBias,
338) -> Result<JsValue, JsValue> {
339    let route: Route = serde_wasm_bindgen::from_value(route)
340        .map_err(|error| JsValue::from_str(&error.to_string()))?;
341
342    location_simulation_from_route(&route, resample_distance, bias)
343        .map(|state| serde_wasm_bindgen::to_value(&state).unwrap())
344        .map_err(|error| JsValue::from_str(&error.to_string()))
345}
346
347/// JavaScript wrapper for `location_simulation_from_polyline`.
348#[cfg(feature = "wasm-bindgen")]
349#[wasm_bindgen(js_name = locationSimulationFromPolyline)]
350pub fn js_location_simulation_from_polyline(
351    polyline: &str,
352    precision: u32,
353    resample_distance: Option<f64>,
354    bias: LocationBias,
355) -> Result<JsValue, JsValue> {
356    location_simulation_from_polyline(polyline, precision, resample_distance, bias)
357        .map(|state| serde_wasm_bindgen::to_value(&state).unwrap())
358        .map_err(|error| JsValue::from_str(&error.to_string()))
359}
360
361/// JavaScript wrapper for `advance_location_simulation`.
362#[cfg(feature = "wasm-bindgen")]
363#[wasm_bindgen(js_name = advanceLocationSimulation)]
364pub fn js_advance_location_simulation(state: JsValue) -> JsValue {
365    let state: LocationSimulationState = serde_wasm_bindgen::from_value(state).unwrap();
366    let new_state = advance_location_simulation(&state);
367    serde_wasm_bindgen::to_value(&new_state).unwrap()
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::algorithms::snap_user_location_to_line;
374    use geo::{Distance, Haversine};
375    use rstest::rstest;
376
377    #[rstest]
378    #[case(None)]
379    #[case(Some(10.0))]
380    fn advance_to_next_location(#[case] resample_distance: Option<f64>) {
381        let mut state = location_simulation_from_coordinates(
382            &[
383                GeographicCoordinate { lng: 0.0, lat: 0.0 },
384                GeographicCoordinate {
385                    lng: 0.0001,
386                    lat: 0.0001,
387                },
388                GeographicCoordinate {
389                    lng: 0.0002,
390                    lat: 0.0002,
391                },
392                GeographicCoordinate {
393                    lng: 0.0003,
394                    lat: 0.0003,
395                },
396            ],
397            resample_distance,
398            LocationBias::None,
399        )
400        .expect("Unable to initialize simulation");
401
402        // Loop until state no longer changes
403        let mut states = vec![state.clone()];
404        loop {
405            let new_state = advance_location_simulation(&state);
406            if new_state == state {
407                break;
408            }
409            state = new_state;
410            states.push(state.clone());
411        }
412
413        insta::assert_yaml_snapshot!(format!("{:?}", resample_distance), states);
414    }
415
416    #[test]
417    fn state_from_polyline() {
418        let state = location_simulation_from_polyline(
419            "wzvmrBxalf|GcCrX}A|Nu@jI}@pMkBtZ{@x^_Afj@Inn@`@veB",
420            6,
421            None,
422            LocationBias::None,
423        )
424        .expect("Unable to parse polyline");
425        insta::assert_yaml_snapshot!(state);
426    }
427
428    #[test]
429    fn test_extended_interpolation_simulation() {
430        let polyline = r#"umrefAzifwgF?yJf@?|C@?sJ?iL@_BBqD@cDzh@L|@?jBuDjCCl@u@^f@nB?|ABd@s@r@_AAiBBiC@kAlAHrEQ|F@pCNpA?pAAfB?~CkAtXsGRXlDw@rCo@jBc@SwAKoDr@}GLyAJ}AEs@]qBs@gE_@qC?aBBqAVkBZwBLmAFcBG_DOuB?}A^wAjA}Av@eBJoAAyA[sBbCUhAEIoCdAaCd@{@Fer@@ae@?aD?o[Ny@Vk@Sg@C_FCcDT[S_@Ow@F}oCXoAVe@_@e@?mE?cDNm@Og@Ok@Ck^N_BRu@a@OJqFFyDV[a@kAIkSLcF|AgNb@{@U_@JaEN}ETW[cA\_TbAkm@P_H\sE`AgFrCkKlAuGrEo\n@_B|@[~sBa@pAc@|AAh`Aa@jGEnGCrh@AfiAAjAx@TW`DO|CK\mEZ?~LBzBA|_@GtA?zPGlKQ?op@?uO@ggA?wE@uFEwXEyOCeFAkMAsKIot@?_FEoYAsI?yC?eH?}C?}GAy]Bux@Aog@AmKCmFC}YA}WVgBRu@vAaBlC{CxDCR?h@AhHQvGApDA|BAhHA`DC|GGzFDlM@jNA|J?bAkBtACvAArCClINfDdAfFGzW[|HI`FE@eMhHEt^KpJE"#;
431        let max_distance = 10.0;
432        let mut state =
433            location_simulation_from_polyline(polyline, 6, Some(max_distance), LocationBias::None)
434                .expect("Unable to create initial state");
435        let original_linestring = decode_polyline(polyline, 6).expect("Unable to decode polyline");
436
437        // Loop until state no longer changes
438        let mut states = vec![state.clone()];
439        loop {
440            let new_state = advance_location_simulation(&state);
441            if new_state == state {
442                break;
443            }
444
445            // The distance between each point in the simulation should be <= max_distance
446            let current_point: Point = state.current_location.into();
447            let next_point: Point = new_state.current_location.into();
448            let distance = Haversine.distance(current_point, next_point);
449            // I'm actually not 100% sure why this extra fudge is needed, but it's not a concern for today.
450            assert!(
451                distance <= max_distance + 7.0,
452                "Expected consecutive points to be <= {max_distance}m apart; was {distance}m"
453            );
454
455            let snapped =
456                snap_user_location_to_line(new_state.current_location, &original_linestring);
457            let snapped_point: Point = snapped.coordinates.into();
458            let distance = Haversine.distance(next_point, snapped_point);
459            assert!(
460                distance <= max_distance,
461                "Expected snapped point to be on the line; was {distance}m away"
462            );
463
464            state = new_state;
465            states.push(state.clone());
466        }
467
468        // Sanity check: the simulation finishes on the last point
469        assert_eq!(
470            state.current_location.coordinates,
471            original_linestring
472                .points()
473                .last()
474                .expect("Expected at least one point")
475                .into()
476        );
477        insta::assert_yaml_snapshot!(states);
478    }
479
480    #[rstest]
481    #[case(LocationBias::None)]
482    #[case(LocationBias::Left(4.0))]
483    #[case(LocationBias::Right(4.0))]
484    #[case(LocationBias::Random(4.0))]
485    fn test_location_bias(#[case] bias: LocationBias) {
486        let coordinates = vec![
487            GeographicCoordinate { lng: 0.0, lat: 0.0 },
488            GeographicCoordinate {
489                lng: 0.0001,
490                lat: 0.0001,
491            },
492            GeographicCoordinate {
493                lng: 0.0002,
494                lat: 0.0002,
495            },
496        ];
497
498        let state = location_simulation_from_coordinates(&coordinates, None, bias.clone())
499            .expect("Failed to create simulation");
500
501        if matches!(bias, LocationBias::None) {
502            assert_eq!(state.current_location.coordinates, coordinates[0]);
503            return;
504        }
505
506        let expected_meters = match bias {
507            LocationBias::Left(m) | LocationBias::Right(m) | LocationBias::Random(m) => m,
508            LocationBias::None => unreachable!(),
509        };
510
511        let original_point: Point = coordinates[0].into();
512        let offset_point: Point = state.current_location.coordinates.into();
513        let distance = Haversine.distance(original_point, offset_point);
514
515        assert!(
516            (distance - expected_meters).abs() < 0.1,
517            "Expected offset of {expected_meters}m but got {distance}m"
518        );
519    }
520
521    #[test]
522    fn test_bias_consistency() {
523        let coordinates = vec![
524            GeographicCoordinate { lng: 0.0, lat: 0.0 },
525            GeographicCoordinate {
526                lng: 0.0001,
527                lat: 0.0001,
528            },
529            GeographicCoordinate {
530                lng: 0.0002,
531                lat: 0.0002,
532            },
533            GeographicCoordinate {
534                lng: 0.0003,
535                lat: 0.0003,
536            },
537        ];
538
539        let mut state =
540            location_simulation_from_coordinates(&coordinates, None, LocationBias::Random(4.0))
541                .expect("Failed to create simulation");
542
543        let first_point: Point = state.current_location.coordinates.into();
544        let first_original: Point = coordinates[0].into();
545        let initial_distance = Haversine.distance(first_point, first_original);
546
547        while let Some((next, _)) = state.remaining_locations.split_first() {
548            let new_state = advance_location_simulation(&state);
549            if new_state == state {
550                break;
551            }
552
553            let current_point: Point = new_state.current_location.coordinates.into();
554            let original_point: Point = (*next).into();
555            let distance = Haversine.distance(current_point, original_point);
556
557            assert!(
558                (distance - initial_distance).abs() < 0.1,
559                "Bias distance changed from {initial_distance}m to {distance}m"
560            );
561
562            state = new_state;
563        }
564    }
565}