Skip to main content

tp_lib_core/io/
csv.rs

1//! CSV parsing and writing
2
3use 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
9// CSV column name constants for projected positions output
10const 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
20// CSV column names for train path output
21const 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
27/// Parse a timestamp string, accepting RFC3339 (with timezone) or ISO 8601 without timezone
28/// (assumed to be UTC).
29fn parse_timestamp(s: &str) -> Result<DateTime<FixedOffset>, String> {
30    // First try full RFC3339 (includes timezone)
31    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
32        return Ok(dt);
33    }
34    // Fall back: treat timezone-less ISO 8601 datetime as UTC
35    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
46/// Parse GNSS positions from CSV file
47pub 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    // Read CSV file using polars
55    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
75/// In-memory variant of [`parse_gnss_csv`] that accepts the full CSV text
76/// directly. No disk I/O is performed; required by the .NET bindings for
77/// database-backed callers (FR-012).
78pub 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
99/// Shared body: convert a polars DataFrame to a sequence of GnssPosition rows.
100fn 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    // Handle empty CSV (only headers) - polars can't infer types from empty data
108    if df.height() == 0 {
109        return Ok(Vec::new());
110    }
111
112    // Validate required columns exist
113    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    // Get all column names for metadata preservation
134    let all_columns: Vec<String> = schema.iter_names().map(|s| s.to_string()).collect();
135
136    // Check if heading and distance columns exist (optional - US4: T115-T116)
137    let has_heading = schema.contains("heading");
138    let has_distance = schema.contains("distance");
139
140    // Extract required columns
141    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    // Convert to f64 arrays
152    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    // Get optional heading and distance series if they exist
163    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    // Convert heading and distance to typed arrays
180    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    // Build GNSS positions
195    let mut positions = Vec::new();
196    let row_count = df.height();
197
198    for i in 0..row_count {
199        // Get coordinates
200        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        // Get and parse timestamp
208        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        // Build metadata from other columns
220        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        // Extract heading if present (0-360°), validate range
241        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        // Extract distance if present (must be >= 0)
252        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        // Create GNSS position with heading and distance if available (US4: T115-T116)
263        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
278/// Write projected positions to CSV
279pub 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    // Write header
288    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    // Write data rows
301    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
319/// Write TrainPath to CSV
320///
321/// Output format: One row per segment with columns:
322/// - netelement_id: ID of the netelement
323/// - probability: Segment probability (0.0 to 1.0)
324/// - start_intrinsic: Entry point on netelement (0.0 to 1.0)
325/// - end_intrinsic: Exit point on netelement (0.0 to 1.0)
326/// - gnss_start_index: First GNSS position index
327/// - gnss_end_index: Last GNSS position index
328///
329/// The overall_probability is written as a comment in the first line.
330///
331/// # Example Output
332///
333/// ```csv
334/// # overall_probability: 0.89
335/// netelement_id,probability,start_intrinsic,end_intrinsic,gnss_start_index,gnss_end_index
336/// NE_A,0.87,0.0,1.0,0,10
337/// NE_B,0.92,0.0,1.0,11,18
338/// ```
339pub fn write_trainpath_csv(
340    train_path: &TrainPath,
341    writer: &mut impl std::io::Write,
342) -> Result<(), ProjectionError> {
343    use csv::Writer;
344
345    // Write overall probability as comment
346    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    // Write header
359    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    // Write data rows
369    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
384/// Parse TrainPath from CSV
385///
386/// Reads a CSV file in the format produced by write_trainpath_csv.
387/// Expects columns: netelement_id, probability, start_intrinsic, end_intrinsic,
388/// gnss_start_index, gnss_end_index
389///
390/// The overall_probability can be specified in a comment line starting with
391/// `# overall_probability:` or will default to the average of segment probabilities.
392///
393/// # Arguments
394///
395/// * `path` - Path to CSV file
396///
397/// # Returns
398///
399/// A TrainPath struct reconstructed from the CSV data
400pub fn parse_trainpath_csv(path: &str) -> Result<TrainPath, ProjectionError> {
401    // Read the file to extract comment lines and filter them out
402    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    // Parse comment lines and collect non-comment lines
408    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    // Write filtered CSV to temporary string for polars
424    // Use thread ID and timestamp to avoid race conditions with parallel tests
425    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    // Read CSV using polars
438    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    // Clean up temp file
456    let _ = std::fs::remove_file(temp_file);
457
458    // Handle empty CSV (only headers)
459    if df.height() == 0 {
460        return TrainPath::new(Vec::new(), 1.0, None, None);
461    }
462
463    // Extract columns and cast to correct types
464    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    // Build segments
527    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    // Calculate overall probability if not provided
565    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    // Create TrainPath
571    TrainPath::new(segments, overall_prob, calculated_at, None)
572}
573
574#[cfg(test)]
575mod tests;
576
577pub mod detections;