use crate::errors::ProjectionError;
use chrono::{DateTime, FixedOffset};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GnssPosition {
pub latitude: f64,
pub longitude: f64,
pub timestamp: DateTime<FixedOffset>,
pub crs: String,
pub metadata: HashMap<String, String>,
pub heading: Option<f64>,
pub distance: Option<f64>,
}
impl GnssPosition {
pub fn new(
latitude: f64,
longitude: f64,
timestamp: DateTime<FixedOffset>,
crs: String,
) -> Result<Self, ProjectionError> {
let position = Self {
latitude,
longitude,
timestamp,
crs,
metadata: HashMap::new(),
heading: None,
distance: None,
};
position.validate()?;
Ok(position)
}
pub fn with_heading_distance(
latitude: f64,
longitude: f64,
timestamp: DateTime<FixedOffset>,
crs: String,
heading: Option<f64>,
distance: Option<f64>,
) -> Result<Self, ProjectionError> {
let position = Self {
latitude,
longitude,
timestamp,
crs,
metadata: HashMap::new(),
heading,
distance,
};
position.validate()?;
position.validate_heading()?;
Ok(position)
}
pub fn validate_heading(&self) -> Result<(), ProjectionError> {
if let Some(heading) = self.heading {
if !(0.0..=360.0).contains(&heading) {
return Err(ProjectionError::InvalidGeometry(format!(
"Heading must be in range [0, 360], got {}",
heading
)));
}
}
Ok(())
}
pub fn is_opposite_heading(h1: f64, h2: f64) -> bool {
let diff_normal = (h1 - h2).abs();
let dist_normal = diff_normal.min(360.0 - diff_normal);
let diff_shifted = (h1 - h2 - 180.0).abs() % 360.0;
let dist_shifted = diff_shifted.min(360.0 - diff_shifted);
dist_shifted < dist_normal
}
pub fn heading_difference(h1: f64, h2: f64) -> f64 {
if Self::is_opposite_heading(h1, h2) {
let diff_shifted = (h1 - h2 - 180.0).abs() % 360.0;
diff_shifted.min(360.0 - diff_shifted)
} else {
let diff = (h1 - h2).abs();
diff.min(360.0 - diff)
}
}
pub fn validate_latitude(&self) -> Result<(), ProjectionError> {
if self.latitude < -90.0 || self.latitude > 90.0 {
return Err(ProjectionError::InvalidCoordinate(format!(
"Latitude {} out of range [-90, 90]",
self.latitude
)));
}
Ok(())
}
pub fn validate_longitude(&self) -> Result<(), ProjectionError> {
if self.longitude < -180.0 || self.longitude > 180.0 {
return Err(ProjectionError::InvalidCoordinate(format!(
"Longitude {} out of range [-180, 180]",
self.longitude
)));
}
Ok(())
}
pub fn validate_timezone(&self) -> Result<(), ProjectionError> {
Ok(())
}
fn validate(&self) -> Result<(), ProjectionError> {
self.validate_latitude()?;
self.validate_longitude()?;
self.validate_timezone()?;
if self.crs.is_empty() {
return Err(ProjectionError::InvalidCrs(
"CRS must not be empty".to_string(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_valid_position() {
let timestamp = FixedOffset::east_opt(3600)
.unwrap()
.with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
.unwrap();
let pos = GnssPosition::new(50.8503, 4.3517, timestamp, "EPSG:4326".to_string());
assert!(pos.is_ok());
}
#[test]
fn test_invalid_latitude() {
let timestamp = FixedOffset::east_opt(3600)
.unwrap()
.with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
.unwrap();
let pos = GnssPosition::new(
91.0, 4.3517,
timestamp,
"EPSG:4326".to_string(),
);
assert!(pos.is_err());
}
#[test]
fn test_invalid_longitude() {
let timestamp = FixedOffset::east_opt(3600)
.unwrap()
.with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
.unwrap();
let pos = GnssPosition::new(
50.8503,
181.0, timestamp,
"EPSG:4326".to_string(),
);
assert!(pos.is_err());
}
}