1use 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#[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
50pub 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
120pub 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
158pub 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 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
224pub 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}