1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 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 nominatim.errors import UsageError
21 # pylint: disable=no-member,too-many-boolean-expressions,too-many-instance-attributes
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.
76 def lat(self) -> float:
77 """ Return the latitude of the point.
83 def lon(self) -> float:
84 """ Return the longitude of the point.
89 def to_geojson(self) -> str:
90 """ Return the point in GeoJSON format.
92 return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
96 def from_wkb(wkb: Union[str, bytes]) -> 'Point':
97 """ Create a point from EWKB as returned from the database.
99 if isinstance(wkb, str):
102 raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
104 gtype, srid, x, y = unpack('>iidd', wkb[1:])
106 gtype, srid, x, y = unpack('<iidd', wkb[1:])
108 raise ValueError("WKB has unknown endian value.")
110 if gtype != 0x20000001:
111 raise ValueError("WKB must be a point geometry.")
113 raise ValueError("Only WGS84 WKB supported.")
119 def from_param(inp: Any) -> 'Point':
120 """ Create a point from an input parameter. The parameter
121 may be given as a point, a string or a sequence of
122 strings or floats. Raises a UsageError if the format is
125 if isinstance(inp, Point):
129 if isinstance(inp, str):
131 elif isinstance(inp, abc.Sequence):
135 raise UsageError('Point parameter needs 2 coordinates.')
137 x, y = filter(math.isfinite, map(float, seq))
138 except ValueError as exc:
139 raise UsageError('Point parameter needs to be numbers.') from exc
141 if x < -180.0 or x > 180.0 or y < -90.0 or y > 90.0:
142 raise UsageError('Point coordinates invalid.')
147 def to_wkt(self) -> str:
148 """ Return the WKT representation of the point.
150 return f'POINT({self.x} {self.y})'
154 AnyPoint = Union[Point, Tuple[float, float]]
156 WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
157 WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
160 """ A bounding box in WGS84 projection.
162 The coordinates are available as an array in the 'coord'
163 property in the order (minx, miny, maxx, maxy).
165 def __init__(self, minx: float, miny: float, maxx: float, maxy: float) -> None:
166 """ Create a new bounding box with the given coordinates in WGS84
169 self.coords = (minx, miny, maxx, maxy)
173 def minlat(self) -> float:
174 """ Southern-most latitude, corresponding to the minimum y coordinate.
176 return self.coords[1]
180 def maxlat(self) -> float:
181 """ Northern-most latitude, corresponding to the maximum y coordinate.
183 return self.coords[3]
187 def minlon(self) -> float:
188 """ Western-most longitude, corresponding to the minimum x coordinate.
190 return self.coords[0]
194 def maxlon(self) -> float:
195 """ Eastern-most longitude, corresponding to the maximum x coordinate.
197 return self.coords[2]
201 def area(self) -> float:
202 """ Return the area of the box in WGS84.
204 return (self.coords[2] - self.coords[0]) * (self.coords[3] - self.coords[1])
207 def contains(self, pt: Point) -> bool:
208 """ Check if the point is inside or on the boundary of the box.
210 return self.coords[0] <= pt[0] and self.coords[1] <= pt[1]\
211 and self.coords[2] >= pt[0] and self.coords[3] >= pt[1]
214 def to_wkt(self) -> str:
215 """ Return the WKT representation of the Bbox. This
216 is a simple polygon with four points.
218 return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
219 .format(*self.coords) # pylint: disable=consider-using-f-string
223 def from_wkb(wkb: Union[None, str, bytes]) -> 'Optional[Bbox]':
224 """ Create a Bbox from a bounding box polygon as returned by
225 the database. Returns `None` if the input value is None.
230 if isinstance(wkb, str):
234 raise ValueError("WKB must be a bounding box polygon")
235 if wkb.startswith(WKB_BBOX_HEADER_LE):
236 x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
237 elif wkb.startswith(WKB_BBOX_HEADER_BE):
238 x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
240 raise ValueError("WKB has wrong header")
242 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
246 def from_point(pt: Point, buffer: float) -> 'Bbox':
247 """ Return a Bbox around the point with the buffer added to all sides.
249 return Bbox(pt[0] - buffer, pt[1] - buffer,
250 pt[0] + buffer, pt[1] + buffer)
254 def from_param(inp: Any) -> 'Bbox':
255 """ Return a Bbox from an input parameter. The box may be
256 given as a Bbox, a string or a list or strings or integer.
257 Raises a UsageError if the format is incorrect.
259 if isinstance(inp, Bbox):
263 if isinstance(inp, str):
265 elif isinstance(inp, abc.Sequence):
269 raise UsageError('Bounding box parameter needs 4 coordinates.')
271 x1, y1, x2, y2 = filter(math.isfinite, map(float, seq))
272 except ValueError as exc:
273 raise UsageError('Bounding box parameter needs to be numbers.') from exc
275 x1 = min(180, max(-180, x1))
276 x2 = min(180, max(-180, x2))
277 y1 = min(90, max(-90, y1))
278 y2 = min(90, max(-90, y2))
280 if x1 == x2 or y1 == y2:
281 raise UsageError('Bounding box with invalid parameters.')
283 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
286 class GeometryFormat(enum.Flag):
287 """ All search functions support returning the full geometry of a place in
288 various formats. The internal geometry is converted by PostGIS to
289 the desired format and then returned as a string. It is possible to
290 request multiple formats at the same time.
293 """ No geometry requested. Alias for a empty flag.
295 GEOJSON = enum.auto()
297 [GeoJSON](https://geojson.org/) format
301 [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
305 [SVG](http://www.w3.org/TR/SVG/paths.html) format
309 [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
313 class DataLayer(enum.Flag):
314 """ The `DataLayer` flag type defines the layers that can be selected
315 for reverse and forward search.
317 ADDRESS = enum.auto()
318 """ The address layer contains all places relavant for addresses:
319 fully qualified addresses with a house number (or a house name equivalent,
320 for some addresses) and places that can be part of an address like
321 roads, cities, states.
324 """ Layer for points of interest like shops, restaurants but also
325 recycling bins or postboxes.
327 RAILWAY = enum.auto()
328 """ Layer with railway features including tracks and other infrastructure.
329 Note that in Nominatim's standard configuration, only very few railway
330 features are imported into the database. Thus a custom configuration
331 is required to make full use of this layer.
333 NATURAL = enum.auto()
334 """ Layer with natural features like rivers, lakes and mountains.
336 MANMADE = enum.auto()
337 """ Layer with other human-made features and boundaries. This layer is
338 the catch-all and includes all features not covered by the other
339 layers. A typical example for this layer are national park boundaries.
343 def format_country(cc: Any) -> List[str]:
344 """ Extract a list of country codes from the input which may be either
345 a string or list of strings. Filters out all values that are not
349 if isinstance(cc, str):
350 clist = cc.split(',')
351 elif isinstance(cc, abc.Sequence):
354 raise UsageError("Parameter 'country' needs to be a comma-separated list "
355 "or a Python list of strings.")
357 return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
360 def format_excluded(ids: Any) -> List[int]:
361 """ Extract a list of place ids from the input which may be either
362 a string or a list of strings or ints. Ignores empty value but
363 throws a UserError on anything that cannot be converted to int.
366 if isinstance(ids, str):
367 plist = [s.strip() for s in ids.split(',')]
368 elif isinstance(ids, abc.Sequence):
371 raise UsageError("Parameter 'excluded' needs to be a comma-separated list "
372 "or a Python list of numbers.")
373 if not all(isinstance(i, int) or
374 (isinstance(i, str) and (not i or i.isdigit())) for i in plist):
375 raise UsageError("Parameter 'excluded' only takes place IDs.")
377 return [int(id) for id in plist if id] or [0]
380 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
381 """ Extract a list of categories. Currently a noop.
385 TParam = TypeVar('TParam', bound='LookupDetails') # pylint: disable=invalid-name
387 @dataclasses.dataclass
389 """ Collection of parameters that define the amount of details
390 returned with a lookup or details result.
392 geometry_output: GeometryFormat = GeometryFormat.NONE
393 """ Add the full geometry of the place to the result. Multiple
394 formats may be selected. Note that geometries can become quite large.
396 address_details: bool = False
397 """ Get detailed information on the places that make up the address
400 linked_places: bool = False
401 """ Get detailed information on the places that link to the result.
403 parented_places: bool = False
404 """ Get detailed information on all places that this place is a parent
405 for, i.e. all places for which it provides the address details.
406 Only POI places can have parents.
408 keywords: bool = False
409 """ Add information about the search terms used for this place.
411 geometry_simplification: float = 0.0
412 """ Simplification factor for a geometry in degrees WGS. A factor of
413 0.0 means the original geometry is kept. The higher the value, the
414 more the geometry gets simplified.
418 def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
419 """ Load the data fields of the class from a dictionary.
420 Unknown entries in the dictionary are ignored, missing ones
421 get the default setting.
423 The function supports type checking and throws a UsageError
424 when the value does not fit.
426 def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
428 return field.default_factory() \
429 if field.default_factory != dataclasses.MISSING \
431 if field.metadata and 'transform' in field.metadata:
432 return field.metadata['transform'](v)
433 if not isinstance(v, field.type):
434 raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
437 return cls(**{f.name: _check_field(kwargs[f.name], f)
438 for f in dataclasses.fields(cls) if f.name in kwargs})
441 @dataclasses.dataclass
442 class ReverseDetails(LookupDetails):
443 """ Collection of parameters for the reverse call.
445 max_rank: int = dataclasses.field(default=30,
446 metadata={'transform': lambda v: max(0, min(v, 30))}
448 """ Highest address rank to return.
450 layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
451 """ Filter which kind of data to include.
454 @dataclasses.dataclass
455 class SearchDetails(LookupDetails):
456 """ Collection of parameters for the search call.
458 max_results: int = 10
459 """ Maximum number of results to be returned. The actual number of results
462 min_rank: int = dataclasses.field(default=0,
463 metadata={'transform': lambda v: max(0, min(v, 30))}
465 """ Lowest address rank to return.
467 max_rank: int = dataclasses.field(default=30,
468 metadata={'transform': lambda v: max(0, min(v, 30))}
470 """ Highest address rank to return.
472 layers: Optional[DataLayer] = dataclasses.field(default=None,
473 metadata={'transform': lambda r : r})
474 """ Filter which kind of data to include. When 'None' (the default) then
475 filtering by layers is disabled.
477 countries: List[str] = dataclasses.field(default_factory=list,
478 metadata={'transform': format_country})
479 """ Restrict search results to the given countries. An empty list (the
480 default) will disable this filter.
482 excluded: List[int] = dataclasses.field(default_factory=list,
483 metadata={'transform': format_excluded})
484 """ List of OSM objects to exclude from the results. Currently only
485 works when the internal place ID is given.
486 An empty list (the default) will disable this filter.
488 viewbox: Optional[Bbox] = dataclasses.field(default=None,
489 metadata={'transform': Bbox.from_param})
490 """ Focus the search on a given map area.
492 bounded_viewbox: bool = False
493 """ Use 'viewbox' as a filter and restrict results to places within the
496 near: Optional[Point] = dataclasses.field(default=None,
497 metadata={'transform': Point.from_param})
498 """ Order results by distance to the given point.
500 near_radius: Optional[float] = dataclasses.field(default=None,
501 metadata={'transform': lambda r : r})
502 """ Use near point as a filter and drop results outside the given
503 radius. Radius is given in degrees WSG84.
505 categories: List[Tuple[str, str]] = dataclasses.field(default_factory=list,
506 metadata={'transform': format_categories})
507 """ Restrict search to places with one of the given class/type categories.
508 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)
520 def restrict_min_max_rank(self, new_min: int, new_max: int) -> None:
521 """ Change the min_rank and max_rank fields to respect the
524 assert new_min <= new_max
525 self.min_rank = max(self.min_rank, new_min)
526 self.max_rank = min(self.max_rank, new_max)
529 def is_impossible(self) -> bool:
530 """ Check if the parameter configuration is contradictionary and
531 cannot yield any results.
533 return (self.min_rank > self.max_rank
534 or (self.bounded_viewbox
535 and self.viewbox is not None and self.near is not None
536 and self.viewbox.contains(self.near))
537 or self.layers is not None and not self.layers)
540 def layer_enabled(self, layer: DataLayer) -> bool:
541 """ Check if the given layer has been choosen. Also returns
542 true when layer restriction has been disabled completely.
544 return self.layers is None or bool(self.layers & layer)