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
22 @dataclasses.dataclass
24 """ Reference a place by Nominatim's internal ID.
26 A PlaceID may reference place from the main table placex, from
27 the interpolation tables or the postcode tables. Place IDs are not
28 stable between installations. You may use this type theefore only
29 with place IDs obtained from the same database.
33 The internal ID of the place to reference.
37 @dataclasses.dataclass
39 """ Reference a place by its OSM ID and potentially the basic category.
41 The OSM ID may refer to places in the main table placex and OSM
45 """ OSM type of the object. Must be one of `N`(node), `W`(way) or
49 """ The OSM ID of the object.
51 osm_class: Optional[str] = None
52 """ The same OSM object may appear multiple times in the database under
53 different categories. The optional class parameter allows to distinguish
54 the different categories and corresponds to the key part of the category.
55 If there are multiple objects in the database and `osm_class` is
56 left out, then one of the objects is returned at random.
59 def __post_init__(self) -> None:
60 if self.osm_type not in ('N', 'W', 'R'):
61 raise ValueError(f"Illegal OSM type '{self.osm_type}'. Must be one of N, W, R.")
63 def class_as_housenumber(self) -> Optional[int]:
64 """ Interpret the class property as a housenumber and return it.
66 If the OSM ID points to an interpolation, then the class may be
67 a number pointing to the exact number requested. This function
68 returns the housenumber as an int, if class is set and is a number.
70 if self.osm_class and self.osm_class.isdigit():
71 return int(self.osm_class)
75 PlaceRef = Union[PlaceID, OsmID]
78 class Point(NamedTuple):
79 """ A geographic point in WGS84 projection.
85 def lat(self) -> float:
86 """ Return the latitude of the point.
91 def lon(self) -> float:
92 """ Return the longitude of the point.
96 def to_geojson(self) -> str:
97 """ Return the point in GeoJSON format.
99 return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
102 def from_wkb(wkb: Union[str, bytes]) -> 'Point':
103 """ Create a point from EWKB as returned from the database.
105 if isinstance(wkb, str):
108 raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
110 gtype, srid, x, y = unpack('>iidd', wkb[1:])
112 gtype, srid, x, y = unpack('<iidd', wkb[1:])
114 raise ValueError("WKB has unknown endian value.")
116 if gtype != 0x20000001:
117 raise ValueError("WKB must be a point geometry.")
119 raise ValueError("Only WGS84 WKB supported.")
124 def from_param(inp: Any) -> 'Point':
125 """ Create a point from an input parameter. The parameter
126 may be given as a point, a string or a sequence of
127 strings or floats. Raises a UsageError if the format is
130 if isinstance(inp, Point):
134 if isinstance(inp, str):
136 elif isinstance(inp, abc.Sequence):
140 raise UsageError('Point parameter needs 2 coordinates.')
142 x, y = filter(math.isfinite, map(float, seq))
143 except ValueError as exc:
144 raise UsageError('Point parameter needs to be numbers.') from exc
146 if not -180 <= x <= 180 or not -90 <= y <= 90.0:
147 raise UsageError('Point coordinates invalid.')
151 def to_wkt(self) -> str:
152 """ Return the WKT representation of the point.
154 return f'POINT({self.x} {self.y})'
157 AnyPoint = Union[Point, Tuple[float, float]]
159 WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
160 WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
164 """ A bounding box in WGS84 projection.
166 The coordinates are available as an array in the 'coord'
167 property in the order (minx, miny, maxx, maxy).
169 def __init__(self, minx: float, miny: float, maxx: float, maxy: float) -> None:
170 """ Create a new bounding box with the given coordinates in WGS84
173 self.coords = (minx, miny, maxx, maxy)
176 def minlat(self) -> float:
177 """ Southern-most latitude, corresponding to the minimum y coordinate.
179 return self.coords[1]
182 def maxlat(self) -> float:
183 """ Northern-most latitude, corresponding to the maximum y coordinate.
185 return self.coords[3]
188 def minlon(self) -> float:
189 """ Western-most longitude, corresponding to the minimum x coordinate.
191 return self.coords[0]
194 def maxlon(self) -> float:
195 """ Eastern-most longitude, corresponding to the maximum x coordinate.
197 return self.coords[2]
200 def area(self) -> float:
201 """ Return the area of the box in WGS84.
203 return (self.coords[2] - self.coords[0]) * (self.coords[3] - self.coords[1])
205 def contains(self, pt: Point) -> bool:
206 """ Check if the point is inside or on the boundary of the box.
208 return self.coords[0] <= pt[0] and self.coords[1] <= pt[1]\
209 and self.coords[2] >= pt[0] and self.coords[3] >= pt[1]
211 def to_wkt(self) -> str:
212 """ Return the WKT representation of the Bbox. This
213 is a simple polygon with four points.
215 return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
216 .format(*self.coords)
219 def from_wkb(wkb: Union[None, str, bytes]) -> 'Optional[Bbox]':
220 """ Create a Bbox from a bounding box polygon as returned by
221 the database. Returns `None` if the input value is None.
226 if isinstance(wkb, str):
230 raise ValueError("WKB must be a bounding box polygon")
231 if wkb.startswith(WKB_BBOX_HEADER_LE):
232 x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
233 elif wkb.startswith(WKB_BBOX_HEADER_BE):
234 x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
236 raise ValueError("WKB has wrong header")
238 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
241 def from_point(pt: Point, buffer: float) -> 'Bbox':
242 """ Return a Bbox around the point with the buffer added to all sides.
244 return Bbox(pt[0] - buffer, pt[1] - buffer,
245 pt[0] + buffer, pt[1] + buffer)
248 def from_param(inp: Any) -> 'Bbox':
249 """ Return a Bbox from an input parameter. The box may be
250 given as a Bbox, a string or a list or strings or integer.
251 Raises a UsageError if the format is incorrect.
253 if isinstance(inp, Bbox):
257 if isinstance(inp, str):
259 elif isinstance(inp, abc.Sequence):
263 raise UsageError('Bounding box parameter needs 4 coordinates.')
265 x1, y1, x2, y2 = filter(math.isfinite, map(float, seq))
266 except ValueError as exc:
267 raise UsageError('Bounding box parameter needs to be numbers.') from exc
269 x1 = min(180, max(-180, x1))
270 x2 = min(180, max(-180, x2))
271 y1 = min(90, max(-90, y1))
272 y2 = min(90, max(-90, y2))
274 if x1 == x2 or y1 == y2:
275 raise UsageError('Bounding box with invalid parameters.')
277 return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
280 class GeometryFormat(enum.Flag):
281 """ All search functions support returning the full geometry of a place in
282 various formats. The internal geometry is converted by PostGIS to
283 the desired format and then returned as a string. It is possible to
284 request multiple formats at the same time.
287 """ No geometry requested. Alias for a empty flag.
289 GEOJSON = enum.auto()
291 [GeoJSON](https://geojson.org/) format
295 [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
299 [SVG](http://www.w3.org/TR/SVG/paths.html) format
303 [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
307 class DataLayer(enum.Flag):
308 """ The `DataLayer` flag type defines the layers that can be selected
309 for reverse and forward search.
311 ADDRESS = enum.auto()
312 """ The address layer contains all places relevant for addresses:
313 fully qualified addresses with a house number (or a house name equivalent,
314 for some addresses) and places that can be part of an address like
315 roads, cities, states.
318 """ Layer for points of interest like shops, restaurants but also
319 recycling bins or postboxes.
321 RAILWAY = enum.auto()
322 """ Layer with railway features including tracks and other infrastructure.
323 Note that in Nominatim's standard configuration, only very few railway
324 features are imported into the database. Thus a custom configuration
325 is required to make full use of this layer.
327 NATURAL = enum.auto()
328 """ Layer with natural features like rivers, lakes and mountains.
330 MANMADE = enum.auto()
331 """ Layer with other human-made features and boundaries. This layer is
332 the catch-all and includes all features not covered by the other
333 layers. A typical example for this layer are national park boundaries.
337 def format_country(cc: Any) -> List[str]:
338 """ Extract a list of country codes from the input which may be either
339 a string or list of strings. Filters out all values that are not
343 if isinstance(cc, str):
344 clist = cc.split(',')
345 elif isinstance(cc, abc.Sequence):
348 raise UsageError("Parameter 'country' needs to be a comma-separated list "
349 "or a Python list of strings.")
351 return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
354 def format_excluded(ids: Any) -> List[int]:
355 """ Extract a list of place ids from the input which may be either
356 a string or a list of strings or ints. Ignores empty value but
357 throws a UserError on anything that cannot be converted to int.
360 if isinstance(ids, str):
361 plist = [s.strip() for s in ids.split(',')]
362 elif isinstance(ids, abc.Sequence):
365 raise UsageError("Parameter 'excluded' needs to be a comma-separated list "
366 "or a Python list of numbers.")
367 if not all(isinstance(i, int) or
368 (isinstance(i, str) and (not i or i.isdigit())) for i in plist):
369 raise UsageError("Parameter 'excluded' only takes place IDs.")
371 return [int(id) for id in plist if id] or [0]
374 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
375 """ Extract a list of categories. Currently a noop.
380 TParam = TypeVar('TParam', bound='LookupDetails')
383 @dataclasses.dataclass
385 """ Collection of parameters that define which kind of details are
386 returned with a lookup or details result.
388 geometry_output: GeometryFormat = GeometryFormat.NONE
389 """ Add the full geometry of the place to the result. Multiple
390 formats may be selected. Note that geometries can become quite large.
392 address_details: bool = False
393 """ Get detailed information on the places that make up the address
396 linked_places: bool = False
397 """ Get detailed information on the places that link to the result.
399 parented_places: bool = False
400 """ Get detailed information on all places that this place is a parent
401 for, i.e. all places for which it provides the address details.
402 Only POI places can have parents.
404 entrances: bool = False
405 """ Get detailed information about the tagged entrances for the result.
407 keywords: bool = False
408 """ Add information about the search terms used for this place.
410 geometry_simplification: float = 0.0
411 """ Simplification factor for a geometry in degrees WGS. A factor of
412 0.0 means the original geometry is kept. The higher the value, the
413 more the geometry gets simplified.
417 def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
418 """ Load the data fields of the class from a dictionary.
419 Unknown entries in the dictionary are ignored, missing ones
420 get the default setting.
422 The function supports type checking and throws a UsageError
423 when the value does not fit.
425 def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
427 return field.default_factory() \
428 if field.default_factory != dataclasses.MISSING \
430 if field.metadata and 'transform' in field.metadata:
431 return field.metadata['transform'](v)
432 if not isinstance(v, field.type): # type: ignore[arg-type]
433 raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
436 return cls(**{f.name: _check_field(kwargs[f.name], f)
437 for f in dataclasses.fields(cls) if f.name in kwargs})
440 @dataclasses.dataclass
441 class ReverseDetails(LookupDetails):
442 """ 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))})
447 """ Highest address rank to return.
450 layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
451 """ Filter which kind of data to include.
455 @dataclasses.dataclass
456 class SearchDetails(LookupDetails):
457 """ Collection of parameters for the search call.
459 max_results: int = 10
460 """ Maximum number of results to be returned. The actual number of results
464 min_rank: int = dataclasses.field(default=0,
465 metadata={'transform': lambda v: max(0, min(v, 30))})
466 """ Lowest address rank to return.
469 max_rank: int = dataclasses.field(default=30,
470 metadata={'transform': lambda v: max(0, min(v, 30))})
471 """ Highest address rank to return.
474 layers: Optional[DataLayer] = dataclasses.field(default=None,
475 metadata={'transform': lambda r: r})
476 """ Filter which kind of data to include. When 'None' (the default) then
477 filtering by layers is disabled.
480 countries: List[str] = dataclasses.field(default_factory=list,
481 metadata={'transform': format_country})
482 """ Restrict search results to the given countries. An empty list (the
483 default) will disable this filter.
486 excluded: List[int] = dataclasses.field(default_factory=list,
487 metadata={'transform': format_excluded})
488 """ List of OSM objects to exclude from the results. Currently only
489 works when the internal place ID is given.
490 An empty list (the default) will disable this filter.
493 viewbox: Optional[Bbox] = dataclasses.field(default=None,
494 metadata={'transform': Bbox.from_param})
495 """ Focus the search on a given map area.
498 bounded_viewbox: bool = False
499 """ Use 'viewbox' as a filter and restrict results to places within the
503 near: Optional[Point] = dataclasses.field(default=None,
504 metadata={'transform': Point.from_param})
505 """ Order results by distance to the given point.
508 near_radius: Optional[float] = dataclasses.field(default=None,
509 metadata={'transform': lambda r: r})
510 """ Use near point as a filter and drop results outside the given
511 radius. Radius is given in degrees WSG84.
514 categories: List[Tuple[str, str]] = dataclasses.field(default_factory=list,
515 metadata={'transform': format_categories})
516 """ Restrict search to places with one of the given class/type categories.
517 An empty list (the default) will disable this filter.
520 viewbox_x2: Optional[Bbox] = None
522 def __post_init__(self) -> None:
523 if self.viewbox is not None:
524 xext = (self.viewbox.maxlon - self.viewbox.minlon)/2
525 yext = (self.viewbox.maxlat - self.viewbox.minlat)/2
526 self.viewbox_x2 = Bbox(self.viewbox.minlon - xext, self.viewbox.minlat - yext,
527 self.viewbox.maxlon + xext, self.viewbox.maxlat + yext)
529 def restrict_min_max_rank(self, new_min: int, new_max: int) -> None:
530 """ Change the min_rank and max_rank fields to respect the
533 assert new_min <= new_max
534 self.min_rank = max(self.min_rank, new_min)
535 self.max_rank = min(self.max_rank, new_max)
537 def is_impossible(self) -> bool:
538 """ Check if the parameter configuration is contradictionary and
539 cannot yield any results.
541 return (self.min_rank > self.max_rank
542 or (self.bounded_viewbox
543 and self.viewbox is not None and self.near is not None
544 and self.viewbox.contains(self.near))
545 or (self.layers is not None and not self.layers)
546 or (self.max_rank <= 4 and
547 self.layers is not None and not self.layers & DataLayer.ADDRESS))
549 def layer_enabled(self, layer: DataLayer) -> bool:
550 """ Check if the given layer has been chosen. Also returns
551 true when layer restriction has been disabled completely.
553 return self.layers is None or bool(self.layers & layer)
556 @dataclasses.dataclass
557 class EntranceDetails:
558 """ Reference a place by its OSM ID and potentially the basic category.
560 The OSM ID may refer to places in the main table placex and OSM
564 """ The OSM ID of the object.
567 """ The value of the OSM entrance tag (i.e. yes, main, secondary, etc.).
570 """ The location of the entrance node.
572 extratags: Dict[str, str]
573 """ The other tags associated with the entrance node.