mirror of
https://github.com/osm-search/Nominatim.git
synced 2026-03-10 12:04:06 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user