1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 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
12 import sqlalchemy as sa
13 from geoalchemy2 import WKTElement
15 from nominatim.typing import SaColumn, SaSelect, SaFromClause, SaLabel, SaRow
16 from nominatim.api.connection import SearchConnection
17 import nominatim.api.results as nres
18 from nominatim.api.logging import log
19 from nominatim.api.types import AnyPoint, DataLayer, LookupDetails, GeometryFormat, Bbox
21 # In SQLAlchemy expression which compare with NULL need to be expressed with
23 # pylint: disable=singleton-comparison
25 RowFunc = Callable[[Optional[SaRow], Type[nres.ReverseResult]], Optional[nres.ReverseResult]]
27 def _select_from_placex(t: SaFromClause, wkt: Optional[str] = None) -> SaSelect:
28 """ Create a select statement with the columns relevant for reverse
32 distance = t.c.distance
34 distance = t.c.geometry.ST_Distance(wkt)
36 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
38 t.c.address, t.c.extratags,
39 t.c.housenumber, t.c.postcode, t.c.country_code,
40 t.c.importance, t.c.wikipedia,
41 t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
43 (t.c.geometry.ST_GeometryType().in_(('ST_LineString',
44 'ST_MultiLineString')),
45 t.c.geometry.ST_ClosestPoint(wkt)),
46 else_=t.c.centroid).label('centroid'),
47 distance.label('distance'),
48 t.c.geometry.ST_Expand(0).label('bbox'))
51 def _interpolated_housenumber(table: SaFromClause) -> SaLabel:
52 return sa.cast(table.c.startnumber
53 + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
54 / table.c.step) * table.c.step,
55 sa.Integer).label('housenumber')
58 def _interpolated_position(table: SaFromClause) -> SaLabel:
59 fac = sa.cast(table.c.step, sa.Float) / (table.c.endnumber - table.c.startnumber)
60 rounded_pos = sa.func.round(table.c.position / fac) * fac
62 (table.c.endnumber == table.c.startnumber, table.c.linegeo.ST_Centroid()),
63 else_=table.c.linegeo.ST_LineInterpolatePoint(rounded_pos)).label('centroid')
66 def _is_address_point(table: SaFromClause) -> SaColumn:
67 return sa.and_(table.c.rank_address == 30,
68 sa.or_(table.c.housenumber != None,
69 table.c.name.has_key('housename')))
71 def _get_closest(*rows: Optional[SaRow]) -> Optional[SaRow]:
72 return min(rows, key=lambda row: 1000 if row is None else row.distance)
74 class ReverseGeocoder:
75 """ Class implementing the logic for looking up a place from a
79 def __init__(self, conn: SearchConnection, max_rank: int, layer: DataLayer,
80 details: LookupDetails) -> None:
82 self.max_rank = max_rank
84 self.details = details
86 def layer_enabled(self, *layer: DataLayer) -> bool:
87 """ Return true when any of the given layer types are requested.
89 return any(self.layer & l for l in layer)
92 def layer_disabled(self, *layer: DataLayer) -> bool:
93 """ Return true when none of the given layer types is requested.
95 return not any(self.layer & l for l in layer)
98 def has_feature_layers(self) -> bool:
99 """ Return true if any layer other than ADDRESS or POI is requested.
101 return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
103 def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect:
104 if not self.details.geometry_output:
109 if self.details.geometry_simplification > 0.0:
110 col = col.ST_SimplifyPreserveTopology(self.details.geometry_simplification)
112 if self.details.geometry_output & GeometryFormat.GEOJSON:
113 out.append(col.ST_AsGeoJSON().label('geometry_geojson'))
114 if self.details.geometry_output & GeometryFormat.TEXT:
115 out.append(col.ST_AsText().label('geometry_text'))
116 if self.details.geometry_output & GeometryFormat.KML:
117 out.append(col.ST_AsKML().label('geometry_kml'))
118 if self.details.geometry_output & GeometryFormat.SVG:
119 out.append(col.ST_AsSVG().label('geometry_svg'))
121 return sql.add_columns(*out)
124 def _filter_by_layer(self, table: SaFromClause) -> SaColumn:
125 if self.layer_enabled(DataLayer.MANMADE):
127 if self.layer_disabled(DataLayer.RAILWAY):
128 exclude.append('railway')
129 if self.layer_disabled(DataLayer.NATURAL):
130 exclude.extend(('natural', 'water', 'waterway'))
131 return table.c.class_.not_in(tuple(exclude))
134 if self.layer_enabled(DataLayer.RAILWAY):
135 include.append('railway')
136 if self.layer_enabled(DataLayer.NATURAL):
137 include.extend(('natural', 'water', 'waterway'))
138 return table.c.class_.in_(tuple(include))
141 async def _find_closest_street_or_poi(self, wkt: WKTElement,
142 distance: float) -> Optional[SaRow]:
143 """ Look up the closest rank 26+ place in the database, which
144 is closer than the given distance.
146 t = self.conn.t.placex
148 sql = _select_from_placex(t, wkt)\
149 .where(t.c.geometry.ST_DWithin(wkt, distance))\
150 .where(t.c.indexed_status == 0)\
151 .where(t.c.linked_place_id == None)\
152 .where(sa.or_(t.c.geometry.ST_GeometryType()
153 .not_in(('ST_Polygon', 'ST_MultiPolygon')),
154 t.c.centroid.ST_Distance(wkt) < distance))\
155 .order_by('distance')\
158 sql = self._add_geometry_columns(sql, t.c.geometry)
160 restrict: List[SaColumn] = []
162 if self.layer_enabled(DataLayer.ADDRESS):
163 restrict.append(sa.and_(t.c.rank_address >= 26,
164 t.c.rank_address <= min(29, self.max_rank)))
165 if self.max_rank == 30:
166 restrict.append(_is_address_point(t))
167 if self.layer_enabled(DataLayer.POI) and self.max_rank == 30:
168 restrict.append(sa.and_(t.c.rank_search == 30,
169 t.c.class_.not_in(('place', 'building')),
170 t.c.geometry.ST_GeometryType() != 'ST_LineString'))
171 if self.has_feature_layers():
172 restrict.append(sa.and_(t.c.rank_search.between(26, self.max_rank),
173 t.c.rank_address == 0,
174 self._filter_by_layer(t)))
179 return (await self.conn.execute(sql.where(sa.or_(*restrict)))).one_or_none()
182 async def _find_housenumber_for_street(self, parent_place_id: int,
183 wkt: WKTElement) -> Optional[SaRow]:
184 t = self.conn.t.placex
186 sql = _select_from_placex(t, wkt)\
187 .where(t.c.geometry.ST_DWithin(wkt, 0.001))\
188 .where(t.c.parent_place_id == parent_place_id)\
189 .where(_is_address_point(t))\
190 .where(t.c.indexed_status == 0)\
191 .where(t.c.linked_place_id == None)\
192 .order_by('distance')\
195 sql = self._add_geometry_columns(sql, t.c.geometry)
197 return (await self.conn.execute(sql)).one_or_none()
200 async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
202 distance: float) -> Optional[SaRow]:
203 t = self.conn.t.osmline
206 t.c.linegeo.ST_Distance(wkt).label('distance'),
207 t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\
208 .where(t.c.linegeo.ST_DWithin(wkt, distance))\
209 .order_by('distance')\
212 if parent_place_id is not None:
213 sql = sql.where(t.c.parent_place_id == parent_place_id)
215 inner = sql.subquery()
217 sql = sa.select(inner.c.place_id, inner.c.osm_id,
218 inner.c.parent_place_id, inner.c.address,
219 _interpolated_housenumber(inner),
220 _interpolated_position(inner),
221 inner.c.postcode, inner.c.country_code,
224 if self.details.geometry_output:
226 sql = self._add_geometry_columns(sql, sub.c.centroid)
228 return (await self.conn.execute(sql)).one_or_none()
231 async def _find_tiger_number_for_street(self, parent_place_id: int,
232 parent_type: str, parent_id: int,
233 wkt: WKTElement) -> Optional[SaRow]:
234 t = self.conn.t.tiger
237 t.c.linegeo.ST_Distance(wkt).label('distance'),
238 sa.func.ST_LineLocatePoint(t.c.linegeo, wkt).label('position'))\
239 .where(t.c.linegeo.ST_DWithin(wkt, 0.001))\
240 .where(t.c.parent_place_id == parent_place_id)\
241 .order_by('distance')\
245 sql = sa.select(inner.c.place_id,
246 inner.c.parent_place_id,
247 sa.literal(parent_type).label('osm_type'),
248 sa.literal(parent_id).label('osm_id'),
249 _interpolated_housenumber(inner),
250 _interpolated_position(inner),
254 if self.details.geometry_output:
256 sql = self._add_geometry_columns(sql, sub.c.centroid)
258 return (await self.conn.execute(sql)).one_or_none()
261 async def lookup_street_poi(self,
262 wkt: WKTElement) -> Tuple[Optional[SaRow], RowFunc]:
263 """ Find a street or POI/address for the given WKT point.
265 log().section('Reverse lookup on street/address level')
267 parent_place_id = None
269 row = await self._find_closest_street_or_poi(wkt, distance)
270 row_func: RowFunc = nres.create_from_placex_row
271 log().var_dump('Result (street/building)', row)
273 # If the closest result was a street, but an address was requested,
274 # check for a housenumber nearby which is part of the street.
276 if self.max_rank > 27 \
277 and self.layer_enabled(DataLayer.ADDRESS) \
278 and row.rank_address <= 27:
280 parent_place_id = row.place_id
281 log().comment('Find housenumber for street')
282 addr_row = await self._find_housenumber_for_street(parent_place_id, wkt)
283 log().var_dump('Result (street housenumber)', addr_row)
285 if addr_row is not None:
287 row_func = nres.create_from_placex_row
288 distance = addr_row.distance
289 elif row.country_code == 'us' and parent_place_id is not None:
290 log().comment('Find TIGER housenumber for street')
291 addr_row = await self._find_tiger_number_for_street(parent_place_id,
295 log().var_dump('Result (street Tiger housenumber)', addr_row)
297 if addr_row is not None:
299 row_func = nres.create_from_tiger_row
301 distance = row.distance
303 # Check for an interpolation that is either closer than our result
304 # or belongs to a close street found.
305 if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS):
306 log().comment('Find interpolation for street')
307 addr_row = await self._find_interpolation_for_street(parent_place_id,
309 log().var_dump('Result (street interpolation)', addr_row)
310 if addr_row is not None:
312 row_func = nres.create_from_osmline_row
317 async def _lookup_area_address(self, wkt: WKTElement) -> Optional[SaRow]:
318 """ Lookup large addressable areas for the given WKT point.
320 log().comment('Reverse lookup by larger address area features')
321 t = self.conn.t.placex
323 # The inner SQL brings results in the right order, so that
324 # later only a minimum of results needs to be checked with ST_Contains.
325 inner = sa.select(t, sa.literal(0.0).label('distance'))\
326 .where(t.c.rank_search.between(5, self.max_rank))\
327 .where(t.c.rank_address.between(5, 25))\
328 .where(t.c.geometry.ST_GeometryType().in_(('ST_Polygon', 'ST_MultiPolygon')))\
329 .where(t.c.geometry.intersects(wkt))\
330 .where(t.c.name != None)\
331 .where(t.c.indexed_status == 0)\
332 .where(t.c.linked_place_id == None)\
333 .where(t.c.type != 'postcode')\
334 .order_by(sa.desc(t.c.rank_search))\
338 sql = _select_from_placex(inner)\
339 .where(inner.c.geometry.ST_Contains(wkt))\
340 .order_by(sa.desc(inner.c.rank_search))\
343 sql = self._add_geometry_columns(sql, inner.c.geometry)
345 address_row = (await self.conn.execute(sql)).one_or_none()
346 log().var_dump('Result (area)', address_row)
348 if address_row is not None and address_row.rank_search < self.max_rank:
349 log().comment('Search for better matching place nodes inside the area')
351 t.c.geometry.ST_Distance(wkt).label('distance'))\
352 .where(t.c.osm_type == 'N')\
353 .where(t.c.rank_search > address_row.rank_search)\
354 .where(t.c.rank_search <= self.max_rank)\
355 .where(t.c.rank_address.between(5, 25))\
356 .where(t.c.name != None)\
357 .where(t.c.indexed_status == 0)\
358 .where(t.c.linked_place_id == None)\
359 .where(t.c.type != 'postcode')\
361 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
363 .order_by(sa.desc(t.c.rank_search))\
367 touter = self.conn.t.placex.alias('outer')
368 sql = _select_from_placex(inner)\
369 .join(touter, touter.c.geometry.ST_Contains(inner.c.geometry))\
370 .where(touter.c.place_id == address_row.place_id)\
371 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
372 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
375 sql = self._add_geometry_columns(sql, inner.c.geometry)
377 place_address_row = (await self.conn.execute(sql)).one_or_none()
378 log().var_dump('Result (place node)', place_address_row)
380 if place_address_row is not None:
381 return place_address_row
386 async def _lookup_area_others(self, wkt: WKTElement) -> Optional[SaRow]:
387 t = self.conn.t.placex
389 inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
390 .where(t.c.rank_address == 0)\
391 .where(t.c.rank_search.between(5, self.max_rank))\
392 .where(t.c.name != None)\
393 .where(t.c.indexed_status == 0)\
394 .where(t.c.linked_place_id == None)\
395 .where(self._filter_by_layer(t))\
397 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
399 .order_by(sa.desc(t.c.rank_search))\
403 sql = _select_from_placex(inner)\
404 .where(sa.or_(inner.c.geometry.ST_GeometryType()
405 .not_in(('ST_Polygon', 'ST_MultiPolygon')),
406 inner.c.geometry.ST_Contains(wkt)))\
407 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
410 sql = self._add_geometry_columns(sql, inner.c.geometry)
412 row = (await self.conn.execute(sql)).one_or_none()
413 log().var_dump('Result (non-address feature)', row)
418 async def lookup_area(self, wkt: WKTElement) -> Optional[SaRow]:
419 """ Lookup large areas for the given WKT point.
421 log().section('Reverse lookup by larger area features')
423 if self.layer_enabled(DataLayer.ADDRESS):
424 address_row = await self._lookup_area_address(wkt)
428 if self.has_feature_layers():
429 other_row = await self._lookup_area_others(wkt)
433 return _get_closest(address_row, other_row)
436 async def lookup_country(self, wkt: WKTElement) -> Optional[SaRow]:
437 """ Lookup the country for the given WKT point.
439 log().section('Reverse lookup by country code')
440 t = self.conn.t.country_grid
441 sql = sa.select(t.c.country_code).distinct()\
442 .where(t.c.geometry.ST_Contains(wkt))
444 ccodes = tuple((r[0] for r in await self.conn.execute(sql)))
445 log().var_dump('Country codes', ccodes)
450 t = self.conn.t.placex
451 if self.max_rank > 4:
452 log().comment('Search for place nodes in country')
455 t.c.geometry.ST_Distance(wkt).label('distance'))\
456 .where(t.c.osm_type == 'N')\
457 .where(t.c.rank_search > 4)\
458 .where(t.c.rank_search <= self.max_rank)\
459 .where(t.c.rank_address.between(5, 25))\
460 .where(t.c.name != None)\
461 .where(t.c.indexed_status == 0)\
462 .where(t.c.linked_place_id == None)\
463 .where(t.c.type != 'postcode')\
464 .where(t.c.country_code.in_(ccodes))\
466 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
468 .order_by(sa.desc(t.c.rank_search))\
472 sql = _select_from_placex(inner)\
473 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
474 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
477 sql = self._add_geometry_columns(sql, inner.c.geometry)
479 address_row = (await self.conn.execute(sql)).one_or_none()
480 log().var_dump('Result (addressable place node)', address_row)
484 if address_row is None:
485 # Still nothing, then return a country with the appropriate country code.
486 sql = _select_from_placex(t, wkt)\
487 .where(t.c.country_code.in_(ccodes))\
488 .where(t.c.rank_address == 4)\
489 .where(t.c.rank_search == 4)\
490 .where(t.c.linked_place_id == None)\
491 .order_by('distance')
493 sql = self._add_geometry_columns(sql, t.c.geometry)
495 address_row = (await self.conn.execute(sql)).one_or_none()
500 async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
501 """ Look up a single coordinate. Returns the place information,
502 if a place was found near the coordinates or None otherwise.
504 log().function('reverse_lookup',
505 coord=coord, max_rank=self.max_rank,
506 layer=self.layer, details=self.details)
509 wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
511 row: Optional[SaRow] = None
512 row_func: RowFunc = nres.create_from_placex_row
514 if self.max_rank >= 26:
515 row, tmp_row_func = await self.lookup_street_poi(wkt)
517 row_func = tmp_row_func
518 if row is None and self.max_rank > 4:
519 row = await self.lookup_area(wkt)
520 if row is None and self.layer_enabled(DataLayer.ADDRESS):
521 row = await self.lookup_country(wkt)
523 result = row_func(row, nres.ReverseResult)
524 if result is not None:
525 assert row is not None
526 result.distance = row.distance
527 if hasattr(row, 'bbox'):
528 result.bbox = Bbox.from_wkb(row.bbox.data)
529 await nres.add_result_details(self.conn, result, self.details)