]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/api/types.py
add bbox output to lookup results
[nominatim.git] / nominatim / api / types.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Complex datatypes used by the Nominatim API.
9 """
10 from typing import Optional, Union, Tuple, NamedTuple, TypeVar, Type, Dict, \
11                    Any, List, Sequence
12 from collections import abc
13 import dataclasses
14 import enum
15 import math
16 from struct import unpack
17 from binascii import unhexlify
18
19 from nominatim.errors import UsageError
20
21 # pylint: disable=no-member,too-many-boolean-expressions,too-many-instance-attributes
22
23 @dataclasses.dataclass
24 class PlaceID:
25     """ Reference a place by Nominatim's internal ID.
26
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.
31     """
32     place_id: int
33     """
34     The internal ID of the place to reference.
35     """
36
37
38 @dataclasses.dataclass
39 class OsmID:
40     """ Reference a place by its OSM ID and potentially the basic category.
41
42         The OSM ID may refer to places in the main table placex and OSM
43         interpolation lines.
44     """
45     osm_type: str
46     """ OSM type of the object. Must be one of `N`(node), `W`(way) or
47         `R`(relation).
48     """
49     osm_id: int
50     """ The OSM ID of the object.
51     """
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.
58     """
59
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.")
63
64
65 PlaceRef = Union[PlaceID, OsmID]
66
67
68 class Point(NamedTuple):
69     """ A geographic point in WGS84 projection.
70     """
71     x: float
72     y: float
73
74
75     @property
76     def lat(self) -> float:
77         """ Return the latitude of the point.
78         """
79         return self.y
80
81
82     @property
83     def lon(self) -> float:
84         """ Return the longitude of the point.
85         """
86         return self.x
87
88
89     def to_geojson(self) -> str:
90         """ Return the point in GeoJSON format.
91         """
92         return f'{{"type": "Point","coordinates": [{self.x}, {self.y}]}}'
93
94
95     @staticmethod
96     def from_wkb(wkb: Union[str, bytes]) -> 'Point':
97         """ Create a point from EWKB as returned from the database.
98         """
99         if isinstance(wkb, str):
100             wkb = unhexlify(wkb)
101         if len(wkb) != 25:
102             raise ValueError(f"Point wkb has unexpected length {len(wkb)}")
103         if wkb[0] == 0:
104             gtype, srid, x, y = unpack('>iidd', wkb[1:])
105         elif wkb[0] == 1:
106             gtype, srid, x, y = unpack('<iidd', wkb[1:])
107         else:
108             raise ValueError("WKB has unknown endian value.")
109
110         if gtype != 0x20000001:
111             raise ValueError("WKB must be a point geometry.")
112         if srid != 4326:
113             raise ValueError("Only WGS84 WKB supported.")
114
115         return Point(x, y)
116
117
118     @staticmethod
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
123             not correct.
124         """
125         if isinstance(inp, Point):
126             return inp
127
128         seq: Sequence[str]
129         if isinstance(inp, str):
130             seq = inp.split(',')
131         elif isinstance(inp, abc.Sequence):
132             seq = inp
133
134         if len(seq) != 2:
135             raise UsageError('Point parameter needs 2 coordinates.')
136         try:
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
140
141         if x < -180.0 or x > 180.0 or y < -90.0 or y > 90.0:
142             raise UsageError('Point coordinates invalid.')
143
144         return Point(x, y)
145
146
147     def to_wkt(self) -> str:
148         """ Return the WKT representation of the point.
149         """
150         return f'POINT({self.x} {self.y})'
151
152
153
154 AnyPoint = Union[Point, Tuple[float, float]]
155
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'
158
159 class Bbox:
160     """ A bounding box in WGS84 projection.
161
162         The coordinates are available as an array in the 'coord'
163         property in the order (minx, miny, maxx, maxy).
164     """
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
167             projection.
168         """
169         self.coords = (minx, miny, maxx, maxy)
170
171
172     @property
173     def minlat(self) -> float:
174         """ Southern-most latitude, corresponding to the minimum y coordinate.
175         """
176         return self.coords[1]
177
178
179     @property
180     def maxlat(self) -> float:
181         """ Northern-most latitude, corresponding to the maximum y coordinate.
182         """
183         return self.coords[3]
184
185
186     @property
187     def minlon(self) -> float:
188         """ Western-most longitude, corresponding to the minimum x coordinate.
189         """
190         return self.coords[0]
191
192
193     @property
194     def maxlon(self) -> float:
195         """ Eastern-most longitude, corresponding to the maximum x coordinate.
196         """
197         return self.coords[2]
198
199
200     @property
201     def area(self) -> float:
202         """ Return the area of the box in WGS84.
203         """
204         return (self.coords[2] - self.coords[0]) * (self.coords[3] - self.coords[1])
205
206
207     def contains(self, pt: Point) -> bool:
208         """ Check if the point is inside or on the boundary of the box.
209         """
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]
212
213
214     def to_wkt(self) -> str:
215         """ Return the WKT representation of the Bbox. This
216             is a simple polygon with four points.
217         """
218         return 'POLYGON(({0} {1},{0} {3},{2} {3},{2} {1},{0} {1}))'\
219                   .format(*self.coords) # pylint: disable=consider-using-f-string
220
221
222     @staticmethod
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.
226         """
227         if wkb is None:
228             return None
229
230         if isinstance(wkb, str):
231             wkb = unhexlify(wkb)
232
233         if len(wkb) != 97:
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])
239         else:
240             raise ValueError("WKB has wrong header")
241
242         return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
243
244
245     @staticmethod
246     def from_point(pt: Point, buffer: float) -> 'Bbox':
247         """ Return a Bbox around the point with the buffer added to all sides.
248         """
249         return Bbox(pt[0] - buffer, pt[1] - buffer,
250                     pt[0] + buffer, pt[1] + buffer)
251
252
253     @staticmethod
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.
258         """
259         if isinstance(inp, Bbox):
260             return inp
261
262         seq: Sequence[str]
263         if isinstance(inp, str):
264             seq = inp.split(',')
265         elif isinstance(inp, abc.Sequence):
266             seq = inp
267
268         if len(seq) != 4:
269             raise UsageError('Bounding box parameter needs 4 coordinates.')
270         try:
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
274
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))
279
280         if x1 == x2 or y1 == y2:
281             raise UsageError('Bounding box with invalid parameters.')
282
283         return Bbox(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
284
285
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.
291     """
292     NONE = 0
293     """ No geometry requested. Alias for a empty flag.
294     """
295     GEOJSON = enum.auto()
296     """
297     [GeoJSON](https://geojson.org/) format
298     """
299     KML = enum.auto()
300     """
301     [KML](https://en.wikipedia.org/wiki/Keyhole_Markup_Language) format
302     """
303     SVG = enum.auto()
304     """
305     [SVG](http://www.w3.org/TR/SVG/paths.html) format
306     """
307     TEXT = enum.auto()
308     """
309     [WKT](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) format
310     """
311
312
313 class DataLayer(enum.Flag):
314     """ The `DataLayer` flag type defines the layers that can be selected
315         for reverse and forward search.
316     """
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.
322     """
323     POI = enum.auto()
324     """ Layer for points of interest like shops, restaurants but also
325         recycling bins or postboxes.
326     """
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.
332     """
333     NATURAL = enum.auto()
334     """ Layer with natural features like rivers, lakes and mountains.
335     """
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.
340     """
341
342
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
346         a two-letter string.
347     """
348     clist: Sequence[str]
349     if isinstance(cc, str):
350         clist = cc.split(',')
351     elif isinstance(cc, abc.Sequence):
352         clist = cc
353     else:
354         raise UsageError("Parameter 'country' needs to be a comma-separated list "
355                          "or a Python list of strings.")
356
357     return [cc.lower() for cc in clist if isinstance(cc, str) and len(cc) == 2]
358
359
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.
364     """
365     plist: Sequence[str]
366     if isinstance(ids, str):
367         plist = [s.strip() for s in ids.split(',')]
368     elif isinstance(ids, abc.Sequence):
369         plist = ids
370     else:
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.")
376
377     return [int(id) for id in plist if id] or [0]
378
379
380 def format_categories(categories: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
381     """ Extract a list of categories. Currently a noop.
382     """
383     return categories
384
385 TParam = TypeVar('TParam', bound='LookupDetails') # pylint: disable=invalid-name
386
387 @dataclasses.dataclass
388 class LookupDetails:
389     """ Collection of parameters that define the amount of details
390         returned with a lookup or details result.
391     """
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.
395     """
396     address_details: bool = False
397     """ Get detailed information on the places that make up the address
398         for the result.
399     """
400     linked_places: bool = False
401     """ Get detailed information on the places that link to the result.
402     """
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.
407     """
408     keywords: bool = False
409     """ Add information about the search terms used for this place.
410     """
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.
415     """
416
417     @classmethod
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.
422
423             The function supports type checking and throws a UsageError
424             when the value does not fit.
425         """
426         def _check_field(v: Any, field: 'dataclasses.Field[Any]') -> Any:
427             if v is None:
428                 return field.default_factory() \
429                        if field.default_factory != dataclasses.MISSING \
430                        else field.default
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}.")
435             return v
436
437         return cls(**{f.name: _check_field(kwargs[f.name], f)
438                       for f in dataclasses.fields(cls) if f.name in kwargs})
439
440
441 @dataclasses.dataclass
442 class ReverseDetails(LookupDetails):
443     """ Collection of parameters for the reverse call.
444     """
445     max_rank: int = dataclasses.field(default=30,
446                                       metadata={'transform': lambda v: max(0, min(v, 30))}
447                                      )
448     """ Highest address rank to return.
449     """
450     layers: DataLayer = DataLayer.ADDRESS | DataLayer.POI
451     """ Filter which kind of data to include.
452     """
453
454 @dataclasses.dataclass
455 class SearchDetails(LookupDetails):
456     """ Collection of parameters for the search call.
457     """
458     max_results: int = 10
459     """ Maximum number of results to be returned. The actual number of results
460         may be less.
461     """
462     min_rank: int = dataclasses.field(default=0,
463                                       metadata={'transform': lambda v: max(0, min(v, 30))}
464                                      )
465     """ Lowest address rank to return.
466     """
467     max_rank: int = dataclasses.field(default=30,
468                                       metadata={'transform': lambda v: max(0, min(v, 30))}
469                                      )
470     """ Highest address rank to return.
471     """
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.
476     """
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.
481     """
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.
487     """
488     viewbox: Optional[Bbox] = dataclasses.field(default=None,
489                                                 metadata={'transform': Bbox.from_param})
490     """ Focus the search on a given map area.
491     """
492     bounded_viewbox: bool = False
493     """ Use 'viewbox' as a filter and restrict results to places within the
494         given area.
495     """
496     near: Optional[Point] = dataclasses.field(default=None,
497                                               metadata={'transform': Point.from_param})
498     """ Order results by distance to the given point.
499     """
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.
504     """
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.
509     """
510     viewbox_x2: Optional[Bbox] = None
511
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)
518
519
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
522             given boundaries.
523         """
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)
527
528
529     def is_impossible(self) -> bool:
530         """ Check if the parameter configuration is contradictionary and
531             cannot yield any results.
532         """
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)
538
539
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.
543         """
544         return self.layers is None or bool(self.layers & layer)