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
12 import sqlalchemy as sa
13 from geoalchemy2 import WKTElement
14 from geoalchemy2.types import Geometry
16 from nominatim.typing import SaColumn, SaSelect, SaTable, SaLabel, SaClause
17 from nominatim.api.connection import SearchConnection
18 import nominatim.api.results as nres
19 from nominatim.api.logging import log
20 from nominatim.api.types import AnyPoint, DataLayer, LookupDetails, GeometryFormat
22 def _select_from_placex(t: SaTable, wkt: Optional[str] = None) -> SaSelect:
23 """ Create a select statement with the columns relevant for reverse
27 distance = t.c.distance
29 distance = t.c.geometry.ST_Distance(wkt)
31 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
33 t.c.address, t.c.extratags,
34 t.c.housenumber, t.c.postcode, t.c.country_code,
35 t.c.importance, t.c.wikipedia,
36 t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
38 distance.label('distance'),
39 t.c.geometry.ST_Expand(0).label('bbox'))
42 def _interpolated_housenumber(table: SaTable) -> SaLabel:
43 # Entries with startnumber = endnumber are legacy from version < 4.1
44 return sa.cast(table.c.startnumber
45 + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
46 / table.c.step) * table.c.step,
47 sa.Integer).label('housenumber')
50 def _is_address_point(table: SaTable) -> SaClause:
51 return sa.and_(table.c.rank_address == 30,
52 sa.or_(table.c.housenumber != None,
53 table.c.name.has_key('housename')))
56 class ReverseGeocoder:
57 """ Class implementing the logic for looking up a place from a
61 def __init__(self, conn: SearchConnection, max_rank: int, layer: DataLayer,
62 details: LookupDetails) -> None:
64 self.max_rank = max_rank
66 self.details = details
69 def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect:
70 if not self.details.geometry_output:
75 if self.details.geometry_simplification > 0.0:
76 col = col.ST_SimplifyPreserveTopology(self.details.geometry_simplification)
78 if self.details.geometry_output & GeometryFormat.GEOJSON:
79 out.append(col.ST_AsGeoJSON().label('geometry_geojson'))
80 if self.details.geometry_output & GeometryFormat.TEXT:
81 out.append(col.ST_AsText().label('geometry_text'))
82 if self.details.geometry_output & GeometryFormat.KML:
83 out.append(col.ST_AsKML().label('geometry_kml'))
84 if self.details.geometry_output & GeometryFormat.SVG:
85 out.append(col.ST_AsSVG().label('geometry_svg'))
87 return sql.add_columns(*out)
90 def _filter_by_layer(self, table: SaTable) -> SaColumn:
91 if self.layer & DataLayer.MANMADE:
93 if not (self.layer & DataLayer.RAILWAY):
94 exclude.append('railway')
95 if not (self.layer & DataLayer.NATURAL):
96 exclude.extend(('natural', 'water', 'waterway'))
97 return table.c.class_.not_in(tuple(exclude))
100 if self.layer & DataLayer.RAILWAY:
101 include.append('railway')
102 if not (self.layer & DataLayer.NATURAL):
103 include.extend(('natural', 'water', 'waterway'))
104 return table.c.class_.in_(tuple(include))
107 async def _find_closest_street_or_poi(self, wkt: WKTElement) -> SaRow:
108 """ Look up the clostest rank 26+ place in the database.
110 t = self.conn.t.placex
112 sql = _select_from_placex(t, wkt)\
113 .where(t.c.geometry.ST_DWithin(wkt, distance))\
114 .where(t.c.indexed_status == 0)\
115 .where(t.c.linked_place_id == None)\
116 .where(sa.or_(t.c.geometry.ST_GeometryType().not_in(('ST_Polygon', 'ST_MultiPolygon')),
117 t.c.centroid.ST_Distance(wkt) < distance))\
118 .order_by('distance')\
121 sql = self._add_geometry_columns(sql, t.c.geometry)
125 if self.layer & DataLayer.ADDRESS:
126 restrict.append(sa.and_(t.c.rank_address >= 26,
127 t.c.rank_address <= self.max_rank))
128 if self.max_rank == 30:
129 restrict.append(_is_address_point(t))
130 if self.layer & DataLayer.POI and max_rank == 30:
131 restrict.append(sa.and_(t.c.rank_search == 30,
132 t.c.class_.not_in(('place', 'building')),
133 t.c.geometry.ST_GeometryType() != 'ST_LineString'))
134 if self.layer & (DataLayer.RAILWAY | DataLayer.MANMADE | DataLayer.NATURAL):
135 restrict.append(sa.and_(t.c.rank_search >= 26,
136 tc.rank_search <= self.max_rank,
137 self._filter_by_layer(t)))
140 sql = sql.where(sa.or_(*restrict))
142 return (await self.conn.execute(sql)).one_or_none()
145 async def _find_housenumber_for_street(self, parent_place_id: int,
146 wkt: WKTElement) -> Optional[SaRow]:
149 sql = _select_from_placex(t, wkt)\
150 .where(t.c.geometry.ST_DWithin(wkt, 0.001))\
151 .where(t.c.parent_place_id == parent_place_id)\
152 .where(_is_address_point(t))\
153 .where(t.c.indexed_status == 0)\
154 .where(t.c.linked_place_id == None)\
155 .order_by('distance')\
158 sql = self._add_geometry_columns(sql, t.c.geometry)
160 return (await self.conn.execute(sql)).one_or_none()
163 async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
164 wkt: WKTElement) -> Optional[SaRow]:
165 t = self.conn.t.osmline
168 t.c.linegeo.ST_Distance(wkt).label('distance'),
169 t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\
170 .where(t.c.linegeo.ST_DWithin(wkt, distance))\
171 .order_by('distance')\
174 if parent_place_id is not None:
175 inner = inner.where(t.c.parent_place_id == parent_place_id)
177 inner = inner.subquery()
179 sql = sa.select(inner.c.place_id, inner.c.osm_id,
180 inner.c.parent_place_id, inner.c.address,
181 _interpolated_housenumber(inner),
182 inner.c.postcode, inner.c.country_code,
183 inner.c.linegeo.ST_LineInterpolatePoint(inner.c.position).label('centroid'),
186 if self.details.geometry_output:
188 sql = self._add_geometry_columns(sql, sub.c.centroid)
190 return (await self.conn.execute(sql)).one_or_none()
193 async def _find_tiger_number_for_street(self, parent_place_id: int,
194 wkt: WKTElement) -> Optional[SaRow]:
195 t = self.conn.t.tiger
198 t.c.linegeo.ST_Distance(wkt).label('distance'),
199 sa.func.ST_LineLocatePoint(t.c.linegeo, wkt).label('position'))\
200 .where(t.c.linegeo.ST_DWithin(wkt, 0.001))\
201 .where(t.c.parent_place_id == parent_place_id)\
202 .order_by('distance')\
206 sql = sa.select(inner.c.place_id,
207 inner.c.parent_place_id,
208 _interpolated_housenumber(inner),
210 inner.c.linegeo.ST_LineInterpolatePoint(inner.c.position).label('centroid'),
213 if self.details.geometry_output:
215 sql = self._add_geometry_columns(sql, sub.c.centroid)
217 return (await conn.execute(sql)).one_or_none()
220 async def lookup_street_poi(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
221 """ Find a street or POI/address for the given WKT point.
223 log().section('Reverse lookup on street/address level')
226 parent_place_id = None
228 row = await self._find_closest_street_or_poi(wkt)
229 log().var_dump('Result (street/building)', row)
231 # If the closest result was a street, but an address was requested,
232 # check for a housenumber nearby which is part of the street.
234 if self.max_rank > 27 \
235 and self.layer & DataLayer.ADDRESS \
236 and row.rank_address <= 27:
238 parent_place_id = row.place_id
239 log().comment('Find housenumber for street')
240 addr_row = await self._find_housenumber_for_street(parent_place_id, wkt)
241 log().var_dump('Result (street housenumber)', addr_row)
243 if addr_row is not None:
245 distance = addr_row.distance
246 elif row.country_code == 'us' and parent_place_id is not None:
247 log().comment('Find TIGER housenumber for street')
248 addr_row = await self._find_tiger_number_for_street(parent_place_id, wkt)
249 log().var_dump('Result (street Tiger housenumber)', addr_row)
251 if addr_row is not None:
252 result = nres.create_from_tiger_row(addr_row)
254 distance = row.distance
256 # Check for an interpolation that is either closer than our result
257 # or belongs to a close street found.
258 if self.max_rank > 27 and self.layer & DataLayer.ADDRESS:
259 log().comment('Find interpolation for street')
260 addr_row = await self._find_interpolation_for_street(parent_place_id, wkt)
261 log().var_dump('Result (street interpolation)', addr_row)
262 if addr_row is not None:
263 result = nres.create_from_osmline_row(addr_row)
265 return result or nres.create_from_placex_row(row)
268 async def _lookup_area_address(self, wkt: WKTElement) -> Optional[SaRow]:
269 """ Lookup large addressable areas for the given WKT point.
271 log().comment('Reverse lookup by larger address area features')
272 t = self.conn.t.placex
274 # The inner SQL brings results in the right order, so that
275 # later only a minimum of results needs to be checked with ST_Contains.
276 inner = sa.select(t, sa.literal(0.0).label('distance'))\
277 .where(t.c.rank_search.between(5, self.max_rank))\
278 .where(t.c.rank_address.between(5, 25))\
279 .where(t.c.geometry.ST_GeometryType().in_(('ST_Polygon', 'ST_MultiPolygon')))\
280 .where(t.c.geometry.intersects(wkt))\
281 .where(t.c.name != None)\
282 .where(t.c.indexed_status == 0)\
283 .where(t.c.linked_place_id == None)\
284 .where(t.c.type != 'postcode')\
285 .order_by(sa.desc(t.c.rank_search))\
289 sql = _select_from_placex(inner)\
290 .where(inner.c.geometry.ST_Contains(wkt))\
291 .order_by(sa.desc(inner.c.rank_search))\
294 sql = self._add_geometry_columns(sql, inner.c.geometry)
296 address_row = (await self.conn.execute(sql)).one_or_none()
297 log().var_dump('Result (area)', address_row)
299 if address_row is not None and address_row.rank_search < max_rank:
300 log().comment('Search for better matching place nodes inside the area')
302 t.c.geometry.ST_Distance(wkt).label('distance'))\
303 .where(t.c.osm_type == 'N')\
304 .where(t.c.rank_search > address_row.rank_search)\
305 .where(t.c.rank_search <= max_rank)\
306 .where(t.c.rank_address.between(5, 25))\
307 .where(t.c.name != None)\
308 .where(t.c.indexed_status == 0)\
309 .where(t.c.linked_place_id == None)\
310 .where(t.c.type != 'postcode')\
312 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
314 .order_by(sa.desc(t.c.rank_search))\
318 touter = conn.t.placex.alias('outer')
319 sql = _select_from_placex(inner)\
320 .where(touter.c.place_id == address_row.place_id)\
321 .where(touter.c.geometry.ST_Contains(inner.c.geometry))\
322 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
323 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
326 sql = self._add_geometry_columns(sql, inner.c.geometry)
328 place_address_row = (await self.conn.execute(sql)).one_or_none()
329 log().var_dump('Result (place node)', place_address_row)
331 if place_address_row is not None:
332 return place_address_row
337 async def _lookup_area_others(self, wkt: WKTElement) -> Optional[SaRow]:
340 inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
341 .where(t.c.rank_address == 0)\
342 .where(t.c.rank_search.between(5, self.max_rank))\
343 .where(t.c.name != None)\
344 .where(t.c.indexed_status == 0)\
345 .where(t.c.linked_place_id == None)\
346 .where(self._filter_by_layer(t))\
347 .where(sa.func.reverse_buffered_extent(t.c.geometry, type_=Geometry)
349 .order_by(sa.desc(t.c.rank_search))\
352 sql = _select_from_placex(inner)\
353 .where(sa._or(inner.c.geometry.ST_GeometryType().not_in(('ST_Polygon', 'ST_MultiPolygon')),
354 inner.c.geometry.ST_Contains(wkt)))\
355 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
358 sql = self._add_geometry_columns(sql, inner.c.geometry)
360 row = (await self.conn.execute(sql)).one_or_none()
361 log().var_dump('Result (non-address feature)', row)
366 async def lookup_area(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
367 """ Lookup large areas for the given WKT point.
369 log().section('Reverse lookup by larger area features')
370 t = self.conn.t.placex
372 if self.layer & DataLayer.ADDRESS:
373 address_row = await self._lookup_area_address(wkt)
374 address_distance = address_row.distance
377 address_distance = 1000
379 if self.layer & (~DataLayer.ADDRESS & ~DataLayer.POI):
380 other_row = await self._lookup_area_others(wkt)
381 other_distance = other_row.distance
384 other_distance = 1000
386 result = address_row if address_distance <= other_distance else other_row
388 return nres.create_from_placex_row(result)
391 async def lookup_country(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
392 """ Lookup the country for the given WKT point.
394 log().section('Reverse lookup by country code')
395 t = self.conn.t.country_grid
396 sql = sa.select(t.c.country_code).distinct()\
397 .where(t.c.geometry.ST_Contains(wkt))
399 ccodes = tuple((r[0] for r in await self.conn.execute(sql)))
400 log().var_dump('Country codes', ccodes)
405 if self.layer & DataLayer.ADDRESS and self.max_rank > 4:
406 log().comment('Search for place nodes in country')
410 t.c.geometry.ST_Distance(wkt).label('distance'))\
411 .where(t.c.osm_type == 'N')\
412 .where(t.c.rank_search > 4)\
413 .where(t.c.rank_search <= self.max_rank)\
414 .where(t.c.rank_address.between(5, 25))\
415 .where(t.c.name != None)\
416 .where(t.c.indexed_status == 0)\
417 .where(t.c.linked_place_id == None)\
418 .where(t.c.type != 'postcode')\
419 .where(t.c.country_code.in_(ccodes))\
421 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
423 .order_by(sa.desc(t.c.rank_search))\
427 sql = _select_from_placex(inner)\
428 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
429 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
432 sql = self._add_geometry_columns(sql, inner.c.geometry)
434 address_row = (await self.conn.execute(sql)).one_or_none()
435 log().var_dump('Result (addressable place node)', address_row)
439 if layer & (~DataLayer.ADDRESS & ~DataLayer.POI) and self.max_rank > 4:
440 log().comment('Search for non-address features inside country')
443 inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
444 .where(t.c.rank_address == 0)\
445 .where(t.c.rank_search.between(5, self.max_rank))\
446 .where(t.c.name != None)\
447 .where(t.c.indexed_status == 0)\
448 .where(t.c.linked_place_id == None)\
449 .where(self._filter_by_layer(t))\
450 .where(t.c.country_code.in_(ccode))\
451 .where(sa.func.reverse_buffered_extent(t.c.geometry, type_=Geometry)
453 .order_by(sa.desc(t.c.rank_search))\
457 sql = _select_from_placex(inner)\
458 .where(sa._or(inner.c.geometry.ST_GeometryType().not_in(('ST_Polygon', 'ST_MultiPolygon')),
459 inner.c.geometry.ST_Contains(wkt)))\
460 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
463 sql = self._add_geometry_columns(sql, inner.c.geometry)
465 other_row = (await self.conn.execute(sql)).one_or_none()
466 log().var_dump('Result (non-address feature)', other_row)
470 if layer & DataLayer.ADDRESS and address_row is None and other_row is None:
471 # Still nothing, then return a country with the appropriate country code.
473 sql = _select_from_placex(t, wkt)\
474 .where(t.c.country_code.in_(ccodes))\
475 .where(t.c.rank_address == 4)\
476 .where(t.c.rank_search == 4)\
477 .where(t.c.linked_place_id == None)\
478 .order_by('distance')
480 sql = self._add_geometry_columns(sql, inner.c.geometry)
482 address_row = (await self.conn.execute(sql)).one_or_none()
484 return nres.create_from_placex_row(_get_closest_row(address_row, other_row))
487 async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
488 """ Look up a single coordinate. Returns the place information,
489 if a place was found near the coordinates or None otherwise.
491 log().function('reverse_lookup',
492 coord=coord, max_rank=self.max_rank,
493 layer=self.layer, details=self.details)
496 wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
498 result: Optional[ReverseResult] = None
501 result = await self.lookup_street_poi(wkt)
502 if result is None and max_rank > 4:
503 result = await self.lookup_area(wkt)
505 result = await self.lookup_country(wkt)
506 if result is not None:
507 await nres.add_result_details(self.conn, result, self.details)