1use crate::errors::ProjectionError;
4use crate::models::{AssociatedNetElement, GnssPosition, ProjectedPosition, TrainPath};
5use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone, Utc};
6use polars::prelude::*;
7use std::collections::HashMap;
8
9const COL_ORIGINAL_LAT: &str = "original_lat";
11const COL_ORIGINAL_LON: &str = "original_lon";
12const COL_ORIGINAL_TIME: &str = "original_time";
13const COL_PROJECTED_LAT: &str = "projected_lat";
14const COL_PROJECTED_LON: &str = "projected_lon";
15const COL_NETELEMENT_ID: &str = "netelement_id";
16const COL_MEASURE_METERS: &str = "measure_meters";
17const COL_PROJECTION_DISTANCE_METERS: &str = "projection_distance_meters";
18const COL_CRS: &str = "crs";
19
20const COL_PROBABILITY: &str = "probability";
22const COL_START_INTRINSIC: &str = "start_intrinsic";
23const COL_END_INTRINSIC: &str = "end_intrinsic";
24const COL_GNSS_START_INDEX: &str = "gnss_start_index";
25const COL_GNSS_END_INDEX: &str = "gnss_end_index";
26
27fn parse_timestamp(s: &str) -> Result<DateTime<FixedOffset>, String> {
30 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
32 return Ok(dt);
33 }
34 let naive = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f")
36 .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
37 .map_err(|e| {
38 format!(
39 "{} (expected RFC3339 with timezone, e.g., 2025-12-09T14:30:00+01:00, or ISO 8601 without timezone assumed UTC)",
40 e
41 )
42 })?;
43 Ok(Utc.from_utc_datetime(&naive).fixed_offset())
44}
45
46pub fn parse_gnss_csv(
48 path: &str,
49 crs: &str,
50 lat_col: &str,
51 lon_col: &str,
52 time_col: &str,
53) -> Result<Vec<GnssPosition>, ProjectionError> {
54 let df = CsvReadOptions::default()
56 .with_has_header(true)
57 .try_into_reader_with_file_path(Some(path.into()))
58 .map_err(|e| {
59 ProjectionError::IoError(std::io::Error::new(
60 std::io::ErrorKind::InvalidData,
61 format!("Failed to read CSV: {}", e),
62 ))
63 })?
64 .finish()
65 .map_err(|e| {
66 ProjectionError::IoError(std::io::Error::new(
67 std::io::ErrorKind::InvalidData,
68 format!("Failed to parse CSV: {}", e),
69 ))
70 })?;
71
72 gnss_positions_from_df(df, crs, lat_col, lon_col, time_col)
73}
74
75pub fn parse_gnss_csv_str(
79 csv_text: &str,
80 crs: &str,
81 lat_col: &str,
82 lon_col: &str,
83 time_col: &str,
84) -> Result<Vec<GnssPosition>, ProjectionError> {
85 let cursor = std::io::Cursor::new(csv_text.as_bytes().to_vec());
86 let df = CsvReadOptions::default()
87 .with_has_header(true)
88 .into_reader_with_file_handle(cursor)
89 .finish()
90 .map_err(|e| {
91 ProjectionError::IoError(std::io::Error::new(
92 std::io::ErrorKind::InvalidData,
93 format!("Failed to parse CSV: {}", e),
94 ))
95 })?;
96 gnss_positions_from_df(df, crs, lat_col, lon_col, time_col)
97}
98
99fn gnss_positions_from_df(
101 df: DataFrame,
102 crs: &str,
103 lat_col: &str,
104 lon_col: &str,
105 time_col: &str,
106) -> Result<Vec<GnssPosition>, ProjectionError> {
107 if df.height() == 0 {
109 return Ok(Vec::new());
110 }
111
112 let schema = df.schema();
114 if !schema.contains(lat_col) {
115 return Err(ProjectionError::InvalidCoordinate(format!(
116 "Latitude column '{}' not found in CSV",
117 lat_col
118 )));
119 }
120 if !schema.contains(lon_col) {
121 return Err(ProjectionError::InvalidCoordinate(format!(
122 "Longitude column '{}' not found in CSV",
123 lon_col
124 )));
125 }
126 if !schema.contains(time_col) {
127 return Err(ProjectionError::InvalidTimestamp(format!(
128 "Timestamp column '{}' not found in CSV",
129 time_col
130 )));
131 }
132
133 let all_columns: Vec<String> = schema.iter_names().map(|s| s.to_string()).collect();
135
136 let has_heading = schema.contains("heading");
138 let has_distance = schema.contains("distance");
139
140 let lat_series = df.column(lat_col).map_err(|e| {
142 ProjectionError::InvalidCoordinate(format!("Failed to get latitude: {}", e))
143 })?;
144 let lon_series = df.column(lon_col).map_err(|e| {
145 ProjectionError::InvalidCoordinate(format!("Failed to get longitude: {}", e))
146 })?;
147 let time_series = df.column(time_col).map_err(|e| {
148 ProjectionError::InvalidTimestamp(format!("Failed to get timestamp: {}", e))
149 })?;
150
151 let lat_array = lat_series.f64().map_err(|e| {
153 ProjectionError::InvalidCoordinate(format!("Latitude must be numeric: {}", e))
154 })?;
155 let lon_array = lon_series.f64().map_err(|e| {
156 ProjectionError::InvalidCoordinate(format!("Longitude must be numeric: {}", e))
157 })?;
158 let time_array = time_series.str().map_err(|e| {
159 ProjectionError::InvalidTimestamp(format!("Timestamp must be string: {}", e))
160 })?;
161
162 let heading_series = if has_heading {
164 Some(df.column("heading").map_err(|e| {
165 ProjectionError::InvalidGeometry(format!("Failed to get heading: {}", e))
166 })?)
167 } else {
168 None
169 };
170
171 let distance_series = if has_distance {
172 Some(df.column("distance").map_err(|e| {
173 ProjectionError::InvalidGeometry(format!("Failed to get distance: {}", e))
174 })?)
175 } else {
176 None
177 };
178
179 let heading_array = heading_series
181 .as_ref()
182 .map(|s| s.f64())
183 .transpose()
184 .map_err(|e| ProjectionError::InvalidGeometry(format!("Heading must be numeric: {}", e)))?;
185
186 let distance_array = distance_series
187 .as_ref()
188 .map(|s| s.f64())
189 .transpose()
190 .map_err(|e| {
191 ProjectionError::InvalidGeometry(format!("Distance must be numeric: {}", e))
192 })?;
193
194 let mut positions = Vec::new();
196 let row_count = df.height();
197
198 for i in 0..row_count {
199 let latitude = lat_array.get(i).ok_or_else(|| {
201 ProjectionError::InvalidCoordinate(format!("Missing latitude at row {}", i))
202 })?;
203 let longitude = lon_array.get(i).ok_or_else(|| {
204 ProjectionError::InvalidCoordinate(format!("Missing longitude at row {}", i))
205 })?;
206
207 let time_str = time_array.get(i).ok_or_else(|| {
209 ProjectionError::InvalidTimestamp(format!("Missing timestamp at row {}", i))
210 })?;
211
212 let timestamp = parse_timestamp(time_str).map_err(|e| {
213 ProjectionError::InvalidTimestamp(format!(
214 "Invalid timestamp '{}' at row {}: {}",
215 time_str, i, e
216 ))
217 })?;
218
219 let mut metadata = HashMap::new();
221 for col_name in &all_columns {
222 if col_name != lat_col
223 && col_name != lon_col
224 && col_name != time_col
225 && col_name != "heading"
226 && col_name != "distance"
227 {
228 if let Ok(series) = df.column(col_name) {
229 if let Ok(str_series) = series.cast(&DataType::String) {
230 if let Ok(str_chunked) = str_series.str() {
231 if let Some(value) = str_chunked.get(i) {
232 metadata.insert(col_name.clone(), value.to_string());
233 }
234 }
235 }
236 }
237 }
238 }
239
240 let heading = heading_array.as_ref().and_then(|arr| arr.get(i));
242 if let Some(h) = heading {
243 if !(0.0..=360.0).contains(&h) {
244 return Err(ProjectionError::InvalidGeometry(format!(
245 "Heading must be in [0, 360], got {} at row {}",
246 h, i
247 )));
248 }
249 }
250
251 let distance = distance_array.as_ref().and_then(|arr| arr.get(i));
253 if let Some(d) = distance {
254 if d < 0.0 {
255 return Err(ProjectionError::InvalidGeometry(format!(
256 "Distance must be >= 0, got {} at row {}",
257 d, i
258 )));
259 }
260 }
261
262 let mut position = GnssPosition::with_heading_distance(
264 latitude,
265 longitude,
266 timestamp,
267 crs.to_string(),
268 heading,
269 distance,
270 )?;
271 position.metadata = metadata;
272 positions.push(position);
273 }
274
275 Ok(positions)
276}
277
278pub fn write_csv(
280 positions: &[ProjectedPosition],
281 writer: &mut impl std::io::Write,
282) -> Result<(), ProjectionError> {
283 use csv::Writer;
284
285 let mut csv_writer = Writer::from_writer(writer);
286
287 csv_writer.write_record([
289 COL_ORIGINAL_LAT,
290 COL_ORIGINAL_LON,
291 COL_ORIGINAL_TIME,
292 COL_PROJECTED_LAT,
293 COL_PROJECTED_LON,
294 COL_NETELEMENT_ID,
295 COL_MEASURE_METERS,
296 COL_PROJECTION_DISTANCE_METERS,
297 COL_CRS,
298 ])?;
299
300 for pos in positions {
302 csv_writer.write_record(&[
303 pos.original.latitude.to_string(),
304 pos.original.longitude.to_string(),
305 pos.original.timestamp.to_rfc3339(),
306 pos.projected_coords.y().to_string(),
307 pos.projected_coords.x().to_string(),
308 pos.netelement_id.clone(),
309 pos.measure_meters.to_string(),
310 pos.projection_distance_meters.to_string(),
311 pos.crs.clone(),
312 ])?;
313 }
314
315 csv_writer.flush()?;
316 Ok(())
317}
318
319pub fn write_trainpath_csv(
340 train_path: &TrainPath,
341 writer: &mut impl std::io::Write,
342) -> Result<(), ProjectionError> {
343 use csv::Writer;
344
345 writeln!(
347 writer,
348 "# overall_probability: {}",
349 train_path.overall_probability
350 )?;
351
352 if let Some(calculated_at) = &train_path.calculated_at {
353 writeln!(writer, "# calculated_at: {}", calculated_at.to_rfc3339())?;
354 }
355
356 let mut csv_writer = Writer::from_writer(writer);
357
358 csv_writer.write_record([
360 COL_NETELEMENT_ID,
361 COL_PROBABILITY,
362 COL_START_INTRINSIC,
363 COL_END_INTRINSIC,
364 COL_GNSS_START_INDEX,
365 COL_GNSS_END_INDEX,
366 ])?;
367
368 for segment in &train_path.segments {
370 csv_writer.write_record(&[
371 segment.netelement_id.clone(),
372 segment.probability.to_string(),
373 segment.start_intrinsic.to_string(),
374 segment.end_intrinsic.to_string(),
375 segment.gnss_start_index.to_string(),
376 segment.gnss_end_index.to_string(),
377 ])?;
378 }
379
380 csv_writer.flush()?;
381 Ok(())
382}
383
384pub fn parse_trainpath_csv(path: &str) -> Result<TrainPath, ProjectionError> {
401 let file_content = std::fs::read_to_string(path)?;
403 let mut overall_probability: Option<f64> = None;
404 let mut calculated_at: Option<chrono::DateTime<chrono::Utc>> = None;
405 let mut csv_lines = Vec::new();
406
407 for line in file_content.lines() {
409 if let Some(comment) = line.strip_prefix('#') {
410 let comment = comment.trim();
411 if let Some(value) = comment.strip_prefix("overall_probability:") {
412 overall_probability = value.trim().parse().ok();
413 } else if let Some(value) = comment.strip_prefix("calculated_at:") {
414 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value.trim()) {
415 calculated_at = Some(dt.with_timezone(&chrono::Utc));
416 }
417 }
418 } else {
419 csv_lines.push(line);
420 }
421 }
422
423 let filtered_csv = csv_lines.join("\n");
426 let unique_id = format!(
427 "{}_{:?}",
428 std::process::id(),
429 std::time::SystemTime::now()
430 .duration_since(std::time::UNIX_EPOCH)
431 .map(|d| d.as_nanos())
432 .unwrap_or(0)
433 );
434 let temp_file = std::env::temp_dir().join(format!("trainpath_{}.csv", unique_id));
435 std::fs::write(&temp_file, filtered_csv)?;
436
437 let df = CsvReadOptions::default()
439 .with_has_header(true)
440 .try_into_reader_with_file_path(Some(temp_file.clone()))
441 .map_err(|e| {
442 ProjectionError::IoError(std::io::Error::new(
443 std::io::ErrorKind::InvalidData,
444 format!("Failed to read TrainPath CSV: {}", e),
445 ))
446 })?
447 .finish()
448 .map_err(|e| {
449 ProjectionError::IoError(std::io::Error::new(
450 std::io::ErrorKind::InvalidData,
451 format!("Failed to parse TrainPath CSV: {}", e),
452 ))
453 })?;
454
455 let _ = std::fs::remove_file(temp_file);
457
458 if df.height() == 0 {
460 return TrainPath::new(Vec::new(), 1.0, None, None);
461 }
462
463 let netelement_id = df
465 .column("netelement_id")
466 .map_err(|e| ProjectionError::GeoJsonError(format!("Missing netelement_id column: {}", e)))?
467 .str()
468 .map_err(|e| ProjectionError::GeoJsonError(format!("netelement_id must be string: {}", e)))?
469 .clone();
470
471 let probability_series = df
472 .column("probability")
473 .map_err(|e| ProjectionError::GeoJsonError(format!("Missing probability column: {}", e)))?
474 .cast(&DataType::Float64)
475 .map_err(|e| ProjectionError::GeoJsonError(format!("probability cast failed: {}", e)))?;
476 let probability = probability_series.f64().map_err(|e| {
477 ProjectionError::GeoJsonError(format!("probability must be numeric: {}", e))
478 })?;
479
480 let start_intrinsic_series = df
481 .column("start_intrinsic")
482 .map_err(|e| {
483 ProjectionError::GeoJsonError(format!("Missing start_intrinsic column: {}", e))
484 })?
485 .cast(&DataType::Float64)
486 .map_err(|e| {
487 ProjectionError::GeoJsonError(format!("start_intrinsic cast failed: {}", e))
488 })?;
489 let start_intrinsic = start_intrinsic_series.f64().map_err(|e| {
490 ProjectionError::GeoJsonError(format!("start_intrinsic must be numeric: {}", e))
491 })?;
492
493 let end_intrinsic_series = df
494 .column("end_intrinsic")
495 .map_err(|e| ProjectionError::GeoJsonError(format!("Missing end_intrinsic column: {}", e)))?
496 .cast(&DataType::Float64)
497 .map_err(|e| ProjectionError::GeoJsonError(format!("end_intrinsic cast failed: {}", e)))?;
498 let end_intrinsic = end_intrinsic_series.f64().map_err(|e| {
499 ProjectionError::GeoJsonError(format!("end_intrinsic must be numeric: {}", e))
500 })?;
501
502 let gnss_start_index_series = df
503 .column("gnss_start_index")
504 .map_err(|e| {
505 ProjectionError::GeoJsonError(format!("Missing gnss_start_index column: {}", e))
506 })?
507 .cast(&DataType::UInt32)
508 .map_err(|e| {
509 ProjectionError::GeoJsonError(format!("gnss_start_index cast failed: {}", e))
510 })?;
511 let gnss_start_index = gnss_start_index_series.u32().map_err(|e| {
512 ProjectionError::GeoJsonError(format!("gnss_start_index must be integer: {}", e))
513 })?;
514
515 let gnss_end_index_series = df
516 .column("gnss_end_index")
517 .map_err(|e| {
518 ProjectionError::GeoJsonError(format!("Missing gnss_end_index column: {}", e))
519 })?
520 .cast(&DataType::UInt32)
521 .map_err(|e| ProjectionError::GeoJsonError(format!("gnss_end_index cast failed: {}", e)))?;
522 let gnss_end_index = gnss_end_index_series.u32().map_err(|e| {
523 ProjectionError::GeoJsonError(format!("gnss_end_index must be integer: {}", e))
524 })?;
525
526 let mut segments = Vec::new();
528 let row_count = df.height();
529
530 for i in 0..row_count {
531 let id = netelement_id
532 .get(i)
533 .ok_or_else(|| {
534 ProjectionError::GeoJsonError(format!("Missing netelement_id at row {}", i))
535 })?
536 .to_string();
537
538 let prob = probability.get(i).ok_or_else(|| {
539 ProjectionError::GeoJsonError(format!("Missing probability at row {}", i))
540 })?;
541
542 let start_intr = start_intrinsic.get(i).ok_or_else(|| {
543 ProjectionError::GeoJsonError(format!("Missing start_intrinsic at row {}", i))
544 })?;
545
546 let end_intr = end_intrinsic.get(i).ok_or_else(|| {
547 ProjectionError::GeoJsonError(format!("Missing end_intrinsic at row {}", i))
548 })?;
549
550 let start_idx = gnss_start_index.get(i).ok_or_else(|| {
551 ProjectionError::GeoJsonError(format!("Missing gnss_start_index at row {}", i))
552 })? as usize;
553
554 let end_idx = gnss_end_index.get(i).ok_or_else(|| {
555 ProjectionError::GeoJsonError(format!("Missing gnss_end_index at row {}", i))
556 })? as usize;
557
558 let segment =
559 AssociatedNetElement::new(id, prob, start_intr, end_intr, start_idx, end_idx)?;
560
561 segments.push(segment);
562 }
563
564 let overall_prob = overall_probability.unwrap_or_else(|| {
566 let sum: f64 = segments.iter().map(|s| s.probability).sum();
567 sum / segments.len() as f64
568 });
569
570 TrainPath::new(segments, overall_prob, calculated_at, None)
572}
573
574#[cfg(test)]
575mod tests;
576
577pub mod detections;