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
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
21 # In SQLAlchemy expression which compare with NULL need to be expressed with
23 # pylint: disable=singleton-comparison
25 def _select_from_placex(t: SaFromClause, wkt: Optional[str] = None) -> SaSelect:
26 """ Create a select statement with the columns relevant for reverse
30 distance = t.c.distance
32 distance = t.c.geometry.ST_Distance(wkt)
34 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
36 t.c.address, t.c.extratags,
37 t.c.housenumber, t.c.postcode, t.c.country_code,
38 t.c.importance, t.c.wikipedia,
39 t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
41 distance.label('distance'),
42 t.c.geometry.ST_Expand(0).label('bbox'))
45 def _interpolated_housenumber(table: SaFromClause) -> SaLabel:
46 return sa.cast(table.c.startnumber
47 + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
48 / table.c.step) * table.c.step,
49 sa.Integer).label('housenumber')
52 def _is_address_point(table: SaFromClause) -> SaColumn:
53 return sa.and_(table.c.rank_address == 30,
54 sa.or_(table.c.housenumber != None,
55 table.c.name.has_key('housename')))
57 def _get_closest(*rows: Optional[SaRow]) -> Optional[SaRow]:
58 return min(rows, key=lambda row: 1000 if row is None else row.distance)
60 class ReverseGeocoder:
61 """ Class implementing the logic for looking up a place from a
65 def __init__(self, conn: SearchConnection, max_rank: int, layer: DataLayer,
66 details: LookupDetails) -> None:
68 self.max_rank = max_rank
70 self.details = details
72 def layer_enabled(self, *layer: DataLayer) -> bool:
73 """ Return true when any of the given layer types are requested.
75 return any(self.layer & l for l in layer)
78 def layer_disabled(self, *layer: DataLayer) -> bool:
79 """ Return true when none of the given layer types is requested.
81 return not any(self.layer & l for l in layer)
84 def has_feature_layers(self) -> bool:
85 """ Return true if any layer other than ADDRESS or POI is requested.
87 return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
89 def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect:
90 if not self.details.geometry_output:
95 if self.details.geometry_simplification > 0.0:
96 col = col.ST_SimplifyPreserveTopology(self.details.geometry_simplification)
98 if self.details.geometry_output & GeometryFormat.GEOJSON:
99 out.append(col.ST_AsGeoJSON().label('geometry_geojson'))
100 if self.details.geometry_output & GeometryFormat.TEXT:
101 out.append(col.ST_AsText().label('geometry_text'))
102 if self.details.geometry_output & GeometryFormat.KML:
103 out.append(col.ST_AsKML().label('geometry_kml'))
104 if self.details.geometry_output & GeometryFormat.SVG:
105 out.append(col.ST_AsSVG().label('geometry_svg'))
107 return sql.add_columns(*out)
110 def _filter_by_layer(self, table: SaFromClause) -> SaColumn:
111 if self.layer_enabled(DataLayer.MANMADE):
113 if self.layer_disabled(DataLayer.RAILWAY):
114 exclude.append('railway')
115 if self.layer_disabled(DataLayer.NATURAL):
116 exclude.extend(('natural', 'water', 'waterway'))
117 return table.c.class_.not_in(tuple(exclude))
120 if self.layer_enabled(DataLayer.RAILWAY):
121 include.append('railway')
122 if self.layer_enabled(DataLayer.NATURAL):
123 include.extend(('natural', 'water', 'waterway'))
124 return table.c.class_.in_(tuple(include))
127 async def _find_closest_street_or_poi(self, wkt: WKTElement,
128 distance: float) -> Optional[SaRow]:
129 """ Look up the closest rank 26+ place in the database, which
130 is closer than the given distance.
132 t = self.conn.t.placex
134 sql = _select_from_placex(t, wkt)\
135 .where(t.c.geometry.ST_DWithin(wkt, distance))\
136 .where(t.c.indexed_status == 0)\
137 .where(t.c.linked_place_id == None)\
138 .where(sa.or_(t.c.geometry.ST_GeometryType()
139 .not_in(('ST_Polygon', 'ST_MultiPolygon')),
140 t.c.centroid.ST_Distance(wkt) < distance))\
141 .order_by('distance')\
144 sql = self._add_geometry_columns(sql, t.c.geometry)
146 restrict: List[SaColumn] = []
148 if self.layer_enabled(DataLayer.ADDRESS):
149 restrict.append(sa.and_(t.c.rank_address >= 26,
150 t.c.rank_address <= min(29, self.max_rank)))
151 if self.max_rank == 30:
152 restrict.append(_is_address_point(t))
153 if self.layer_enabled(DataLayer.POI) and self.max_rank == 30:
154 restrict.append(sa.and_(t.c.rank_search == 30,
155 t.c.class_.not_in(('place', 'building')),
156 t.c.geometry.ST_GeometryType() != 'ST_LineString'))
157 if self.has_feature_layers():
158 restrict.append(sa.and_(t.c.rank_search.between(26, self.max_rank),
159 t.c.rank_address == 0,
160 self._filter_by_layer(t)))
165 return (await self.conn.execute(sql.where(sa.or_(*restrict)))).one_or_none()
168 async def _find_housenumber_for_street(self, parent_place_id: int,
169 wkt: WKTElement) -> Optional[SaRow]:
170 t = self.conn.t.placex
172 sql = _select_from_placex(t, wkt)\
173 .where(t.c.geometry.ST_DWithin(wkt, 0.001))\
174 .where(t.c.parent_place_id == parent_place_id)\
175 .where(_is_address_point(t))\
176 .where(t.c.indexed_status == 0)\
177 .where(t.c.linked_place_id == None)\
178 .order_by('distance')\
181 sql = self._add_geometry_columns(sql, t.c.geometry)
183 return (await self.conn.execute(sql)).one_or_none()
186 async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
188 distance: float) -> Optional[SaRow]:
189 t = self.conn.t.osmline
192 t.c.linegeo.ST_Distance(wkt).label('distance'),
193 t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\
194 .where(t.c.linegeo.ST_DWithin(wkt, distance))\
195 .order_by('distance')\
198 if parent_place_id is not None:
199 sql = sql.where(t.c.parent_place_id == parent_place_id)
201 inner = sql.subquery()
203 sql = sa.select(inner.c.place_id, inner.c.osm_id,
204 inner.c.parent_place_id, inner.c.address,
205 _interpolated_housenumber(inner),
206 inner.c.postcode, inner.c.country_code,
207 inner.c.linegeo.ST_LineInterpolatePoint(inner.c.position).label('centroid'),
210 if self.details.geometry_output:
212 sql = self._add_geometry_columns(sql, sub.c.centroid)
214 return (await self.conn.execute(sql)).one_or_none()
217 async def _find_tiger_number_for_street(self, parent_place_id: int,
218 wkt: WKTElement) -> Optional[SaRow]:
219 t = self.conn.t.tiger
222 t.c.linegeo.ST_Distance(wkt).label('distance'),
223 sa.func.ST_LineLocatePoint(t.c.linegeo, wkt).label('position'))\
224 .where(t.c.linegeo.ST_DWithin(wkt, 0.001))\
225 .where(t.c.parent_place_id == parent_place_id)\
226 .order_by('distance')\
230 sql = sa.select(inner.c.place_id,
231 inner.c.parent_place_id,
232 _interpolated_housenumber(inner),
234 inner.c.linegeo.ST_LineInterpolatePoint(inner.c.position).label('centroid'),
237 if self.details.geometry_output:
239 sql = self._add_geometry_columns(sql, sub.c.centroid)
241 return (await self.conn.execute(sql)).one_or_none()
244 async def lookup_street_poi(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
245 """ Find a street or POI/address for the given WKT point.
247 log().section('Reverse lookup on street/address level')
250 parent_place_id = None
252 row = await self._find_closest_street_or_poi(wkt, distance)
253 log().var_dump('Result (street/building)', row)
255 # If the closest result was a street, but an address was requested,
256 # check for a housenumber nearby which is part of the street.
258 if self.max_rank > 27 \
259 and self.layer_enabled(DataLayer.ADDRESS) \
260 and row.rank_address <= 27:
262 parent_place_id = row.place_id
263 log().comment('Find housenumber for street')
264 addr_row = await self._find_housenumber_for_street(parent_place_id, wkt)
265 log().var_dump('Result (street housenumber)', addr_row)
267 if addr_row is not None:
269 distance = addr_row.distance
270 elif row.country_code == 'us' and parent_place_id is not None:
271 log().comment('Find TIGER housenumber for street')
272 addr_row = await self._find_tiger_number_for_street(parent_place_id, wkt)
273 log().var_dump('Result (street Tiger housenumber)', addr_row)
275 if addr_row is not None:
276 result = nres.create_from_tiger_row(addr_row, nres.ReverseResult)
278 distance = row.distance
280 # Check for an interpolation that is either closer than our result
281 # or belongs to a close street found.
282 if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS):
283 log().comment('Find interpolation for street')
284 addr_row = await self._find_interpolation_for_street(parent_place_id,
286 log().var_dump('Result (street interpolation)', addr_row)
287 if addr_row is not None:
288 result = nres.create_from_osmline_row(addr_row, nres.ReverseResult)
290 return result or nres.create_from_placex_row(row, nres.ReverseResult)
293 async def _lookup_area_address(self, wkt: WKTElement) -> Optional[SaRow]:
294 """ Lookup large addressable areas for the given WKT point.
296 log().comment('Reverse lookup by larger address area features')
297 t = self.conn.t.placex
299 # The inner SQL brings results in the right order, so that
300 # later only a minimum of results needs to be checked with ST_Contains.
301 inner = sa.select(t, sa.literal(0.0).label('distance'))\
302 .where(t.c.rank_search.between(5, self.max_rank))\
303 .where(t.c.rank_address.between(5, 25))\
304 .where(t.c.geometry.ST_GeometryType().in_(('ST_Polygon', 'ST_MultiPolygon')))\
305 .where(t.c.geometry.intersects(wkt))\
306 .where(t.c.name != None)\
307 .where(t.c.indexed_status == 0)\
308 .where(t.c.linked_place_id == None)\
309 .where(t.c.type != 'postcode')\
310 .order_by(sa.desc(t.c.rank_search))\
314 sql = _select_from_placex(inner)\
315 .where(inner.c.geometry.ST_Contains(wkt))\
316 .order_by(sa.desc(inner.c.rank_search))\
319 sql = self._add_geometry_columns(sql, inner.c.geometry)
321 address_row = (await self.conn.execute(sql)).one_or_none()
322 log().var_dump('Result (area)', address_row)
324 if address_row is not None and address_row.rank_search < self.max_rank:
325 log().comment('Search for better matching place nodes inside the area')
327 t.c.geometry.ST_Distance(wkt).label('distance'))\
328 .where(t.c.osm_type == 'N')\
329 .where(t.c.rank_search > address_row.rank_search)\
330 .where(t.c.rank_search <= self.max_rank)\
331 .where(t.c.rank_address.between(5, 25))\
332 .where(t.c.name != None)\
333 .where(t.c.indexed_status == 0)\
334 .where(t.c.linked_place_id == None)\
335 .where(t.c.type != 'postcode')\
337 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
339 .order_by(sa.desc(t.c.rank_search))\
343 touter = self.conn.t.placex.alias('outer')
344 sql = _select_from_placex(inner)\
345 .join(touter, touter.c.geometry.ST_Contains(inner.c.geometry))\
346 .where(touter.c.place_id == address_row.place_id)\
347 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
348 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
351 sql = self._add_geometry_columns(sql, inner.c.geometry)
353 place_address_row = (await self.conn.execute(sql)).one_or_none()
354 log().var_dump('Result (place node)', place_address_row)
356 if place_address_row is not None:
357 return place_address_row
362 async def _lookup_area_others(self, wkt: WKTElement) -> Optional[SaRow]:
363 t = self.conn.t.placex
365 inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
366 .where(t.c.rank_address == 0)\
367 .where(t.c.rank_search.between(5, self.max_rank))\
368 .where(t.c.name != None)\
369 .where(t.c.indexed_status == 0)\
370 .where(t.c.linked_place_id == None)\
371 .where(self._filter_by_layer(t))\
373 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
375 .order_by(sa.desc(t.c.rank_search))\
379 sql = _select_from_placex(inner)\
380 .where(sa.or_(inner.c.geometry.ST_GeometryType()
381 .not_in(('ST_Polygon', 'ST_MultiPolygon')),
382 inner.c.geometry.ST_Contains(wkt)))\
383 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
386 sql = self._add_geometry_columns(sql, inner.c.geometry)
388 row = (await self.conn.execute(sql)).one_or_none()
389 log().var_dump('Result (non-address feature)', row)
394 async def lookup_area(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
395 """ Lookup large areas for the given WKT point.
397 log().section('Reverse lookup by larger area features')
399 if self.layer_enabled(DataLayer.ADDRESS):
400 address_row = await self._lookup_area_address(wkt)
404 if self.has_feature_layers():
405 other_row = await self._lookup_area_others(wkt)
409 return nres.create_from_placex_row(_get_closest(address_row, other_row), nres.ReverseResult)
412 async def lookup_country(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
413 """ Lookup the country for the given WKT point.
415 log().section('Reverse lookup by country code')
416 t = self.conn.t.country_grid
417 sql = sa.select(t.c.country_code).distinct()\
418 .where(t.c.geometry.ST_Contains(wkt))
420 ccodes = tuple((r[0] for r in await self.conn.execute(sql)))
421 log().var_dump('Country codes', ccodes)
426 t = self.conn.t.placex
427 if self.max_rank > 4:
428 log().comment('Search for place nodes in country')
431 t.c.geometry.ST_Distance(wkt).label('distance'))\
432 .where(t.c.osm_type == 'N')\
433 .where(t.c.rank_search > 4)\
434 .where(t.c.rank_search <= self.max_rank)\
435 .where(t.c.rank_address.between(5, 25))\
436 .where(t.c.name != None)\
437 .where(t.c.indexed_status == 0)\
438 .where(t.c.linked_place_id == None)\
439 .where(t.c.type != 'postcode')\
440 .where(t.c.country_code.in_(ccodes))\
442 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
444 .order_by(sa.desc(t.c.rank_search))\
448 sql = _select_from_placex(inner)\
449 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
450 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
453 sql = self._add_geometry_columns(sql, inner.c.geometry)
455 address_row = (await self.conn.execute(sql)).one_or_none()
456 log().var_dump('Result (addressable place node)', address_row)
460 if address_row is None:
461 # Still nothing, then return a country with the appropriate country code.
462 sql = _select_from_placex(t, wkt)\
463 .where(t.c.country_code.in_(ccodes))\
464 .where(t.c.rank_address == 4)\
465 .where(t.c.rank_search == 4)\
466 .where(t.c.linked_place_id == None)\
467 .order_by('distance')
469 sql = self._add_geometry_columns(sql, t.c.geometry)
471 address_row = (await self.conn.execute(sql)).one_or_none()
473 return nres.create_from_placex_row(address_row, nres.ReverseResult)
476 async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
477 """ Look up a single coordinate. Returns the place information,
478 if a place was found near the coordinates or None otherwise.
480 log().function('reverse_lookup',
481 coord=coord, max_rank=self.max_rank,
482 layer=self.layer, details=self.details)
485 wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
487 result: Optional[nres.ReverseResult] = None
489 if self.max_rank >= 26:
490 result = await self.lookup_street_poi(wkt)
491 if result is None and self.max_rank > 4:
492 result = await self.lookup_area(wkt)
493 if result is None and self.layer_enabled(DataLayer.ADDRESS):
494 result = await self.lookup_country(wkt)
495 if result is not None:
496 await nres.add_result_details(self.conn, result, self.details)