Skip to main content

tp_lib_core/models/
gnss.rs

1//! GNSS Position data model
2
3use crate::errors::ProjectionError;
4use chrono::{DateTime, FixedOffset};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Represents a single GNSS measurement from a train journey
9///
10/// Each `GnssPosition` captures a timestamped geographic location with explicit
11/// coordinate reference system (CRS) information. Additional metadata can be
12/// preserved for audit trails and debugging.
13///
14/// # Validation
15///
16/// - Latitude must be in range [-90.0, 90.0]
17/// - Longitude must be in range [-180.0, 180.0]
18/// - Timestamp must include timezone information (RFC3339 format)
19///
20/// # Examples
21///
22/// ```
23/// use tp_lib_core::GnssPosition;
24/// use chrono::{DateTime, FixedOffset};
25///
26/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
27/// let timestamp = DateTime::parse_from_rfc3339("2025-12-09T14:30:00+01:00")?;
28///
29/// let position = GnssPosition::new(
30///     50.8503,  // latitude
31///     4.3517,   // longitude
32///     timestamp,
33///     "EPSG:4326".to_string(),
34/// )?;
35///
36/// assert_eq!(position.latitude, 50.8503);
37/// assert_eq!(position.crs, "EPSG:4326");
38/// # Ok(())
39/// # }
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct GnssPosition {
43    /// Latitude in decimal degrees (-90.0 to 90.0)
44    pub latitude: f64,
45
46    /// Longitude in decimal degrees (-180.0 to 180.0)
47    pub longitude: f64,
48
49    /// Timestamp with timezone offset (e.g., 2025-12-09T14:30:00+01:00)
50    pub timestamp: DateTime<FixedOffset>,
51
52    /// Coordinate Reference System (e.g., "EPSG:4326" for WGS84)
53    pub crs: String,
54
55    /// Additional metadata from CSV (preserved for output)
56    pub metadata: HashMap<String, String>,
57
58    /// Train heading in degrees (0-360), None if not available
59    /// 0° = North, 90° = East, 180° = South, 270° = West
60    pub heading: Option<f64>,
61
62    /// Distance from previous GNSS position in meters, None if not available or first position
63    pub distance: Option<f64>,
64}
65
66impl GnssPosition {
67    /// Create a new GNSS position with validation
68    pub fn new(
69        latitude: f64,
70        longitude: f64,
71        timestamp: DateTime<FixedOffset>,
72        crs: String,
73    ) -> Result<Self, ProjectionError> {
74        let position = Self {
75            latitude,
76            longitude,
77            timestamp,
78            crs,
79            metadata: HashMap::new(),
80            heading: None,
81            distance: None,
82        };
83
84        position.validate()?;
85        Ok(position)
86    }
87
88    /// Create a new GNSS position with optional heading and distance
89    pub fn with_heading_distance(
90        latitude: f64,
91        longitude: f64,
92        timestamp: DateTime<FixedOffset>,
93        crs: String,
94        heading: Option<f64>,
95        distance: Option<f64>,
96    ) -> Result<Self, ProjectionError> {
97        let position = Self {
98            latitude,
99            longitude,
100            timestamp,
101            crs,
102            metadata: HashMap::new(),
103            heading,
104            distance,
105        };
106
107        position.validate()?;
108        position.validate_heading()?;
109        Ok(position)
110    }
111
112    /// Validate heading if present (must be 0-360°)
113    pub fn validate_heading(&self) -> Result<(), ProjectionError> {
114        if let Some(heading) = self.heading {
115            if !(0.0..=360.0).contains(&heading) {
116                return Err(ProjectionError::InvalidGeometry(format!(
117                    "Heading must be in range [0, 360], got {}",
118                    heading
119                )));
120            }
121        }
122        Ok(())
123    }
124
125    /// Check if two headings are opposite
126    /// Returns true if headings are closer to 180° apart than to 0° apart
127    ///
128    /// Logic: Compare distance to 180° shift vs normal distance
129    /// If shifting by 180° gives smaller circular distance, they're opposite
130    pub fn is_opposite_heading(h1: f64, h2: f64) -> bool {
131        // Calculate normal circular distance
132        let diff_normal = (h1 - h2).abs();
133        let dist_normal = diff_normal.min(360.0 - diff_normal);
134
135        // Calculate distance when one heading is shifted by 180°
136        let diff_shifted = (h1 - h2 - 180.0).abs() % 360.0;
137        let dist_shifted = diff_shifted.min(360.0 - diff_shifted);
138
139        // If shifted distance is smaller, they're opposite
140        dist_shifted < dist_normal
141    }
142
143    /// Calculate angular difference between two headings
144    /// Accounts for circular nature of compass bearings
145    /// Accounts for possible opposite headings (180° apart)
146    pub fn heading_difference(h1: f64, h2: f64) -> f64 {
147        // Check if headings are opposite
148        if Self::is_opposite_heading(h1, h2) {
149            // Opposite headings: return the small angular deviation from exactly 180°
150            let diff_shifted = (h1 - h2 - 180.0).abs() % 360.0;
151            diff_shifted.min(360.0 - diff_shifted)
152        } else {
153            // Not opposite: return normal circular distance
154            let diff = (h1 - h2).abs();
155            diff.min(360.0 - diff)
156        }
157    }
158
159    /// Validate latitude range
160    pub fn validate_latitude(&self) -> Result<(), ProjectionError> {
161        if self.latitude < -90.0 || self.latitude > 90.0 {
162            return Err(ProjectionError::InvalidCoordinate(format!(
163                "Latitude {} out of range [-90, 90]",
164                self.latitude
165            )));
166        }
167        Ok(())
168    }
169
170    /// Validate longitude range
171    pub fn validate_longitude(&self) -> Result<(), ProjectionError> {
172        if self.longitude < -180.0 || self.longitude > 180.0 {
173            return Err(ProjectionError::InvalidCoordinate(format!(
174                "Longitude {} out of range [-180, 180]",
175                self.longitude
176            )));
177        }
178        Ok(())
179    }
180
181    /// Validate timezone is present (type-level guarantee with `DateTime<FixedOffset>`)
182    pub fn validate_timezone(&self) -> Result<(), ProjectionError> {
183        // DateTime<FixedOffset> always has timezone information
184        // This function exists for API completeness
185        Ok(())
186    }
187
188    /// Validate all fields
189    fn validate(&self) -> Result<(), ProjectionError> {
190        self.validate_latitude()?;
191        self.validate_longitude()?;
192        self.validate_timezone()?;
193
194        // Validate CRS format (basic check)
195        if self.crs.is_empty() {
196            return Err(ProjectionError::InvalidCrs(
197                "CRS must not be empty".to_string(),
198            ));
199        }
200
201        Ok(())
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use chrono::TimeZone;
209
210    #[test]
211    fn test_valid_position() {
212        let timestamp = FixedOffset::east_opt(3600)
213            .unwrap()
214            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
215            .unwrap();
216
217        let pos = GnssPosition::new(50.8503, 4.3517, timestamp, "EPSG:4326".to_string());
218
219        assert!(pos.is_ok());
220    }
221
222    #[test]
223    fn test_invalid_latitude() {
224        let timestamp = FixedOffset::east_opt(3600)
225            .unwrap()
226            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
227            .unwrap();
228
229        let pos = GnssPosition::new(
230            91.0, // Invalid
231            4.3517,
232            timestamp,
233            "EPSG:4326".to_string(),
234        );
235
236        assert!(pos.is_err());
237    }
238
239    #[test]
240    fn test_invalid_longitude() {
241        let timestamp = FixedOffset::east_opt(3600)
242            .unwrap()
243            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
244            .unwrap();
245
246        let pos = GnssPosition::new(
247            50.8503,
248            181.0, // Invalid
249            timestamp,
250            "EPSG:4326".to_string(),
251        );
252
253        assert!(pos.is_err());
254    }
255}