diff --git a/.pylintrc b/.pylintrc index cbb26a4e..da858deb 100644 --- a/.pylintrc +++ b/.pylintrc @@ -13,6 +13,6 @@ ignored-classes=NominatimArgs,closing # 'too-many-ancestors' is triggered already by deriving from UserDict # 'not-context-manager' disabled because it causes false positives once # 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 diff --git a/nominatim/api/core.py b/nominatim/api/core.py index 32c9b5e5..116a2625 100644 --- a/nominatim/api/core.py +++ b/nominatim/api/core.py @@ -21,7 +21,7 @@ from nominatim.config import Configuration from nominatim.api.connection import SearchConnection from nominatim.api.status import get_status, StatusResult 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.results import DetailedResult, ReverseResult @@ -156,8 +156,9 @@ class NominatimAPIAsync: max_rank = max(0, min(max_rank or 30, 30)) async with self.begin() as conn: - return await reverse_lookup(conn, coord, max_rank, layer, - details or LookupDetails()) + geocoder = ReverseGeocoder(conn, max_rank, layer, + details or LookupDetails()) + return await geocoder.lookup(coord) class NominatimAPI: diff --git a/nominatim/api/logging.py b/nominatim/api/logging.py index 3759ba1b..ec359063 100644 --- a/nominatim/api/logging.py +++ b/nominatim/api/logging.py @@ -60,6 +60,17 @@ class BaseLogger: """ 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): """ Logger that formats messages in HTML. @@ -92,8 +103,7 @@ class HTMLLogger(BaseLogger): def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None: - sqlstr = str(cast('sa.ClauseElement', statement) - .compile(conn.sync_engine, compile_kwargs={"literal_binds": True})) + sqlstr = self.format_sql(conn, statement) if CODE_HIGHLIGHT: sqlstr = highlight(sqlstr, PostgresLexer(), HtmlFormatter(nowrap=True, lineseparator='
')) @@ -147,9 +157,7 @@ class TextLogger(BaseLogger): def sql(self, conn: AsyncConnection, statement: 'sa.Executable') -> None: - sqlstr = str(cast('sa.ClauseElement', statement) - .compile(conn.sync_engine, compile_kwargs={"literal_binds": True})) - sqlstr = '\n| '.join(textwrap.wrap(sqlstr, width=78)) + sqlstr = '\n| '.join(textwrap.wrap(self.format_sql(conn, statement), width=78)) self._write(f"| {sqlstr}\n\n") diff --git a/nominatim/api/lookup.py b/nominatim/api/lookup.py index de06441d..3952d4b8 100644 --- a/nominatim/api/lookup.py +++ b/nominatim/api/lookup.py @@ -172,6 +172,7 @@ async def get_place_by_id(conn: SearchConnection, place: ntyp.PlaceRef, assert result is not None result.parent_place_id = row.parent_place_id 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) if indexed_date is not None: result.indexed_date = indexed_date.replace(tzinfo=dt.timezone.utc) diff --git a/nominatim/api/results.py b/nominatim/api/results.py index 84d4ced9..2999b9a7 100644 --- a/nominatim/api/results.py +++ b/nominatim/api/results.py @@ -46,8 +46,6 @@ class AddressLine: names: Dict[str, str] extratags: Optional[Dict[str, str]] - local_name: Optional[str] = None - admin_level: Optional[int] fromarea: bool isaddress: bool @@ -81,7 +79,6 @@ class BaseResult: place_id : Optional[int] = None osm_object: Optional[Tuple[str, int]] = None - admin_level: int = 15 names: Optional[Dict[str, str]] = None address: Optional[Dict[str, str]] = None @@ -135,6 +132,7 @@ class DetailedResult(BaseResult): """ parent_place_id: Optional[int] = None linked_place_id: Optional[int] = None + admin_level: int = 15 indexed_date: Optional[dt.datetime] = None @@ -164,7 +162,6 @@ def create_from_placex_row(row: Optional[SaRow], place_id=row.place_id, osm_object=(row.osm_type, row.osm_id), category=(row.class_, row.type), - admin_level=row.admin_level, names=row.name, address=row.address, extratags=row.extratags, diff --git a/nominatim/api/reverse.py b/nominatim/api/reverse.py index 053b96dd..9bf904a8 100644 --- a/nominatim/api/reverse.py +++ b/nominatim/api/reverse.py @@ -7,19 +7,22 @@ """ Implementation of reverse geocoding. """ -from typing import Optional +from typing import Optional, List import sqlalchemy as sa 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 import nominatim.api.results as nres from nominatim.api.logging import log 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 results. """ @@ -39,19 +42,20 @@ def _select_from_placex(t: SaTable, wkt: Optional[str] = None) -> SaSelect: t.c.geometry.ST_Expand(0).label('bbox')) -def _interpolated_housenumber(table: SaTable) -> SaLabel: - # Entries with startnumber = endnumber are legacy from version < 4.1 +def _interpolated_housenumber(table: SaFromClause) -> SaLabel: return sa.cast(table.c.startnumber + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position) / table.c.step) * table.c.step, 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, sa.or_(table.c.housenumber != None, 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 implementing the logic for looking up a place from a @@ -87,25 +91,27 @@ class ReverseGeocoder: 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: exclude = [] - if not (self.layer & DataLayer.RAILWAY): + if not self.layer & DataLayer.RAILWAY: exclude.append('railway') - if not (self.layer & DataLayer.NATURAL): + if not self.layer & DataLayer.NATURAL: exclude.extend(('natural', 'water', 'waterway')) return table.c.class_.not_in(tuple(exclude)) include = [] if self.layer & DataLayer.RAILWAY: include.append('railway') - if not (self.layer & DataLayer.NATURAL): + if self.layer & DataLayer.NATURAL: include.extend(('natural', 'water', 'waterway')) return table.c.class_.in_(tuple(include)) - async def _find_closest_street_or_poi(self, wkt: WKTElement) -> SaRow: - """ Look up the clostest rank 26+ place in the database. + async def _find_closest_street_or_poi(self, wkt: WKTElement, + 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 @@ -113,38 +119,39 @@ class ReverseGeocoder: .where(t.c.geometry.ST_DWithin(wkt, distance))\ .where(t.c.indexed_status == 0)\ .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))\ .order_by('distance')\ .limit(1) sql = self._add_geometry_columns(sql, t.c.geometry) - restrict = [] + restrict: List[SaColumn] = [] if self.layer & DataLayer.ADDRESS: 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: 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, t.c.class_.not_in(('place', 'building')), t.c.geometry.ST_GeometryType() != 'ST_LineString')) if self.layer & (DataLayer.RAILWAY | DataLayer.MANMADE | DataLayer.NATURAL): - restrict.append(sa.and_(t.c.rank_search >= 26, - tc.rank_search <= self.max_rank, + restrict.append(sa.and_(t.c.rank_search.between(26, self.max_rank), + t.c.rank_address == 0, self._filter_by_layer(t))) - if restrict: - sql = sql.where(sa.or_(*restrict)) + if not 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, wkt: WKTElement) -> Optional[SaRow]: - t = conn.t.placex + t = self.conn.t.placex sql = _select_from_placex(t, wkt)\ .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], - wkt: WKTElement) -> Optional[SaRow]: + wkt: WKTElement, + distance: float) -> Optional[SaRow]: t = self.conn.t.osmline - inner = sa.select(t, - t.c.linegeo.ST_Distance(wkt).label('distance'), - t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\ - .where(t.c.linegeo.ST_DWithin(wkt, distance))\ - .order_by('distance')\ - .limit(1) + sql = sa.select(t, + t.c.linegeo.ST_Distance(wkt).label('distance'), + t.c.linegeo.ST_LineLocatePoint(wkt).label('position'))\ + .where(t.c.linegeo.ST_DWithin(wkt, distance))\ + .order_by('distance')\ + .limit(1) 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, inner.c.parent_place_id, inner.c.address, @@ -214,7 +222,7 @@ class ReverseGeocoder: sub = sql.subquery() 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]: @@ -225,7 +233,7 @@ class ReverseGeocoder: distance = 0.006 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) # 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) 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: distance = row.distance @@ -257,12 +265,13 @@ class ReverseGeocoder: # or belongs to a close street found. if self.max_rank > 27 and self.layer & DataLayer.ADDRESS: 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) 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]: @@ -296,13 +305,13 @@ class ReverseGeocoder: address_row = (await self.conn.execute(sql)).one_or_none() 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') inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\ .where(t.c.osm_type == 'N')\ .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.name != None)\ .where(t.c.indexed_status == 0)\ @@ -315,10 +324,10 @@ class ReverseGeocoder: .limit(50)\ .subquery() - touter = conn.t.placex.alias('outer') + touter = self.conn.t.placex.alias('outer') 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.geometry.ST_Contains(inner.c.geometry))\ .where(inner.c.distance < sa.func.reverse_place_diameter(inner.c.rank_search))\ .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\ .limit(1) @@ -335,7 +344,7 @@ class ReverseGeocoder: 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'))\ .where(t.c.rank_address == 0)\ @@ -344,13 +353,16 @@ class ReverseGeocoder: .where(t.c.indexed_status == 0)\ .where(t.c.linked_place_id == None)\ .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))\ .order_by(sa.desc(t.c.rank_search))\ - .limit(50) + .limit(50)\ + .subquery() 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)))\ .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\ .limit(1) @@ -367,25 +379,18 @@ class ReverseGeocoder: """ Lookup large areas for the given WKT point. """ log().section('Reverse lookup by larger area features') - t = self.conn.t.placex if self.layer & DataLayer.ADDRESS: address_row = await self._lookup_area_address(wkt) - address_distance = address_row.distance else: address_row = None - address_distance = 1000 if self.layer & (~DataLayer.ADDRESS & ~DataLayer.POI): other_row = await self._lookup_area_others(wkt) - other_distance = other_row.distance else: other_row = None - other_distance = 1000 - result = address_row if address_distance <= other_distance else other_row - - return nres.create_from_placex_row(result) + return nres.create_from_placex_row(_get_closest(address_row, other_row), nres.ReverseResult) async def lookup_country(self, wkt: WKTElement) -> Optional[nres.ReverseResult]: @@ -402,10 +407,10 @@ class ReverseGeocoder: if not ccodes: 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') - t = conn.t.placex inner = sa.select(t, t.c.geometry.ST_Distance(wkt).label('distance'))\ .where(t.c.osm_type == 'N')\ @@ -436,40 +441,8 @@ class ReverseGeocoder: else: address_row = None - if layer & (~DataLayer.ADDRESS & ~DataLayer.POI) and self.max_rank > 4: - 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: + if address_row is None: # Still nothing, then return a country with the appropriate country code. - t = conn.t.placex sql = _select_from_placex(t, wkt)\ .where(t.c.country_code.in_(ccodes))\ .where(t.c.rank_address == 4)\ @@ -477,11 +450,11 @@ class ReverseGeocoder: .where(t.c.linked_place_id == None)\ .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() - 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]: @@ -495,13 +468,13 @@ class ReverseGeocoder: 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) - if result is None and max_rank > 4: + if result is None and self.max_rank > 4: 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) if result is not None: await nres.add_result_details(self.conn, result, self.details) diff --git a/nominatim/api/types.py b/nominatim/api/types.py index 344fd91b..e262935a 100644 --- a/nominatim/api/types.py +++ b/nominatim/api/types.py @@ -85,6 +85,8 @@ class Point(NamedTuple): 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: """ A bounding box in WSG84 projection. @@ -134,9 +136,9 @@ class Bbox: if len(wkb) != 97: 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]) else: 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)) + @staticmethod def from_point(pt: Point, buffer: float) -> 'Bbox': """ Return a Bbox around the point with the buffer added to all sides. """ diff --git a/nominatim/typing.py b/nominatim/typing.py index 1946c1a6..bc4c5534 100644 --- a/nominatim/typing.py +++ b/nominatim/typing.py @@ -64,7 +64,7 @@ else: SaSelect: TypeAlias = 'sa.Select[Any]' SaRow: TypeAlias = 'sa.Row[Any]' -SaColumn: TypeAlias = 'sa.Column[Any]' +SaColumn: TypeAlias = 'sa.ColumnElement[Any]' SaLabel: TypeAlias = 'sa.Label[Any]' -SaTable: TypeAlias = 'sa.Table[Any]' -SaClause: TypeAlias = 'sa.ClauseElement[Any]' +SaFromClause: TypeAlias = 'sa.FromClause' +SaSelectable: TypeAlias = 'sa.Selectable' diff --git a/test/python/api/conftest.py b/test/python/api/conftest.py index 0275e275..d8a6dfa0 100644 --- a/test/python/api/conftest.py +++ b/test/python/api/conftest.py @@ -42,6 +42,9 @@ class APITester: if isinstance(name, str): name = {'name': name} + centroid = kw.get('centroid', (23.0, 34.0)) + geometry = kw.get('geometry', 'POINT(%f %f)' % centroid) + self.add_data('placex', {'place_id': kw.get('place_id', 1000), 'osm_type': kw.get('osm_type', 'W'), @@ -61,10 +64,11 @@ class APITester: 'rank_search': kw.get('rank_search', 30), 'rank_address': kw.get('rank_address', 30), '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', 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): @@ -118,6 +122,13 @@ class APITester: '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 with self.api._async_api.begin() as conn: return await conn.execute(sql, *args, **kwargs) @@ -136,8 +147,9 @@ def apiobj(temp_db_with_extensions, temp_db_conn, monkeypatch): testapi = APITester() testapi.async_to_sync(testapi.create_tables()) - SQLPreprocessor(temp_db_conn, testapi.api.config)\ - .run_sql_file(temp_db_conn, 'functions/address_lookup.sql') + proc = SQLPreprocessor(temp_db_conn, testapi.api.config) + 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') yield testapi diff --git a/test/python/api/test_api_reverse.py b/test/python/api/test_api_reverse.py new file mode 100644 index 00000000..e78dc071 --- /dev/null +++ b/test/python/api/test_api_reverse.py @@ -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'} +