1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Complex datatypes used by the Nominatim API.
10 from typing import Optional, Union, Tuple, NamedTuple, TypeVar, Type, Dict, \
12 from collections import abc
16 from struct import unpack
17 from binascii import unhexlify
19 from .errors import UsageError
20 from .localization import Locales
23 @dataclasses.dataclass
25 """ Reference a place by Nominatim's internal ID.
27 A PlaceID may reference place from the main table placex, from
28 the interpolation tables or the postcode tables. Place IDs are not
29 stable between installations. You may use this type theefore only
30 with place IDs obtained from the same database.
34 The internal ID of the place to reference.
38 @dataclasses.dataclass
40 """ Reference a place by its OSM ID and potentially the basic category.
42 The OSM ID may refer to places in the main table placex and OSM
46 """ OSM type of the object. Must be one of `N`(node), `W`(way) or
50 """ The OSM ID of the object.
52 osm_class: Optional[str] = None
53 """ The same OSM object may appear multiple times in the database under
54 different categories. The optional class parameter allows to distinguish
55 the different categories and corresponds to the key part of the category.
56 If there are multiple objects in the database and `osm_class` is
57 left out, then one of the objects is returned at random.
60 def __post_init__(self) -> None:
61 if self.osm_type not in ('N', 'W', 'R'):
62 raise ValueError(f"Illegal OSM type '{self.osm_type}'. Must be one of N, W, R.")
65 PlaceRef = Union[PlaceID, OsmID]
68 class Point(NamedTuple):
69 """ A geographic point in WGS84 projection.
75 def lat(self) -> float:
76 """ Return the latitude of the point.
81 def lon(self) -> float:
82 """ Return the longitude of the point.
86 def to_geojson(self) -> str:
87 """ Return the point in GeoJSON format.
89 return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
92 def from_wkb(wkb: Union[str, bytes]) -> 'Point':
93 """ Create a point from EWKB as returned from the database.
95 if isinstance(wkb, str):
98 raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
100 gtype, srid, x, y = unpack('>iidd', wkb[1:])
102 gtype, srid, x, y = unpack('<iidd', wkb[1:])
104 raise ValueError("WKB has unknown endian value.")
106 if gtype != 0x20000001:
107 raise ValueError("WKB must be a point geometry.")
109 raise ValueError("Only WGS84 WKB supported.")
114 def from_param(inp: Any) -> 'Point':
115 """ Create a point from an input parameter. The parameter
116 may be given as a point, a string or a sequence of
117 strings or floats. Raises a UsageError if the format is
120 if isinstance(inp, Point):
124 if isinstance(inp, str):
126 elif isinstance(inp, abc.Sequence):
130 raise UsageError('Point parameter needs 2 coordinates.')
132 x, y = filter(math.isfinite, map(float, seq))
133 except ValueError as exc:
134 raise UsageError('Point parameter needs to be numbers.') from exc
136 if x < -180.0 or x > 180.0 or y < -90.0 or y > 90.0:
137 raise UsageError('Point coordinates invalid.')
141 def to_wkt(self) -> str:
142 """ Return the WKT representation of the point.
144 return f'POINT({self.x} {self.y})'
147 AnyPoint = Union[Point, Tuple[float, float]]
149 WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
150 WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
154 """ A bounding box in WGS84 projection.
156 The coordinates are available as an array in the 'coord'
157 property in the order (minx, miny, maxx, maxy).
159 def __init__(self, minx: float, miny: float, maxx: float, maxy: float) -> None:
160 """ Create a new bounding box with the given coordinates in WGS84
163 self.coords = (minx, miny, maxx, maxy)
166 def minlat(self) -> float:
167 """ Southern-most latitude, corresponding to the minimum y coordinate.
169 return self.coords[1]
172 def maxlat(self) -> float:
173 """ Northern-most latitude, corresponding to the maximum y coordinate.
175 return self.coords[3]
178 def minlon(self) -> float:
179 """ Western-most longitude, corresponding to the minimum x coordinate.
181 return self.coords[0]
184 def maxlon(self) -> float:
185 """ Eastern-most longitude, corresponding to the maximum x coordinate.
187 return self.coords[2]
190 def area(self) -> float:
191 """ Return the area of the box in WGS84.
193 return (self.coords[2] - self.coords[0]) * (self.coords[3] - self.coords[1])
195 def contains(self, pt: Point) -> bool:
196 """ Check if the point is inside or on the boundary of the box.
198 return self.coords[0] <= pt[0] and self.coords[1] <= pt[1]\
199 and self.coords[2] >= pt[0] and self.coords[3] >= pt[1]
201 def to_wkt(self) -> str:
202 """ Return the WKT representation of the Bbox. This
203 is a simple polygon with four points.
205 return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
206 .format(*self.coords)
209 def from_wkb(wkb: Union[None, str, bytes]) -> 'Optional[Bbox]':
210 """ Create a Bbox from a bounding box polygon as returned by
211 the database. Returns `None` if the input value is None.
216 if isinstance(wkb, str):
220 raise ValueError("WKB must be a bounding box polygon")
221 if wkb.startswith(WKB_BBOX_HEADER_LE):
222 x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
223 elif wkb.startswith(WKB_BBOX_HEADER_BE):
224 x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
226 raise ValueError("WKB has wrong header")
228 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
231 def from_point(pt: Point, buffer: float) -> 'Bbox':
232 """ Return a Bbox around the point with the buffer added to all sides.
234 return Bbox(pt[0] - buffer, pt[1] - buffer,
235 pt[0] + buffer, pt[1] + buffer)
238 def from_param(inp: Any) -> 'Bbox':
239 """ Return a Bbox from an input parameter. The box may be
240 given as a Bbox, a string or a list or strings or integer.
241 Raises a UsageError if the format is incorrect.
243 if isinstance(inp, Bbox):
247 if isinstance(inp, str):
249 elif isinstance(inp, abc.Sequence):
253 raise UsageError('Bounding box parameter needs 4 coordinates.')
255 x1, y1, x2, y2 = filter(math.isfinite, map(float, seq))
256 except ValueError as exc:
257 raise UsageError('Bounding box parameter needs to be numbers.') from exc
259 x1 = min(180, max(-180, x1))
260 x2 = min(180, max(-180, x2))
261 y1 = min(90, max(-90, y1))
262 y2 = min(90, max(-90, y2))
264 if x1 == x2 or y1 == y2:
265 raise UsageError('Bounding box with invalid parameters.')
267 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
270 class GeometryFormat(enum.Flag):
271 """ All search functions support returning the full geometry of a place in
272 various formats. The internal geometry is converted by PostGIS to
273 the desired format and then returned as a string. It is possible to
274 request multiple formats at the same time.
277 """ No geometry requested. Alias for a empty flag.
279 GEOJSON = enum.auto()
281 [GeoJSON](https://geojson.org/) format
285 [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
289 [SVG](http://www.w3.org/TR/SVG/paths.html) format
293 [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
297 class DataLayer(enum.Flag):
298 """ The `DataLayer` flag type defines the layers that can be selected
299 for reverse and forward search.
301 ADDRESS = enum.auto()
302 """ The address layer contains all places relevant for addresses:
303 fully qualified addresses with a house number (or a house name equivalent,
304 for some addresses) and places that can be part of an address like
305 roads, cities, states.
308 """ Layer for points of interest like shops, restaurants but also
309 recycling bins or postboxes.
311 RAILWAY = enum.auto()
312 """ Layer with railway features including tracks and other infrastructure.
313 Note that in Nominatim's standard configuration, only very few railway
314 features are imported into the database. Thus a custom configuration
315 is required to make full use of this layer.
317 NATURAL = enum.auto()
318 """ Layer with natural features like rivers, lakes and mountains.
320 MANMADE = enum.auto()
321 """ Layer with other human-made features and boundaries. This layer is
322 the catch-all and includes all features not covered by the other
323 layers. A typical example for this layer are national park boundaries.
327 def format_country(cc: Any) -> List[str]:
328 """ Extract a list of country codes from the input which may be either
329 a string or list of strings. Filters out all values that are not
333 if isinstance(cc, str):
334 clist = cc.split(',')
335 elif isinstance(cc, abc.Sequence):
338 raise UsageError("Parameter 'country' needs to be a comma-separated list "
339 "or a Python list of strings.")
341 return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
344 def format_excluded(ids: Any) -> List[int]:
345 """ Extract a list of place ids from the input which may be either
346 a string or a list of strings or ints. Ignores empty value but
347 throws a UserError on anything that cannot be converted to int.
350 if isinstance(ids, str):
351 plist = [s.strip() for s in ids.split(',')]
352 elif isinstance(ids, abc.Sequence):
355 raise UsageError("Parameter 'excluded' needs to be a comma-separated list "
356 "or a Python list of numbers.")
357 if not all(isinstance(i, int) or
358 (isinstance(i, str) and (not i or i.isdigit())) for i in plist):
359 raise UsageError("Parameter 'excluded' only takes place IDs.")
361 return [int(id) for id in plist if id] or [0]
364 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
365 """ Extract a list of categories. Currently a noop.
370 TParam = TypeVar('TParam', bound='LookupDetails')
373 @dataclasses.dataclass
375 """ Collection of parameters that define which kind of details are
376 returned with a lookup or details result.
378 geometry_output: GeometryFormat = GeometryFormat.NONE
379 """ Add the full geometry of the place to the result. Multiple
380 formats may be selected. Note that geometries can become quite large.
382 address_details: bool = False
383 """ Get detailed information on the places that make up the address
386 linked_places: bool = False
387 """ Get detailed information on the places that link to the result.
389 parented_places: bool = False
390 """ Get detailed information on all places that this place is a parent
391 for, i.e. all places for which it provides the address details.
392 Only POI places can have parents.
394 keywords: bool = False
395 """ Add information about the search terms used for this place.
397 geometry_simplification: float = 0.0
398 """ Simplification factor for a geometry in degrees WGS. A factor of
399 0.0 means the original geometry is kept. The higher the value, the
400 more the geometry gets simplified.
402 locales: Locales = Locales()
403 """ Preferred languages for localization of results.
407 def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
408 """ Load the data fields of the class from a dictionary.
409 Unknown entries in the dictionary are ignored, missing ones
410 get the default setting.
412 The function supports type checking and throws a UsageError
413 when the value does not fit.
415 def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
417 return field.default_factory() \
418 if field.default_factory != dataclasses.MISSING \
420 if field.metadata and 'transform' in field.metadata:
421 return field.metadata['transform'](v)
422 if not isinstance(v, field.type): # type: ignore[arg-type]
423 raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
426 return cls(**{f.name: _check_field(kwargs[f.name], f)
427 for f in dataclasses.fields(cls) if f.name in kwargs})
430 @dataclasses.dataclass
431 class ReverseDetails(LookupDetails):
432 """ Collection of parameters for the reverse call.
435 max_rank: int = dataclasses.field(default=30,
436 metadata={'transform': lambda v: max(0, min(v, 30))})
437 """ Highest address rank to return.
440 layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
441 """ Filter which kind of data to include.
445 @dataclasses.dataclass
446 class SearchDetails(LookupDetails):
447 """ Collection of parameters for the search call.
449 max_results: int = 10
450 """ Maximum number of results to be returned. The actual number of results
454 min_rank: int = dataclasses.field(default=0,
455 metadata={'transform': lambda v: max(0, min(v, 30))})
456 """ Lowest address rank to return.
459 max_rank: int = dataclasses.field(default=30,
460 metadata={'transform': lambda v: max(0, min(v, 30))})
461 """ Highest address rank to return.
464 layers: Optional[DataLayer] = dataclasses.field(default=None,
465 metadata={'transform': lambda r: r})
466 """ Filter which kind of data to include. When 'None' (the default) then
467 filtering by layers is disabled.
470 countries: List[str] = dataclasses.field(default_factory=list,
471 metadata={'transform': format_country})
472 """ Restrict search results to the given countries. An empty list (the
473 default) will disable this filter.
476 excluded: List[int] = dataclasses.field(default_factory=list,
477 metadata={'transform': format_excluded})
478 """ List of OSM objects to exclude from the results. Currently only
479 works when the internal place ID is given.
480 An empty list (the default) will disable this filter.
483 viewbox: Optional[Bbox] = dataclasses.field(default=None,
484 metadata={'transform': Bbox.from_param})
485 """ Focus the search on a given map area.
488 bounded_viewbox: bool = False
489 """ Use 'viewbox' as a filter and restrict results to places within the
493 near: Optional[Point] = dataclasses.field(default=None,
494 metadata={'transform': Point.from_param})
495 """ Order results by distance to the given point.
498 near_radius: Optional[float] = dataclasses.field(default=None,
499 metadata={'transform': lambda r: r})
500 """ Use near point as a filter and drop results outside the given
501 radius. Radius is given in degrees WSG84.
504 categories: List[Tuple[str, str]] = dataclasses.field(default_factory=list,
505 metadata={'transform': format_categories})
506 """ Restrict search to places with one of the given class/type categories.
507 An empty list (the default) will disable this filter.
510 viewbox_x2: Optional[Bbox] = None
512 def __post_init__(self) -> None:
513 if self.viewbox is not None:
514 xext = (self.viewbox.maxlon - self.viewbox.minlon)/2
515 yext = (self.viewbox.maxlat - self.viewbox.minlat)/2
516 self.viewbox_x2 = Bbox(self.viewbox.minlon - xext, self.viewbox.minlat - yext,
517 self.viewbox.maxlon + xext, self.viewbox.maxlat + yext)
519 def restrict_min_max_rank(self, new_min: int, new_max: int) -> None:
520 """ Change the min_rank and max_rank fields to respect the
523 assert new_min <= new_max
524 self.min_rank = max(self.min_rank, new_min)
525 self.max_rank = min(self.max_rank, new_max)
527 def is_impossible(self) -> bool:
528 """ Check if the parameter configuration is contradictionary and
529 cannot yield any results.
531 return (self.min_rank > self.max_rank
532 or (self.bounded_viewbox
533 and self.viewbox is not None and self.near is not None
534 and self.viewbox.contains(self.near))
535 or (self.layers is not None and not self.layers)
536 or (self.max_rank <= 4 and
537 self.layers is not None and not self.layers & DataLayer.ADDRESS))
539 def layer_enabled(self, layer: DataLayer) -> bool:
540 """ Check if the given layer has been chosen. Also returns
541 true when layer restriction has been disabled completely.
543 return self.layers is None or bool(self.layers & layer)