tp_lib_core/models/
gnss.rs1use crate::errors::ProjectionError;
4use chrono::{DateTime, FixedOffset};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct GnssPosition {
43 pub latitude: f64,
45
46 pub longitude: f64,
48
49 pub timestamp: DateTime<FixedOffset>,
51
52 pub crs: String,
54
55 pub metadata: HashMap<String, String>,
57
58 pub heading: Option<f64>,
61
62 pub distance: Option<f64>,
64}
65
66impl GnssPosition {
67 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 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 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 pub fn is_opposite_heading(h1: f64, h2: f64) -> bool {
131 let diff_normal = (h1 - h2).abs();
133 let dist_normal = diff_normal.min(360.0 - diff_normal);
134
135 let diff_shifted = (h1 - h2 - 180.0).abs() % 360.0;
137 let dist_shifted = diff_shifted.min(360.0 - diff_shifted);
138
139 dist_shifted < dist_normal
141 }
142
143 pub fn heading_difference(h1: f64, h2: f64) -> f64 {
147 if Self::is_opposite_heading(h1, h2) {
149 let diff_shifted = (h1 - h2 - 180.0).abs() % 360.0;
151 diff_shifted.min(360.0 - diff_shifted)
152 } else {
153 let diff = (h1 - h2).abs();
155 diff.min(360.0 - diff)
156 }
157 }
158
159 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 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 pub fn validate_timezone(&self) -> Result<(), ProjectionError> {
183 Ok(())
186 }
187
188 fn validate(&self) -> Result<(), ProjectionError> {
190 self.validate_latitude()?;
191 self.validate_longitude()?;
192 self.validate_timezone()?;
193
194 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, 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, timestamp,
250 "EPSG:4326".to_string(),
251 );
252
253 assert!(pos.is_err());
254 }
255}