Skip to main content

tp_lib_core/
workflow.rs

1//! Source selection and retrieval-area construction for automatic topology
2//! retrieval (feature 006).
3//!
4//! Combines GNSS-derived bounding boxes, the RINF SPARQL client trait, and the
5//! validation pipeline that produces a [`RetrievedTopology`] ready for use by
6//! the existing path/projection/detection algorithms.
7
8use chrono::Utc;
9
10use crate::errors::ProjectionError;
11use crate::io::rinf::{
12    fetch_netelements, fetch_netrelations, map_netelements_to_core, map_netrelations_to_core,
13    SparqlClient,
14};
15use crate::models::{
16    AutoTopologyRequest, GnssPosition, NetRelation, Netelement, RetrievalArea, RetrievalOutcome,
17    RetrievalStatus, RetrievedTopology, TopologySource, TopologyValidationReport,
18    TopologyValidationStatus, WorkflowKind, COARSE_GEOMETRY_LENGTH_THRESHOLD_METERS,
19    DEFAULT_RETRIEVAL_BUFFER_METERS, DEFAULT_RINF_ENDPOINT,
20};
21
22/// Retrieval configuration shared by the CLI and language bindings.
23#[derive(Debug, Clone)]
24pub struct RetrievalConfig {
25    pub endpoint_url: String,
26    pub buffer_meters: f64,
27}
28
29impl Default for RetrievalConfig {
30    fn default() -> Self {
31        Self {
32            endpoint_url: DEFAULT_RINF_ENDPOINT.to_string(),
33            buffer_meters: DEFAULT_RETRIEVAL_BUFFER_METERS,
34        }
35    }
36}
37
38impl RetrievalConfig {
39    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
40        self.endpoint_url = endpoint.into();
41        self
42    }
43
44    pub fn with_buffer_meters(mut self, buffer_meters: f64) -> Self {
45        self.buffer_meters = buffer_meters;
46        self
47    }
48}
49
50/// Build a 1 km-expanded (configurable) WGS84 axis-aligned search polygon from
51/// GNSS positions.
52///
53/// Returns `Err(InvalidGnssInput)` if no usable WGS84 coordinates are present.
54pub fn build_retrieval_area(
55    positions: &[GnssPosition],
56    buffer_meters: f64,
57) -> Result<RetrievalArea, ProjectionError> {
58    if positions.is_empty() {
59        return Err(ProjectionError::InvalidGnssInput(
60            "GNSS dataset is empty".to_string(),
61        ));
62    }
63
64    let mut min_lon = f64::INFINITY;
65    let mut max_lon = f64::NEG_INFINITY;
66    let mut min_lat = f64::INFINITY;
67    let mut max_lat = f64::NEG_INFINITY;
68    let mut count = 0usize;
69
70    for p in positions {
71        let lat = p.latitude;
72        let lon = p.longitude;
73        if !lat.is_finite() || !lon.is_finite() {
74            continue;
75        }
76        if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
77            continue;
78        }
79        min_lon = min_lon.min(lon);
80        max_lon = max_lon.max(lon);
81        min_lat = min_lat.min(lat);
82        max_lat = max_lat.max(lat);
83        count += 1;
84    }
85
86    if count == 0 {
87        return Err(ProjectionError::InvalidGnssInput(
88            "No usable WGS84 coordinates in GNSS dataset".to_string(),
89        ));
90    }
91
92    let center_lat = (min_lat + max_lat) / 2.0;
93    let lat_expand = buffer_meters / 111_320.0;
94    let lon_expand = buffer_meters / (111_320.0 * center_lat.to_radians().cos().max(1e-6));
95
96    let exp_min_lon = min_lon - lon_expand;
97    let exp_max_lon = max_lon + lon_expand;
98    let exp_min_lat = min_lat - lat_expand;
99    let exp_max_lat = max_lat + lat_expand;
100
101    let polygon_wkt = format!(
102        "POLYGON(({lo1} {la1}, {lo2} {la1}, {lo2} {la2}, {lo1} {la2}, {lo1} {la1}))",
103        lo1 = exp_min_lon,
104        lo2 = exp_max_lon,
105        la1 = exp_min_lat,
106        la2 = exp_max_lat,
107    );
108
109    Ok(RetrievalArea {
110        min_longitude: exp_min_lon,
111        max_longitude: exp_max_lon,
112        min_latitude: exp_min_lat,
113        max_latitude: exp_max_lat,
114        expansion_meters: buffer_meters,
115        polygon_wkt,
116        source_crs: "EPSG:4326".to_string(),
117    })
118}
119
120/// Find indices of GNSS positions that fall outside the bounding box of the
121/// retrieved netelements (used for `uncovered_gnss_indices` diagnostics).
122pub fn uncovered_gnss_indices(
123    positions: &[GnssPosition],
124    netelements: &[Netelement],
125) -> Vec<usize> {
126    if netelements.is_empty() {
127        return (0..positions.len()).collect();
128    }
129    let mut min_lon = f64::INFINITY;
130    let mut max_lon = f64::NEG_INFINITY;
131    let mut min_lat = f64::INFINITY;
132    let mut max_lat = f64::NEG_INFINITY;
133    for ne in netelements {
134        for c in ne.geometry.coords() {
135            min_lon = min_lon.min(c.x);
136            max_lon = max_lon.max(c.x);
137            min_lat = min_lat.min(c.y);
138            max_lat = max_lat.max(c.y);
139        }
140    }
141    positions
142        .iter()
143        .enumerate()
144        .filter_map(|(i, p)| {
145            let inside = p.longitude >= min_lon
146                && p.longitude <= max_lon
147                && p.latitude >= min_lat
148                && p.latitude <= max_lat;
149            if inside {
150                None
151            } else {
152                Some(i)
153            }
154        })
155        .collect()
156}
157
158/// Validate a topology bundle produced from RINF.
159pub fn validate_topology(
160    netelements: &[Netelement],
161    netrelations: &[NetRelation],
162    netelement_lengths: &[(String, f64, usize)],
163    positions: &[GnssPosition],
164) -> TopologyValidationReport {
165    if netelements.is_empty() {
166        return TopologyValidationReport {
167            status: TopologyValidationStatus::MissingCoverage,
168            netelement_count: 0,
169            netrelation_count: 0,
170            coarse_geometry_ids: Vec::new(),
171            uncovered_gnss_indices: (0..positions.len()).collect(),
172            message: "No netelements returned for the search area".to_string(),
173        };
174    }
175
176    let coarse_ids: Vec<String> = netelement_lengths
177        .iter()
178        .filter_map(|(id, length, points)| {
179            if *length > COARSE_GEOMETRY_LENGTH_THRESHOLD_METERS && *points <= 2 {
180                Some(id.clone())
181            } else {
182                None
183            }
184        })
185        .collect();
186
187    // Only treat the topology as incomplete when *every* netelement is coarse.
188    // A few 2-point segments are expected (straight sections, short links) and
189    // are not a reason to reject the whole bundle.
190    if !coarse_ids.is_empty() && coarse_ids.len() == netelements.len() {
191        return TopologyValidationReport {
192            status: TopologyValidationStatus::IncompleteTopology,
193            netelement_count: netelements.len(),
194            netrelation_count: netrelations.len(),
195            coarse_geometry_ids: coarse_ids,
196            uncovered_gnss_indices: Vec::new(),
197            message: "Retrieved topology contains only coarse netelement geometries".to_string(),
198        };
199    }
200
201    if netrelations.is_empty() {
202        return TopologyValidationReport {
203            status: TopologyValidationStatus::IncompleteTopology,
204            netelement_count: netelements.len(),
205            netrelation_count: 0,
206            coarse_geometry_ids: Vec::new(),
207            uncovered_gnss_indices: Vec::new(),
208            message: "Retrieved topology has zero netrelations".to_string(),
209        };
210    }
211
212    let uncovered = uncovered_gnss_indices(positions, netelements);
213
214    TopologyValidationReport {
215        status: TopologyValidationStatus::Valid,
216        netelement_count: netelements.len(),
217        netrelation_count: netrelations.len(),
218        coarse_geometry_ids: coarse_ids,
219        uncovered_gnss_indices: uncovered,
220        message: "Topology validated successfully".to_string(),
221    }
222}
223
224/// Resolve the topology for a workflow. Returns the bundle plus an outcome
225/// summary suitable for surfacing to callers.
226///
227/// If `supplied` is `Some`, it is used verbatim and no retrieval is performed.
228/// Otherwise the SPARQL client is invoked with a polygon derived from `positions`.
229pub fn resolve_topology(
230    workflow_kind: WorkflowKind,
231    positions: &[GnssPosition],
232    supplied: Option<(Vec<Netelement>, Vec<NetRelation>)>,
233    config: &RetrievalConfig,
234    client: &dyn SparqlClient,
235) -> Result<(RetrievedTopology, RetrievalOutcome), ProjectionError> {
236    if let Some((nes, nrs)) = supplied {
237        let area = RetrievalArea {
238            min_longitude: 0.0,
239            max_longitude: 0.0,
240            min_latitude: 0.0,
241            max_latitude: 0.0,
242            expansion_meters: 0.0,
243            polygon_wkt: String::new(),
244            source_crs: "EPSG:4326".to_string(),
245        };
246        let report = TopologyValidationReport {
247            status: TopologyValidationStatus::Valid,
248            netelement_count: nes.len(),
249            netrelation_count: nrs.len(),
250            coarse_geometry_ids: Vec::new(),
251            uncovered_gnss_indices: Vec::new(),
252            message: "Supplied topology".to_string(),
253        };
254        let topology = RetrievedTopology {
255            netelements: nes,
256            netrelations: nrs,
257            retrieval_area: area,
258            endpoint_url: String::new(),
259            retrieved_at: Utc::now(),
260            validation_report: report,
261        };
262        return Ok((topology, RetrievalOutcome::supplied_success()));
263    }
264
265    let area = build_retrieval_area(positions, config.buffer_meters)?;
266
267    let _request = AutoTopologyRequest {
268        workflow_kind,
269        supplied_topology_present: false,
270        rinf_endpoint_url: config.endpoint_url.clone(),
271        retrieval_area: Some(area.clone()),
272        requested_at: Utc::now(),
273    };
274
275    let netelement_rows = fetch_netelements(client, &config.endpoint_url, &area.polygon_wkt)
276        .map_err(|e| ProjectionError::RinfRetrievalFailed(e.to_string()))?;
277
278    if netelement_rows.is_empty() {
279        let report = TopologyValidationReport {
280            status: TopologyValidationStatus::MissingCoverage,
281            netelement_count: 0,
282            netrelation_count: 0,
283            coarse_geometry_ids: Vec::new(),
284            uncovered_gnss_indices: (0..positions.len()).collect(),
285            message: "No netelements returned for the search area".to_string(),
286        };
287        let outcome = RetrievalOutcome {
288            source_used: TopologySource::EraRinf,
289            status: RetrievalStatus::MissingCoverage,
290            detail_message: report.message.clone(),
291            diagnostic_area_wkt: Some(area.polygon_wkt.clone()),
292            affected_gnss_indices: report.uncovered_gnss_indices.clone(),
293        };
294        let topology = RetrievedTopology {
295            netelements: Vec::new(),
296            netrelations: Vec::new(),
297            retrieval_area: area,
298            endpoint_url: config.endpoint_url.clone(),
299            retrieved_at: Utc::now(),
300            validation_report: report,
301        };
302        return Ok((topology, outcome));
303    }
304
305    let (netelements, lengths) = map_netelements_to_core(&netelement_rows)?;
306
307    let seed_iris: Vec<String> = netelement_rows
308        .iter()
309        .map(|r| r.netelement_iri.clone())
310        .collect();
311    let netrelation_rows = fetch_netrelations(client, &config.endpoint_url, &seed_iris)
312        .map_err(|e| ProjectionError::RinfRetrievalFailed(e.to_string()))?;
313    let netrelations = map_netrelations_to_core(&netrelation_rows, &netelements)?;
314
315    let report = validate_topology(&netelements, &netrelations, &lengths, positions);
316    let status = match report.status {
317        TopologyValidationStatus::Valid => RetrievalStatus::Success,
318        TopologyValidationStatus::MissingCoverage => RetrievalStatus::MissingCoverage,
319        TopologyValidationStatus::IncompleteTopology => RetrievalStatus::IncompleteTopology,
320        TopologyValidationStatus::EndpointFailure => RetrievalStatus::EndpointFailure,
321        TopologyValidationStatus::InvalidInput => RetrievalStatus::InvalidInput,
322    };
323    let outcome = RetrievalOutcome {
324        source_used: TopologySource::EraRinf,
325        status,
326        detail_message: report.message.clone(),
327        diagnostic_area_wkt: Some(area.polygon_wkt.clone()),
328        affected_gnss_indices: report.uncovered_gnss_indices.clone(),
329    };
330
331    let topology = RetrievedTopology {
332        netelements,
333        netrelations,
334        retrieval_area: area,
335        endpoint_url: config.endpoint_url.clone(),
336        retrieved_at: Utc::now(),
337        validation_report: report,
338    };
339
340    Ok((topology, outcome))
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use chrono::{DateTime, FixedOffset};
347    use geo::LineString;
348    use std::collections::HashMap;
349
350    fn gnss(lat: f64, lon: f64) -> GnssPosition {
351        let ts: DateTime<FixedOffset> =
352            DateTime::parse_from_rfc3339("2026-05-13T08:00:00+00:00").unwrap();
353        GnssPosition {
354            latitude: lat,
355            longitude: lon,
356            timestamp: ts,
357            crs: "EPSG:4326".to_string(),
358            metadata: HashMap::new(),
359            heading: None,
360            distance: None,
361        }
362    }
363
364    fn ne(id: &str, wkt: &str) -> Netelement {
365        Netelement::new(
366            id.to_string(),
367            crate::io::rinf::parse_wkt_linestring(wkt).unwrap(),
368            "EPSG:4326".to_string(),
369        )
370        .unwrap()
371    }
372
373    #[test]
374    fn retrieval_config_builder_methods_override_defaults() {
375        let cfg = RetrievalConfig::default()
376            .with_endpoint("https://example.invalid/sparql")
377            .with_buffer_meters(250.0);
378        assert_eq!(cfg.endpoint_url, "https://example.invalid/sparql");
379        assert_eq!(cfg.buffer_meters, 250.0);
380    }
381
382    #[test]
383    fn build_retrieval_area_skips_non_finite_and_out_of_range_points() {
384        let positions = vec![
385            gnss(f64::NAN, 4.0),
386            gnss(200.0, 4.0),
387            gnss(50.0, 4.0),
388            gnss(50.1, 4.2),
389        ];
390
391        let area = build_retrieval_area(&positions, 100.0).unwrap();
392        assert!(area.min_latitude < 50.0);
393        assert!(area.max_latitude > 50.1);
394        assert!(area.min_longitude < 4.0);
395        assert!(area.max_longitude > 4.2);
396    }
397
398    #[test]
399    fn build_retrieval_area_rejects_when_all_points_invalid() {
400        let positions = vec![gnss(f64::NAN, 4.0), gnss(95.0, 4.0), gnss(40.0, 190.0)];
401        let err = build_retrieval_area(&positions, 100.0).unwrap_err();
402        assert!(err.to_string().contains("No usable WGS84 coordinates"));
403    }
404
405    #[test]
406    fn uncovered_gnss_indices_returns_all_when_no_netelements() {
407        let positions = vec![gnss(50.0, 4.0), gnss(50.1, 4.1)];
408        let uncovered = uncovered_gnss_indices(&positions, &[]);
409        assert_eq!(uncovered, vec![0, 1]);
410    }
411
412    #[test]
413    fn uncovered_gnss_indices_marks_outside_points() {
414        let positions = vec![gnss(50.0, 4.0), gnss(51.0, 5.0)];
415        let netelements = vec![ne("NE-1", "LINESTRING(3.9 49.9, 4.2 50.2)")];
416        let uncovered = uncovered_gnss_indices(&positions, &netelements);
417        assert_eq!(uncovered, vec![1]);
418    }
419
420    #[test]
421    fn validate_topology_returns_missing_coverage_for_empty_netelements() {
422        let report = validate_topology(&[], &[], &[], &[gnss(50.0, 4.0)]);
423        assert_eq!(report.status, TopologyValidationStatus::MissingCoverage);
424        assert_eq!(report.uncovered_gnss_indices, vec![0]);
425    }
426
427    #[test]
428    fn validate_topology_returns_incomplete_when_all_netelements_coarse() {
429        let netelements = vec![ne("NE-1", "LINESTRING(4.0 50.0, 4.2 50.0)")];
430        let report = validate_topology(
431            &netelements,
432            &[],
433            &[("NE-1".to_string(), 20_000.0, 2)],
434            &[gnss(50.0, 4.0)],
435        );
436        assert_eq!(report.status, TopologyValidationStatus::IncompleteTopology);
437        assert_eq!(report.coarse_geometry_ids, vec!["NE-1".to_string()]);
438    }
439
440    #[test]
441    fn validate_topology_returns_incomplete_when_no_netrelations() {
442        let netelements = vec![Netelement::new(
443            "NE-1".to_string(),
444            LineString::from(vec![(4.0, 50.0), (4.0001, 50.0001), (4.0002, 50.0002)]),
445            "EPSG:4326".to_string(),
446        )
447        .unwrap()];
448        let report = validate_topology(&netelements, &[], &[("NE-1".to_string(), 100.0, 3)], &[]);
449        assert_eq!(report.status, TopologyValidationStatus::IncompleteTopology);
450    }
451}