Skip to main content

aviso_validators/
polygon.rs

1// (C) Copyright 2024- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9use anyhow::{Result, bail};
10use tracing::debug;
11
12/// Polygon coordinate validator
13///
14/// Validates polygon coordinate strings in the format "(lat1,lon1,lat2,lon2,lat1,lon1)"
15/// and ensures proper polygon geometry (closed, minimum vertices, valid coordinates).
16pub struct PolygonHandler;
17
18impl PolygonHandler {
19    pub fn validate_and_canonicalize(value: &str, field_name: &str) -> Result<String> {
20        debug!(
21            "Validating polygon field '{}' with value: {}",
22            field_name, value
23        );
24
25        // Parse the coordinate string
26        let coordinates = Self::parse_polygon_coordinates(value)?;
27        debug!(
28            "Parsed {} coordinate pairs for field '{}'",
29            coordinates.len(),
30            field_name
31        );
32
33        // Validate the polygon
34        Self::validate_polygon_geometry(&coordinates)?;
35        debug!(
36            "Polygon geometry validation passed for field '{}'",
37            field_name
38        );
39
40        // Return the original validated string
41        // (JSON conversion will happen elsewhere when building the payload)
42        Ok(value.to_string())
43    }
44
45    /// Parse a string of coordinates "(lat,lon,lat,lon,...)" into a vector of (lat, lon) tuples.
46    ///
47    /// This function ALWAYS returns (lat, lon)
48    /// DO NOT swap here. Only swap to (lon, lat) when passing to geo crate.
49    pub fn parse_polygon_coordinates(coord_string: &str) -> Result<Vec<(f64, f64)>> {
50        let trimmed = coord_string
51            .trim()
52            .trim_start_matches('(')
53            .trim_end_matches(')')
54            .trim();
55
56        if trimmed.is_empty() {
57            bail!("Empty polygon coordinate string");
58        }
59
60        let coord_parts: Vec<&str> = trimmed.split(',').collect();
61
62        if !coord_parts.len().is_multiple_of(2) {
63            bail!("Polygon coordinates must be in pairs (lat,lon)");
64        }
65
66        let mut coordinates = Vec::new();
67        let mut iter = coord_parts.iter();
68
69        while let Some(lat_str) = iter.next() {
70            let lon_str = iter.next().unwrap(); // Already checked length above
71
72            let lat: f64 = lat_str
73                .trim()
74                .parse()
75                .map_err(|_| anyhow::anyhow!("Invalid latitude value: {}", lat_str))?;
76
77            let lon: f64 = lon_str
78                .trim()
79                .parse()
80                .map_err(|_| anyhow::anyhow!("Invalid longitude value: {}", lon_str))?;
81
82            coordinates.push((lat, lon));
83        }
84
85        Ok(coordinates)
86    }
87
88    /// Validates polygon geometry requirements
89    fn validate_polygon_geometry(coordinates: &[(f64, f64)]) -> Result<()> {
90        if coordinates.len() < 3 {
91            bail!("Polygon must have at least 3 coordinate pairs");
92        }
93
94        // Check if polygon is closed (first and last coordinates are the same)
95        let first = coordinates.first().unwrap();
96        let last = coordinates.last().unwrap();
97
98        if first != last {
99            bail!("Polygon must be closed (first and last coordinates must be identical)");
100        }
101
102        Ok(())
103    }
104
105    /// Calculates bounding box for spatial filtering optimization
106    /// This will be used later when we handle the payload and headers
107    pub fn calculate_bounding_box(coordinates: &[(f64, f64)]) -> String {
108        let mut min_lat = f64::INFINITY;
109        let mut min_lon = f64::INFINITY;
110        let mut max_lat = f64::NEG_INFINITY;
111        let mut max_lon = f64::NEG_INFINITY;
112
113        for &(lat, lon) in coordinates {
114            min_lat = min_lat.min(lat);
115            min_lon = min_lon.min(lon);
116            max_lat = max_lat.max(lat);
117            max_lon = max_lon.max(lon);
118        }
119
120        format!("{},{},{},{}", min_lat, min_lon, max_lat, max_lon)
121    }
122
123    pub fn parse_bbox_coordinates(s: &str) -> Result<(f64, f64, f64, f64)> {
124        // expects (lat_min,lon_min,lat_max,lon_max)
125        let s = s.trim_matches(|c| c == '(' || c == ')');
126        let coords: Vec<f64> = s
127            .split(',')
128            .map(|part| part.trim().parse())
129            .collect::<Result<_, _>>()?;
130        if coords.len() != 4 {
131            anyhow::bail!("BBox must have 4 numbers");
132        }
133        Ok((coords[0], coords[1], coords[2], coords[3]))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_validate_and_canonicalize_valid_polygon() {
143        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
144        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
145
146        assert!(result.is_ok());
147        assert_eq!(result.unwrap(), polygon_str);
148    }
149
150    #[test]
151    fn test_validate_and_canonicalize_with_spaces() {
152        let polygon_str = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.6 , 52.4 , 13.5 , 52.5 , 13.4 )";
153        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
154
155        assert!(result.is_ok());
156        assert_eq!(result.unwrap(), polygon_str);
157    }
158
159    #[test]
160    fn test_validate_and_canonicalize_not_closed() {
161        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5)"; // Missing closing point
162        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
163
164        assert!(result.is_err());
165    }
166
167    #[test]
168    fn test_validate_and_canonicalize_too_few_points() {
169        let polygon_str = "(52.5,13.4,52.6,13.5)"; // Only 2 points
170        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
171
172        assert!(result.is_err());
173    }
174
175    #[test]
176    fn test_validate_and_canonicalize_empty_string() {
177        let polygon_str = "";
178        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
179
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn test_validate_and_canonicalize_empty_parentheses() {
185        let polygon_str = "()";
186        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
187
188        assert!(result.is_err());
189    }
190
191    #[test]
192    fn test_parse_polygon_coordinates_valid() {
193        let coord_string = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
194        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
195
196        assert!(result.is_ok());
197        let coordinates = result.unwrap();
198        assert_eq!(coordinates.len(), 5);
199        assert_eq!(coordinates[0], (52.5, 13.4));
200        assert_eq!(coordinates[1], (52.6, 13.5));
201        assert_eq!(coordinates[4], (52.5, 13.4)); // Should be closed
202    }
203
204    #[test]
205    fn test_parse_polygon_coordinates_without_parentheses() {
206        let coord_string = "52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4";
207        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
208
209        assert!(result.is_ok());
210        let coordinates = result.unwrap();
211        assert_eq!(coordinates.len(), 5);
212        assert_eq!(coordinates[0], (52.5, 13.4));
213    }
214
215    #[test]
216    fn test_parse_polygon_coordinates_with_spaces() {
217        let coord_string = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.4 )";
218        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
219
220        assert!(result.is_ok());
221        let coordinates = result.unwrap();
222        assert_eq!(coordinates.len(), 3);
223        assert_eq!(coordinates[0], (52.5, 13.4));
224        assert_eq!(coordinates[1], (52.6, 13.5));
225        assert_eq!(coordinates[2], (52.5, 13.4));
226    }
227
228    #[test]
229    fn test_parse_polygon_coordinates_odd_number() {
230        let coord_string = "(52.5,13.4,52.6)"; // Odd number of coordinates
231        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
232
233        assert!(result.is_err());
234    }
235
236    #[test]
237    fn test_parse_polygon_coordinates_invalid_latitude() {
238        let coord_string = "(invalid,13.4,52.6,13.5,52.5,13.4)";
239        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
240
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn test_parse_polygon_coordinates_invalid_longitude() {
246        let coord_string = "(52.5,invalid,52.6,13.5,52.5,13.4)";
247        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
248
249        assert!(result.is_err());
250    }
251
252    #[test]
253    fn test_parse_polygon_coordinates_empty() {
254        let coord_string = "()";
255        let result = PolygonHandler::parse_polygon_coordinates(coord_string);
256
257        assert!(result.is_err());
258    }
259
260    #[test]
261    fn test_validate_polygon_geometry_valid_triangle() {
262        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
263        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
264
265        assert!(result.is_ok());
266    }
267
268    #[test]
269    fn test_validate_polygon_geometry_valid_rectangle() {
270        let coordinates = vec![
271            (52.5, 13.4),
272            (52.6, 13.4),
273            (52.6, 13.5),
274            (52.5, 13.5),
275            (52.5, 13.4),
276        ];
277        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
278
279        assert!(result.is_ok());
280    }
281
282    #[test]
283    fn test_validate_polygon_geometry_too_few_points() {
284        let coordinates = vec![(0.0, 0.0), (1.0, 0.0)]; // Only 2 points
285        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
286
287        assert!(result.is_err());
288    }
289
290    #[test]
291    fn test_validate_polygon_geometry_not_closed() {
292        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.1, 0.1)]; // Not closed
293        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
294
295        assert!(result.is_err());
296    }
297
298    #[test]
299    fn test_validate_polygon_geometry_minimum_valid() {
300        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)]; // Minimum valid triangle
301        let result = PolygonHandler::validate_polygon_geometry(&coordinates);
302
303        assert!(result.is_ok());
304    }
305
306    #[test]
307    fn test_calculate_bounding_box_rectangle() {
308        let coordinates = vec![
309            (52.5, 13.4),
310            (52.6, 13.4),
311            (52.6, 13.5),
312            (52.5, 13.5),
313            (52.5, 13.4),
314        ];
315        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
316
317        assert_eq!(bbox, "52.5,13.4,52.6,13.5");
318    }
319
320    #[test]
321    fn test_calculate_bounding_box_triangle() {
322        let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
323        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
324
325        assert_eq!(bbox, "0,0,1,1");
326    }
327
328    #[test]
329    fn test_calculate_bounding_box_single_point() {
330        let coordinates = vec![(52.5, 13.4), (52.5, 13.4), (52.5, 13.4), (52.5, 13.4)];
331        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
332
333        assert_eq!(bbox, "52.5,13.4,52.5,13.4");
334    }
335
336    #[test]
337    fn test_calculate_bounding_box_negative_coordinates() {
338        let coordinates = vec![
339            (-1.0, -1.0),
340            (1.0, -1.0),
341            (1.0, 1.0),
342            (-1.0, 1.0),
343            (-1.0, -1.0),
344        ];
345        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
346
347        assert_eq!(bbox, "-1,-1,1,1");
348    }
349
350    #[test]
351    fn test_integration_parse_and_validate() {
352        let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
353
354        // Test the full pipeline: parse -> validate -> calculate bbox
355        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
356        let validation_result = PolygonHandler::validate_polygon_geometry(&coordinates);
357        assert!(validation_result.is_ok());
358
359        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
360        assert_eq!(bbox, "52.4,13.4,52.6,13.6");
361    }
362
363    #[test]
364    fn test_real_world_berlin_polygon() {
365        // Real-world coordinates around Berlin
366        let polygon_str =
367            "(52.5200,13.4050,52.5200,13.4500,52.4800,13.4500,52.4800,13.4050,52.5200,13.4050)";
368        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "berlin_area");
369
370        assert!(result.is_ok());
371
372        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
373        let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
374        assert_eq!(bbox, "52.48,13.405,52.52,13.45");
375    }
376
377    #[test]
378    fn test_precision_handling() {
379        // Test with high precision coordinates
380        let polygon_str = "(52.123456789,13.987654321,52.234567890,13.876543210,52.345678901,13.765432109,52.123456789,13.987654321)";
381        let result = PolygonHandler::validate_and_canonicalize(polygon_str, "precision_test");
382
383        assert!(result.is_ok());
384
385        let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
386        assert_eq!(coordinates[0].0, 52.123456789);
387        assert_eq!(coordinates[0].1, 13.987654321);
388    }
389}