use crate::errors::ProjectionError;
use crate::models::{AssociatedNetElement, GnssPosition, ProjectedPosition, TrainPath};
use crate::temporal::parse_timestamp_flexible_str;
use chrono::{DateTime, FixedOffset};
use polars::prelude::*;
use std::collections::HashMap;
const COL_ORIGINAL_LAT: &str = "original_lat";
const COL_ORIGINAL_LON: &str = "original_lon";
const COL_ORIGINAL_TIME: &str = "original_time";
const COL_PROJECTED_LAT: &str = "projected_lat";
const COL_PROJECTED_LON: &str = "projected_lon";
const COL_NETELEMENT_ID: &str = "netelement_id";
const COL_MEASURE_METERS: &str = "measure_meters";
const COL_PROJECTION_DISTANCE_METERS: &str = "projection_distance_meters";
const COL_CRS: &str = "crs";
const COL_PROBABILITY: &str = "probability";
const COL_START_INTRINSIC: &str = "start_intrinsic";
const COL_END_INTRINSIC: &str = "end_intrinsic";
const COL_GNSS_START_INDEX: &str = "gnss_start_index";
const COL_GNSS_END_INDEX: &str = "gnss_end_index";
fn parse_timestamp(s: &str) -> Result<DateTime<FixedOffset>, String> {
parse_timestamp_flexible_str(s)
}
pub fn parse_gnss_csv(
path: &str,
crs: &str,
lat_col: &str,
lon_col: &str,
time_col: &str,
) -> Result<Vec<GnssPosition>, ProjectionError> {
let df = CsvReadOptions::default()
.with_has_header(true)
.try_into_reader_with_file_path(Some(path.into()))
.map_err(|e| {
ProjectionError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to read CSV: {}", e),
))
})?
.finish()
.map_err(|e| {
ProjectionError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse CSV: {}", e),
))
})?;
gnss_positions_from_df(df, crs, lat_col, lon_col, time_col)
}
pub fn parse_gnss_csv_str(
csv_text: &str,
crs: &str,
lat_col: &str,
lon_col: &str,
time_col: &str,
) -> Result<Vec<GnssPosition>, ProjectionError> {
let cursor = std::io::Cursor::new(csv_text.as_bytes().to_vec());
let df = CsvReadOptions::default()
.with_has_header(true)
.into_reader_with_file_handle(cursor)
.finish()
.map_err(|e| {
ProjectionError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse CSV: {}", e),
))
})?;
gnss_positions_from_df(df, crs, lat_col, lon_col, time_col)
}
fn gnss_positions_from_df(
df: DataFrame,
crs: &str,
lat_col: &str,
lon_col: &str,
time_col: &str,
) -> Result<Vec<GnssPosition>, ProjectionError> {
if df.height() == 0 {
return Ok(Vec::new());
}
let schema = df.schema();
if !schema.contains(lat_col) {
return Err(ProjectionError::InvalidCoordinate(format!(
"Latitude column '{}' not found in CSV",
lat_col
)));
}
if !schema.contains(lon_col) {
return Err(ProjectionError::InvalidCoordinate(format!(
"Longitude column '{}' not found in CSV",
lon_col
)));
}
if !schema.contains(time_col) {
return Err(ProjectionError::InvalidTimestamp(format!(
"Timestamp column '{}' not found in CSV",
time_col
)));
}
let all_columns: Vec<String> = schema.iter_names().map(|s| s.to_string()).collect();
let has_heading = schema.contains("heading");
let has_distance = schema.contains("distance");
let lat_series = df.column(lat_col).map_err(|e| {
ProjectionError::InvalidCoordinate(format!("Failed to get latitude: {}", e))
})?;
let lon_series = df.column(lon_col).map_err(|e| {
ProjectionError::InvalidCoordinate(format!("Failed to get longitude: {}", e))
})?;
let time_series = df.column(time_col).map_err(|e| {
ProjectionError::InvalidTimestamp(format!("Failed to get timestamp: {}", e))
})?;
let lat_array = lat_series.f64().map_err(|e| {
ProjectionError::InvalidCoordinate(format!("Latitude must be numeric: {}", e))
})?;
let lon_array = lon_series.f64().map_err(|e| {
ProjectionError::InvalidCoordinate(format!("Longitude must be numeric: {}", e))
})?;
let time_array = time_series.str().map_err(|e| {
ProjectionError::InvalidTimestamp(format!("Timestamp must be string: {}", e))
})?;
let heading_series = if has_heading {
Some(df.column("heading").map_err(|e| {
ProjectionError::InvalidGeometry(format!("Failed to get heading: {}", e))
})?)
} else {
None
};
let distance_series = if has_distance {
Some(df.column("distance").map_err(|e| {
ProjectionError::InvalidGeometry(format!("Failed to get distance: {}", e))
})?)
} else {
None
};
let heading_array = heading_series
.as_ref()
.map(|s| s.f64())
.transpose()
.map_err(|e| ProjectionError::InvalidGeometry(format!("Heading must be numeric: {}", e)))?;
let distance_array = distance_series
.as_ref()
.map(|s| s.f64())
.transpose()
.map_err(|e| {
ProjectionError::InvalidGeometry(format!("Distance must be numeric: {}", e))
})?;
let mut positions = Vec::new();
let row_count = df.height();
for i in 0..row_count {
let latitude = lat_array.get(i).ok_or_else(|| {
ProjectionError::InvalidCoordinate(format!("Missing latitude at row {}", i))
})?;
let longitude = lon_array.get(i).ok_or_else(|| {
ProjectionError::InvalidCoordinate(format!("Missing longitude at row {}", i))
})?;
let time_str = time_array.get(i).ok_or_else(|| {
ProjectionError::InvalidTimestamp(format!("Missing timestamp at row {}", i))
})?;
let timestamp = parse_timestamp(time_str).map_err(|e| {
ProjectionError::InvalidTimestamp(format!(
"Invalid timestamp '{}' at row {}: {}",
time_str, i, e
))
})?;
let mut metadata = HashMap::new();
for col_name in &all_columns {
if col_name != lat_col
&& col_name != lon_col
&& col_name != time_col
&& col_name != "heading"
&& col_name != "distance"
{
if let Ok(series) = df.column(col_name) {
if let Ok(str_series) = series.cast(&DataType::String) {
if let Ok(str_chunked) = str_series.str() {
if let Some(value) = str_chunked.get(i) {
metadata.insert(col_name.clone(), value.to_string());
}
}
}
}
}
}
let heading = heading_array.as_ref().and_then(|arr| arr.get(i));
if let Some(h) = heading {
if !(0.0..=360.0).contains(&h) {
return Err(ProjectionError::InvalidGeometry(format!(
"Heading must be in [0, 360], got {} at row {}",
h, i
)));
}
}
let distance = distance_array.as_ref().and_then(|arr| arr.get(i));
if let Some(d) = distance {
if d < 0.0 {
return Err(ProjectionError::InvalidGeometry(format!(
"Distance must be >= 0, got {} at row {}",
d, i
)));
}
}
let mut position = GnssPosition::with_heading_distance(
latitude,
longitude,
timestamp,
crs.to_string(),
heading,
distance,
)?;
position.metadata = metadata;
positions.push(position);
}
Ok(positions)
}
pub fn write_csv(
positions: &[ProjectedPosition],
writer: &mut impl std::io::Write,
) -> Result<(), ProjectionError> {
use csv::Writer;
let mut csv_writer = Writer::from_writer(writer);
csv_writer.write_record([
COL_ORIGINAL_LAT,
COL_ORIGINAL_LON,
COL_ORIGINAL_TIME,
COL_PROJECTED_LAT,
COL_PROJECTED_LON,
COL_NETELEMENT_ID,
COL_MEASURE_METERS,
COL_PROJECTION_DISTANCE_METERS,
COL_CRS,
])?;
for pos in positions {
csv_writer.write_record(&[
pos.original.latitude.to_string(),
pos.original.longitude.to_string(),
pos.original.timestamp.to_rfc3339(),
pos.projected_coords.y().to_string(),
pos.projected_coords.x().to_string(),
pos.netelement_id.clone(),
pos.measure_meters.to_string(),
pos.projection_distance_meters.to_string(),
pos.crs.clone(),
])?;
}
csv_writer.flush()?;
Ok(())
}
pub fn write_trainpath_csv(
train_path: &TrainPath,
writer: &mut impl std::io::Write,
) -> Result<(), ProjectionError> {
use csv::Writer;
writeln!(
writer,
"# overall_probability: {}",
train_path.overall_probability
)?;
if let Some(calculated_at) = &train_path.calculated_at {
writeln!(writer, "# calculated_at: {}", calculated_at.to_rfc3339())?;
}
let mut csv_writer = Writer::from_writer(writer);
csv_writer.write_record([
COL_NETELEMENT_ID,
COL_PROBABILITY,
COL_START_INTRINSIC,
COL_END_INTRINSIC,
COL_GNSS_START_INDEX,
COL_GNSS_END_INDEX,
])?;
for segment in &train_path.segments {
csv_writer.write_record(&[
segment.netelement_id.clone(),
segment.probability.to_string(),
segment.start_intrinsic.to_string(),
segment.end_intrinsic.to_string(),
segment.gnss_start_index.to_string(),
segment.gnss_end_index.to_string(),
])?;
}
csv_writer.flush()?;
Ok(())
}
pub fn parse_trainpath_csv(path: &str) -> Result<TrainPath, ProjectionError> {
let file_content = std::fs::read_to_string(path)?;
let mut overall_probability: Option<f64> = None;
let mut calculated_at: Option<chrono::DateTime<chrono::Utc>> = None;
let mut csv_lines = Vec::new();
for line in file_content.lines() {
if let Some(comment) = line.strip_prefix('#') {
let comment = comment.trim();
if let Some(value) = comment.strip_prefix("overall_probability:") {
overall_probability = value.trim().parse().ok();
} else if let Some(value) = comment.strip_prefix("calculated_at:") {
if let Ok(dt) = parse_timestamp_flexible_str(value.trim()) {
calculated_at = Some(dt.with_timezone(&chrono::Utc));
}
}
} else {
csv_lines.push(line);
}
}
let filtered_csv = csv_lines.join("\n");
let unique_id = format!(
"{}_{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let temp_file = std::env::temp_dir().join(format!("trainpath_{}.csv", unique_id));
std::fs::write(&temp_file, filtered_csv)?;
let df = CsvReadOptions::default()
.with_has_header(true)
.try_into_reader_with_file_path(Some(temp_file.clone()))
.map_err(|e| {
ProjectionError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to read TrainPath CSV: {}", e),
))
})?
.finish()
.map_err(|e| {
ProjectionError::IoError(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to parse TrainPath CSV: {}", e),
))
})?;
let _ = std::fs::remove_file(temp_file);
if df.height() == 0 {
return TrainPath::new(Vec::new(), 1.0, None, None);
}
let netelement_id = df
.column("netelement_id")
.map_err(|e| ProjectionError::GeoJsonError(format!("Missing netelement_id column: {}", e)))?
.str()
.map_err(|e| ProjectionError::GeoJsonError(format!("netelement_id must be string: {}", e)))?
.clone();
let probability_series = df
.column("probability")
.map_err(|e| ProjectionError::GeoJsonError(format!("Missing probability column: {}", e)))?
.cast(&DataType::Float64)
.map_err(|e| ProjectionError::GeoJsonError(format!("probability cast failed: {}", e)))?;
let probability = probability_series.f64().map_err(|e| {
ProjectionError::GeoJsonError(format!("probability must be numeric: {}", e))
})?;
let start_intrinsic_series = df
.column("start_intrinsic")
.map_err(|e| {
ProjectionError::GeoJsonError(format!("Missing start_intrinsic column: {}", e))
})?
.cast(&DataType::Float64)
.map_err(|e| {
ProjectionError::GeoJsonError(format!("start_intrinsic cast failed: {}", e))
})?;
let start_intrinsic = start_intrinsic_series.f64().map_err(|e| {
ProjectionError::GeoJsonError(format!("start_intrinsic must be numeric: {}", e))
})?;
let end_intrinsic_series = df
.column("end_intrinsic")
.map_err(|e| ProjectionError::GeoJsonError(format!("Missing end_intrinsic column: {}", e)))?
.cast(&DataType::Float64)
.map_err(|e| ProjectionError::GeoJsonError(format!("end_intrinsic cast failed: {}", e)))?;
let end_intrinsic = end_intrinsic_series.f64().map_err(|e| {
ProjectionError::GeoJsonError(format!("end_intrinsic must be numeric: {}", e))
})?;
let gnss_start_index_series = df
.column("gnss_start_index")
.map_err(|e| {
ProjectionError::GeoJsonError(format!("Missing gnss_start_index column: {}", e))
})?
.cast(&DataType::UInt32)
.map_err(|e| {
ProjectionError::GeoJsonError(format!("gnss_start_index cast failed: {}", e))
})?;
let gnss_start_index = gnss_start_index_series.u32().map_err(|e| {
ProjectionError::GeoJsonError(format!("gnss_start_index must be integer: {}", e))
})?;
let gnss_end_index_series = df
.column("gnss_end_index")
.map_err(|e| {
ProjectionError::GeoJsonError(format!("Missing gnss_end_index column: {}", e))
})?
.cast(&DataType::UInt32)
.map_err(|e| ProjectionError::GeoJsonError(format!("gnss_end_index cast failed: {}", e)))?;
let gnss_end_index = gnss_end_index_series.u32().map_err(|e| {
ProjectionError::GeoJsonError(format!("gnss_end_index must be integer: {}", e))
})?;
let mut segments = Vec::new();
let row_count = df.height();
for i in 0..row_count {
let id = netelement_id
.get(i)
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Missing netelement_id at row {}", i))
})?
.to_string();
let prob = probability.get(i).ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Missing probability at row {}", i))
})?;
let start_intr = start_intrinsic.get(i).ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Missing start_intrinsic at row {}", i))
})?;
let end_intr = end_intrinsic.get(i).ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Missing end_intrinsic at row {}", i))
})?;
let start_idx = gnss_start_index.get(i).ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Missing gnss_start_index at row {}", i))
})? as usize;
let end_idx = gnss_end_index.get(i).ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Missing gnss_end_index at row {}", i))
})? as usize;
let segment =
AssociatedNetElement::new(id, prob, start_intr, end_intr, start_idx, end_idx)?;
segments.push(segment);
}
let overall_prob = overall_probability.unwrap_or_else(|| {
let sum: f64 = segments.iter().map(|s| s.probability).sum();
sum / segments.len() as f64
});
TrainPath::new(segments, overall_prob, calculated_at, None)
}
#[cfg(test)]
mod tests;
pub mod detections;