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.