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)