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
33 centroid = t.c.centroid
35 distance = t.c.geometry.ST_Distance(wkt)
37 (t.c.geometry.ST_GeometryType().in_(('ST_LineString',
38 'ST_MultiLineString')),
39 t.c.geometry.ST_ClosestPoint(wkt)),
40 else_=t.c.centroid).label('centroid')
43 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
45 t.c.address, t.c.extratags,
46 t.c.housenumber, t.c.postcode, t.c.country_code,
47 t.c.importance, t.c.wikipedia,
48 t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
50 distance.label('distance'),
51 t.c.geometry.ST_Expand(0).label('bbox'))
54 def _interpolated_housenumber(table: SaFromClause) -> SaLabel:
55 return sa.cast(table.c.startnumber
56 + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
57 / table.c.step) * table.c.step,
58 sa.Integer).label('housenumber')
61 def _interpolated_position(table: SaFromClause) -> SaLabel:
62 fac = sa.cast(table.c.step, sa.Float) / (table.c.endnumber - table.c.startnumber)
63 rounded_pos = sa.func.round(table.c.position / fac) * fac
65 (table.c.endnumber == table.c.startnumber, table.c.linegeo.ST_Centroid()),
66 else_=table.c.linegeo.ST_LineInterpolatePoint(rounded_pos)).label('centroid')
69 def _is_address_point(table: SaFromClause) -> SaColumn:
70 return sa.and_(table.c.rank_address == 30,
71 sa.or_(table.c.housenumber != None,
72 table.c.name.has_key('housename')))
74 def _get_closest(*rows: Optional[SaRow]) -> Optional[SaRow]:
75 return min(rows, key=lambda row: 1000 if row is None else row.distance)
77 class ReverseGeocoder:
78 """ Class implementing the logic for looking up a place from a
82 def __init__(self, conn: SearchConnection, max_rank: int, layer: DataLayer,
83 details: LookupDetails) -> None:
85 self.max_rank = max_rank
87 self.details = details
89 def layer_enabled(self, *layer: DataLayer) -> bool:
90 """ Return true when any of the given layer types are requested.
92 return any(self.layer & l for l in layer)
95 def layer_disabled(self, *layer: DataLayer) -> bool:
96 """ Return true when none of the given layer types is requested.
98 return not any(self.layer & l for l in layer)
101 def has_feature_layers(self) -> bool:
102 """ Return true if any layer other than ADDRESS or POI is requested.
104 return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
106 def _add_geometry_columns(self, sql: SaSelect, col: SaColumn) -> SaSelect:
107 if not self.details.geometry_output:
112 if self.details.geometry_simplification > 0.0:
113 col = col.ST_SimplifyPreserveTopology(self.details.geometry_simplification)
115 if self.details.geometry_output & GeometryFormat.GEOJSON:
116 out.append(col.ST_AsGeoJSON().label('geometry_geojson'))
117 if self.details.geometry_output & GeometryFormat.TEXT:
118 out.append(col.ST_AsText().label('geometry_text'))
119 if self.details.geometry_output & GeometryFormat.KML:
120 out.append(col.ST_AsKML().label('geometry_kml'))
121 if self.details.geometry_output & GeometryFormat.SVG:
122 out.append(col.ST_AsSVG().label('geometry_svg'))
124 return sql.add_columns(*out)
127 def _filter_by_layer(self, table: SaFromClause) -> SaColumn:
128 if self.layer_enabled(DataLayer.MANMADE):
130 if self.layer_disabled(DataLayer.RAILWAY):
131 exclude.append('railway')
132 if self.layer_disabled(DataLayer.NATURAL):
133 exclude.extend(('natural', 'water', 'waterway'))
134 return table.c.class_.not_in(tuple(exclude))
137 if self.layer_enabled(DataLayer.RAILWAY):
138 include.append('railway')
139 if self.layer_enabled(DataLayer.NATURAL):
140 include.extend(('natural', 'water', 'waterway'))
141 return table.c.class_.in_(tuple(include))
144 async def _find_closest_street_or_poi(self, wkt: WKTElement,
145 distance: float) -> Optional[SaRow]:
146 """ Look up the closest rank 26+ place in the database, which
147 is closer than the given distance.
149 t = self.conn.t.placex
151 sql = _select_from_placex(t, wkt)\
152 .where(t.c.geometry.ST_DWithin(wkt, distance))\
153 .where(t.c.indexed_status == 0)\
154 .where(t.c.linked_place_id == None)\
155 .where(sa.or_(t.c.geometry.ST_GeometryType()
156 .not_in(('ST_Polygon', 'ST_MultiPolygon')),
157 t.c.centroid.ST_Distance(wkt) < distance))\
158 .order_by('distance')\
161 sql = self._add_geometry_columns(sql, t.c.geometry)
163 restrict: List[SaColumn] = []
165 if self.layer_enabled(DataLayer.ADDRESS):
166 restrict.append(sa.and_(t.c.rank_address >= 26,
167 t.c.rank_address <= min(29, self.max_rank)))
168 if self.max_rank == 30:
169 restrict.append(_is_address_point(t))
170 if self.layer_enabled(DataLayer.POI) and self.max_rank == 30:
171 restrict.append(sa.and_(t.c.rank_search == 30,
172 t.c.class_.not_in(('place', 'building')),
173 t.c.geometry.ST_GeometryType() != 'ST_LineString'))
174 if self.has_feature_layers():
175 restrict.append(sa.and_(t.c.rank_search.between(26, self.max_rank),
176 t.c.rank_address == 0,
177 self._filter_by_layer(t)))
182 return (await self.conn.execute(sql.where(sa.or_(*restrict)))).one_or_none()
185 async def _find_housenumber_for_street(self, parent_place_id: int,
186 wkt: WKTElement) -> Optional[SaRow]:
187 t = self.conn.t.placex
189 sql = _select_from_placex(t, wkt)\
190 .where(t.c.geometry.ST_DWithin(wkt, 0.001))\
191 .where(t.c.parent_place_id == parent_place_id)\
192 .where(_is_address_point(t))\
193 .where(t.c.indexed_status == 0)\
194 .where(t.c.linked_place_id == None)\
195 .order_by('distance')\
198 sql = self._add_geometry_columns(sql, t.c.geometry)
200 return (await self.conn.execute(sql)).one_or_none()
203 async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
205 distance: float) -> Optional[SaRow]:
206 t = self.conn.t.osmline
209 t.c.linegeo.ST_Distance(wkt).label('distance'),
210 t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\
211 .where(t.c.linegeo.ST_DWithin(wkt, distance))\
212 .where(t.c.startnumber != None)\
213 .order_by('distance')\
216 if parent_place_id is not None:
217 sql = sql.where(t.c.parent_place_id == parent_place_id)
219 inner = sql.subquery()
221 sql = sa.select(inner.c.place_id, inner.c.osm_id,
222 inner.c.parent_place_id, inner.c.address,
223 _interpolated_housenumber(inner),
224 _interpolated_position(inner),
225 inner.c.postcode, inner.c.country_code,
228 if self.details.geometry_output:
230 sql = self._add_geometry_columns(sql, sub.c.centroid)
232 return (await self.conn.execute(sql)).one_or_none()
235 async def _find_tiger_number_for_street(self, parent_place_id: int,
236 parent_type: str, parent_id: int,
237 wkt: WKTElement) -> Optional[SaRow]:
238 t = self.conn.t.tiger
241 t.c.linegeo.ST_Distance(wkt).label('distance'),
242 sa.func.ST_LineLocatePoint(t.c.linegeo, wkt).label('position'))\
243 .where(t.c.linegeo.ST_DWithin(wkt, 0.001))\
244 .where(t.c.parent_place_id == parent_place_id)\
245 .order_by('distance')\
249 sql = sa.select(inner.c.place_id,
250 inner.c.parent_place_id,
251 sa.literal(parent_type).label('osm_type'),
252 sa.literal(parent_id).label('osm_id'),
253 _interpolated_housenumber(inner),
254 _interpolated_position(inner),
258 if self.details.geometry_output:
260 sql = self._add_geometry_columns(sql, sub.c.centroid)
262 return (await self.conn.execute(sql)).one_or_none()
265 async def lookup_street_poi(self,
266 wkt: WKTElement) -> Tuple[Optional[SaRow], RowFunc]:
267 """ Find a street or POI/address for the given WKT point.
269 log().section('Reverse lookup on street/address level')
271 parent_place_id = None
273 row = await self._find_closest_street_or_poi(wkt, distance)
274 row_func: RowFunc = nres.create_from_placex_row
275 log().var_dump('Result (street/building)', row)
277 # If the closest result was a street, but an address was requested,
278 # check for a housenumber nearby which is part of the street.
280 if self.max_rank > 27 \
281 and self.layer_enabled(DataLayer.ADDRESS) \
282 and row.rank_address <= 27:
284 parent_place_id = row.place_id
285 log().comment('Find housenumber for street')
286 addr_row = await self._find_housenumber_for_street(parent_place_id, wkt)
287 log().var_dump('Result (street housenumber)', addr_row)
289 if addr_row is not None:
291 row_func = nres.create_from_placex_row
292 distance = addr_row.distance
293 elif row.country_code == 'us' and parent_place_id is not None:
294 log().comment('Find TIGER housenumber for street')
295 addr_row = await self._find_tiger_number_for_street(parent_place_id,
299 log().var_dump('Result (street Tiger housenumber)', addr_row)
301 if addr_row is not None:
303 row_func = nres.create_from_tiger_row
305 distance = row.distance
307 # Check for an interpolation that is either closer than our result
308 # or belongs to a close street found.
309 if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS):
310 log().comment('Find interpolation for street')
311 addr_row = await self._find_interpolation_for_street(parent_place_id,
313 log().var_dump('Result (street interpolation)', addr_row)
314 if addr_row is not None:
316 row_func = nres.create_from_osmline_row
321 async def _lookup_area_address(self, wkt: WKTElement) -> Optional[SaRow]:
322 """ Lookup large addressable areas for the given WKT point.
324 log().comment('Reverse lookup by larger address area features')
325 t = self.conn.t.placex
327 # The inner SQL brings results in the right order, so that
328 # later only a minimum of results needs to be checked with ST_Contains.
329 inner = sa.select(t, sa.literal(0.0).label('distance'))\
330 .where(t.c.rank_search.between(5, self.max_rank))\
331 .where(t.c.rank_address.between(5, 25))\
332 .where(t.c.geometry.ST_GeometryType().in_(('ST_Polygon', 'ST_MultiPolygon')))\
333 .where(t.c.geometry.intersects(wkt))\
334 .where(t.c.name != None)\
335 .where(t.c.indexed_status == 0)\
336 .where(t.c.linked_place_id == None)\
337 .where(t.c.type != 'postcode')\
338 .order_by(sa.desc(t.c.rank_search))\
342 sql = _select_from_placex(inner)\
343 .where(inner.c.geometry.ST_Contains(wkt))\
344 .order_by(sa.desc(inner.c.rank_search))\
347 sql = self._add_geometry_columns(sql, inner.c.geometry)
349 address_row = (await self.conn.execute(sql)).one_or_none()
350 log().var_dump('Result (area)', address_row)
352 if address_row is not None and address_row.rank_search < self.max_rank:
353 log().comment('Search for better matching place nodes inside the area')
355 t.c.geometry.ST_Distance(wkt).label('distance'))\
356 .where(t.c.osm_type == 'N')\
357 .where(t.c.rank_search > address_row.rank_search)\
358 .where(t.c.rank_search <= self.max_rank)\
359 .where(t.c.rank_address.between(5, 25))\
360 .where(t.c.name != None)\
361 .where(t.c.indexed_status == 0)\
362 .where(t.c.linked_place_id == None)\
363 .where(t.c.type != 'postcode')\
365 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
367 .order_by(sa.desc(t.c.rank_search))\
371 touter = self.conn.t.placex.alias('outer')
372 sql = _select_from_placex(inner)\
373 .join(touter, touter.c.geometry.ST_Contains(inner.c.geometry))\
374 .where(touter.c.place_id == address_row.place_id)\
375 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
376 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
379 sql = self._add_geometry_columns(sql, inner.c.geometry)
381 place_address_row = (await self.conn.execute(sql)).one_or_none()
382 log().var_dump('Result (place node)', place_address_row)
384 if place_address_row is not None:
385 return place_address_row
390 async def _lookup_area_others(self, wkt: WKTElement) -> Optional[SaRow]:
391 t = self.conn.t.placex
393 inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
394 .where(t.c.rank_address == 0)\
395 .where(t.c.rank_search.between(5, self.max_rank))\
396 .where(t.c.name != None)\
397 .where(t.c.indexed_status == 0)\
398 .where(t.c.linked_place_id == None)\
399 .where(self._filter_by_layer(t))\
401 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
403 .order_by(sa.desc(t.c.rank_search))\
407 sql = _select_from_placex(inner)\
408 .where(sa.or_(inner.c.geometry.ST_GeometryType()
409 .not_in(('ST_Polygon', 'ST_MultiPolygon')),
410 inner.c.geometry.ST_Contains(wkt)))\
411 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
414 sql = self._add_geometry_columns(sql, inner.c.geometry)
416 row = (await self.conn.execute(sql)).one_or_none()
417 log().var_dump('Result (non-address feature)', row)
422 async def lookup_area(self, wkt: WKTElement) -> Optional[SaRow]:
423 """ Lookup large areas for the given WKT point.
425 log().section('Reverse lookup by larger area features')
427 if self.layer_enabled(DataLayer.ADDRESS):
428 address_row = await self._lookup_area_address(wkt)
432 if self.has_feature_layers():
433 other_row = await self._lookup_area_others(wkt)
437 return _get_closest(address_row, other_row)
440 async def lookup_country(self, wkt: WKTElement) -> Optional[SaRow]:
441 """ Lookup the country for the given WKT point.
443 log().section('Reverse lookup by country code')
444 t = self.conn.t.country_grid
445 sql = sa.select(t.c.country_code).distinct()\
446 .where(t.c.geometry.ST_Contains(wkt))
448 ccodes = tuple((r[0] for r in await self.conn.execute(sql)))
449 log().var_dump('Country codes', ccodes)
454 t = self.conn.t.placex
455 if self.max_rank > 4:
456 log().comment('Search for place nodes in country')
459 t.c.geometry.ST_Distance(wkt).label('distance'))\
460 .where(t.c.osm_type == 'N')\
461 .where(t.c.rank_search > 4)\
462 .where(t.c.rank_search <= self.max_rank)\
463 .where(t.c.rank_address.between(5, 25))\
464 .where(t.c.name != None)\
465 .where(t.c.indexed_status == 0)\
466 .where(t.c.linked_place_id == None)\
467 .where(t.c.type != 'postcode')\
468 .where(t.c.country_code.in_(ccodes))\
470 .ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
472 .order_by(sa.desc(t.c.rank_search))\
476 sql = _select_from_placex(inner)\
477 .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
478 .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
481 sql = self._add_geometry_columns(sql, inner.c.geometry)
483 address_row = (await self.conn.execute(sql)).one_or_none()
484 log().var_dump('Result (addressable place node)', address_row)
488 if address_row is None:
489 # Still nothing, then return a country with the appropriate country code.
490 sql = _select_from_placex(t, wkt)\
491 .where(t.c.country_code.in_(ccodes))\
492 .where(t.c.rank_address == 4)\
493 .where(t.c.rank_search == 4)\
494 .where(t.c.linked_place_id == None)\
495 .order_by('distance')
497 sql = self._add_geometry_columns(sql, t.c.geometry)
499 address_row = (await self.conn.execute(sql)).one_or_none()
504 async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
505 """ Look up a single coordinate. Returns the place information,
506 if a place was found near the coordinates or None otherwise.
508 log().function('reverse_lookup',
509 coord=coord, max_rank=self.max_rank,
510 layer=self.layer, details=self.details)
513 wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
515 row: Optional[SaRow] = None
516 row_func: RowFunc = nres.create_from_placex_row
518 if self.max_rank >= 26:
519 row, tmp_row_func = await self.lookup_street_poi(wkt)
521 row_func = tmp_row_func
522 if row is None and self.max_rank > 4:
523 row = await self.lookup_area(wkt)
524 if row is None and self.layer_enabled(DataLayer.ADDRESS):
525 row = await self.lookup_country(wkt)
527 result = row_func(row, nres.ReverseResult)
528 if result is not None:
529 assert row is not None
530 result.distance = row.distance
531 if hasattr(row, 'bbox'):
532 result.bbox = Bbox.from_wkb(row.bbox.data)
533 await nres.add_result_details(self.conn, result, self.details)