1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Implementation of reverse geocoding.
10 from typing import Optional, List, Callable, Type, Tuple, Dict, Any, cast, Union
13 import sqlalchemy as sa
15 from .typing import SaColumn, SaSelect, SaFromClause, SaLabel, SaRow, \
16 SaBind, SaLambdaSelect
17 from .sql.sqlalchemy_types import Geometry
18 from .connection import SearchConnection
19 from . import results as nres
20 from .logging import log
21 from .types import AnyPoint, DataLayer, ReverseDetails, GeometryFormat, Bbox
24 RowFunc = Callable[[SaRow, Type[nres.ReverseResult]], nres.ReverseResult]
26 WKT_PARAM: SaBind = sa.bindparam('wkt', type_=Geometry)
27 MAX_RANK_PARAM: SaBind = sa.bindparam('max_rank')
30 def no_index(expr: SaColumn) -> SaColumn:
31 """ Wrap the given expression, so that the query planner will
32 refrain from using the expression for index lookup.
34 return sa.func.coalesce(sa.null(), expr)
37 def _select_from_placex(t: SaFromClause, use_wkt: bool = True) -> SaSelect:
38 """ Create a select statement with the columns relevant for reverse
42 distance = t.c.distance
43 centroid = t.c.centroid
45 distance = t.c.geometry.ST_Distance(WKT_PARAM)
46 centroid = sa.case((t.c.geometry.is_line_like(), t.c.geometry.ST_ClosestPoint(WKT_PARAM)),
47 else_=t.c.centroid).label('centroid')
49 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
51 t.c.address, t.c.extratags,
52 t.c.housenumber, t.c.postcode, t.c.country_code,
53 t.c.importance, t.c.wikipedia,
54 t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
56 t.c.linked_place_id, t.c.admin_level,
57 distance.label('distance'),
58 t.c.geometry.ST_Expand(0).label('bbox'))
61 def _interpolated_housenumber(table: SaFromClause) -> SaLabel:
62 return sa.cast(table.c.startnumber
63 + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
64 / table.c.step) * table.c.step,
65 sa.Integer).label('housenumber')
68 def _interpolated_position(table: SaFromClause) -> SaLabel:
69 fac = sa.cast(table.c.step, sa.Float) / (table.c.endnumber - table.c.startnumber)
70 rounded_pos = sa.func.round(table.c.position / fac) * fac
72 (table.c.endnumber == table.c.startnumber, table.c.linegeo.ST_Centroid()),
73 else_=table.c.linegeo.ST_LineInterpolatePoint(rounded_pos)).label('centroid')
76 def _locate_interpolation(table: SaFromClause) -> SaLabel:
77 """ Given a position, locate the closest point on the line.
79 return sa.case((table.c.linegeo.is_line_like(),
80 table.c.linegeo.ST_LineLocatePoint(WKT_PARAM)),
81 else_=0).label('position')
84 def _get_closest(*rows: Optional[SaRow]) -> Optional[SaRow]:
85 return min(rows, key=lambda row: 1000 if row is None else row.distance)
88 class ReverseGeocoder:
89 """ Class implementing the logic for looking up a place from a
93 def __init__(self, conn: SearchConnection, params: ReverseDetails,
94 restrict_to_country_areas: bool = False) -> None:
97 self.restrict_to_country_areas = restrict_to_country_areas
99 self.bind_params: Dict[str, Any] = {'max_rank': params.max_rank}
102 def max_rank(self) -> int:
103 """ Return the maximum configured rank.
105 return self.params.max_rank
107 def has_geometries(self) -> bool:
108 """ Check if any geometries are requested.
110 return bool(self.params.geometry_output)
112 def layer_enabled(self, *layer: DataLayer) -> bool:
113 """ Return true when any of the given layer types are requested.
115 return any(self.params.layers & ly for ly in layer)
117 def layer_disabled(self, *layer: DataLayer) -> bool:
118 """ Return true when none of the given layer types is requested.
120 return not any(self.params.layers & ly for ly in layer)
122 def has_feature_layers(self) -> bool:
123 """ Return true if any layer other than ADDRESS or POI is requested.
125 return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
127 def _add_geometry_columns(self, sql: SaLambdaSelect, col: SaColumn) -> SaSelect:
130 if self.params.geometry_simplification > 0.0:
131 col = sa.func.ST_SimplifyPreserveTopology(col, self.params.geometry_simplification)
133 if self.params.geometry_output & GeometryFormat.GEOJSON:
134 out.append(sa.func.ST_AsGeoJSON(col, 7).label('geometry_geojson'))
135 if self.params.geometry_output & GeometryFormat.TEXT:
136 out.append(sa.func.ST_AsText(col).label('geometry_text'))
137 if self.params.geometry_output & GeometryFormat.KML:
138 out.append(sa.func.ST_AsKML(col, 7).label('geometry_kml'))
139 if self.params.geometry_output & GeometryFormat.SVG:
140 out.append(sa.func.ST_AsSVG(col, 0, 7).label('geometry_svg'))
142 return sql.add_columns(*out)
144 def _filter_by_layer(self, table: SaFromClause) -> SaColumn:
145 if self.layer_enabled(DataLayer.MANMADE):
147 if self.layer_disabled(DataLayer.RAILWAY):
148 exclude.append('railway')
149 if self.layer_disabled(DataLayer.NATURAL):
150 exclude.extend(('natural', 'water', 'waterway'))
151 return table.c.class_.not_in(tuple(exclude))
154 if self.layer_enabled(DataLayer.RAILWAY):
155 include.append('railway')
156 if self.layer_enabled(DataLayer.NATURAL):
157 include.extend(('natural', 'water', 'waterway'))
158 return table.c.class_.in_(tuple(include))
160 async def _find_closest_street_or_pois(self, distance: float,
161 fuzziness: float) -> list[SaRow]:
162 """ Look up the closest rank 26+ place in the database.
163 The function finds the object that is closest to the reverse
164 search point as well as all objects within 'fuzziness' distance
167 t = self.conn.t.placex
169 # PostgreSQL must not get the distance as a parameter because
170 # there is a danger it won't be able to properly estimate index use
171 # when used with prepared statements
172 diststr = sa.text(f"{distance + fuzziness}")
174 sql: SaLambdaSelect = sa.lambda_stmt(
175 lambda: _select_from_placex(t)
176 .where(t.c.geometry.within_distance(WKT_PARAM, diststr))
177 .where(t.c.indexed_status == 0)
178 .where(t.c.linked_place_id == None)
179 .where(sa.or_(sa.not_(t.c.geometry.is_area()),
180 t.c.centroid.ST_Distance(WKT_PARAM) < diststr)))
182 if self.has_geometries():
183 sql = self._add_geometry_columns(sql, t.c.geometry)
185 restrict: List[Union[SaColumn, Callable[[], SaColumn]]] = []
187 if self.layer_enabled(DataLayer.ADDRESS):
188 max_rank = min(29, self.max_rank)
189 restrict.append(lambda: no_index(t.c.rank_address).between(26, max_rank))
190 if self.max_rank == 30:
191 restrict.append(lambda: sa.func.IsAddressPoint(t))
192 if self.layer_enabled(DataLayer.POI) and self.max_rank == 30:
193 restrict.append(lambda: sa.and_(no_index(t.c.rank_search) == 30,
194 t.c.class_.not_in(('place', 'building')),
195 sa.not_(t.c.geometry.is_line_like())))
196 if self.has_feature_layers():
197 restrict.append(sa.and_(no_index(t.c.rank_search).between(26, MAX_RANK_PARAM),
198 no_index(t.c.rank_address) == 0,
199 self._filter_by_layer(t)))
204 inner = sql.where(sa.or_(*restrict)) \
205 .add_columns(t.c.geometry.label('_geometry')) \
208 # Use a window function to get the closest results to the best result.
209 windowed = sa.select(inner,
210 sa.func.first_value(inner.c.distance)
211 .over(order_by=inner.c.distance)
212 .label('_min_distance'),
213 sa.func.first_value(inner.c._geometry.ST_ClosestPoint(WKT_PARAM))
214 .over(order_by=inner.c.distance)
215 .label('_closest_point'),
216 sa.func.first_value(sa.case((sa.or_(inner.c.rank_search <= 27,
217 inner.c.osm_type == 'N'), None),
218 else_=inner.c._geometry))
219 .over(order_by=inner.c.distance)
220 .label('_best_geometry')) \
223 outer = sa.select(*(c for c in windowed.c if not c.key.startswith('_')),
224 windowed.c.centroid.ST_Distance(windowed.c._closest_point)
225 .label('best_distance'),
226 sa.case((sa.or_(windowed.c._best_geometry == None,
227 windowed.c.rank_search <= 27,
228 windowed.c.osm_type != 'N'), False),
229 else_=windowed.c.centroid.ST_CoveredBy(windowed.c._best_geometry))
230 .label('best_inside')) \
231 .where(windowed.c.distance < windowed.c._min_distance + fuzziness) \
232 .order_by(windowed.c.distance)
234 return list(await self.conn.execute(outer, self.bind_params))
236 async def _find_housenumber_for_street(self, parent_place_id: int) -> Optional[SaRow]:
237 t = self.conn.t.placex
239 def _base_query() -> SaSelect:
240 return _select_from_placex(t)\
241 .where(t.c.geometry.within_distance(WKT_PARAM, 0.001))\
242 .where(t.c.parent_place_id == parent_place_id)\
243 .where(sa.func.IsAddressPoint(t))\
244 .where(t.c.indexed_status == 0)\
245 .where(t.c.linked_place_id == None)\
246 .order_by('distance')\
250 if self.has_geometries():
251 sql = self._add_geometry_columns(_base_query(), t.c.geometry)
253 sql = sa.lambda_stmt(_base_query)
255 return (await self.conn.execute(sql, self.bind_params)).one_or_none()
257 async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
258 distance: float) -> Optional[SaRow]:
259 t = self.conn.t.osmline
262 t.c.linegeo.ST_Distance(WKT_PARAM).label('distance'),
263 _locate_interpolation(t))\
264 .where(t.c.linegeo.within_distance(WKT_PARAM, distance))\
265 .where(t.c.startnumber != None)\
266 .order_by('distance')\
269 if parent_place_id is not None:
270 sql = sql.where(t.c.parent_place_id == parent_place_id)
272 inner = sql.subquery('ipol')
274 sql = sa.select(inner.c.place_id, inner.c.osm_id,
275 inner.c.parent_place_id, inner.c.address,
276 _interpolated_housenumber(inner),
277 _interpolated_position(inner),
278 inner.c.postcode, inner.c.country_code,
281 if self.has_geometries():
282 sub = sql.subquery('geom')
283 sql = self._add_geometry_columns(sa.select(sub), sub.c.centroid)
285 return (await self.conn.execute(sql, self.bind_params)).one_or_none()
287 async def _find_tiger_number_for_street(self, parent_place_id: int) -> Optional[SaRow]:
288 t = self.conn.t.tiger
290 def _base_query() -> SaSelect:
292 t.c.linegeo.ST_Distance(WKT_PARAM).label('distance'),
293 _locate_interpolation(t))\
294 .where(t.c.linegeo.within_distance(WKT_PARAM, 0.001))\
295 .where(t.c.parent_place_id == parent_place_id)\
296 .order_by('distance')\
300 return sa.select(inner.c.place_id,
301 inner.c.parent_place_id,
302 _interpolated_housenumber(inner),
303 _interpolated_position(inner),
308 if self.has_geometries():
309 sub = _base_query().subquery('geom')
310 sql = self._add_geometry_columns(sa.select(sub), sub.c.centroid)
312 sql = sa.lambda_stmt(_base_query)
314 return (await self.conn.execute(sql, self.bind_params)).one_or_none()
316 async def lookup_street_poi(self) -> Tuple[Optional[SaRow], RowFunc]:
317 """ Find a street or POI/address for the given WKT point.
319 log().section('Reverse lookup on street/address level')
320 row_func: RowFunc = nres.create_from_placex_row
326 for row in await self._find_closest_street_or_pois(distance, 0.001):
328 log().var_dump('Closest result', row)
330 if self.max_rank > 27 \
331 and self.layer_enabled(DataLayer.ADDRESS) \
332 and result.rank_address <= 27:
333 parent_street = result.place_id
336 distance = row.distance
337 # If the closest result was a street but an address was requested,
338 # see if we can refine the result with a housenumber closeby.
339 elif parent_street is not None \
340 and row.rank_address > 27 \
341 and row.best_distance < 0.001 \
342 and (hnr_distance is None or hnr_distance > row.best_distance) \
343 and row.parent_place_id == parent_street:
344 log().var_dump('Housenumber to closest result', row)
346 hnr_distance = row.best_distance
347 distance = row.distance
348 # If the closest object is inside an area, then check if there is
349 # a POI nearby and return that with preference.
350 elif result.osm_type != 'N' and result.rank_search > 27 \
351 and result.distance == 0 \
353 log().var_dump('POI near closest result area', row)
355 break # it can't get better than that, everything else is farther away
357 # For the US also check the TIGER data, when no housenumber/POI was found.
358 if result is not None and parent_street is not None and hnr_distance is None \
359 and result.country_code == 'us':
360 log().comment('Find TIGER housenumber for street')
361 addr_row = await self._find_tiger_number_for_street(parent_street)
362 log().var_dump('Result (street Tiger housenumber)', addr_row)
364 if addr_row is not None:
365 row_func = cast(RowFunc,
366 functools.partial(nres.create_from_tiger_row,
367 osm_type=row.osm_type,
371 # Check for an interpolation that is either closer than our result
372 # or belongs to a close street found.
373 # No point in doing this when the result is already inside a building,
374 # i.e. when the distance is already 0.
375 if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS) and distance > 0:
376 log().comment('Find interpolation for street')
377 addr_row = await self._find_interpolation_for_street(parent_street, distance)
378 log().var_dump('Result (street interpolation)', addr_row)
379 if addr_row is not None:
380 return addr_row, nres.create_from_osmline_row
382 return result, row_func
384 async def _lookup_area_address(self) -> Optional[SaRow]:
385 """ Lookup large addressable areas for the given WKT point.
387 log().comment('Reverse lookup by larger address area features')
388 t = self.conn.t.placex
390 def _base_query() -> SaSelect:
391 # The inner SQL brings results in the right order, so that
392 # later only a minimum of results needs to be checked with ST_Contains.
393 inner = sa.select(t, sa.literal(0.0).label('distance'))\
394 .where(t.c.rank_search.between(5, MAX_RANK_PARAM))\
395 .where(t.c.rank_address != 5)\
396 .where(t.c.rank_address != 11)\
397 .where(t.c.geometry.intersects(WKT_PARAM))\
398 .where(sa.func.PlacexGeometryReverseLookuppolygon())\
399 .order_by(sa.desc(t.c.rank_search))\
403 return _select_from_placex(inner, False)\
404 .where(inner.c.geometry.ST_Contains(WKT_PARAM))\
405 .order_by(sa.desc(inner.c.rank_search))\
408 sql: SaLambdaSelect = sa.lambda_stmt(_base_query)
409 if self.has_geometries():
410 sql = self._add_geometry_columns(sql, sa.literal_column('area.geometry'))
412 address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
413 log().var_dump('Result (area)', address_row)
415 if address_row is not None and address_row.rank_search < self.max_rank:
416 log().comment('Search for better matching place nodes inside the area')
418 address_rank = address_row.rank_search
419 address_id = address_row.place_id
421 def _place_inside_area_query() -> SaSelect:
423 sa.select(t, t.c.geometry.ST_Distance(WKT_PARAM).label('distance'))\
424 .where(t.c.rank_search > address_rank)\
425 .where(t.c.rank_search <= MAX_RANK_PARAM)\
426 .where(t.c.indexed_status == 0)\
427 .where(sa.func.IntersectsReverseDistance(t, WKT_PARAM))\
428 .order_by(sa.desc(t.c.rank_search))\
432 touter = t.alias('outer')
433 return _select_from_placex(inner, False)\
434 .join(touter, touter.c.geometry.ST_Contains(inner.c.geometry))\
435 .where(touter.c.place_id == address_id)\
436 .where(sa.func.IsBelowReverseDistance(inner.c.distance, inner.c.rank_search))\
437 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
440 if self.has_geometries():
441 sql = self._add_geometry_columns(_place_inside_area_query(),
442 sa.literal_column('places.geometry'))
444 sql = sa.lambda_stmt(_place_inside_area_query)
446 place_address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
447 log().var_dump('Result (place node)', place_address_row)
449 if place_address_row is not None:
450 return place_address_row
454 async def _lookup_area_others(self) -> Optional[SaRow]:
455 t = self.conn.t.placex
457 inner = sa.select(t, t.c.geometry.ST_Distance(WKT_PARAM).label('distance'))\
458 .where(t.c.rank_address == 0)\
459 .where(t.c.rank_search.between(5, MAX_RANK_PARAM))\
460 .where(t.c.name != None)\
461 .where(t.c.indexed_status == 0)\
462 .where(t.c.linked_place_id == None)\
463 .where(self._filter_by_layer(t))\
464 .where(t.c.geometry.intersects(sa.func.ST_Expand(WKT_PARAM, 0.007)))\
465 .order_by(sa.desc(t.c.rank_search))\
466 .order_by('distance')\
470 sql = _select_from_placex(inner, False)\
471 .where(sa.or_(sa.not_(inner.c.geometry.is_area()),
472 inner.c.geometry.ST_Contains(WKT_PARAM)))\
473 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
476 if self.has_geometries():
477 sql = self._add_geometry_columns(sql, inner.c.geometry)
479 row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
480 log().var_dump('Result (non-address feature)', row)
484 async def lookup_area(self) -> Optional[SaRow]:
485 """ Lookup large areas for the current search.
487 log().section('Reverse lookup by larger area features')
489 if self.layer_enabled(DataLayer.ADDRESS):
490 address_row = await self._lookup_area_address()
494 if self.has_feature_layers():
495 other_row = await self._lookup_area_others()
499 return _get_closest(address_row, other_row)
501 async def lookup_country_codes(self) -> List[str]:
502 """ Lookup the country for the current search.
504 log().section('Reverse lookup by country code')
505 t = self.conn.t.country_grid
506 sql = sa.select(t.c.country_code).distinct()\
507 .where(t.c.geometry.ST_Contains(WKT_PARAM))
509 ccodes = [cast(str, r[0]) for r in await self.conn.execute(sql, self.bind_params)]
510 log().var_dump('Country codes', ccodes)
513 async def lookup_country(self, ccodes: List[str]) -> Tuple[Optional[SaRow], RowFunc]:
514 """ Lookup the country for the current search.
516 row_func = nres.create_from_placex_row
518 ccodes = await self.lookup_country_codes()
521 return None, row_func
523 t = self.conn.t.placex
524 if self.max_rank > 4:
525 log().comment('Search for place nodes in country')
527 def _base_query() -> SaSelect:
528 inner = sa.select(t, t.c.geometry.ST_Distance(WKT_PARAM).label('distance'))\
529 .where(t.c.rank_search > 4)\
530 .where(t.c.rank_search <= MAX_RANK_PARAM)\
531 .where(t.c.indexed_status == 0)\
532 .where(t.c.country_code.in_(ccodes))\
533 .where(sa.func.IntersectsReverseDistance(t, WKT_PARAM))\
534 .order_by(sa.desc(t.c.rank_search))\
538 return _select_from_placex(inner, False)\
539 .where(sa.func.IsBelowReverseDistance(inner.c.distance, inner.c.rank_search))\
540 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
544 if self.has_geometries():
545 sql = self._add_geometry_columns(_base_query(),
546 sa.literal_column('area.geometry'))
548 sql = sa.lambda_stmt(_base_query)
550 address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
551 log().var_dump('Result (addressable place node)', address_row)
555 if address_row is None:
556 # Still nothing, then return a country with the appropriate country code.
557 def _country_base_query() -> SaSelect:
558 return _select_from_placex(t)\
559 .where(t.c.country_code.in_(ccodes))\
560 .where(t.c.rank_address == 4)\
561 .where(t.c.rank_search == 4)\
562 .where(t.c.linked_place_id == None)\
563 .order_by('distance')\
566 if self.has_geometries():
567 sql = self._add_geometry_columns(_country_base_query(), t.c.geometry)
569 sql = sa.lambda_stmt(_country_base_query)
571 address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
573 if address_row is None:
574 # finally fall back to country table
575 t = self.conn.t.country_name
576 tgrid = self.conn.t.country_grid
578 sql = sa.select(tgrid.c.country_code,
579 tgrid.c.geometry.ST_Centroid().ST_Collect().ST_Centroid()
581 tgrid.c.geometry.ST_Collect().ST_Expand(0).label('bbox'))\
582 .where(tgrid.c.country_code.in_(ccodes))\
583 .group_by(tgrid.c.country_code)
585 sub = sql.subquery('grid')
586 sql = sa.select(t.c.country_code,
587 t.c.name.merge(t.c.derived_name).label('name'),
588 sub.c.centroid, sub.c.bbox)\
589 .join(sub, t.c.country_code == sub.c.country_code)\
590 .order_by(t.c.country_code)\
593 sql = self._add_geometry_columns(sql, sub.c.centroid)
595 address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
596 row_func = nres.create_from_country_row
598 return address_row, row_func
600 async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
601 """ Look up a single coordinate. Returns the place information,
602 if a place was found near the coordinates or None otherwise.
604 log().function('reverse_lookup', coord=coord, params=self.params)
606 self.bind_params['wkt'] = f'POINT({coord[0]} {coord[1]})'
608 row: Optional[SaRow] = None
609 row_func: RowFunc = nres.create_from_placex_row
611 if self.max_rank >= 26:
612 row, tmp_row_func = await self.lookup_street_poi()
614 row_func = tmp_row_func
617 if self.restrict_to_country_areas:
618 ccodes = await self.lookup_country_codes()
624 if self.max_rank > 4:
625 row = await self.lookup_area()
626 if row is None and self.layer_enabled(DataLayer.ADDRESS):
627 row, row_func = await self.lookup_country(ccodes)
632 result = row_func(row, nres.ReverseResult)
633 result.distance = getattr(row, 'distance', 0)
634 if hasattr(row, 'bbox'):
635 result.bbox = Bbox.from_wkb(row.bbox)
636 await nres.add_result_details(self.conn, [result], self.params)