1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2025 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
 
  17 from struct import unpack
 
  18 from binascii import unhexlify
 
  20 from .errors import UsageError
 
  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.")
 
  64     def class_as_housenumber(self) -> Optional[int]:
 
  65         """ Interpret the class property as a housenumber and return it.
 
  67             If the OSM ID points to an interpolation, then the class may be
 
  68             a number pointing to the exact number requested. This function
 
  69             returns the housenumber as an int, if class is set and is a number.
 
  71         if self.osm_class and self.osm_class.isdigit():
 
  72             return int(self.osm_class)
 
  76 PlaceRef = Union[PlaceID, OsmID]
 
  79 class Point(NamedTuple):
 
  80     """ A geographic point in WGS84 projection.
 
  86     def lat(self) -> float:
 
  87         """ Return the latitude of the point.
 
  92     def lon(self) -> float:
 
  93         """ Return the longitude of the point.
 
  97     def to_geojson(self) -> str:
 
  98         """ Return the point in GeoJSON format.
 
 100         return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
 
 103     def from_wkb(wkb: Union[str, bytes]) -> 'Point':
 
 104         """ Create a point from EWKB as returned from the database.
 
 106         if isinstance(wkb, str):
 
 109             raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
 
 111             gtype, srid, x, y = unpack('>iidd', wkb[1:])
 
 113             gtype, srid, x, y = unpack('<iidd', wkb[1:])
 
 115             raise ValueError("WKB has unknown endian value.")
 
 117         if gtype != 0x20000001:
 
 118             raise ValueError("WKB must be a point geometry.")
 
 120             raise ValueError("Only WGS84 WKB supported.")
 
 125     def from_param(inp: Any) -> 'Point':
 
 126         """ Create a point from an input parameter. The parameter
 
 127             may be given as a point, a string or a sequence of
 
 128             strings or floats. Raises a UsageError if the format is
 
 131         if isinstance(inp, Point):
 
 135         if isinstance(inp, str):
 
 137         elif isinstance(inp, abc.Sequence):
 
 141             raise UsageError('Point parameter needs 2 coordinates.')
 
 143             x, y = filter(math.isfinite, map(float, seq))
 
 144         except ValueError as exc:
 
 145             raise UsageError('Point parameter needs to be numbers.') from exc
 
 147         if not -180 <= x <= 180 or not -90 <= y <= 90.0:
 
 148             raise UsageError('Point coordinates invalid.')
 
 152     def to_wkt(self) -> str:
 
 153         """ Return the WKT representation of the point.
 
 155         return f'POINT({self.x} {self.y})'
 
 158 AnyPoint = Union[Point, Tuple[float, float]]
 
 160 WKB_BBOX_HEADER_LE = b'\x01\x03\x00\x00\x20\xE6\x10\x00\x00\x01\x00\x00\x00\x05\x00\x00\x00'
 
 161 WKB_BBOX_HEADER_BE = b'\x00\x20\x00\x00\x03\x00\x00\x10\xe6\x00\x00\x00\x01\x00\x00\x00\x05'
 
 165     """ A bounding box in WGS84 projection.
 
 167         The coordinates are available as an array in the 'coord'
 
 168         property in the order (minx, miny, maxx, maxy).
 
 170     def __init__(self, minx: float, miny: float, maxx: float, maxy: float) -> None:
 
 171         """ Create a new bounding box with the given coordinates in WGS84
 
 174         self.coords = (minx, miny, maxx, maxy)
 
 177     def minlat(self) -> float:
 
 178         """ Southern-most latitude, corresponding to the minimum y coordinate.
 
 180         return self.coords[1]
 
 183     def maxlat(self) -> float:
 
 184         """ Northern-most latitude, corresponding to the maximum y coordinate.
 
 186         return self.coords[3]
 
 189     def minlon(self) -> float:
 
 190         """ Western-most longitude, corresponding to the minimum x coordinate.
 
 192         return self.coords[0]
 
 195     def maxlon(self) -> float:
 
 196         """ Eastern-most longitude, corresponding to the maximum x coordinate.
 
 198         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])
 
 206     def contains(self, pt: Point) -> bool:
 
 207         """ Check if the point is inside or on the boundary of the box.
 
 209         return self.coords[0] <= pt[0] and self.coords[1] <= pt[1]\
 
 210             and self.coords[2] >= pt[0] and self.coords[3] >= pt[1]
 
 212     def to_wkt(self) -> str:
 
 213         """ Return the WKT representation of the Bbox. This
 
 214             is a simple polygon with four points.
 
 216         return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
 
 217             .format(*self.coords)
 
 220     def from_wkb(wkb: Union[None, str, bytes]) -> 'Optional[Bbox]':
 
 221         """ Create a Bbox from a bounding box polygon as returned by
 
 222             the database. Returns `None` if the input value is None.
 
 227         if isinstance(wkb, str):
 
 231             raise ValueError("WKB must be a bounding box polygon")
 
 232         if wkb.startswith(WKB_BBOX_HEADER_LE):
 
 233             x1, y1, _, _, x2, y2 = unpack('<dddddd', wkb[17:65])
 
 234         elif wkb.startswith(WKB_BBOX_HEADER_BE):
 
 235             x1, y1, _, _, x2, y2 = unpack('>dddddd', wkb[17:65])
 
 237             raise ValueError("WKB has wrong header")
 
 239         return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
 
 242     def from_point(pt: Point, buffer: float) -> 'Bbox':
 
 243         """ Return a Bbox around the point with the buffer added to all sides.
 
 245         return Bbox(pt[0] - buffer, pt[1] - buffer,
 
 246                     pt[0] + buffer, pt[1] + buffer)
 
 249     def from_param(inp: Any) -> 'Bbox':
 
 250         """ Return a Bbox from an input parameter. The box may be
 
 251             given as a Bbox, a string or a list or strings or integer.
 
 252             Raises a UsageError if the format is incorrect.
 
 254         if isinstance(inp, Bbox):
 
 258         if isinstance(inp, str):
 
 260         elif isinstance(inp, abc.Sequence):
 
 264             raise UsageError('Bounding box parameter needs 4 coordinates.')
 
 266             x1, y1, x2, y2 = filter(math.isfinite, map(float, seq))
 
 267         except ValueError as exc:
 
 268             raise UsageError('Bounding box parameter needs to be numbers.') from exc
 
 270         x1 = min(180, max(-180, x1))
 
 271         x2 = min(180, max(-180, x2))
 
 272         y1 = min(90, max(-90, y1))
 
 273         y2 = min(90, max(-90, y2))
 
 275         if x1 == x2 or y1 == y2:
 
 276             raise UsageError('Bounding box with invalid parameters.')
 
 278         return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
 
 281 class GeometryFormat(enum.Flag):
 
 282     """ All search functions support returning the full geometry of a place in
 
 283         various formats. The internal geometry is converted by PostGIS to
 
 284         the desired format and then returned as a string. It is possible to
 
 285         request multiple formats at the same time.
 
 288     """ No geometry requested. Alias for a empty flag.
 
 290     GEOJSON = enum.auto()
 
 292     [GeoJSON](https://geojson.org/) format
 
 296     [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
 
 300     [SVG](http://www.w3.org/TR/SVG/paths.html) format
 
 304     [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
 
 308 class DataLayer(enum.Flag):
 
 309     """ The `DataLayer` flag type defines the layers that can be selected
 
 310         for reverse and forward search.
 
 312     ADDRESS = enum.auto()
 
 313     """ The address layer contains all places relevant for addresses:
 
 314         fully qualified addresses with a house number (or a house name equivalent,
 
 315         for some addresses) and places that can be part of an address like
 
 316         roads, cities, states.
 
 319     """ Layer for points of interest like shops, restaurants but also
 
 320         recycling bins or postboxes.
 
 322     RAILWAY = enum.auto()
 
 323     """ Layer with railway features including tracks and other infrastructure.
 
 324         Note that in Nominatim's standard configuration, only very few railway
 
 325         features are imported into the database. Thus a custom configuration
 
 326         is required to make full use of this layer.
 
 328     NATURAL = enum.auto()
 
 329     """ Layer with natural features like rivers, lakes and mountains.
 
 331     MANMADE = enum.auto()
 
 332     """ Layer with other human-made features and boundaries. This layer is
 
 333         the catch-all and includes all features not covered by the other
 
 334         layers. A typical example for this layer are national park boundaries.
 
 338 class QueryStatistics(dict[str, Any]):
 
 339     """ A specialised dictionary for collecting query statistics.
 
 342     def __enter__(self) -> 'QueryStatistics':
 
 343         self.log_time('start')
 
 346     def __exit__(self, *_: Any) -> None:
 
 348         self['total_time'] = (self['end'] - self['start']).total_seconds()
 
 349         if 'start_query' in self:
 
 350             self['wait_time'] = (self['start_query'] - self['start']).total_seconds()
 
 352             self['wait_time'] = self['total_time']
 
 353             self['start_query'] = self['end']
 
 354         self['query_time'] = self['total_time'] - self['wait_time']
 
 356     def __missing__(self, key: str) -> str:
 
 359     def log_time(self, key: str) -> None:
 
 360         self[key] = dt.datetime.now(tz=dt.timezone.utc)
 
 364     """ Null object to use, when no query statistics are requested.
 
 367     def __enter__(self) -> 'NoQueryStats':
 
 370     def __exit__(self, *_: Any) -> None:
 
 373     def __setitem__(self, key: str, value: Any) -> None:
 
 376     def __getitem__(self, key: str) -> Any:
 
 379     def __contains__(self, key: str, default: Any = None) -> bool:
 
 382     def log_time(self, key: str) -> None:
 
 386 def format_country(cc: Any) -> List[str]:
 
 387     """ Extract a list of country codes from the input which may be either
 
 388         a string or list of strings. Filters out all values that are not
 
 392     if isinstance(cc, str):
 
 393         clist = cc.split(',')
 
 394     elif isinstance(cc, abc.Sequence):
 
 397         raise UsageError("Parameter 'country' needs to be a comma-separated list "
 
 398                          "or a Python list of strings.")
 
 400     return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
 
 403 def format_excluded(ids: Any) -> List[int]:
 
 404     """ Extract a list of place ids from the input which may be either
 
 405         a string or a list of strings or ints. Ignores empty value but
 
 406         throws a UserError on anything that cannot be converted to int.
 
 409     if isinstance(ids, str):
 
 410         plist = [s.strip() for s in ids.split(',')]
 
 411     elif isinstance(ids, abc.Sequence):
 
 414         raise UsageError("Parameter 'excluded' needs to be a comma-separated list "
 
 415                          "or a Python list of numbers.")
 
 416     if not all(isinstance(i, int) or
 
 417                (isinstance(i, str) and (not i or i.isdigit())) for i in plist):
 
 418         raise UsageError("Parameter 'excluded' only takes place IDs.")
 
 420     return [int(id) for id in plist if id] or [0]
 
 423 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
 
 424     """ Extract a list of categories. Currently a noop.
 
 429 TParam = TypeVar('TParam', bound='LookupDetails')
 
 432 @dataclasses.dataclass
 
 434     """ Collection of parameters that define which kind of details are
 
 435         returned with a lookup or details result.
 
 437     geometry_output: GeometryFormat = GeometryFormat.NONE
 
 438     """ Add the full geometry of the place to the result. Multiple
 
 439         formats may be selected. Note that geometries can become quite large.
 
 441     address_details: bool = False
 
 442     """ Get detailed information on the places that make up the address
 
 445     linked_places: bool = False
 
 446     """ Get detailed information on the places that link to the result.
 
 448     parented_places: bool = False
 
 449     """ Get detailed information on all places that this place is a parent
 
 450         for, i.e. all places for which it provides the address details.
 
 451         Only POI places can have parents.
 
 453     entrances: bool = False
 
 454     """ Get detailed information about the tagged entrances for the result.
 
 456     keywords: bool = False
 
 457     """ Add information about the search terms used for this place.
 
 459     geometry_simplification: float = 0.0
 
 460     """ Simplification factor for a geometry in degrees WGS. A factor of
 
 461         0.0 means the original geometry is kept. The higher the value, the
 
 462         more the geometry gets simplified.
 
 464     query_stats: Union[QueryStatistics, NoQueryStats] = \
 
 465         dataclasses.field(default_factory=NoQueryStats)
 
 466     """ Optional QueryStatistics object collecting information about
 
 467         runtime behaviour of the call.
 
 471     def from_kwargs(cls: Type[TParam], kwargs: Dict[str, Any]) -> TParam:
 
 472         """ Load the data fields of the class from a dictionary.
 
 473             Unknown entries in the dictionary are ignored, missing ones
 
 474             get the default setting.
 
 476             The function supports type checking and throws a UsageError
 
 477             when the value does not fit.
 
 479         def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
 
 481                 return field.default_factory() \
 
 482                        if field.default_factory != dataclasses.MISSING \
 
 484             if field.metadata and 'transform' in field.metadata:
 
 485                 return field.metadata['transform'](v)
 
 486             if not isinstance(v, field.type):  # type: ignore[arg-type]
 
 487                 raise UsageError(f"Parameter '{field.name}' needs to be of {field.type!s}.")
 
 490         return cls(**{f.name: _check_field(kwargs[f.name], f)
 
 491                       for f in dataclasses.fields(cls) if f.name in kwargs})
 
 494 @dataclasses.dataclass
 
 495 class ReverseDetails(LookupDetails):
 
 496     """ Collection of parameters for the reverse call.
 
 499     max_rank: int = dataclasses.field(default=30,
 
 500                                       metadata={'transform': lambda v: max(0, min(v, 30))})
 
 501     """ Highest address rank to return.
 
 504     layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
 
 505     """ Filter which kind of data to include.
 
 509 @dataclasses.dataclass
 
 510 class SearchDetails(LookupDetails):
 
 511     """ Collection of parameters for the search call.
 
 513     max_results: int = 10
 
 514     """ Maximum number of results to be returned. The actual number of results
 
 518     min_rank: int = dataclasses.field(default=0,
 
 519                                       metadata={'transform': lambda v: max(0, min(v, 30))})
 
 520     """ Lowest address rank to return.
 
 523     max_rank: int = dataclasses.field(default=30,
 
 524                                       metadata={'transform': lambda v: max(0, min(v, 30))})
 
 525     """ Highest address rank to return.
 
 528     layers: Optional[DataLayer] = dataclasses.field(default=None,
 
 529                                                     metadata={'transform': lambda r: r})
 
 530     """ Filter which kind of data to include. When 'None' (the default) then
 
 531         filtering by layers is disabled.
 
 534     countries: List[str] = dataclasses.field(default_factory=list,
 
 535                                              metadata={'transform': format_country})
 
 536     """ Restrict search results to the given countries. An empty list (the
 
 537         default) will disable this filter.
 
 540     excluded: List[int] = dataclasses.field(default_factory=list,
 
 541                                             metadata={'transform': format_excluded})
 
 542     """ List of OSM objects to exclude from the results. Currently only
 
 543         works when the internal place ID is given.
 
 544         An empty list (the default) will disable this filter.
 
 547     viewbox: Optional[Bbox] = dataclasses.field(default=None,
 
 548                                                 metadata={'transform': Bbox.from_param})
 
 549     """ Focus the search on a given map area.
 
 552     bounded_viewbox: bool = False
 
 553     """ Use 'viewbox' as a filter and restrict results to places within the
 
 557     near: Optional[Point] = dataclasses.field(default=None,
 
 558                                               metadata={'transform': Point.from_param})
 
 559     """ Order results by distance to the given point.
 
 562     near_radius: Optional[float] = dataclasses.field(default=None,
 
 563                                                      metadata={'transform': lambda r: r})
 
 564     """ Use near point as a filter and drop results outside the given
 
 565         radius. Radius is given in degrees WSG84.
 
 568     categories: List[Tuple[str, str]] = dataclasses.field(default_factory=list,
 
 569                                                           metadata={'transform': format_categories})
 
 570     """ Restrict search to places with one of the given class/type categories.
 
 571         An empty list (the default) will disable this filter.
 
 574     viewbox_x2: Optional[Bbox] = None
 
 576     def __post_init__(self) -> None:
 
 577         if self.viewbox is not None:
 
 578             xext = (self.viewbox.maxlon - self.viewbox.minlon)/2
 
 579             yext = (self.viewbox.maxlat - self.viewbox.minlat)/2
 
 580             self.viewbox_x2 = Bbox(self.viewbox.minlon - xext, self.viewbox.minlat - yext,
 
 581                                    self.viewbox.maxlon + xext, self.viewbox.maxlat + yext)
 
 583     def restrict_min_max_rank(self, new_min: int, new_max: int) -> None:
 
 584         """ Change the min_rank and max_rank fields to respect the
 
 587         assert new_min <= new_max
 
 588         self.min_rank = max(self.min_rank, new_min)
 
 589         self.max_rank = min(self.max_rank, new_max)
 
 591     def is_impossible(self) -> bool:
 
 592         """ Check if the parameter configuration is contradictionary and
 
 593             cannot yield any results.
 
 595         return (self.min_rank > self.max_rank
 
 596                 or (self.bounded_viewbox
 
 597                     and self.viewbox is not None and self.near is not None
 
 598                     and self.viewbox.contains(self.near))
 
 599                 or (self.layers is not None and not self.layers)
 
 600                 or (self.max_rank <= 4 and
 
 601                     self.layers is not None and not self.layers & DataLayer.ADDRESS))
 
 603     def layer_enabled(self, layer: DataLayer) -> bool:
 
 604         """ Check if the given layer has been chosen. Also returns
 
 605             true when layer restriction has been disabled completely.
 
 607         return self.layers is None or bool(self.layers & layer)
 
 610 @dataclasses.dataclass
 
 611 class EntranceDetails:
 
 612     """ Reference a place by its OSM ID and potentially the basic category.
 
 614         The OSM ID may refer to places in the main table placex and OSM
 
 618     """ The OSM ID of the object.
 
 621     """ The value of the OSM entrance tag (i.e. yes, main, secondary, etc.).
 
 624     """ The location of the entrance node.
 
 626     extratags: Dict[str, str]
 
 627     """ The other tags associated with the entrance node.