tp_lib/
lib.rs

1//! Python bindings for tp-core
2//!
3//! This module provides Python FFI via PyO3 for the GNSS track axis projection library.
4//!
5//! # Example Usage (Python)
6//!
7//! ```python
8//! from tp_lib import project_gnss, ProjectionConfig
9//!
10//! # Project GNSS positions onto railway network
11//! results = project_gnss(
12//!     gnss_file="positions.csv",
13//!     gnss_crs="EPSG:4326",
14//!     network_file="network.geojson",
15//!     network_crs="EPSG:4326",
16//!     target_crs="EPSG:31370",  # Belgian Lambert 72
17//!     config=ProjectionConfig()
18//! )
19//!
20//! for result in results:
21//!     print(f"Position at {result.measure_meters}m on {result.netelement_id}")
22//! ```
23
24use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError};
25use pyo3::prelude::*;
26use tp_lib_core::{
27    parse_gnss_csv, parse_network_geojson, project_gnss as core_project_gnss,
28    ProjectedPosition as CoreProjectedPosition, ProjectionConfig as CoreProjectionConfig,
29    ProjectionError, RailwayNetwork,
30};
31
32// ============================================================================
33// Error Conversion (T058)
34// ============================================================================
35
36/// Convert ProjectionError to appropriate Python exception
37fn convert_error(error: ProjectionError) -> PyErr {
38    match error {
39        ProjectionError::InvalidCrs(msg) => PyValueError::new_err(format!("Invalid CRS: {}", msg)),
40        ProjectionError::TransformFailed(msg) => {
41            PyRuntimeError::new_err(format!("Coordinate transformation failed: {}", msg))
42        }
43        ProjectionError::InvalidCoordinate(msg) => {
44            PyValueError::new_err(format!("Invalid coordinate: {}", msg))
45        }
46        ProjectionError::MissingTimezone(msg) => {
47            PyValueError::new_err(format!("Missing timezone: {}", msg))
48        }
49        ProjectionError::InvalidTimestamp(msg) => {
50            PyValueError::new_err(format!("Invalid timestamp: {}", msg))
51        }
52        ProjectionError::EmptyNetwork => PyValueError::new_err("Railway network is empty"),
53        ProjectionError::InvalidGeometry(msg) => {
54            PyValueError::new_err(format!("Invalid geometry: {}", msg))
55        }
56        ProjectionError::CsvError(err) => PyIOError::new_err(format!("CSV error: {}", err)),
57        ProjectionError::GeoJsonError(msg) => PyIOError::new_err(format!("GeoJSON error: {}", msg)),
58        ProjectionError::IoError(err) => PyIOError::new_err(format!("IO error: {}", err)),
59    }
60}
61
62// ============================================================================
63// Python Data Classes
64// ============================================================================
65
66/// Python-exposed projection configuration
67#[pyclass]
68#[derive(Clone)]
69pub struct ProjectionConfig {
70    /// Warning threshold for large projection distances
71    #[pyo3(get, set)]
72    pub projection_distance_warning_threshold: f64,
73
74    /// Enable CRS transformation
75    #[pyo3(get, set)]
76    pub transform_crs: bool,
77
78    /// Suppress warning messages during projection
79    #[pyo3(get, set)]
80    pub suppress_warnings: bool,
81}
82
83#[pymethods]
84impl ProjectionConfig {
85    #[new]
86    #[pyo3(signature = (projection_distance_warning_threshold=50.0, transform_crs=true, suppress_warnings=false))]
87    fn new(
88        projection_distance_warning_threshold: f64,
89        transform_crs: bool,
90        suppress_warnings: bool,
91    ) -> Self {
92        Self {
93            projection_distance_warning_threshold,
94            transform_crs,
95            suppress_warnings,
96        }
97    }
98
99    fn __repr__(&self) -> String {
100        format!(
101            "ProjectionConfig(projection_distance_warning_threshold={}, transform_crs={}, suppress_warnings={})",
102            self.projection_distance_warning_threshold,
103            self.transform_crs,
104            self.suppress_warnings
105        )
106    }
107}
108
109impl From<ProjectionConfig> for CoreProjectionConfig {
110    fn from(py_config: ProjectionConfig) -> Self {
111        CoreProjectionConfig {
112            projection_distance_warning_threshold: py_config.projection_distance_warning_threshold,
113            transform_crs: py_config.transform_crs,
114            suppress_warnings: py_config.suppress_warnings,
115        }
116    }
117}
118
119/// Python-exposed projected position result
120#[pyclass]
121#[derive(Clone)]
122pub struct ProjectedPosition {
123    /// Original latitude (WGS84)
124    #[pyo3(get)]
125    pub original_latitude: f64,
126
127    /// Original longitude (WGS84)
128    #[pyo3(get)]
129    pub original_longitude: f64,
130
131    /// Original timestamp (RFC3339 string)
132    #[pyo3(get)]
133    pub timestamp: String,
134
135    /// Projected X coordinate in target CRS
136    #[pyo3(get)]
137    pub projected_x: f64,
138
139    /// Projected Y coordinate in target CRS
140    #[pyo3(get)]
141    pub projected_y: f64,
142
143    /// Network element ID
144    #[pyo3(get)]
145    pub netelement_id: String,
146
147    /// Linear measure along track in meters
148    #[pyo3(get)]
149    pub measure_meters: f64,
150
151    /// Perpendicular distance from track in meters
152    #[pyo3(get)]
153    pub projection_distance_meters: f64,
154
155    /// Coordinate reference system of projected coordinates
156    #[pyo3(get)]
157    pub crs: String,
158}
159
160#[pymethods]
161impl ProjectedPosition {
162    fn __repr__(&self) -> String {
163        format!(
164            "ProjectedPosition(netelement_id='{}', measure={}m, distance={}m)",
165            self.netelement_id, self.measure_meters, self.projection_distance_meters
166        )
167    }
168
169    fn to_dict(&self) -> PyResult<std::collections::HashMap<String, String>> {
170        let mut dict = std::collections::HashMap::new();
171        dict.insert(
172            "original_latitude".to_string(),
173            self.original_latitude.to_string(),
174        );
175        dict.insert(
176            "original_longitude".to_string(),
177            self.original_longitude.to_string(),
178        );
179        dict.insert("timestamp".to_string(), self.timestamp.clone());
180        dict.insert("projected_x".to_string(), self.projected_x.to_string());
181        dict.insert("projected_y".to_string(), self.projected_y.to_string());
182        dict.insert("netelement_id".to_string(), self.netelement_id.clone());
183        dict.insert(
184            "measure_meters".to_string(),
185            self.measure_meters.to_string(),
186        );
187        dict.insert(
188            "projection_distance_meters".to_string(),
189            self.projection_distance_meters.to_string(),
190        );
191        dict.insert("crs".to_string(), self.crs.clone());
192        Ok(dict)
193    }
194}
195
196impl From<&CoreProjectedPosition> for ProjectedPosition {
197    fn from(core_result: &CoreProjectedPosition) -> Self {
198        ProjectedPosition {
199            original_latitude: core_result.original.latitude,
200            original_longitude: core_result.original.longitude,
201            timestamp: core_result.original.timestamp.to_rfc3339(),
202            projected_x: core_result.projected_coords.x(),
203            projected_y: core_result.projected_coords.y(),
204            netelement_id: core_result.netelement_id.clone(),
205            measure_meters: core_result.measure_meters,
206            projection_distance_meters: core_result.projection_distance_meters,
207            crs: core_result.crs.clone(),
208        }
209    }
210}
211
212// ============================================================================
213// Main Python API (T057)
214// ============================================================================
215
216/// Project GNSS positions onto railway network elements
217///
218/// # Arguments
219///
220/// * `gnss_file` - Path to CSV file containing GNSS positions (columns: latitude, longitude, timestamp)
221/// * `gnss_crs` - CRS of input GNSS coordinates (e.g., "EPSG:4326" for WGS84)
222/// * `network_file` - Path to GeoJSON file containing network elements with LineString geometries
223/// * `network_crs` - CRS of network geometries (e.g., "EPSG:4326")
224/// * `target_crs` - CRS for output projected coordinates (e.g., "EPSG:31370" for Belgian Lambert 72)
225/// * `config` - Optional projection configuration (defaults provided)
226///
227/// # Returns
228///
229/// List of `ProjectedPosition` objects, one per input GNSS position
230///
231/// # Raises
232///
233/// * `ValueError` - Invalid CRS, coordinates, or geometry
234/// * `IOError` - File reading errors or invalid CSV/GeoJSON format
235/// * `RuntimeError` - Coordinate transformation failures
236///
237/// # Example
238///
239/// ```python
240/// from tp_lib import project_gnss, ProjectionConfig
241///
242/// results = project_gnss(
243///     gnss_file="data/positions.csv",
244///     gnss_crs="EPSG:4326",
245///     network_file="data/network.geojson",
246///     network_crs="EPSG:4326",
247///     target_crs="EPSG:31370",
248///     config=ProjectionConfig(max_search_radius_meters=500.0)
249/// )
250///
251/// for pos in results:
252///     print(f"{pos.netelement_id}: {pos.measure_meters}m")
253/// ```
254#[pyfunction]
255#[pyo3(signature = (gnss_file, gnss_crs, network_file, _network_crs, _target_crs, config=None))]
256fn project_gnss(
257    gnss_file: &str,
258    gnss_crs: &str,
259    network_file: &str,
260    _network_crs: &str, // Reserved for future use when CRS per file is supported
261    _target_crs: &str,  // Reserved for future use when explicit target CRS is supported
262    config: Option<ProjectionConfig>,
263) -> PyResult<Vec<ProjectedPosition>> {
264    // Convert Python config to Rust config
265    let core_config: CoreProjectionConfig = config
266        .unwrap_or_else(|| ProjectionConfig::new(50.0, true, false))
267        .into();
268
269    // Parse GNSS positions from CSV
270    // Note: parse_gnss_csv signature is (path, crs, lat_col, lon_col, time_col)
271    let gnss_positions = parse_gnss_csv(gnss_file, gnss_crs, "latitude", "longitude", "timestamp")
272        .map_err(convert_error)?;
273
274    // Parse network from GeoJSON
275    let netelements = parse_network_geojson(network_file).map_err(convert_error)?;
276
277    // Build spatial index
278    let network = RailwayNetwork::new(netelements).map_err(convert_error)?;
279
280    // Project positions (CRS transformation happens inside project_gnss if transform_crs=true)
281    let results =
282        core_project_gnss(&gnss_positions, &network, &core_config).map_err(convert_error)?;
283
284    // Convert to Python objects
285    Ok(results.iter().map(ProjectedPosition::from).collect())
286}
287
288// ============================================================================
289// Python Module Definition
290// ============================================================================
291
292/// Python module for train positioning library
293#[pymodule]
294fn tp_lib(m: &Bound<'_, PyModule>) -> PyResult<()> {
295    m.add_function(wrap_pyfunction!(project_gnss, m)?)?;
296    m.add_class::<ProjectionConfig>()?;
297    m.add_class::<ProjectedPosition>()?;
298    Ok(())
299}