Skip to main content

open_geocode/
reverse.rs

1use std::{collections::BTreeSet, path::Path, sync::Arc};
2
3use anyhow::Result;
4use serde::Serialize;
5
6use crate::{
7    pack::{ContextRecord, PackReader, RecordId},
8    record::{AddressComponents, InterpolationAddressComponents, InterpolationRange, Layer},
9    spatial_index::SpatialIndexReader,
10};
11
12const ADDRESS_RADIUS_M: f64 = 30.0;
13const INTERPOLATION_RADIUS_M: f64 = 30.0;
14const STREET_RADIUS_M: f64 = 30.0;
15const CONTEXT_RADIUS_M: f64 = 50_000.0;
16const CANDIDATE_LIMIT: usize = 16;
17
18#[derive(Debug)]
19pub struct PackReverseGeocoder {
20    pack: Arc<PackReader>,
21    spatial: SpatialIndexReader,
22}
23
24#[derive(Debug, Clone, Copy)]
25pub struct ReverseGeocodeOptions {
26    pub lon: f64,
27    pub lat: f64,
28}
29
30#[derive(Debug, Serialize, PartialEq)]
31pub struct ReverseGeocodeResponse {
32    pub lon: f64,
33    pub lat: f64,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub result: Option<ReverseGeocodeResult>,
36}
37
38#[derive(Debug, Serialize, PartialEq)]
39pub struct ReverseGeocodeResult {
40    pub match_kind: ReverseMatchKind,
41    pub label: String,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub primary_record_id: Option<RecordId>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub id: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub layer: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub distance_m: Option<f64>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub point: Option<ReversePoint>,
52    pub context: ReverseContext,
53    pub evidence: ReverseEvidence,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum ReverseMatchKind {
59    ExplicitAddress,
60    EstimatedAddress,
61    NearestStreet,
62    ContextOnly,
63}
64
65#[derive(Debug, Clone, Serialize, PartialEq)]
66pub struct ReversePoint {
67    pub lon: f64,
68    pub lat: f64,
69    pub precision: ReversePointPrecision,
70}
71
72#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
73#[serde(rename_all = "snake_case")]
74pub enum ReversePointPrecision {
75    Point,
76    Estimated,
77    Street,
78    Context,
79}
80
81#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
82pub struct ReverseContext {
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub street: Option<String>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub place: Option<String>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub postcode: Option<String>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub neighbourhood: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub locality: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub district: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub region: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub country: Option<String>,
99}
100
101#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
102pub struct ReverseEvidence {
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub explicit_address_record_id: Option<RecordId>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub interpolation_record_id: Option<RecordId>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub street_record_id: Option<RecordId>,
109    #[serde(skip_serializing_if = "Vec::is_empty")]
110    pub context_record_ids: Vec<RecordId>,
111}
112
113impl PackReverseGeocoder {
114    pub fn open(pack_path: impl AsRef<Path>) -> Result<Self> {
115        Self::from_pack(Arc::new(PackReader::open(pack_path)?))
116    }
117
118    /// Open the spatial index using an existing shared pack reader.
119    pub fn from_pack(pack: Arc<PackReader>) -> Result<Self> {
120        let spatial = SpatialIndexReader::open(pack.container(), pack.records().clone())?;
121        Ok(Self { pack, spatial })
122    }
123
124    pub fn reverse(&self, options: ReverseGeocodeOptions) -> Result<ReverseGeocodeResponse> {
125        let mut context = ReverseContext::default();
126        let mut context_record_ids = Vec::new();
127
128        let mut result = self.explicit_address(options, &mut context, &mut context_record_ids)?;
129        if result.is_none() {
130            result = self.estimated_address(options, &mut context, &mut context_record_ids)?;
131        }
132        if result.is_none() {
133            result = self.nearest_street(options, &mut context, &mut context_record_ids)?;
134        }
135        if result.is_none() {
136            result = self.context_only(options, &mut context, &mut context_record_ids)?;
137        }
138
139        Ok(ReverseGeocodeResponse {
140            lon: options.lon,
141            lat: options.lat,
142            result,
143        })
144    }
145
146    fn explicit_address(
147        &self,
148        options: ReverseGeocodeOptions,
149        context: &mut ReverseContext,
150        context_record_ids: &mut Vec<RecordId>,
151    ) -> Result<Option<ReverseGeocodeResult>> {
152        let Some(candidate) = self
153            .spatial
154            .point_candidates(
155                options.lon,
156                options.lat,
157                Layer::Address,
158                ADDRESS_RADIUS_M,
159                1,
160            )?
161            .into_iter()
162            .next()
163        else {
164            return Ok(None);
165        };
166        let Some(address) = self.pack.address(candidate.record_id)? else {
167            return Ok(None);
168        };
169
170        self.enrich_record_context(candidate.record_id, context, context_record_ids)?;
171        apply_address_context(context, &address.address);
172        self.enrich_context(options, context, context_record_ids)?;
173
174        Ok(Some(ReverseGeocodeResult {
175            match_kind: ReverseMatchKind::ExplicitAddress,
176            label: address.label(),
177            primary_record_id: Some(candidate.record_id),
178            id: Some(address.id()),
179            layer: Some("address".to_string()),
180            distance_m: Some(candidate.distance_m),
181            point: Some(ReversePoint {
182                lon: candidate.lon,
183                lat: candidate.lat,
184                precision: ReversePointPrecision::Point,
185            }),
186            context: context.clone(),
187            evidence: ReverseEvidence {
188                explicit_address_record_id: Some(candidate.record_id),
189                context_record_ids: context_record_ids.clone(),
190                ..ReverseEvidence::default()
191            },
192        }))
193    }
194
195    fn estimated_address(
196        &self,
197        options: ReverseGeocodeOptions,
198        context: &mut ReverseContext,
199        context_record_ids: &mut Vec<RecordId>,
200    ) -> Result<Option<ReverseGeocodeResult>> {
201        for candidate in self.spatial.segment_candidates(
202            options.lon,
203            options.lat,
204            Layer::Interpolation,
205            INTERPOLATION_RADIUS_M,
206            CANDIDATE_LIMIT,
207        )? {
208            let Some(interpolation) = self.pack.interpolation(candidate.record_id)? else {
209                continue;
210            };
211            let number = estimated_number(&interpolation.interpolation, candidate.fraction);
212            self.enrich_record_context(candidate.record_id, context, context_record_ids)?;
213            apply_interpolation_context(context, &interpolation.address);
214            self.enrich_context(options, context, context_record_ids)?;
215            let primary = estimated_primary_label(number, &interpolation.address)
216                .unwrap_or_else(|| format!("{} {}", number, interpolation.name()));
217
218            return Ok(Some(ReverseGeocodeResult {
219                match_kind: ReverseMatchKind::EstimatedAddress,
220                label: compose_label(&primary, context),
221                primary_record_id: Some(candidate.record_id),
222                id: Some(interpolation.id()),
223                layer: Some("interpolation".to_string()),
224                distance_m: Some(candidate.distance_m),
225                point: Some(ReversePoint {
226                    lon: candidate.closest_lon,
227                    lat: candidate.closest_lat,
228                    precision: ReversePointPrecision::Estimated,
229                }),
230                context: context.clone(),
231                evidence: ReverseEvidence {
232                    interpolation_record_id: Some(candidate.record_id),
233                    context_record_ids: context_record_ids.clone(),
234                    ..ReverseEvidence::default()
235                },
236            }));
237        }
238
239        Ok(None)
240    }
241
242    fn nearest_street(
243        &self,
244        options: ReverseGeocodeOptions,
245        context: &mut ReverseContext,
246        context_record_ids: &mut Vec<RecordId>,
247    ) -> Result<Option<ReverseGeocodeResult>> {
248        let Some(candidate) = self
249            .spatial
250            .segment_candidates(options.lon, options.lat, Layer::Street, STREET_RADIUS_M, 1)?
251            .into_iter()
252            .next()
253        else {
254            return Ok(None);
255        };
256        let Some(street) = self.pack.street(candidate.record_id)? else {
257            return Ok(None);
258        };
259
260        set_if_missing(&mut context.street, street.name.clone());
261        self.enrich_record_context(candidate.record_id, context, context_record_ids)?;
262        self.enrich_context(options, context, context_record_ids)?;
263
264        Ok(Some(ReverseGeocodeResult {
265            match_kind: ReverseMatchKind::NearestStreet,
266            label: compose_label(&street.label(), context),
267            primary_record_id: Some(candidate.record_id),
268            id: Some(street.id()),
269            layer: Some("street".to_string()),
270            distance_m: Some(candidate.distance_m),
271            point: Some(ReversePoint {
272                lon: candidate.closest_lon,
273                lat: candidate.closest_lat,
274                precision: ReversePointPrecision::Street,
275            }),
276            context: context.clone(),
277            evidence: ReverseEvidence {
278                street_record_id: Some(candidate.record_id),
279                context_record_ids: context_record_ids.clone(),
280                ..ReverseEvidence::default()
281            },
282        }))
283    }
284
285    fn context_only(
286        &self,
287        options: ReverseGeocodeOptions,
288        context: &mut ReverseContext,
289        context_record_ids: &mut Vec<RecordId>,
290    ) -> Result<Option<ReverseGeocodeResult>> {
291        self.enrich_context(options, context, context_record_ids)?;
292        let Some(primary_record_id) = context_record_ids.first().copied() else {
293            return Ok(None);
294        };
295        let Some(record) = self.pack.context_record(primary_record_id)? else {
296            return Ok(None);
297        };
298        let Some(label) = context_only_label(context).or_else(|| Some(record.label.clone())) else {
299            return Ok(None);
300        };
301
302        Ok(Some(ReverseGeocodeResult {
303            match_kind: ReverseMatchKind::ContextOnly,
304            label,
305            primary_record_id: Some(primary_record_id),
306            id: Some(record.id),
307            layer: Some(record.layer),
308            distance_m: None,
309            point: record.point.map(context_point),
310            context: context.clone(),
311            evidence: ReverseEvidence {
312                context_record_ids: context_record_ids.clone(),
313                ..ReverseEvidence::default()
314            },
315        }))
316    }
317
318    fn enrich_context(
319        &self,
320        options: ReverseGeocodeOptions,
321        context: &mut ReverseContext,
322        context_record_ids: &mut Vec<RecordId>,
323    ) -> Result<()> {
324        let mut seen = context_record_ids.iter().copied().collect::<BTreeSet<_>>();
325        for candidate in self.spatial.context_candidates(
326            options.lon,
327            options.lat,
328            CONTEXT_RADIUS_M,
329            CANDIDATE_LIMIT,
330        )? {
331            if !seen.insert(candidate.record_id) {
332                continue;
333            }
334            if let Some(record) = self.pack.context_record(candidate.record_id)?
335                && apply_context_record(context, &record)
336            {
337                context_record_ids.push(candidate.record_id);
338            }
339        }
340        Ok(())
341    }
342
343    fn enrich_record_context(
344        &self,
345        record_id: RecordId,
346        context: &mut ReverseContext,
347        context_record_ids: &mut Vec<RecordId>,
348    ) -> Result<()> {
349        let Some(boundary_context) = self.pack.boundary_context(record_id)? else {
350            return Ok(());
351        };
352        let mut seen = context_record_ids.iter().copied().collect::<BTreeSet<_>>();
353        for context_record_id in boundary_context.admin_context.parent_record_ids() {
354            if !seen.insert(context_record_id) {
355                continue;
356            }
357            if let Some(record) = self.pack.context_record(context_record_id)?
358                && apply_context_record(context, &record)
359            {
360                context_record_ids.push(context_record_id);
361            }
362        }
363        Ok(())
364    }
365}
366
367fn apply_address_context(context: &mut ReverseContext, address: &AddressComponents) {
368    set_option_if_missing(&mut context.street, address.street.clone());
369    set_option_if_missing(&mut context.place, address.place.clone());
370    set_option_if_missing(&mut context.postcode, address.postcode.clone());
371    set_option_if_missing(&mut context.locality, address.locality.clone());
372    set_option_if_missing(&mut context.region, address.region.clone());
373    set_option_if_missing(&mut context.country, address.country.clone());
374}
375
376fn apply_interpolation_context(
377    context: &mut ReverseContext,
378    address: &InterpolationAddressComponents,
379) {
380    set_option_if_missing(&mut context.street, address.street.clone());
381    set_option_if_missing(&mut context.place, address.place.clone());
382    set_option_if_missing(&mut context.postcode, address.postcode.clone());
383    set_option_if_missing(&mut context.locality, address.locality.clone());
384    set_option_if_missing(&mut context.region, address.region.clone());
385    set_option_if_missing(&mut context.country, address.country.clone());
386}
387
388fn apply_context_record(context: &mut ReverseContext, record: &ContextRecord) -> bool {
389    match record.layer.as_str() {
390        "postcode" => set_option_if_missing(&mut context.postcode, record.postcode.clone()),
391        "neighbourhood" => set_if_missing(&mut context.neighbourhood, record.name.clone()),
392        "locality" => set_if_missing(&mut context.locality, record.name.clone()),
393        "district" => set_if_missing(&mut context.district, record.name.clone()),
394        "region" => set_if_missing(&mut context.region, record.name.clone()),
395        "country" => set_if_missing(&mut context.country, record.name.clone()),
396        "place" => set_if_missing(&mut context.place, record.name.clone()),
397        _ => false,
398    }
399}
400
401fn estimated_number(range: &InterpolationRange, fraction: f64) -> u32 {
402    let fraction = fraction.clamp(0.0, 1.0);
403    let span = range.end.saturating_sub(range.start);
404    if span == 0 || range.step == 0 {
405        return range.start;
406    }
407    let raw = range.start as f64 + fraction * span as f64;
408    let step_index = ((raw - range.start as f64) / range.step as f64).round() as u32;
409    (range.start + step_index * range.step).min(range.end)
410}
411
412fn estimated_primary_label(
413    number: u32,
414    address: &InterpolationAddressComponents,
415) -> Option<String> {
416    address
417        .street
418        .as_deref()
419        .or(address.place.as_deref())
420        .map(|street_or_place| format!("{number} {street_or_place}"))
421}
422
423fn compose_label(primary: &str, context: &ReverseContext) -> String {
424    let mut parts = vec![primary.to_string()];
425    for part in [
426        context.place.as_deref(),
427        context.neighbourhood.as_deref(),
428        context.locality.as_deref(),
429        context.district.as_deref(),
430        context.region.as_deref(),
431        context.postcode.as_deref(),
432        context.country.as_deref(),
433    ]
434    .into_iter()
435    .flatten()
436    {
437        if !parts.iter().any(|existing| same_text(existing, part)) {
438            parts.push(part.to_string());
439        }
440    }
441    parts.join(", ")
442}
443
444fn context_only_label(context: &ReverseContext) -> Option<String> {
445    [
446        context.place.as_deref(),
447        context.neighbourhood.as_deref(),
448        context.locality.as_deref(),
449        context.district.as_deref(),
450        context.region.as_deref(),
451        context.postcode.as_deref(),
452        context.country.as_deref(),
453    ]
454    .into_iter()
455    .flatten()
456    .next()
457    .map(|primary| compose_label(primary, context))
458}
459
460fn context_point(point: crate::pack::RecordPoint) -> ReversePoint {
461    ReversePoint {
462        lon: point.lon,
463        lat: point.lat,
464        precision: ReversePointPrecision::Context,
465    }
466}
467
468fn set_option_if_missing(target: &mut Option<String>, value: Option<String>) -> bool {
469    if let Some(value) = value {
470        set_if_missing(target, value)
471    } else {
472        false
473    }
474}
475
476fn set_if_missing(target: &mut Option<String>, value: String) -> bool {
477    if target.is_none() && !value.trim().is_empty() {
478        *target = Some(value);
479        true
480    } else {
481        false
482    }
483}
484
485fn same_text(left: &str, right: &str) -> bool {
486    left.trim().eq_ignore_ascii_case(right.trim())
487}
488
489#[cfg(test)]
490mod tests {
491    use std::{
492        fs,
493        time::{SystemTime, UNIX_EPOCH},
494    };
495
496    use geojson::{Geometry, GeometryValue};
497
498    use crate::{
499        context::AdminContextTuple,
500        pack::{PackWriter, RecordContext},
501        record::{
502            AddressRecord, LocationPrecision, OsmObjectType, PlaceLayer, PlaceRecord, Record,
503            SourceProvenance, StreetRecord, point_geometry,
504        },
505    };
506
507    use super::*;
508
509    #[test]
510    fn reverse_prefers_explicit_address() {
511        let temp_dir = temp_pack_dir("explicit");
512        let mut writer = PackWriter::create(&temp_dir).expect("writer");
513        writer.write(&street_record().into(), None).expect("street");
514        writer
515            .write(&address_record().into(), None)
516            .expect("address");
517        writer.finish().expect("finish");
518
519        let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
520        let response = geocoder
521            .reverse(ReverseGeocodeOptions {
522                lon: -79.0,
523                lat: 43.0,
524            })
525            .expect("reverse");
526        let result = response.result.expect("result");
527
528        assert_eq!(result.match_kind, ReverseMatchKind::ExplicitAddress);
529        assert_eq!(result.primary_record_id, Some(1));
530        assert_eq!(result.evidence.explicit_address_record_id, Some(1));
531
532        let _ = fs::remove_dir_all(temp_dir);
533    }
534
535    #[test]
536    fn reverse_estimates_address_from_interpolation() {
537        let temp_dir = temp_pack_dir("interpolation");
538        let mut writer = PackWriter::create(&temp_dir).expect("writer");
539        writer
540            .write(&interpolation_record().into(), None)
541            .expect("interpolation");
542        writer.finish().expect("finish");
543
544        let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
545        let response = geocoder
546            .reverse(ReverseGeocodeOptions {
547                lon: -79.00001,
548                lat: 43.0005,
549            })
550            .expect("reverse");
551        let result = response.result.expect("result");
552
553        assert_eq!(result.match_kind, ReverseMatchKind::EstimatedAddress);
554        assert!(result.label.starts_with("50 Queen Street"));
555        assert_eq!(result.evidence.interpolation_record_id, Some(0));
556
557        let _ = fs::remove_dir_all(temp_dir);
558    }
559
560    #[test]
561    fn reverse_falls_back_to_street() {
562        let temp_dir = temp_pack_dir("street");
563        let mut writer = PackWriter::create(&temp_dir).expect("writer");
564        writer.write(&street_record().into(), None).expect("street");
565        writer.finish().expect("finish");
566
567        let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
568        let response = geocoder
569            .reverse(ReverseGeocodeOptions {
570                lon: -79.00001,
571                lat: 43.0005,
572            })
573            .expect("reverse");
574        let result = response.result.expect("result");
575
576        assert_eq!(result.match_kind, ReverseMatchKind::NearestStreet);
577        assert!(result.label.starts_with("Queen Street"));
578        assert_eq!(result.evidence.street_record_id, Some(0));
579
580        let _ = fs::remove_dir_all(temp_dir);
581    }
582
583    #[test]
584    fn reverse_prefers_materialized_boundary_context_over_source_locality() {
585        let temp_dir = temp_pack_dir("boundary-context");
586        let mut writer = PackWriter::create(&temp_dir).expect("writer");
587        let locality_id = writer
588            .write(&Record::Place(PlaceLayer::Locality, place_record()), None)
589            .expect("place");
590        let mut address = address_record();
591        address.address.locality = Some("North York".to_string());
592        writer
593            .write(
594                &address.into(),
595                Some(RecordContext {
596                    admin_context: AdminContextTuple {
597                        locality_record_id: Some(locality_id),
598                        ..AdminContextTuple::default()
599                    },
600                    flags: 0,
601                }),
602            )
603            .expect("address");
604        writer.finish().expect("finish");
605
606        let geocoder = PackReverseGeocoder::open(&temp_dir).expect("geocoder");
607        let response = geocoder
608            .reverse(ReverseGeocodeOptions {
609                lon: -79.0,
610                lat: 43.0,
611            })
612            .expect("reverse");
613        let result = response.result.expect("result");
614
615        assert_eq!(result.context.locality.as_deref(), Some("Toronto"));
616        assert_eq!(result.evidence.context_record_ids, vec![locality_id]);
617
618        let _ = fs::remove_dir_all(temp_dir);
619    }
620
621    fn temp_pack_dir(label: &str) -> std::path::PathBuf {
622        let nanos = SystemTime::now()
623            .duration_since(UNIX_EPOCH)
624            .expect("clock")
625            .as_nanos();
626        std::env::temp_dir().join(format!(
627            "open-geocode-reverse-{label}-{}-{nanos}",
628            std::process::id()
629        ))
630    }
631
632    fn address_record() -> AddressRecord {
633        AddressRecord {
634            address: AddressComponents {
635                number: "10".to_string(),
636                street: Some("Queen Street".to_string()),
637                place: None,
638                unit: None,
639                locality: Some("Toronto".to_string()),
640                region: Some("Ontario".to_string()),
641                postcode: None,
642                country: Some("Canada".to_string()),
643            },
644            geometry: point_geometry(-79.0, 43.0),
645            location_precision: LocationPrecision::Point,
646            source: SourceProvenance::osm(OsmObjectType::Node, 10),
647        }
648    }
649
650    fn place_record() -> PlaceRecord {
651        PlaceRecord {
652            name: "Toronto".to_string(),
653            place_type: "admin_level:8".to_string(),
654            geometry: point_geometry(-79.0, 43.0),
655            source: SourceProvenance::osm(OsmObjectType::Relation, 99),
656        }
657    }
658
659    fn interpolation_record() -> crate::record::InterpolationRecord {
660        crate::record::InterpolationRecord {
661            address: InterpolationAddressComponents {
662                street: Some("Queen Street".to_string()),
663                place: None,
664                locality: Some("Toronto".to_string()),
665                region: Some("Ontario".to_string()),
666                postcode: None,
667                country: Some("Canada".to_string()),
668            },
669            interpolation: InterpolationRange {
670                kind: "even".to_string(),
671                start: 2,
672                end: 98,
673                step: 2,
674            },
675            anchor_node_ids: [2, 98],
676            geometry: line_geometry(),
677            representative_point: [-79.0, 43.0005],
678            source: SourceProvenance::osm(OsmObjectType::Way, 20),
679        }
680    }
681
682    fn street_record() -> StreetRecord {
683        StreetRecord {
684            name: "Queen Street".to_string(),
685            geometry: line_geometry(),
686            representative_point: [-79.0, 43.0005],
687            source: SourceProvenance::osm(OsmObjectType::Way, 30),
688        }
689    }
690
691    fn line_geometry() -> Geometry {
692        Geometry::new(GeometryValue::LineString {
693            coordinates: vec![vec![-79.0, 43.0].into(), vec![-79.0, 43.001].into()],
694        })
695    }
696}