Skip to main content

jpx_core/extensions/
geo.rs

1//! Geographic/geospatial functions.
2
3use std::collections::HashSet;
4
5use geohash::Coord;
6use geoutils::Location;
7use serde_json::{Value, json};
8
9use crate::functions::{Function, custom_error, number_value};
10use crate::interpreter::SearchResult;
11use crate::registry::register_if_enabled;
12use crate::{Context, Runtime, arg, defn};
13
14// =============================================================================
15// geo_distance(lat1, lon1, lat2, lon2) -> number (meters)
16// =============================================================================
17
18defn!(
19    GeoDistanceFn,
20    vec![arg!(number), arg!(number), arg!(number), arg!(number)],
21    None
22);
23
24impl Function for GeoDistanceFn {
25    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
26        self.signature.validate(args, ctx)?;
27        let lat1 = args[0].as_f64().unwrap();
28        let lon1 = args[1].as_f64().unwrap();
29        let lat2 = args[2].as_f64().unwrap();
30        let lon2 = args[3].as_f64().unwrap();
31
32        let loc1 = Location::new(lat1, lon1);
33        let loc2 = Location::new(lat2, lon2);
34
35        let distance = loc1.haversine_distance_to(&loc2);
36        Ok(number_value(distance.meters()))
37    }
38}
39
40// =============================================================================
41// geo_distance_km(lat1, lon1, lat2, lon2) -> number (kilometers)
42// =============================================================================
43
44defn!(
45    GeoDistanceKmFn,
46    vec![arg!(number), arg!(number), arg!(number), arg!(number)],
47    None
48);
49
50impl Function for GeoDistanceKmFn {
51    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
52        self.signature.validate(args, ctx)?;
53        let lat1 = args[0].as_f64().unwrap();
54        let lon1 = args[1].as_f64().unwrap();
55        let lat2 = args[2].as_f64().unwrap();
56        let lon2 = args[3].as_f64().unwrap();
57
58        let loc1 = Location::new(lat1, lon1);
59        let loc2 = Location::new(lat2, lon2);
60
61        let distance = loc1.haversine_distance_to(&loc2);
62        Ok(number_value(distance.meters() / 1000.0))
63    }
64}
65
66// =============================================================================
67// geo_distance_miles(lat1, lon1, lat2, lon2) -> number (miles)
68// =============================================================================
69
70defn!(
71    GeoDistanceMilesFn,
72    vec![arg!(number), arg!(number), arg!(number), arg!(number)],
73    None
74);
75
76impl Function for GeoDistanceMilesFn {
77    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
78        self.signature.validate(args, ctx)?;
79        let lat1 = args[0].as_f64().unwrap();
80        let lon1 = args[1].as_f64().unwrap();
81        let lat2 = args[2].as_f64().unwrap();
82        let lon2 = args[3].as_f64().unwrap();
83
84        let loc1 = Location::new(lat1, lon1);
85        let loc2 = Location::new(lat2, lon2);
86
87        // 1 meter = 0.000621371 miles
88        const METERS_TO_MILES: f64 = 0.000621371;
89
90        let distance = loc1.haversine_distance_to(&loc2);
91        Ok(number_value(distance.meters() * METERS_TO_MILES))
92    }
93}
94
95// =============================================================================
96// geo_bearing(lat1, lon1, lat2, lon2) -> number (degrees 0-360)
97// =============================================================================
98
99defn!(
100    GeoBearingFn,
101    vec![arg!(number), arg!(number), arg!(number), arg!(number)],
102    None
103);
104
105impl Function for GeoBearingFn {
106    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
107        self.signature.validate(args, ctx)?;
108        let lat1 = args[0].as_f64().unwrap();
109        let lon1 = args[1].as_f64().unwrap();
110        let lat2 = args[2].as_f64().unwrap();
111        let lon2 = args[3].as_f64().unwrap();
112
113        // Calculate initial bearing using the forward azimuth formula
114        let lat1_rad = lat1.to_radians();
115        let lat2_rad = lat2.to_radians();
116        let delta_lon = (lon2 - lon1).to_radians();
117
118        let x = delta_lon.sin() * lat2_rad.cos();
119        let y = lat1_rad.cos() * lat2_rad.sin() - lat1_rad.sin() * lat2_rad.cos() * delta_lon.cos();
120
121        let bearing_rad = x.atan2(y);
122        let mut bearing = bearing_rad.to_degrees();
123
124        // Normalize to 0-360
125        if bearing < 0.0 {
126            bearing += 360.0;
127        }
128
129        Ok(number_value(bearing))
130    }
131}
132
133// =============================================================================
134// geo_bounding_box(array_of_points) -> {min_lat, max_lat, min_lon, max_lon}
135// =============================================================================
136
137defn!(GeoBoundingBoxFn, vec![arg!(array)], None);
138
139impl Function for GeoBoundingBoxFn {
140    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
141        self.signature.validate(args, ctx)?;
142        let points = args[0].as_array().unwrap();
143
144        if points.is_empty() {
145            return Err(custom_error(
146                ctx,
147                "geo_bounding_box requires a non-empty array of [lat, lon] points",
148            ));
149        }
150
151        let mut min_lat = f64::MAX;
152        let mut max_lat = f64::MIN;
153        let mut min_lon = f64::MAX;
154        let mut max_lon = f64::MIN;
155
156        for (i, point) in points.iter().enumerate() {
157            let arr = point.as_array().ok_or_else(|| {
158                custom_error(
159                    ctx,
160                    &format!("geo_bounding_box: element {i} is not an array"),
161                )
162            })?;
163            if arr.len() < 2 {
164                return Err(custom_error(
165                    ctx,
166                    &format!(
167                        "geo_bounding_box: element {i} must have at least 2 elements [lat, lon]"
168                    ),
169                ));
170            }
171            let lat = arr[0].as_f64().ok_or_else(|| {
172                custom_error(
173                    ctx,
174                    &format!("geo_bounding_box: element {i} lat is not a number"),
175                )
176            })?;
177            let lon = arr[1].as_f64().ok_or_else(|| {
178                custom_error(
179                    ctx,
180                    &format!("geo_bounding_box: element {i} lon is not a number"),
181                )
182            })?;
183
184            if lat < min_lat {
185                min_lat = lat;
186            }
187            if lat > max_lat {
188                max_lat = lat;
189            }
190            if lon < min_lon {
191                min_lon = lon;
192            }
193            if lon > max_lon {
194                max_lon = lon;
195            }
196        }
197
198        Ok(json!({
199            "min_lat": min_lat,
200            "max_lat": max_lat,
201            "min_lon": min_lon,
202            "max_lon": max_lon,
203        }))
204    }
205}
206
207// =============================================================================
208// geo_in_bbox(lat, lon, min_lat, max_lat, min_lon, max_lon) -> boolean
209// =============================================================================
210
211defn!(
212    GeoInBboxFn,
213    vec![
214        arg!(number),
215        arg!(number),
216        arg!(number),
217        arg!(number),
218        arg!(number),
219        arg!(number)
220    ],
221    None
222);
223
224impl Function for GeoInBboxFn {
225    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
226        self.signature.validate(args, ctx)?;
227        let lat = args[0].as_f64().unwrap();
228        let lon = args[1].as_f64().unwrap();
229        let min_lat = args[2].as_f64().unwrap();
230        let max_lat = args[3].as_f64().unwrap();
231        let min_lon = args[4].as_f64().unwrap();
232        let max_lon = args[5].as_f64().unwrap();
233
234        let inside = lat >= min_lat && lat <= max_lat && lon >= min_lon && lon <= max_lon;
235        Ok(Value::Bool(inside))
236    }
237}
238
239// =============================================================================
240// geo_in_radius(lat, lon, center_lat, center_lon, radius_km) -> boolean
241// =============================================================================
242
243defn!(
244    GeoInRadiusFn,
245    vec![
246        arg!(number),
247        arg!(number),
248        arg!(number),
249        arg!(number),
250        arg!(number)
251    ],
252    None
253);
254
255impl Function for GeoInRadiusFn {
256    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
257        self.signature.validate(args, ctx)?;
258        let lat = args[0].as_f64().unwrap();
259        let lon = args[1].as_f64().unwrap();
260        let center_lat = args[2].as_f64().unwrap();
261        let center_lon = args[3].as_f64().unwrap();
262        let radius_km = args[4].as_f64().unwrap();
263
264        let point = Location::new(lat, lon);
265        let center = Location::new(center_lat, center_lon);
266        let distance_m = point.haversine_distance_to(&center).meters();
267
268        Ok(Value::Bool(distance_m <= radius_km * 1000.0))
269    }
270}
271
272// =============================================================================
273// geo_midpoint(array_of_points) -> [lat, lon]
274// =============================================================================
275
276defn!(GeoMidpointFn, vec![arg!(array)], None);
277
278impl Function for GeoMidpointFn {
279    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
280        self.signature.validate(args, ctx)?;
281        let points = args[0].as_array().unwrap();
282
283        if points.is_empty() {
284            return Err(custom_error(
285                ctx,
286                "geo_midpoint requires a non-empty array of [lat, lon] points",
287            ));
288        }
289
290        // Cartesian averaging on unit sphere (accurate for large distances)
291        let mut x = 0.0_f64;
292        let mut y = 0.0_f64;
293        let mut z = 0.0_f64;
294
295        for (i, point) in points.iter().enumerate() {
296            let arr = point.as_array().ok_or_else(|| {
297                custom_error(ctx, &format!("geo_midpoint: element {i} is not an array"))
298            })?;
299            if arr.len() < 2 {
300                return Err(custom_error(
301                    ctx,
302                    &format!("geo_midpoint: element {i} must have at least 2 elements [lat, lon]"),
303                ));
304            }
305            let lat = arr[0].as_f64().ok_or_else(|| {
306                custom_error(
307                    ctx,
308                    &format!("geo_midpoint: element {i} lat is not a number"),
309                )
310            })?;
311            let lon = arr[1].as_f64().ok_or_else(|| {
312                custom_error(
313                    ctx,
314                    &format!("geo_midpoint: element {i} lon is not a number"),
315                )
316            })?;
317
318            let lat_rad = lat.to_radians();
319            let lon_rad = lon.to_radians();
320
321            x += lat_rad.cos() * lon_rad.cos();
322            y += lat_rad.cos() * lon_rad.sin();
323            z += lat_rad.sin();
324        }
325
326        let n = points.len() as f64;
327        x /= n;
328        y /= n;
329        z /= n;
330
331        let lon_mid = y.atan2(x).to_degrees();
332        let hyp = (x * x + y * y).sqrt();
333        let lat_mid = z.atan2(hyp).to_degrees();
334
335        Ok(json!([lat_mid, lon_mid]))
336    }
337}
338
339// =============================================================================
340// geohash_encode(lat, lon, precision?) -> string
341// =============================================================================
342
343defn!(
344    GeohashEncodeFn,
345    vec![arg!(number), arg!(number)],
346    Some(arg!(number))
347);
348
349impl Function for GeohashEncodeFn {
350    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
351        self.signature.validate(args, ctx)?;
352        let lat = args[0].as_f64().unwrap();
353        let lon = args[1].as_f64().unwrap();
354        let precision = if args.len() > 2 {
355            args[2].as_f64().unwrap() as usize
356        } else {
357            12
358        };
359
360        let hash = geohash::encode(Coord { x: lon, y: lat }, precision)
361            .map_err(|e| custom_error(ctx, &format!("geohash_encode: {e}")))?;
362        Ok(Value::String(hash))
363    }
364}
365
366// =============================================================================
367// geohash_decode(hash) -> {lat, lon}
368// =============================================================================
369
370defn!(GeohashDecodeFn, vec![arg!(string)], None);
371
372impl Function for GeohashDecodeFn {
373    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
374        self.signature.validate(args, ctx)?;
375        let hash = args[0].as_str().unwrap();
376
377        let (coord, _, _) = geohash::decode(hash)
378            .map_err(|e| custom_error(ctx, &format!("geohash_decode: {e}")))?;
379
380        Ok(json!({
381            "lat": coord.y,
382            "lon": coord.x,
383        }))
384    }
385}
386
387/// Register geo functions filtered by the enabled set.
388pub fn register_filtered(runtime: &mut Runtime, enabled: &HashSet<&str>) {
389    register_if_enabled(
390        runtime,
391        "geo_distance",
392        enabled,
393        Box::new(GeoDistanceFn::new()),
394    );
395    register_if_enabled(
396        runtime,
397        "geo_distance_km",
398        enabled,
399        Box::new(GeoDistanceKmFn::new()),
400    );
401    register_if_enabled(
402        runtime,
403        "geo_distance_miles",
404        enabled,
405        Box::new(GeoDistanceMilesFn::new()),
406    );
407    register_if_enabled(
408        runtime,
409        "geo_bearing",
410        enabled,
411        Box::new(GeoBearingFn::new()),
412    );
413    register_if_enabled(
414        runtime,
415        "geo_bounding_box",
416        enabled,
417        Box::new(GeoBoundingBoxFn::new()),
418    );
419    register_if_enabled(
420        runtime,
421        "geo_in_bbox",
422        enabled,
423        Box::new(GeoInBboxFn::new()),
424    );
425    register_if_enabled(
426        runtime,
427        "geo_in_radius",
428        enabled,
429        Box::new(GeoInRadiusFn::new()),
430    );
431    register_if_enabled(
432        runtime,
433        "geo_midpoint",
434        enabled,
435        Box::new(GeoMidpointFn::new()),
436    );
437    register_if_enabled(
438        runtime,
439        "geohash_encode",
440        enabled,
441        Box::new(GeohashEncodeFn::new()),
442    );
443    register_if_enabled(
444        runtime,
445        "geohash_decode",
446        enabled,
447        Box::new(GeohashDecodeFn::new()),
448    );
449}
450
451#[cfg(test)]
452mod tests {
453    use crate::Runtime;
454    use serde_json::json;
455
456    fn setup_runtime() -> Runtime {
457        Runtime::builder()
458            .with_standard()
459            .with_all_extensions()
460            .build()
461    }
462
463    #[test]
464    fn test_geo_distance() {
465        let runtime = setup_runtime();
466        // NYC to LA: approximately 3940 km
467        let data = json!({"nyc": [40.7128, -74.0060], "la": [34.0522, -118.2437]});
468        let expr = runtime
469            .compile("geo_distance(nyc[0], nyc[1], la[0], la[1])")
470            .unwrap();
471        let result = expr.search(&data).unwrap();
472        let meters = result.as_f64().unwrap();
473        // Should be approximately 3940000 meters
474        assert!(meters > 3900000.0 && meters < 4000000.0);
475    }
476
477    #[test]
478    fn test_geo_distance_km() {
479        let runtime = setup_runtime();
480        let data = json!({"nyc": [40.7128, -74.0060], "la": [34.0522, -118.2437]});
481        let expr = runtime
482            .compile("geo_distance_km(nyc[0], nyc[1], la[0], la[1])")
483            .unwrap();
484        let result = expr.search(&data).unwrap();
485        let km = result.as_f64().unwrap();
486        // Should be approximately 3940 km
487        assert!(km > 3900.0 && km < 4000.0);
488    }
489
490    #[test]
491    fn test_geo_distance_miles() {
492        let runtime = setup_runtime();
493        let data = json!({"nyc": [40.7128, -74.0060], "la": [34.0522, -118.2437]});
494        let expr = runtime
495            .compile("geo_distance_miles(nyc[0], nyc[1], la[0], la[1])")
496            .unwrap();
497        let result = expr.search(&data).unwrap();
498        let miles = result.as_f64().unwrap();
499        // Should be approximately 2450 miles
500        assert!(miles > 2400.0 && miles < 2500.0);
501    }
502
503    #[test]
504    fn test_geo_bearing() {
505        let runtime = setup_runtime();
506        // NYC to LA should be roughly west (270 degrees)
507        let data = json!({"nyc": [40.7128, -74.0060], "la": [34.0522, -118.2437]});
508        let expr = runtime
509            .compile("geo_bearing(nyc[0], nyc[1], la[0], la[1])")
510            .unwrap();
511        let result = expr.search(&data).unwrap();
512        let bearing = result.as_f64().unwrap();
513        // Should be roughly 273 degrees (west-southwest)
514        assert!(bearing > 260.0 && bearing < 290.0);
515    }
516
517    #[test]
518    fn test_geo_distance_same_point() {
519        let runtime = setup_runtime();
520        let data = json!([40.7128, -74.0060]);
521        let expr = runtime
522            .compile("geo_distance(@[0], @[1], @[0], @[1])")
523            .unwrap();
524        let result = expr.search(&data).unwrap();
525        let meters = result.as_f64().unwrap();
526        assert!(meters < 1.0); // Should be essentially 0
527    }
528
529    #[test]
530    fn test_geo_midpoint_two_points() {
531        let runtime = setup_runtime();
532        // Midpoint of equator points (0,0) and (0,90) should be roughly (0, 45)
533        let data = json!([[0, 0], [0, 90]]);
534        let expr = runtime.compile("geo_midpoint(@)").unwrap();
535        let result = expr.search(&data).unwrap();
536        let arr = result.as_array().unwrap();
537        let lat = arr[0].as_f64().unwrap();
538        let lon = arr[1].as_f64().unwrap();
539        assert!(lat.abs() < 0.01, "lat should be ~0, got {lat}");
540        assert!((lon - 45.0).abs() < 0.01, "lon should be ~45, got {lon}");
541    }
542
543    #[test]
544    fn test_geo_midpoint_same_point() {
545        let runtime = setup_runtime();
546        let data = json!([[40.7128, -74.0060], [40.7128, -74.0060]]);
547        let expr = runtime.compile("geo_midpoint(@)").unwrap();
548        let result = expr.search(&data).unwrap();
549        let arr = result.as_array().unwrap();
550        let lat = arr[0].as_f64().unwrap();
551        let lon = arr[1].as_f64().unwrap();
552        assert!((lat - 40.7128).abs() < 0.001);
553        assert!((lon - (-74.0060)).abs() < 0.001);
554    }
555
556    #[test]
557    fn test_geo_bounding_box() {
558        let runtime = setup_runtime();
559        let data = json!([
560            [40.7128, -74.0060],
561            [34.0522, -118.2437],
562            [37.7749, -122.4194]
563        ]);
564        let expr = runtime.compile("geo_bounding_box(@)").unwrap();
565        let result = expr.search(&data).unwrap();
566        let obj = result.as_object().unwrap();
567        assert!((obj["min_lat"].as_f64().unwrap() - 34.0522).abs() < 0.001);
568        assert!((obj["max_lat"].as_f64().unwrap() - 40.7128).abs() < 0.001);
569        assert!((obj["min_lon"].as_f64().unwrap() - (-122.4194)).abs() < 0.001);
570        assert!((obj["max_lon"].as_f64().unwrap() - (-74.0060)).abs() < 0.001);
571    }
572
573    #[test]
574    fn test_geo_bounding_box_single_point() {
575        let runtime = setup_runtime();
576        let data = json!([[40.7128, -74.0060]]);
577        let expr = runtime.compile("geo_bounding_box(@)").unwrap();
578        let result = expr.search(&data).unwrap();
579        let obj = result.as_object().unwrap();
580        assert!((obj["min_lat"].as_f64().unwrap() - 40.7128).abs() < 0.001);
581        assert!((obj["max_lat"].as_f64().unwrap() - 40.7128).abs() < 0.001);
582    }
583
584    #[test]
585    fn test_geo_in_radius_inside() {
586        let runtime = setup_runtime();
587        // Times Square is ~1.3 km from Empire State Building
588        let data = json!(null);
589        let expr = runtime
590            .compile("geo_in_radius(`40.7580`, `-73.9855`, `40.7484`, `-73.9857`, `2`)")
591            .unwrap();
592        let result = expr.search(&data).unwrap();
593        assert_eq!(result, json!(true));
594    }
595
596    #[test]
597    fn test_geo_in_radius_outside() {
598        let runtime = setup_runtime();
599        // NYC to LA is ~3940 km, so 100 km radius won't contain it
600        let data = json!(null);
601        let expr = runtime
602            .compile("geo_in_radius(`34.0522`, `-118.2437`, `40.7128`, `-74.0060`, `100`)")
603            .unwrap();
604        let result = expr.search(&data).unwrap();
605        assert_eq!(result, json!(false));
606    }
607
608    #[test]
609    fn test_geo_in_bbox_inside() {
610        let runtime = setup_runtime();
611        let data = json!(null);
612        let expr = runtime
613            .compile("geo_in_bbox(`40.0`, `-75.0`, `39.0`, `41.0`, `-76.0`, `-74.0`)")
614            .unwrap();
615        let result = expr.search(&data).unwrap();
616        assert_eq!(result, json!(true));
617    }
618
619    #[test]
620    fn test_geo_in_bbox_outside() {
621        let runtime = setup_runtime();
622        let data = json!(null);
623        let expr = runtime
624            .compile("geo_in_bbox(`42.0`, `-75.0`, `39.0`, `41.0`, `-76.0`, `-74.0`)")
625            .unwrap();
626        let result = expr.search(&data).unwrap();
627        assert_eq!(result, json!(false));
628    }
629
630    #[test]
631    fn test_geo_in_bbox_boundary() {
632        let runtime = setup_runtime();
633        let data = json!(null);
634        // Exactly on the boundary should be inside (inclusive)
635        let expr = runtime
636            .compile("geo_in_bbox(`39.0`, `-76.0`, `39.0`, `41.0`, `-76.0`, `-74.0`)")
637            .unwrap();
638        let result = expr.search(&data).unwrap();
639        assert_eq!(result, json!(true));
640    }
641
642    #[test]
643    fn test_geohash_encode_default_precision() {
644        let runtime = setup_runtime();
645        let data = json!(null);
646        let expr = runtime
647            .compile("geohash_encode(`40.7128`, `-74.0060`)")
648            .unwrap();
649        let result = expr.search(&data).unwrap();
650        let hash = result.as_str().unwrap();
651        assert_eq!(hash.len(), 12);
652        assert!(hash.starts_with("dr5r"));
653    }
654
655    #[test]
656    fn test_geohash_encode_custom_precision() {
657        let runtime = setup_runtime();
658        let data = json!(null);
659        let expr = runtime
660            .compile("geohash_encode(`40.7128`, `-74.0060`, `5`)")
661            .unwrap();
662        let result = expr.search(&data).unwrap();
663        let hash = result.as_str().unwrap();
664        assert_eq!(hash.len(), 5);
665    }
666
667    #[test]
668    fn test_geohash_decode() {
669        let runtime = setup_runtime();
670        let data = json!(null);
671        let expr = runtime.compile("geohash_decode('dr5ru7')").unwrap();
672        let result = expr.search(&data).unwrap();
673        let obj = result.as_object().unwrap();
674        let lat = obj["lat"].as_f64().unwrap();
675        let lon = obj["lon"].as_f64().unwrap();
676        // dr5ru7 is in the NYC area
677        assert!(lat > 40.0 && lat < 41.0, "lat should be ~40.7, got {lat}");
678        assert!(lon > -75.0 && lon < -73.0, "lon should be ~-74, got {lon}");
679    }
680
681    #[test]
682    fn test_geohash_roundtrip() {
683        let runtime = setup_runtime();
684        let data = json!(null);
685        // Encode then decode should give back approximately the same point
686        let encode_expr = runtime
687            .compile("geohash_encode(`48.8566`, `2.3522`, `8`)")
688            .unwrap();
689        let hash_val = encode_expr.search(&data).unwrap();
690        let hash = hash_val.as_str().unwrap();
691
692        let decode_expr_str = format!("geohash_decode('{hash}')");
693        let decode_expr = runtime.compile(&decode_expr_str).unwrap();
694        let result = decode_expr.search(&data).unwrap();
695        let obj = result.as_object().unwrap();
696        let lat = obj["lat"].as_f64().unwrap();
697        let lon = obj["lon"].as_f64().unwrap();
698        assert!(
699            (lat - 48.8566).abs() < 0.01,
700            "lat roundtrip: expected ~48.8566, got {lat}"
701        );
702        assert!(
703            (lon - 2.3522).abs() < 0.01,
704            "lon roundtrip: expected ~2.3522, got {lon}"
705        );
706    }
707}