python: implement reverse lookup function

The implementation follows for most part the PHP code but introduces an
additional layer parameter with which the kind of places to be returned
can be restricted. This replaces the hard-coded exclusion lists.
This commit is contained in:
Sarah Hoffmann
2023-03-23 22:38:37 +01:00
parent 41da298b18
commit 2f54732500
10 changed files with 424 additions and 118 deletions

View File

@@ -13,6 +13,6 @@ ignored-classes=NominatimArgs,closing
# 'too-many-ancestors' is triggered already by deriving from UserDict # 'too-many-ancestors' is triggered already by deriving from UserDict
# 'not-context-manager' disabled because it causes false positives once # 'not-context-manager' disabled because it causes false positives once
# typed Python is enabled. See also https://github.com/PyCQA/pylint/issues/5273 # typed Python is enabled. See also https://github.com/PyCQA/pylint/issues/5273
disable=too-few-public-methods,duplicate-code,too-many-ancestors,bad-option-value,no-self-use,not-context-manager,use-dict-literal disable=too-few-public-methods,duplicate-code,too-many-ancestors,bad-option-value,no-self-use,not-context-manager,use-dict-literal,chained-comparison
good-names=i,x,y,m,t,fd,db,cc good-names=i,x,y,m,t,fd,db,cc,x1,x2,y1,y2,pt

View File

@@ -21,7 +21,7 @@ from nominatim.config import Configuration
from nominatim.api.connection import SearchConnection from nominatim.api.connection import SearchConnection
from nominatim.api.status import get_status, StatusResult from nominatim.api.status import get_status, StatusResult
from nominatim.api.lookup import get_place_by_id from nominatim.api.lookup import get_place_by_id
from nominatim.api.reverse import reverse_lookup from nominatim.api.reverse import ReverseGeocoder
from nominatim.api.types import PlaceRef, LookupDetails, AnyPoint, DataLayer from nominatim.api.types import PlaceRef, LookupDetails, AnyPoint, DataLayer
from nominatim.api.results import DetailedResult, ReverseResult from nominatim.api.results import DetailedResult, ReverseResult
@@ -156,8 +156,9 @@ class NominatimAPIAsync:
max_rank = max(0, min(max_rank or 30, 30)) max_rank = max(0, min(max_rank or 30, 30))
async with self.begin() as conn: async with self.begin() as conn:
return await reverse_lookup(conn, coord, max_rank, layer, geocoder = ReverseGeocoder(conn, max_rank, layer,
details or LookupDetails()) details or LookupDetails())
return await geocoder.lookup(coord)
class NominatimAPI: class NominatimAPI:

View File

@@ -60,6 +60,17 @@ class BaseLogger:
""" Print the SQL for the given statement. """ Print the SQL for the given statement.
""" """
def format_sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> str:
""" Return the comiled version of the statement.
"""
try:
return str(cast('sa.ClauseElement', statement)
.compile(conn.sync_engine, compile_kwargs={"literal_binds": True}))
except sa.exc.CompileError:
pass
return str(cast('sa.ClauseElement', statement).compile(conn.sync_engine))
class HTMLLogger(BaseLogger): class HTMLLogger(BaseLogger):
""" Logger that formats messages in HTML. """ Logger that formats messages in HTML.
@@ -92,8 +103,7 @@ class HTMLLogger(BaseLogger):
def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None: def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None:
sqlstr = str(cast('sa.ClauseElement', statement) sqlstr = self.format_sql(conn, statement)
.compile(conn.sync_engine, compile_kwargs={"literal_binds": True}))
if CODE_HIGHLIGHT: if CODE_HIGHLIGHT:
sqlstr = highlight(sqlstr, PostgresLexer(), sqlstr = highlight(sqlstr, PostgresLexer(),
HtmlFormatter(nowrap=True, lineseparator='<br />')) HtmlFormatter(nowrap=True, lineseparator='<br />'))
@@ -147,9 +157,7 @@ class TextLogger(BaseLogger):
def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None: def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None:
sqlstr = str(cast('sa.ClauseElement', statement) sqlstr = '\n| '.join(textwrap.wrap(self.format_sql(conn, statement), width=78))
.compile(conn.sync_engine, compile_kwargs={"literal_binds": True}))
sqlstr = '\n| '.join(textwrap.wrap(sqlstr, width=78))
self._write(f"| {sqlstr}\n\n") self._write(f"| {sqlstr}\n\n")

View File

@@ -172,6 +172,7 @@ async def get_place_by_id(conn: SearchConnection, place: ntyp.PlaceRef,
assert result is not None assert result is not None
result.parent_place_id = row.parent_place_id result.parent_place_id = row.parent_place_id
result.linked_place_id = getattr(row, 'linked_place_id', None) result.linked_place_id = getattr(row, 'linked_place_id', None)
result.admin_level = getattr(row, 'admin_level', 15)
indexed_date = getattr(row, 'indexed_date', None) indexed_date = getattr(row, 'indexed_date', None)
if indexed_date is not None: if indexed_date is not None:
result.indexed_date = indexed_date.replace(tzinfo=dt.timezone.utc) result.indexed_date = indexed_date.replace(tzinfo=dt.timezone.utc)

View File

@@ -46,8 +46,6 @@ class AddressLine:
names: Dict[str, str] names: Dict[str, str]
extratags: Optional[Dict[str, str]] extratags: Optional[Dict[str, str]]
local_name: Optional[str] = None
admin_level: Optional[int] admin_level: Optional[int]
fromarea: bool fromarea: bool
isaddress: bool isaddress: bool
@@ -81,7 +79,6 @@ class BaseResult:
place_id : Optional[int] = None place_id : Optional[int] = None
osm_object: Optional[Tuple[str, int]] = None osm_object: Optional[Tuple[str, int]] = None
admin_level: int = 15
names: Optional[Dict[str, str]] = None names: Optional[Dict[str, str]] = None
address: Optional[Dict[str, str]] = None address: Optional[Dict[str, str]] = None
@@ -135,6 +132,7 @@ class DetailedResult(BaseResult):
""" """
parent_place_id: Optional[int] = None parent_place_id: Optional[int] = None
linked_place_id: Optional[int] = None linked_place_id: Optional[int] = None
admin_level: int = 15
indexed_date: Optional[dt.datetime] = None indexed_date: Optional[dt.datetime] = None
@@ -164,7 +162,6 @@ def create_from_placex_row(row: Optional[SaRow],
place_id=row.place_id, place_id=row.place_id,
osm_object=(row.osm_type, row.osm_id), osm_object=(row.osm_type, row.osm_id),
category=(row.class_, row.type), category=(row.class_, row.type),
admin_level=row.admin_level,
names=row.name, names=row.name,
address=row.address, address=row.address,
extratags=row.extratags, extratags=row.extratags,

View File

@@ -7,19 +7,22 @@
""" """
Implementation of reverse geocoding. Implementation of reverse geocoding.
""" """
from typing import Optional from typing import Optional, List
import sqlalchemy as sa import sqlalchemy as sa
from geoalchemy2 import WKTElement from geoalchemy2 import WKTElement
from geoalchemy2.types import Geometry
from nominatim.typing import SaColumn, SaSelect, SaTable, SaLabel, SaClause from nominatim.typing import SaColumn, SaSelect, SaFromClause, SaLabel, SaRow
from nominatim.api.connection import SearchConnection from nominatim.api.connection import SearchConnection
import nominatim.api.results as nres import nominatim.api.results as nres
from nominatim.api.logging import log from nominatim.api.logging import log
from nominatim.api.types import AnyPoint, DataLayer, LookupDetails, GeometryFormat from nominatim.api.types import AnyPoint, DataLayer, LookupDetails, GeometryFormat
def _select_from_placex(t: SaTable, wkt: Optional[str] = None) -> SaSelect: # In SQLAlchemy expression which compare with NULL need to be expressed with
# the equal sign.
# pylint: disable=singleton-comparison
def _select_from_placex(t: SaFromClause, wkt: Optional[str] = None) -> SaSelect:
""" Create a select statement with the columns relevant for reverse """ Create a select statement with the columns relevant for reverse
results. results.
""" """
@@ -39,19 +42,20 @@ def _select_from_placex(t: SaTable, wkt: Optional[str] = None) -> SaSelect:
t.c.geometry.ST_Expand(0).label('bbox')) t.c.geometry.ST_Expand(0).label('bbox'))
def _interpolated_housenumber(table: SaTable) -> SaLabel: def _interpolated_housenumber(table: SaFromClause) -> SaLabel:
# Entries with startnumber = endnumber are legacy from version < 4.1
return sa.cast(table.c.startnumber return sa.cast(table.c.startnumber
+ sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position) + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
/ table.c.step) * table.c.step, / table.c.step) * table.c.step,
sa.Integer).label('housenumber') sa.Integer).label('housenumber')
def _is_address_point(table: SaTable) -> SaClause: def _is_address_point(table: SaFromClause) -> SaColumn:
return sa.and_(table.c.rank_address == 30, return sa.and_(table.c.rank_address == 30,
sa.or_(table.c.housenumber != None, sa.or_(table.c.housenumber != None,
table.c.name.has_key('housename'))) table.c.name.has_key('housename')))
def _get_closest(*rows: Optional[SaRow]) -> Optional[SaRow]:
return min(rows, key=lambda row: 1000 if row is None else row.distance)
class ReverseGeocoder: class ReverseGeocoder:
""" Class implementing the logic for looking up a place from a """ Class implementing the logic for looking up a place from a
@@ -87,25 +91,27 @@ class ReverseGeocoder:
return sql.add_columns(*out) return sql.add_columns(*out)
def _filter_by_layer(self, table: SaTable) -> SaColumn: def _filter_by_layer(self, table: SaFromClause) -> SaColumn:
if self.layer & DataLayer.MANMADE: if self.layer & DataLayer.MANMADE:
exclude = [] exclude = []
if not (self.layer & DataLayer.RAILWAY): if not self.layer & DataLayer.RAILWAY:
exclude.append('railway') exclude.append('railway')
if not (self.layer & DataLayer.NATURAL): if not self.layer & DataLayer.NATURAL:
exclude.extend(('natural', 'water', 'waterway')) exclude.extend(('natural', 'water', 'waterway'))
return table.c.class_.not_in(tuple(exclude)) return table.c.class_.not_in(tuple(exclude))
include = [] include = []
if self.layer & DataLayer.RAILWAY: if self.layer & DataLayer.RAILWAY:
include.append('railway') include.append('railway')
if not (self.layer & DataLayer.NATURAL): if self.layer & DataLayer.NATURAL:
include.extend(('natural', 'water', 'waterway')) include.extend(('natural', 'water', 'waterway'))
return table.c.class_.in_(tuple(include)) return table.c.class_.in_(tuple(include))
async def _find_closest_street_or_poi(self, wkt: WKTElement) -> SaRow: async def _find_closest_street_or_poi(self, wkt: WKTElement,
""" Look up the clostest rank 26+ place in the database. distance: float) -> Optional[SaRow]:
""" Look up the closest rank 26+ place in the database, which
is closer than the given distance.
""" """
t = self.conn.t.placex t = self.conn.t.placex
@@ -113,38 +119,39 @@ class ReverseGeocoder:
.where(t.c.geometry.ST_DWithin(wkt, distance))\ .where(t.c.geometry.ST_DWithin(wkt, distance))\
.where(t.c.indexed_status == 0)\ .where(t.c.indexed_status == 0)\
.where(t.c.linked_place_id == None)\ .where(t.c.linked_place_id == None)\
.where(sa.or_(t.c.geometry.ST_GeometryType().not_in(('ST_Polygon', 'ST_MultiPolygon')), .where(sa.or_(t.c.geometry.ST_GeometryType()
.not_in(('ST_Polygon', 'ST_MultiPolygon')),
t.c.centroid.ST_Distance(wkt) < distance))\ t.c.centroid.ST_Distance(wkt) < distance))\
.order_by('distance')\ .order_by('distance')\
.limit(1) .limit(1)
sql = self._add_geometry_columns(sql, t.c.geometry) sql = self._add_geometry_columns(sql, t.c.geometry)
restrict = [] restrict: List[SaColumn] = []
if self.layer & DataLayer.ADDRESS: if self.layer & DataLayer.ADDRESS:
restrict.append(sa.and_(t.c.rank_address >= 26, restrict.append(sa.and_(t.c.rank_address >= 26,
t.c.rank_address <= self.max_rank)) t.c.rank_address <= min(29, self.max_rank)))
if self.max_rank == 30: if self.max_rank == 30:
restrict.append(_is_address_point(t)) restrict.append(_is_address_point(t))
if self.layer & DataLayer.POI and max_rank == 30: if self.layer & DataLayer.POI and self.max_rank == 30:
restrict.append(sa.and_(t.c.rank_search == 30, restrict.append(sa.and_(t.c.rank_search == 30,
t.c.class_.not_in(('place', 'building')), t.c.class_.not_in(('place', 'building')),
t.c.geometry.ST_GeometryType() != 'ST_LineString')) t.c.geometry.ST_GeometryType() != 'ST_LineString'))
if self.layer & (DataLayer.RAILWAY | DataLayer.MANMADE | DataLayer.NATURAL): if self.layer & (DataLayer.RAILWAY | DataLayer.MANMADE | DataLayer.NATURAL):
restrict.append(sa.and_(t.c.rank_search >= 26, restrict.append(sa.and_(t.c.rank_search.between(26, self.max_rank),
tc.rank_search <= self.max_rank, t.c.rank_address == 0,
self._filter_by_layer(t))) self._filter_by_layer(t)))
if restrict: if not restrict:
sql = sql.where(sa.or_(*restrict)) return None
return (await self.conn.execute(sql)).one_or_none() return (await self.conn.execute(sql.where(sa.or_(*restrict)))).one_or_none()
async def _find_housenumber_for_street(self, parent_place_id: int, async def _find_housenumber_for_street(self, parent_place_id: int,
wkt: WKTElement) -> Optional[SaRow]: wkt: WKTElement) -> Optional[SaRow]:
t = conn.t.placex t = self.conn.t.placex
sql = _select_from_placex(t, wkt)\ sql = _select_from_placex(t, wkt)\
.where(t.c.geometry.ST_DWithin(wkt, 0.001))\ .where(t.c.geometry.ST_DWithin(wkt, 0.001))\
@@ -161,20 +168,21 @@ class ReverseGeocoder:
async def _find_interpolation_for_street(self, parent_place_id: Optional[int], async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
wkt: WKTElement) -> Optional[SaRow]: wkt: WKTElement,
distance: float) -> Optional[SaRow]:
t = self.conn.t.osmline t = self.conn.t.osmline
inner = sa.select(t, sql = sa.select(t,
t.c.linegeo.ST_Distance(wkt).label('distance'), t.c.linegeo.ST_Distance(wkt).label('distance'),
t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\ t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\
.where(t.c.linegeo.ST_DWithin(wkt, distance))\ .where(t.c.linegeo.ST_DWithin(wkt, distance))\
.order_by('distance')\ .order_by('distance')\
.limit(1) .limit(1)
if parent_place_id is not None: if parent_place_id is not None:
inner = inner.where(t.c.parent_place_id == parent_place_id) sql = sql.where(t.c.parent_place_id == parent_place_id)
inner = inner.subquery() inner = sql.subquery()
sql = sa.select(inner.c.place_id, inner.c.osm_id, sql = sa.select(inner.c.place_id, inner.c.osm_id,
inner.c.parent_place_id, inner.c.address, inner.c.parent_place_id, inner.c.address,
@@ -214,7 +222,7 @@ class ReverseGeocoder:
sub = sql.subquery() sub = sql.subquery()
sql = self._add_geometry_columns(sql, sub.c.centroid) sql = self._add_geometry_columns(sql, sub.c.centroid)
return (await conn.execute(sql)).one_or_none() return (await self.conn.execute(sql)).one_or_none()
async def lookup_street_poi(self, wkt: WKTElement) -> Optional[nres.ReverseResult]: async def lookup_street_poi(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
@@ -225,7 +233,7 @@ class ReverseGeocoder:
distance = 0.006 distance = 0.006
parent_place_id = None parent_place_id = None
row = await self._find_closest_street_or_poi(wkt) row = await self._find_closest_street_or_poi(wkt, distance)
log().var_dump('Result (street/building)', row) log().var_dump('Result (street/building)', row)
# If the closest result was a street, but an address was requested, # If the closest result was a street, but an address was requested,
@@ -249,7 +257,7 @@ class ReverseGeocoder:
log().var_dump('Result (street Tiger housenumber)', addr_row) log().var_dump('Result (street Tiger housenumber)', addr_row)
if addr_row is not None: if addr_row is not None:
result = nres.create_from_tiger_row(addr_row) result = nres.create_from_tiger_row(addr_row, nres.ReverseResult)
else: else:
distance = row.distance distance = row.distance
@@ -257,12 +265,13 @@ class ReverseGeocoder:
# or belongs to a close street found. # or belongs to a close street found.
if self.max_rank > 27 and self.layer & DataLayer.ADDRESS: if self.max_rank > 27 and self.layer & DataLayer.ADDRESS:
log().comment('Find interpolation for street') log().comment('Find interpolation for street')
addr_row = await self._find_interpolation_for_street(parent_place_id, wkt) addr_row = await self._find_interpolation_for_street(parent_place_id,
wkt, distance)
log().var_dump('Result (street interpolation)', addr_row) log().var_dump('Result (street interpolation)', addr_row)
if addr_row is not None: if addr_row is not None:
result = nres.create_from_osmline_row(addr_row) result = nres.create_from_osmline_row(addr_row, nres.ReverseResult)
return result or nres.create_from_placex_row(row) return result or nres.create_from_placex_row(row, nres.ReverseResult)
async def _lookup_area_address(self, wkt: WKTElement) -> Optional[SaRow]: async def _lookup_area_address(self, wkt: WKTElement) -> Optional[SaRow]:
@@ -296,13 +305,13 @@ class ReverseGeocoder:
address_row = (await self.conn.execute(sql)).one_or_none() address_row = (await self.conn.execute(sql)).one_or_none()
log().var_dump('Result (area)', address_row) log().var_dump('Result (area)', address_row)
if address_row is not None and address_row.rank_search < max_rank: if address_row is not None and address_row.rank_search < self.max_rank:
log().comment('Search for better matching place nodes inside the area') log().comment('Search for better matching place nodes inside the area')
inner = sa.select(t, inner = sa.select(t,
t.c.geometry.ST_Distance(wkt).label('distance'))\ t.c.geometry.ST_Distance(wkt).label('distance'))\
.where(t.c.osm_type == 'N')\ .where(t.c.osm_type == 'N')\
.where(t.c.rank_search > address_row.rank_search)\ .where(t.c.rank_search > address_row.rank_search)\
.where(t.c.rank_search <= max_rank)\ .where(t.c.rank_search <= self.max_rank)\
.where(t.c.rank_address.between(5, 25))\ .where(t.c.rank_address.between(5, 25))\
.where(t.c.name != None)\ .where(t.c.name != None)\
.where(t.c.indexed_status == 0)\ .where(t.c.indexed_status == 0)\
@@ -315,10 +324,10 @@ class ReverseGeocoder:
.limit(50)\ .limit(50)\
.subquery() .subquery()
touter = conn.t.placex.alias('outer') touter = self.conn.t.placex.alias('outer')
sql = _select_from_placex(inner)\ sql = _select_from_placex(inner)\
.join(touter, touter.c.geometry.ST_Contains(inner.c.geometry))\
.where(touter.c.place_id == address_row.place_id)\ .where(touter.c.place_id == address_row.place_id)\
.where(touter.c.geometry.ST_Contains(inner.c.geometry))\
.where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\ .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\
.order_by(sa.desc(inner.c.rank_search), inner.c.distance)\ .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
.limit(1) .limit(1)
@@ -335,7 +344,7 @@ class ReverseGeocoder:
async def _lookup_area_others(self, wkt: WKTElement) -> Optional[SaRow]: async def _lookup_area_others(self, wkt: WKTElement) -> Optional[SaRow]:
t = conn.t.placex t = self.conn.t.placex
inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\ inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
.where(t.c.rank_address == 0)\ .where(t.c.rank_address == 0)\
@@ -344,13 +353,16 @@ class ReverseGeocoder:
.where(t.c.indexed_status == 0)\ .where(t.c.indexed_status == 0)\
.where(t.c.linked_place_id == None)\ .where(t.c.linked_place_id == None)\
.where(self._filter_by_layer(t))\ .where(self._filter_by_layer(t))\
.where(sa.func.reverse_buffered_extent(t.c.geometry, type_=Geometry) .where(t.c.geometry
.ST_Buffer(sa.func.reverse_place_diameter(t.c.rank_search))
.intersects(wkt))\ .intersects(wkt))\
.order_by(sa.desc(t.c.rank_search))\ .order_by(sa.desc(t.c.rank_search))\
.limit(50) .limit(50)\
.subquery()
sql = _select_from_placex(inner)\ sql = _select_from_placex(inner)\
.where(sa._or(inner.c.geometry.ST_GeometryType().not_in(('ST_Polygon', 'ST_MultiPolygon')), .where(sa.or_(inner.c.geometry.ST_GeometryType()
.not_in(('ST_Polygon', 'ST_MultiPolygon')),
inner.c.geometry.ST_Contains(wkt)))\ inner.c.geometry.ST_Contains(wkt)))\
.order_by(sa.desc(inner.c.rank_search), inner.c.distance)\ .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
.limit(1) .limit(1)
@@ -367,25 +379,18 @@ class ReverseGeocoder:
""" Lookup large areas for the given WKT point. """ Lookup large areas for the given WKT point.
""" """
log().section('Reverse lookup by larger area features') log().section('Reverse lookup by larger area features')
t = self.conn.t.placex
if self.layer & DataLayer.ADDRESS: if self.layer & DataLayer.ADDRESS:
address_row = await self._lookup_area_address(wkt) address_row = await self._lookup_area_address(wkt)
address_distance = address_row.distance
else: else:
address_row = None address_row = None
address_distance = 1000
if self.layer & (~DataLayer.ADDRESS & ~DataLayer.POI): if self.layer & (~DataLayer.ADDRESS & ~DataLayer.POI):
other_row = await self._lookup_area_others(wkt) other_row = await self._lookup_area_others(wkt)
other_distance = other_row.distance
else: else:
other_row = None other_row = None
other_distance = 1000
result = address_row if address_distance <= other_distance else other_row return nres.create_from_placex_row(_get_closest(address_row, other_row), nres.ReverseResult)
return nres.create_from_placex_row(result)
async def lookup_country(self, wkt: WKTElement) -> Optional[nres.ReverseResult]: async def lookup_country(self, wkt: WKTElement) -> Optional[nres.ReverseResult]:
@@ -402,10 +407,10 @@ class ReverseGeocoder:
if not ccodes: if not ccodes:
return None return None
if self.layer & DataLayer.ADDRESS and self.max_rank > 4: t = self.conn.t.placex
if self.max_rank > 4:
log().comment('Search for place nodes in country') log().comment('Search for place nodes in country')
t = conn.t.placex
inner = sa.select(t, inner = sa.select(t,
t.c.geometry.ST_Distance(wkt).label('distance'))\ t.c.geometry.ST_Distance(wkt).label('distance'))\
.where(t.c.osm_type == 'N')\ .where(t.c.osm_type == 'N')\
@@ -436,40 +441,8 @@ class ReverseGeocoder:
else: else:
address_row = None address_row = None
if layer & (~DataLayer.ADDRESS & ~DataLayer.POI) and self.max_rank > 4: if address_row is None:
log().comment('Search for non-address features inside country')
t = conn.t.placex
inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\
.where(t.c.rank_address == 0)\
.where(t.c.rank_search.between(5, self.max_rank))\
.where(t.c.name != None)\
.where(t.c.indexed_status == 0)\
.where(t.c.linked_place_id == None)\
.where(self._filter_by_layer(t))\
.where(t.c.country_code.in_(ccode))\
.where(sa.func.reverse_buffered_extent(t.c.geometry, type_=Geometry)
.intersects(wkt))\
.order_by(sa.desc(t.c.rank_search))\
.limit(50)\
.subquery()
sql = _select_from_placex(inner)\
.where(sa._or(inner.c.geometry.ST_GeometryType().not_in(('ST_Polygon', 'ST_MultiPolygon')),
inner.c.geometry.ST_Contains(wkt)))\
.order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
.limit(1)
sql = self._add_geometry_columns(sql, inner.c.geometry)
other_row = (await self.conn.execute(sql)).one_or_none()
log().var_dump('Result (non-address feature)', other_row)
else:
other_row = None
if layer & DataLayer.ADDRESS and address_row is None and other_row is None:
# Still nothing, then return a country with the appropriate country code. # Still nothing, then return a country with the appropriate country code.
t = conn.t.placex
sql = _select_from_placex(t, wkt)\ sql = _select_from_placex(t, wkt)\
.where(t.c.country_code.in_(ccodes))\ .where(t.c.country_code.in_(ccodes))\
.where(t.c.rank_address == 4)\ .where(t.c.rank_address == 4)\
@@ -477,11 +450,11 @@ class ReverseGeocoder:
.where(t.c.linked_place_id == None)\ .where(t.c.linked_place_id == None)\
.order_by('distance') .order_by('distance')
sql = self._add_geometry_columns(sql, inner.c.geometry) sql = self._add_geometry_columns(sql, t.c.geometry)
address_row = (await self.conn.execute(sql)).one_or_none() address_row = (await self.conn.execute(sql)).one_or_none()
return nres.create_from_placex_row(_get_closest_row(address_row, other_row)) return nres.create_from_placex_row(address_row, nres.ReverseResult)
async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]: async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
@@ -495,13 +468,13 @@ class ReverseGeocoder:
wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326) wkt = WKTElement(f'POINT({coord[0]} {coord[1]})', srid=4326)
result: Optional[ReverseResult] = None result: Optional[nres.ReverseResult] = None
if max_rank >= 26: if self.max_rank >= 26:
result = await self.lookup_street_poi(wkt) result = await self.lookup_street_poi(wkt)
if result is None and max_rank > 4: if result is None and self.max_rank > 4:
result = await self.lookup_area(wkt) result = await self.lookup_area(wkt)
if result is None: if result is None and self.layer & DataLayer.ADDRESS:
result = await self.lookup_country(wkt) result = await self.lookup_country(wkt)
if result is not None: if result is not None:
await nres.add_result_details(self.conn, result, self.details) await nres.add_result_details(self.conn, result, self.details)

View File

@@ -85,6 +85,8 @@ class Point(NamedTuple):
AnyPoint = Union[Point, Tuple[float, float]] AnyPoint = Union[Point, Tuple[float, float]]
WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
class Bbox: class Bbox:
""" A bounding box in WSG84 projection. """ A bounding box in WSG84 projection.
@@ -134,9 +136,9 @@ class Bbox:
if len(wkb) != 97: if len(wkb) != 97:
raise ValueError("WKB must be a bounding box polygon") raise ValueError("WKB must be a bounding box polygon")
if wkb.startswith(b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'): if wkb.startswith(WKB_BBOX_HEADER_LE):
x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65]) x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
elif wkb.startswith(b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'): elif wkb.startswith(WKB_BBOX_HEADER_BE):
x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65]) x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
else: else:
raise ValueError("WKB has wrong header") raise ValueError("WKB has wrong header")
@@ -144,6 +146,7 @@ class Bbox:
return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
@staticmethod
def from_point(pt: Point, buffer: float) -> 'Bbox': def from_point(pt: Point, buffer: float) -> 'Bbox':
""" Return a Bbox around the point with the buffer added to all sides. """ Return a Bbox around the point with the buffer added to all sides.
""" """

View File

@@ -64,7 +64,7 @@ else:
SaSelect: TypeAlias = 'sa.Select[Any]' SaSelect: TypeAlias = 'sa.Select[Any]'
SaRow: TypeAlias = 'sa.Row[Any]' SaRow: TypeAlias = 'sa.Row[Any]'
SaColumn: TypeAlias = 'sa.Column[Any]' SaColumn: TypeAlias = 'sa.ColumnElement[Any]'
SaLabel: TypeAlias = 'sa.Label[Any]' SaLabel: TypeAlias = 'sa.Label[Any]'
SaTable: TypeAlias = 'sa.Table[Any]' SaFromClause: TypeAlias = 'sa.FromClause'
SaClause: TypeAlias = 'sa.ClauseElement[Any]' SaSelectable: TypeAlias = 'sa.Selectable'

View File

@@ -42,6 +42,9 @@ class APITester:
if isinstance(name, str): if isinstance(name, str):
name = {'name': name} name = {'name': name}
centroid = kw.get('centroid', (23.0, 34.0))
geometry = kw.get('geometry', 'POINT(%f %f)' % centroid)
self.add_data('placex', self.add_data('placex',
{'place_id': kw.get('place_id', 1000), {'place_id': kw.get('place_id', 1000),
'osm_type': kw.get('osm_type', 'W'), 'osm_type': kw.get('osm_type', 'W'),
@@ -61,10 +64,11 @@ class APITester:
'rank_search': kw.get('rank_search', 30), 'rank_search': kw.get('rank_search', 30),
'rank_address': kw.get('rank_address', 30), 'rank_address': kw.get('rank_address', 30),
'importance': kw.get('importance'), 'importance': kw.get('importance'),
'centroid': 'SRID=4326;POINT(%f %f)' % kw.get('centroid', (23.0, 34.0)), 'centroid': 'SRID=4326;POINT(%f %f)' % centroid,
'indexed_status': kw.get('indexed_status', 0),
'indexed_date': kw.get('indexed_date', 'indexed_date': kw.get('indexed_date',
dt.datetime(2022, 12, 7, 14, 14, 46, 0)), dt.datetime(2022, 12, 7, 14, 14, 46, 0)),
'geometry': 'SRID=4326;' + kw.get('geometry', 'POINT(23 34)')}) 'geometry': 'SRID=4326;' + geometry})
def add_address_placex(self, object_id, **kw): def add_address_placex(self, object_id, **kw):
@@ -118,6 +122,13 @@ class APITester:
'geometry': 'SRID=4326;' + kw.get('geometry', 'POINT(23 34)')}) 'geometry': 'SRID=4326;' + kw.get('geometry', 'POINT(23 34)')})
def add_country(self, country_code, geometry):
self.add_data('country_grid',
{'country_code': country_code,
'area': 0.1,
'geometry': 'SRID=4326;' + geometry})
async def exec_async(self, sql, *args, **kwargs): async def exec_async(self, sql, *args, **kwargs):
async with self.api._async_api.begin() as conn: async with self.api._async_api.begin() as conn:
return await conn.execute(sql, *args, **kwargs) return await conn.execute(sql, *args, **kwargs)
@@ -136,8 +147,9 @@ def apiobj(temp_db_with_extensions, temp_db_conn, monkeypatch):
testapi = APITester() testapi = APITester()
testapi.async_to_sync(testapi.create_tables()) testapi.async_to_sync(testapi.create_tables())
SQLPreprocessor(temp_db_conn, testapi.api.config)\ proc = SQLPreprocessor(temp_db_conn, testapi.api.config)
.run_sql_file(temp_db_conn, 'functions/address_lookup.sql') proc.run_sql_file(temp_db_conn, 'functions/address_lookup.sql')
proc.run_sql_file(temp_db_conn, 'functions/ranking.sql')
loglib.set_log_output('text') loglib.set_log_output('text')
yield testapi yield testapi

View File

@@ -0,0 +1,311 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of Nominatim. (https://nominatim.org)
#
# Copyright (C) 2023 by the Nominatim developer community.
# For a full list of authors see the git log.
"""
Tests for reverse API call.
These tests make sure that all Python code is correct and executable.
Functional tests can be found in the BDD test suite.
"""
import json
import pytest
import nominatim.api as napi
def test_reverse_rank_30(apiobj):
apiobj.add_placex(place_id=223, class_='place', type='house',
housenumber='1',
centroid=(1.3, 0.7),
geometry='POINT(1.3 0.7)')
result = apiobj.api.reverse((1.3, 0.7))
assert result is not None
assert result.place_id == 223
@pytest.mark.parametrize('country', ['de', 'us'])
def test_reverse_street(apiobj, country):
apiobj.add_placex(place_id=990, class_='highway', type='service',
rank_search=27, rank_address=27,
name = {'name': 'My Street'},
centroid=(10.0, 10.0),
country_code=country,
geometry='LINESTRING(9.995 10, 10.005 10)')
assert apiobj.api.reverse((9.995, 10)).place_id == 990
def test_reverse_ignore_unindexed(apiobj):
apiobj.add_placex(place_id=223, class_='place', type='house',
housenumber='1',
indexed_status=2,
centroid=(1.3, 0.7),
geometry='POINT(1.3 0.7)')
result = apiobj.api.reverse((1.3, 0.7))
assert result is None
@pytest.mark.parametrize('y,layer,place_id', [(0.7, napi.DataLayer.ADDRESS, 223),
(0.70001, napi.DataLayer.POI, 224),
(0.7, napi.DataLayer.ADDRESS | napi.DataLayer.POI, 224),
(0.70001, napi.DataLayer.ADDRESS | napi.DataLayer.POI, 223),
(0.7, napi.DataLayer.MANMADE, 225),
(0.7, napi.DataLayer.RAILWAY, 226),
(0.7, napi.DataLayer.NATURAL, 227),
(0.70003, napi.DataLayer.MANMADE | napi.DataLayer.RAILWAY, 225),
(0.70003, napi.DataLayer.MANMADE | napi.DataLayer.NATURAL, 225)])
def test_reverse_rank_30_layers(apiobj, y, layer, place_id):
apiobj.add_placex(place_id=223, class_='place', type='house',
housenumber='1',
rank_address=30,
rank_search=30,
centroid=(1.3, 0.70001))
apiobj.add_placex(place_id=224, class_='amenity', type='toilet',
rank_address=30,
rank_search=30,
centroid=(1.3, 0.7))
apiobj.add_placex(place_id=225, class_='man_made', type='tower',
rank_address=0,
rank_search=30,
centroid=(1.3, 0.70003))
apiobj.add_placex(place_id=226, class_='railway', type='station',
rank_address=0,
rank_search=30,
centroid=(1.3, 0.70004))
apiobj.add_placex(place_id=227, class_='natural', type='cave',
rank_address=0,
rank_search=30,
centroid=(1.3, 0.70005))
assert apiobj.api.reverse((1.3, y), layer=layer).place_id == place_id
def test_reverse_poi_layer_with_no_pois(apiobj):
apiobj.add_placex(place_id=223, class_='place', type='house',
housenumber='1',
rank_address=30,
rank_search=30,
centroid=(1.3, 0.70001))
assert apiobj.api.reverse((1.3, 0.70001), max_rank=29,
layer=napi.DataLayer.POI) is None
def test_reverse_housenumber_on_street(apiobj):
apiobj.add_placex(place_id=990, class_='highway', type='service',
rank_search=27, rank_address=27,
name = {'name': 'My Street'},
centroid=(10.0, 10.0),
geometry='LINESTRING(9.995 10, 10.005 10)')
apiobj.add_placex(place_id=991, class_='place', type='house',
parent_place_id=990,
rank_search=30, rank_address=30,
housenumber='23',
centroid=(10.0, 10.00001))
assert apiobj.api.reverse((10.0, 10.0), max_rank=30).place_id == 991
assert apiobj.api.reverse((10.0, 10.0), max_rank=27).place_id == 990
assert apiobj.api.reverse((10.0, 10.00001), max_rank=30).place_id == 991
def test_reverse_housenumber_interpolation(apiobj):
apiobj.add_placex(place_id=990, class_='highway', type='service',
rank_search=27, rank_address=27,
name = {'name': 'My Street'},
centroid=(10.0, 10.0),
geometry='LINESTRING(9.995 10, 10.005 10)')
apiobj.add_placex(place_id=991, class_='place', type='house',
parent_place_id=990,
rank_search=30, rank_address=30,
housenumber='23',
centroid=(10.0, 10.00002))
apiobj.add_osmline(place_id=992,
parent_place_id=990,
startnumber=1, endnumber=3, step=1,
centroid=(10.0, 10.00001),
geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)')
assert apiobj.api.reverse((10.0, 10.0)).place_id == 992
def test_reverse_tiger_number(apiobj):
apiobj.add_placex(place_id=990, class_='highway', type='service',
rank_search=27, rank_address=27,
name = {'name': 'My Street'},
centroid=(10.0, 10.0),
country_code='us',
geometry='LINESTRING(9.995 10, 10.005 10)')
apiobj.add_tiger(place_id=992,
parent_place_id=990,
startnumber=1, endnumber=3, step=1,
centroid=(10.0, 10.00001),
geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)')
assert apiobj.api.reverse((10.0, 10.0)).place_id == 992
assert apiobj.api.reverse((10.0, 10.00001)).place_id == 992
def test_reverse_low_zoom_address(apiobj):
apiobj.add_placex(place_id=1001, class_='place', type='house',
housenumber='1',
rank_address=30,
rank_search=30,
centroid=(59.3, 80.70001))
apiobj.add_placex(place_id=1002, class_='place', type='town',
name={'name': 'Town'},
rank_address=16,
rank_search=16,
centroid=(59.3, 80.70001),
geometry="""POLYGON((59.3 80.70001, 59.3001 80.70001,
59.3001 80.70101, 59.3 80.70101, 59.3 80.70001))""")
assert apiobj.api.reverse((59.30005, 80.7005)).place_id == 1001
assert apiobj.api.reverse((59.30005, 80.7005), max_rank=18).place_id == 1002
def test_reverse_place_node_in_area(apiobj):
apiobj.add_placex(place_id=1002, class_='place', type='town',
name={'name': 'Town Area'},
rank_address=16,
rank_search=16,
centroid=(59.3, 80.70001),
geometry="""POLYGON((59.3 80.70001, 59.3001 80.70001,
59.3001 80.70101, 59.3 80.70101, 59.3 80.70001))""")
apiobj.add_placex(place_id=1003, class_='place', type='suburb',
name={'name': 'Suburb Point'},
osm_type='N',
rank_address=18,
rank_search=18,
centroid=(59.30004, 80.70055))
assert apiobj.api.reverse((59.30004, 80.70055)).place_id == 1003
@pytest.mark.parametrize('layer,place_id', [(napi.DataLayer.MANMADE, 225),
(napi.DataLayer.RAILWAY, 226),
(napi.DataLayer.NATURAL, 227),
(napi.DataLayer.MANMADE | napi.DataLayer.RAILWAY, 225),
(napi.DataLayer.MANMADE | napi.DataLayer.NATURAL, 225)])
def test_reverse_larger_area_layers(apiobj, layer, place_id):
apiobj.add_placex(place_id=225, class_='man_made', type='dam',
name={'name': 'Dam'},
rank_address=0,
rank_search=25,
centroid=(1.3, 0.70003))
apiobj.add_placex(place_id=226, class_='railway', type='yard',
name={'name': 'Dam'},
rank_address=0,
rank_search=20,
centroid=(1.3, 0.70004))
apiobj.add_placex(place_id=227, class_='natural', type='spring',
name={'name': 'Dam'},
rank_address=0,
rank_search=16,
centroid=(1.3, 0.70005))
assert apiobj.api.reverse((1.3, 0.7), layer=layer).place_id == place_id
def test_reverse_country_lookup_no_objects(apiobj):
apiobj.add_country('xx', 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')
assert apiobj.api.reverse((0.5, 0.5)) is None
@pytest.mark.parametrize('rank', [4, 30])
def test_reverse_country_lookup_country_only(apiobj, rank):
apiobj.add_country('xx', 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')
apiobj.add_placex(place_id=225, class_='place', type='country',
name={'name': 'My Country'},
rank_address=4,
rank_search=4,
country_code='xx',
centroid=(0.7, 0.7))
assert apiobj.api.reverse((0.5, 0.5), max_rank=rank).place_id == 225
def test_reverse_country_lookup_place_node_inside(apiobj):
apiobj.add_country('xx', 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')
apiobj.add_placex(place_id=225, class_='place', type='state',
osm_type='N',
name={'name': 'My State'},
rank_address=6,
rank_search=6,
country_code='xx',
centroid=(0.5, 0.505))
assert apiobj.api.reverse((0.5, 0.5)).place_id == 225
@pytest.mark.parametrize('gtype', list(napi.GeometryFormat))
def test_reverse_geometry_output_placex(apiobj, gtype):
apiobj.add_country('xx', 'POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))')
apiobj.add_placex(place_id=1001, class_='place', type='house',
housenumber='1',
rank_address=30,
rank_search=30,
centroid=(59.3, 80.70001))
apiobj.add_placex(place_id=1003, class_='place', type='suburb',
name={'name': 'Suburb Point'},
osm_type='N',
rank_address=18,
rank_search=18,
country_code='xx',
centroid=(0.5, 0.5))
details = napi.LookupDetails(geometry_output=gtype)
assert apiobj.api.reverse((59.3, 80.70001), details=details).place_id == 1001
assert apiobj.api.reverse((0.5, 0.5), details=details).place_id == 1003
def test_reverse_simplified_geometry(apiobj):
apiobj.add_placex(place_id=1001, class_='place', type='house',
housenumber='1',
rank_address=30,
rank_search=30,
centroid=(59.3, 80.70001))
details = napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON,
geometry_simplification=0.1)
assert apiobj.api.reverse((59.3, 80.70001), details=details).place_id == 1001
def test_reverse_interpolation_geometry(apiobj):
apiobj.add_osmline(place_id=992,
parent_place_id=990,
startnumber=1, endnumber=3, step=1,
centroid=(10.0, 10.00001),
geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)')
details = napi.LookupDetails(geometry_output=napi.GeometryFormat.TEXT)
assert apiobj.api.reverse((10.0, 10.0), details=details)\
.geometry['text'] == 'POINT(10 10.00001)'
def test_reverse_tiger_geometry(apiobj):
apiobj.add_placex(place_id=990, class_='highway', type='service',
rank_search=27, rank_address=27,
name = {'name': 'My Street'},
centroid=(10.0, 10.0),
country_code='us',
geometry='LINESTRING(9.995 10, 10.005 10)')
apiobj.add_tiger(place_id=992,
parent_place_id=990,
startnumber=1, endnumber=3, step=1,
centroid=(10.0, 10.00001),
geometry='LINESTRING(9.995 10.00001, 10.005 10.00001)')
details = napi.LookupDetails(geometry_output=napi.GeometryFormat.GEOJSON)
output = apiobj.api.reverse((10.0, 10.0), details=details).geometry['geojson']
assert json.loads(output) == {'coordinates': [10, 10.00001], 'type': 'Point'}