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_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
59impl GnssPosition {
60    /// Create a new GNSS position with validation
61    pub fn new(
62        latitude: f64,
63        longitude: f64,
64        timestamp: DateTime<FixedOffset>,
65        crs: String,
66    ) -> Result<Self, ProjectionError> {
67        let position = Self {
68            latitude,
69            longitude,
70            timestamp,
71            crs,
72            metadata: HashMap::new(),
73        };
74
75        position.validate()?;
76        Ok(position)
77    }
78
79    /// Validate latitude range
80    pub fn validate_latitude(&self) -> Result<(), ProjectionError> {
81        if self.latitude < -90.0 || self.latitude > 90.0 {
82            return Err(ProjectionError::InvalidCoordinate(format!(
83                "Latitude {} out of range [-90, 90]",
84                self.latitude
85            )));
86        }
87        Ok(())
88    }
89
90    /// Validate longitude range
91    pub fn validate_longitude(&self) -> Result<(), ProjectionError> {
92        if self.longitude < -180.0 || self.longitude > 180.0 {
93            return Err(ProjectionError::InvalidCoordinate(format!(
94                "Longitude {} out of range [-180, 180]",
95                self.longitude
96            )));
97        }
98        Ok(())
99    }
100
101    /// Validate timezone is present (type-level guarantee with `DateTime<FixedOffset>`)
102    pub fn validate_timezone(&self) -> Result<(), ProjectionError> {
103        // DateTime<FixedOffset> always has timezone information
104        // This function exists for API completeness
105        Ok(())
106    }
107
108    /// Validate all fields
109    fn validate(&self) -> Result<(), ProjectionError> {
110        self.validate_latitude()?;
111        self.validate_longitude()?;
112        self.validate_timezone()?;
113
114        // Validate CRS format (basic check)
115        if self.crs.is_empty() {
116            return Err(ProjectionError::InvalidCrs(
117                "CRS must not be empty".to_string(),
118            ));
119        }
120
121        Ok(())
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use chrono::TimeZone;
129
130    #[test]
131    fn test_valid_position() {
132        let timestamp = FixedOffset::east_opt(3600)
133            .unwrap()
134            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
135            .unwrap();
136
137        let pos = GnssPosition::new(50.8503, 4.3517, timestamp, "EPSG:4326".to_string());
138
139        assert!(pos.is_ok());
140    }
141
142    #[test]
143    fn test_invalid_latitude() {
144        let timestamp = FixedOffset::east_opt(3600)
145            .unwrap()
146            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
147            .unwrap();
148
149        let pos = GnssPosition::new(
150            91.0, // Invalid
151            4.3517,
152            timestamp,
153            "EPSG:4326".to_string(),
154        );
155
156        assert!(pos.is_err());
157    }
158
159    #[test]
160    fn test_invalid_longitude() {
161        let timestamp = FixedOffset::east_opt(3600)
162            .unwrap()
163            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
164            .unwrap();
165
166        let pos = GnssPosition::new(
167            50.8503,
168            181.0, // Invalid
169            timestamp,
170            "EPSG:4326".to_string(),
171        );
172
173        assert!(pos.is_err());
174    }
175}