make sure PHP and Python reverse code does the same

The only allowable difference is precision of coordinates. Python uses
a precision of 7 digits where possible, which corresponds to the
precision of OSM data.

Also fixes some smaller bugs found by the BDD tests.
This commit is contained in:
Sarah Hoffmann
2023-03-26 12:22:34 +02:00
parent 300921a93e
commit 86b43dc605
20 changed files with 235 additions and 162 deletions

View File

@@ -12,12 +12,16 @@ version a more flexible formatting is required.
"""
from typing import Tuple, Optional, Mapping
import nominatim.api as napi
def get_label_tag(category: Tuple[str, str], extratags: Optional[Mapping[str, str]],
rank: int, country: Optional[str]) -> str:
""" Create a label tag for the given place that can be used as an XML name.
"""
if rank < 26 and extratags and 'place'in extratags:
if rank < 26 and extratags and 'place' in extratags:
label = extratags['place']
elif rank < 26 and extratags and 'linked_place' in extratags:
label = extratags['linked_place']
elif category == ('boundary', 'administrative'):
label = ADMIN_LABELS.get((country or '', int(rank/2)))\
or ADMIN_LABELS.get(('', int(rank/2)))\
@@ -37,6 +41,30 @@ def get_label_tag(category: Tuple[str, str], extratags: Optional[Mapping[str, st
return label.lower().replace(' ', '_')
def bbox_from_result(result: napi.ReverseResult) -> napi.Bbox:
""" Compute a bounding box for the result. For ways and relations
a given boundingbox is used. For all other object, a box is computed
around the centroid according to dimensions dereived from the
search rank.
"""
if (result.osm_object and result.osm_object[0] == 'N') or result.bbox is None:
extent = NODE_EXTENT.get(result.category, 0.00005)
return napi.Bbox.from_point(result.centroid, extent)
return result.bbox
# pylint: disable=line-too-long
OSM_ATTRIBUTION = 'Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright'
OSM_TYPE_NAME = {
'N': 'node',
'W': 'way',
'R': 'relation'
}
ADMIN_LABELS = {
('', 1): 'Continent',
('', 2): 'Country',
@@ -142,3 +170,31 @@ ICONS = {
('amenity', 'prison'): 'amenity_prison',
('highway', 'bus_stop'): 'transport_bus_stop2'
}
NODE_EXTENT = {
('place', 'continent'): 25,
('place', 'country'): 7,
('place', 'state'): 2.6,
('place', 'province'): 2.6,
('place', 'region'): 1.0,
('place', 'county'): 0.7,
('place', 'city'): 0.16,
('place', 'municipality'): 0.16,
('place', 'island'): 0.32,
('place', 'postcode'): 0.16,
('place', 'town'): 0.04,
('place', 'village'): 0.02,
('place', 'hamlet'): 0.02,
('place', 'district'): 0.02,
('place', 'borough'): 0.02,
('place', 'suburb'): 0.02,
('place', 'locality'): 0.01,
('place', 'neighbourhood'): 0.01,
('place', 'quarter'): 0.01,
('place', 'city_block'): 0.01,
('landuse', 'farm'): 0.01,
('place', 'farm'): 0.01,
('place', 'airport'): 0.015,
('aeroway', 'aerodrome'): 0.015,
('railway', 'station'): 0.005
}

View File

@@ -1,43 +0,0 @@
# 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.
"""
Constants shared by all formats.
"""
import nominatim.api as napi
# pylint: disable=line-too-long
OSM_ATTRIBUTION = 'Data © OpenStreetMap contributors, ODbL 1.0. http://www.openstreetmap.org/copyright'
OSM_TYPE_NAME = {
'N': 'node',
'W': 'way',
'R': 'relation'
}
NODE_EXTENT = [25, 25, 25, 25,
7,
2.6, 2.6, 2.0, 1.0, 1.0,
0.7, 0.7, 0.7,
0.16, 0.16, 0.16, 0.16,
0.04, 0.04,
0.02, 0.02,
0.01, 0.01, 0.01, 0.01, 0.01,
0.015, 0.015, 0.015, 0.015,
0.005]
def bbox_from_result(result: napi.ReverseResult) -> napi.Bbox:
""" Compute a bounding box for the result. For ways and relations
a given boundingbox is used. For all other object, a box is computed
around the centroid according to dimensions dereived from the
search rank.
"""
if (result.osm_object and result.osm_object[0] == 'N') or result.bbox is None:
return napi.Bbox.from_point(result.centroid, NODE_EXTENT[result.rank_search])
return result.bbox

View File

@@ -10,13 +10,12 @@ Helper functions for output of results in json formats.
from typing import Mapping, Any, Optional, Tuple
import nominatim.api as napi
from nominatim.api.v1.constants import OSM_ATTRIBUTION, OSM_TYPE_NAME, bbox_from_result
from nominatim.api.v1.classtypes import ICONS, get_label_tag
import nominatim.api.v1.classtypes as cl
from nominatim.utils.json_writer import JsonWriter
def _write_osm_id(out: JsonWriter, osm_object: Optional[Tuple[str, int]]) -> None:
if osm_object is not None:
out.keyval_not_none('osm_type', OSM_TYPE_NAME.get(osm_object[0], None))\
out.keyval_not_none('osm_type', cl.OSM_TYPE_NAME.get(osm_object[0], None))\
.keyval('osm_id', osm_object[1])
@@ -24,11 +23,15 @@ def _write_typed_address(out: JsonWriter, address: Optional[napi.AddressLines],
country_code: Optional[str]) -> None:
parts = {}
for line in (address or []):
if line.isaddress and line.local_name:
label = get_label_tag(line.category, line.extratags,
line.rank_address, country_code)
if label not in parts:
parts[label] = line.local_name
if line.isaddress:
if line.local_name:
label = cl.get_label_tag(line.category, line.extratags,
line.rank_address, country_code)
if label not in parts:
print(label)
parts[label] = line.local_name
if line.names and 'ISO3166-2' in line.names and line.admin_level:
parts[f"ISO3166-2-lvl{line.admin_level}"] = line.names['ISO3166-2']
for k, v in parts.items():
out.keyval(k, v)
@@ -79,7 +82,7 @@ def format_base_json(results: napi.ReverseResults, #pylint: disable=too-many-bra
out.start_object()\
.keyval_not_none('place_id', result.place_id)\
.keyval('licence', OSM_ATTRIBUTION)\
.keyval('licence', cl.OSM_ATTRIBUTION)\
_write_osm_id(out, result.osm_object)
@@ -89,15 +92,15 @@ def format_base_json(results: napi.ReverseResults, #pylint: disable=too-many-bra
.keyval('type', result.category[1])\
.keyval('place_rank', result.rank_search)\
.keyval('importance', result.calculated_importance())\
.keyval('addresstype', get_label_tag(result.category, result.extratags,
result.rank_address,
result.country_code))\
.keyval('addresstype', cl.get_label_tag(result.category, result.extratags,
result.rank_address,
result.country_code))\
.keyval('name', locales.display_name(result.names))\
.keyval('display_name', ', '.join(label_parts))
if options.get('icon_base_url', None):
icon = ICONS.get(result.category)
icon = cl.ICONS.get(result.category)
if icon:
out.keyval('icon', f"{options['icon_base_url']}/{icon}.p.20.png")
@@ -112,12 +115,12 @@ def format_base_json(results: napi.ReverseResults, #pylint: disable=too-many-bra
if options.get('namedetails', False):
out.keyval('namedetails', result.names)
bbox = bbox_from_result(result)
bbox = cl.bbox_from_result(result)
out.key('boundingbox').start_array()\
.value(bbox.minlat).next()\
.value(bbox.maxlat).next()\
.value(bbox.minlon).next()\
.value(bbox.maxlon).next()\
.value(f"{bbox.minlat:0.7f}").next()\
.value(f"{bbox.maxlat:0.7f}").next()\
.value(f"{bbox.minlon:0.7f}").next()\
.value(f"{bbox.maxlon:0.7f}").next()\
.end_array().next()
if result.geometry:
@@ -153,7 +156,7 @@ def format_base_geojson(results: napi.ReverseResults,
out.start_object()\
.keyval('type', 'FeatureCollection')\
.keyval('licence', OSM_ATTRIBUTION)\
.keyval('licence', cl.OSM_ATTRIBUTION)\
.key('features').start_array()
for result in results:
@@ -174,9 +177,9 @@ def format_base_geojson(results: napi.ReverseResults,
.keyval('category', result.category[0])\
.keyval('type', result.category[1])\
.keyval('importance', result.calculated_importance())\
.keyval('addresstype', get_label_tag(result.category, result.extratags,
result.rank_address,
result.country_code))\
.keyval('addresstype', cl.get_label_tag(result.category, result.extratags,
result.rank_address,
result.country_code))\
.keyval('name', locales.display_name(result.names))\
.keyval('display_name', ', '.join(label_parts))
@@ -193,8 +196,10 @@ def format_base_geojson(results: napi.ReverseResults,
out.end_object().next() # properties
bbox = bbox_from_result(result)
out.keyval('bbox', bbox.coords)
out.key('bbox').start_array()
for coord in cl.bbox_from_result(result).coords:
out.float(coord, 7).next()
out.end_array().next()
out.key('geometry').raw(result.geometry.get('geojson')
or result.centroid.to_geojson()).next()
@@ -221,7 +226,7 @@ def format_base_geocodejson(results: napi.ReverseResults,
.keyval('type', 'FeatureCollection')\
.key('geocoding').start_object()\
.keyval('version', '0.1.0')\
.keyval('attribution', OSM_ATTRIBUTION)\
.keyval('attribution', cl.OSM_ATTRIBUTION)\
.keyval('licence', 'ODbL')\
.keyval_not_none('query', options.get('query'))\
.end_object().next()\
@@ -245,9 +250,9 @@ def format_base_geocodejson(results: napi.ReverseResults,
out.keyval('osm_key', result.category[0])\
.keyval('osm_value', result.category[1])\
.keyval('type', GEOCODEJSON_RANKS[max(3, min(28, result.rank_address))])\
.keyval_not_none('accuracy', result.distance)\
.keyval_not_none('accuracy', result.distance, transform=int)\
.keyval('label', ', '.join(label_parts))\
.keyval_not_none('name', locales.display_name(result.names))\
.keyval_not_none('name', result.names, transform=locales.display_name)\
if options.get('addressdetails', False):
_write_geocodejson_address(out, result.address_rows, result.place_id,

View File

@@ -12,18 +12,20 @@ import datetime as dt
import xml.etree.ElementTree as ET
import nominatim.api as napi
from nominatim.api.v1.constants import OSM_ATTRIBUTION, OSM_TYPE_NAME, bbox_from_result
from nominatim.api.v1.classtypes import ICONS, get_label_tag
import nominatim.api.v1.classtypes as cl
def _write_xml_address(root: ET.Element, address: napi.AddressLines,
country_code: Optional[str]) -> None:
parts = {}
for line in address:
if line.isaddress and line.local_name:
label = get_label_tag(line.category, line.extratags,
line.rank_address, country_code)
if label not in parts:
parts[label] = line.local_name
if line.isaddress:
if line.local_name:
label = cl.get_label_tag(line.category, line.extratags,
line.rank_address, country_code)
if label not in parts:
parts[label] = line.local_name
if line.names and 'ISO3166-2' in line.names and line.admin_level:
parts[f"ISO3166-2-lvl{line.admin_level}"] = line.names['ISO3166-2']
for k,v in parts.items():
ET.SubElement(root, k).text = v
@@ -44,18 +46,21 @@ def _create_base_entry(result: napi.ReverseResult, #pylint: disable=too-many-bra
if result.place_id is not None:
place.set('place_id', str(result.place_id))
if result.osm_object:
osm_type = OSM_TYPE_NAME.get(result.osm_object[0], None)
osm_type = cl.OSM_TYPE_NAME.get(result.osm_object[0], None)
if osm_type is not None:
place.set('osm_type', osm_type)
place.set('osm_id', str(result.osm_object[1]))
if result.names and 'ref' in result.names:
place.set('place_id', result.names['ref'])
place.set('lat', str(result.centroid.lat))
place.set('lon', str(result.centroid.lon))
place.set('ref', result.names['ref'])
elif label_parts:
# bug reproduced from PHP
place.set('ref', label_parts[0])
place.set('lat', f"{result.centroid.lat:.7f}")
place.set('lon', f"{result.centroid.lon:.7f}")
bbox = bbox_from_result(result)
place.set('boundingbox', ','.join(map(str, [bbox.minlat, bbox.maxlat,
bbox.minlon, bbox.maxlon])))
bbox = cl.bbox_from_result(result)
place.set('boundingbox',
f"{bbox.minlat:.7f},{bbox.maxlat:.7f},{bbox.minlon:.7f},{bbox.maxlon:.7f}")
place.set('place_rank', str(result.rank_search))
place.set('address_rank', str(result.rank_address))
@@ -92,7 +97,7 @@ def format_base_xml(results: napi.ReverseResults,
root = ET.Element(xml_root_tag)
root.set('timestamp', dt.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S +00:00'))
root.set('attribution', OSM_ATTRIBUTION)
root.set('attribution', cl.OSM_ATTRIBUTION)
for k, v in xml_extra_info.items():
root.set(k, v)
@@ -103,7 +108,7 @@ def format_base_xml(results: napi.ReverseResults,
place = _create_base_entry(result, root, simple, locales)
if not simple and options.get('icon_base_url', None):
icon = ICONS.get(result.category)
icon = cl.ICONS.get(result.category)
if icon:
place.set('icon', icon)

View File

@@ -8,8 +8,10 @@
Generic part of the server implementation of the v1 API.
Combine with the scaffolding provided for the various Python ASGI frameworks.
"""
from typing import Optional, Any, Type, Callable, NoReturn, TypeVar
from typing import Optional, Any, Type, Callable, NoReturn, cast
from functools import reduce
import abc
import math
from nominatim.config import Configuration
import nominatim.api as napi
@@ -22,8 +24,6 @@ CONTENT_TYPE = {
'debug': 'text/html; charset=utf-8'
}
ConvT = TypeVar('ConvT', int, float)
class ASGIAdaptor(abc.ABC):
""" Adapter class for the different ASGI frameworks.
Wraps functionality over concrete requests and responses.
@@ -107,10 +107,9 @@ class ASGIAdaptor(abc.ABC):
raise self.error(msg, status)
def _get_typed(self, name: str, dest_type: Type[ConvT], type_name: str,
default: Optional[ConvT] = None) -> ConvT:
""" Return an input parameter as the type 'dest_type'. Raises an
exception if the parameter is given but not in the given format.
def get_int(self, name: str, default: Optional[int] = None) -> int:
""" Return an input parameter as an int. Raises an exception if
the parameter is given but not in an integer format.
If 'default' is given, then it will be returned when the parameter
is missing completely. When 'default' is None, an error will be
@@ -125,25 +124,14 @@ class ASGIAdaptor(abc.ABC):
self.raise_error(f"Parameter '{name}' missing.")
try:
intval = dest_type(value)
intval = int(value)
except ValueError:
self.raise_error(f"Parameter '{name}' must be a {type_name}.")
self.raise_error(f"Parameter '{name}' must be a number.")
return intval
def get_int(self, name: str, default: Optional[int] = None) -> int:
""" Return an input parameter as an int. Raises an exception if
the parameter is given but not in an integer format.
If 'default' is given, then it will be returned when the parameter
is missing completely. When 'default' is None, an error will be
raised on a missing parameter.
"""
return self._get_typed(name, int, 'number', default)
def get_float(self, name: str, default: Optional[float] = None) -> int:
def get_float(self, name: str, default: Optional[float] = None) -> float:
""" Return an input parameter as a flaoting-point number. Raises an
exception if the parameter is given but not in an float format.
@@ -151,7 +139,23 @@ class ASGIAdaptor(abc.ABC):
is missing completely. When 'default' is None, an error will be
raised on a missing parameter.
"""
return self._get_typed(name, float, 'number', default)
value = self.get(name)
if value is None:
if default is not None:
return default
self.raise_error(f"Parameter '{name}' missing.")
try:
fval = float(value)
except ValueError:
self.raise_error(f"Parameter '{name}' must be a number.")
if math.isnan(fval) or math.isinf(fval):
self.raise_error(f"Parameter '{name}' must be a number.")
return fval
def get_bool(self, name: str, default: Optional[bool] = None) -> bool:
@@ -194,15 +198,16 @@ class ASGIAdaptor(abc.ABC):
return False
def get_layers(self) -> napi.DataLayer:
def get_layers(self) -> Optional[napi.DataLayer]:
""" Return a parsed version of the layer parameter.
"""
param = self.get('layer', None)
if param is None:
return None
return reduce(napi.DataLayer.__or__,
(getattr(napi.DataLayer, s.upper()) for s in param.split(',')))
return cast(napi.DataLayer,
reduce(napi.DataLayer.__or__,
(getattr(napi.DataLayer, s.upper()) for s in param.split(','))))
def parse_format(self, result_type: Type[Any], default: str) -> str:
@@ -289,10 +294,6 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
zoom = max(0, min(18, params.get_int('zoom', 18)))
# Negation makes sure that NaN is handled. Don't change.
if not abs(coord[0]) <= 180 or not abs(coord[1]) <= 90:
params.raise_error('Invalid coordinates.')
details = napi.LookupDetails(address_details=True,
geometry_simplification=params.get_float('polygon_threshold', 0.0))
numgeoms = 0
@@ -311,7 +312,7 @@ async def reverse_endpoint(api: napi.NominatimAPIAsync, params: ASGIAdaptor) ->
numgeoms += 1
if numgeoms > params.config().get_int('POLYGON_OUTPUT_MAX_TYPES'):
params.raise_error(f'Too many polgyon output options selected.')
params.raise_error('Too many polgyon output options selected.')
result = await api.reverse(coord, REVERSE_MAX_RANKS[zoom],
params.get_layers() or