]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_api/reverse.py
avoid extra query for finding closest housenumber in reverse
[nominatim.git] / src / nominatim_api / reverse.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) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Implementation of reverse geocoding.
9 """
10 from typing import Optional, List, Callable, Type, Tuple, Dict, Any, cast, Union
11 import functools
12
13 import sqlalchemy as sa
14
15 from .typing import SaColumn, SaSelect, SaFromClause, SaLabel, SaRow, \
16                     SaBind, SaLambdaSelect
17 from .sql.sqlalchemy_types import Geometry
18 from .connection import SearchConnection
19 from . import results as nres
20 from .logging import log
21 from .types import AnyPoint, DataLayer, ReverseDetails, GeometryFormat, Bbox
22
23
24 RowFunc = Callable[[SaRow, Type[nres.ReverseResult]], nres.ReverseResult]
25
26 WKT_PARAM: SaBind = sa.bindparam('wkt', type_=Geometry)
27 MAX_RANK_PARAM: SaBind = sa.bindparam('max_rank')
28
29
30 def no_index(expr: SaColumn) -> SaColumn:
31     """ Wrap the given expression, so that the query planner will
32         refrain from using the expression for index lookup.
33     """
34     return sa.func.coalesce(sa.null(), expr)
35
36
37 def _select_from_placex(t: SaFromClause, use_wkt: bool = True) -> SaSelect:
38     """ Create a select statement with the columns relevant for reverse
39         results.
40     """
41     if not use_wkt:
42         distance = t.c.distance
43         centroid = t.c.centroid
44     else:
45         distance = t.c.geometry.ST_Distance(WKT_PARAM)
46         centroid = sa.case((t.c.geometry.is_line_like(), t.c.geometry.ST_ClosestPoint(WKT_PARAM)),
47                            else_=t.c.centroid).label('centroid')
48
49     return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
50                      t.c.class_, t.c.type,
51                      t.c.address, t.c.extratags,
52                      t.c.housenumber, t.c.postcode, t.c.country_code,
53                      t.c.importance, t.c.wikipedia,
54                      t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
55                      centroid,
56                      t.c.linked_place_id, t.c.admin_level,
57                      distance.label('distance'),
58                      t.c.geometry.ST_Expand(0).label('bbox'))
59
60
61 def _interpolated_housenumber(table: SaFromClause) -> SaLabel:
62     return sa.cast(table.c.startnumber
63                    + sa.func.round(((table.c.endnumber - table.c.startnumber) * table.c.position)
64                                    / table.c.step) * table.c.step,
65                    sa.Integer).label('housenumber')
66
67
68 def _interpolated_position(table: SaFromClause) -> SaLabel:
69     fac = sa.cast(table.c.step, sa.Float) / (table.c.endnumber - table.c.startnumber)
70     rounded_pos = sa.func.round(table.c.position / fac) * fac
71     return sa.case(
72         (table.c.endnumber == table.c.startnumber, table.c.linegeo.ST_Centroid()),
73         else_=table.c.linegeo.ST_LineInterpolatePoint(rounded_pos)).label('centroid')
74
75
76 def _locate_interpolation(table: SaFromClause) -> SaLabel:
77     """ Given a position, locate the closest point on the line.
78     """
79     return sa.case((table.c.linegeo.is_line_like(),
80                     table.c.linegeo.ST_LineLocatePoint(WKT_PARAM)),
81                    else_=0).label('position')
82
83
84 def _get_closest(*rows: Optional[SaRow]) -> Optional[SaRow]:
85     return min(rows, key=lambda row: 1000 if row is None else row.distance)
86
87
88 class ReverseGeocoder:
89     """ Class implementing the logic for looking up a place from a
90         coordinate.
91     """
92
93     def __init__(self, conn: SearchConnection, params: ReverseDetails,
94                  restrict_to_country_areas: bool = False) -> None:
95         self.conn = conn
96         self.params = params
97         self.restrict_to_country_areas = restrict_to_country_areas
98
99         self.bind_params: Dict[str, Any] = {'max_rank': params.max_rank}
100
101     @property
102     def max_rank(self) -> int:
103         """ Return the maximum configured rank.
104         """
105         return self.params.max_rank
106
107     def has_geometries(self) -> bool:
108         """ Check if any geometries are requested.
109         """
110         return bool(self.params.geometry_output)
111
112     def layer_enabled(self, *layer: DataLayer) -> bool:
113         """ Return true when any of the given layer types are requested.
114         """
115         return any(self.params.layers & ly for ly in layer)
116
117     def layer_disabled(self, *layer: DataLayer) -> bool:
118         """ Return true when none of the given layer types is requested.
119         """
120         return not any(self.params.layers & ly for ly in layer)
121
122     def has_feature_layers(self) -> bool:
123         """ Return true if any layer other than ADDRESS or POI is requested.
124         """
125         return self.layer_enabled(DataLayer.RAILWAY, DataLayer.MANMADE, DataLayer.NATURAL)
126
127     def _add_geometry_columns(self, sql: SaLambdaSelect, col: SaColumn) -> SaSelect:
128         out = []
129
130         if self.params.geometry_simplification > 0.0:
131             col = sa.func.ST_SimplifyPreserveTopology(col, self.params.geometry_simplification)
132
133         if self.params.geometry_output & GeometryFormat.GEOJSON:
134             out.append(sa.func.ST_AsGeoJSON(col, 7).label('geometry_geojson'))
135         if self.params.geometry_output & GeometryFormat.TEXT:
136             out.append(sa.func.ST_AsText(col).label('geometry_text'))
137         if self.params.geometry_output & GeometryFormat.KML:
138             out.append(sa.func.ST_AsKML(col, 7).label('geometry_kml'))
139         if self.params.geometry_output & GeometryFormat.SVG:
140             out.append(sa.func.ST_AsSVG(col, 0, 7).label('geometry_svg'))
141
142         return sql.add_columns(*out)
143
144     def _filter_by_layer(self, table: SaFromClause) -> SaColumn:
145         if self.layer_enabled(DataLayer.MANMADE):
146             exclude = []
147             if self.layer_disabled(DataLayer.RAILWAY):
148                 exclude.append('railway')
149             if self.layer_disabled(DataLayer.NATURAL):
150                 exclude.extend(('natural', 'water', 'waterway'))
151             return table.c.class_.not_in(tuple(exclude))
152
153         include = []
154         if self.layer_enabled(DataLayer.RAILWAY):
155             include.append('railway')
156         if self.layer_enabled(DataLayer.NATURAL):
157             include.extend(('natural', 'water', 'waterway'))
158         return table.c.class_.in_(tuple(include))
159
160     async def _find_closest_street_or_pois(self, distance: float,
161                                            fuzziness: float) -> list[SaRow]:
162         """ Look up the closest rank 26+ place in the database.
163             The function finds the object that is closest to the reverse
164             search point as well as all objects within 'fuzziness' distance
165             to that best result.
166         """
167         t = self.conn.t.placex
168
169         # PostgreSQL must not get the distance as a parameter because
170         # there is a danger it won't be able to properly estimate index use
171         # when used with prepared statements
172         diststr = sa.text(f"{distance + fuzziness}")
173
174         sql: SaLambdaSelect = sa.lambda_stmt(
175             lambda: _select_from_placex(t)
176             .where(t.c.geometry.within_distance(WKT_PARAM, diststr))
177             .where(t.c.indexed_status == 0)
178             .where(t.c.linked_place_id == None)
179             .where(sa.or_(sa.not_(t.c.geometry.is_area()),
180                           t.c.centroid.ST_Distance(WKT_PARAM) < diststr)))
181
182         if self.has_geometries():
183             sql = self._add_geometry_columns(sql, t.c.geometry)
184
185         restrict: List[Union[SaColumn, Callable[[], SaColumn]]] = []
186
187         if self.layer_enabled(DataLayer.ADDRESS):
188             max_rank = min(29, self.max_rank)
189             restrict.append(lambda: no_index(t.c.rank_address).between(26, max_rank))
190             if self.max_rank == 30:
191                 restrict.append(lambda: sa.func.IsAddressPoint(t))
192         if self.layer_enabled(DataLayer.POI) and self.max_rank == 30:
193             restrict.append(lambda: sa.and_(no_index(t.c.rank_search) == 30,
194                                             t.c.class_.not_in(('place', 'building')),
195                                             sa.not_(t.c.geometry.is_line_like())))
196         if self.has_feature_layers():
197             restrict.append(sa.and_(no_index(t.c.rank_search).between(26, MAX_RANK_PARAM),
198                                     no_index(t.c.rank_address) == 0,
199                                     self._filter_by_layer(t)))
200
201         if not restrict:
202             return []
203
204         inner = sql.where(sa.or_(*restrict)) \
205                    .add_columns(t.c.geometry.label('_geometry')) \
206                    .subquery()
207
208         # Use a window function to get the closest results to the best result.
209         windowed = sa.select(inner,
210                              sa.func.first_value(inner.c.distance)
211                                     .over(order_by=inner.c.distance)
212                                     .label('_min_distance'),
213                              sa.func.first_value(inner.c._geometry.ST_ClosestPoint(WKT_PARAM))
214                                     .over(order_by=inner.c.distance)
215                                     .label('_closest_point'),
216                              sa.func.first_value(sa.case((sa.or_(inner.c.rank_search <= 27,
217                                                                  inner.c.osm_type == 'N'), None),
218                                                          else_=inner.c._geometry))
219                                     .over(order_by=inner.c.distance)
220                                     .label('_best_geometry')) \
221                      .subquery()
222
223         outer = sa.select(*(c for c in windowed.c if not c.key.startswith('_')),
224                           windowed.c.centroid.ST_Distance(windowed.c._closest_point)
225                                              .label('best_distance'),
226                           sa.case((sa.or_(windowed.c._best_geometry == None,
227                                           windowed.c.rank_search <= 27,
228                                           windowed.c.osm_type != 'N'), False),
229                                   else_=windowed.c.centroid.ST_CoveredBy(windowed.c._best_geometry))
230                             .label('best_inside')) \
231                   .where(windowed.c.distance < windowed.c._min_distance + fuzziness) \
232                   .order_by(windowed.c.distance)
233
234         return list(await self.conn.execute(outer, self.bind_params))
235
236     async def _find_housenumber_for_street(self, parent_place_id: int) -> Optional[SaRow]:
237         t = self.conn.t.placex
238
239         def _base_query() -> SaSelect:
240             return _select_from_placex(t)\
241                 .where(t.c.geometry.within_distance(WKT_PARAM, 0.001))\
242                 .where(t.c.parent_place_id == parent_place_id)\
243                 .where(sa.func.IsAddressPoint(t))\
244                 .where(t.c.indexed_status == 0)\
245                 .where(t.c.linked_place_id == None)\
246                 .order_by('distance')\
247                 .limit(1)
248
249         sql: SaLambdaSelect
250         if self.has_geometries():
251             sql = self._add_geometry_columns(_base_query(), t.c.geometry)
252         else:
253             sql = sa.lambda_stmt(_base_query)
254
255         return (await self.conn.execute(sql, self.bind_params)).one_or_none()
256
257     async def _find_interpolation_for_street(self, parent_place_id: Optional[int],
258                                              distance: float) -> Optional[SaRow]:
259         t = self.conn.t.osmline
260
261         sql = sa.select(t,
262                         t.c.linegeo.ST_Distance(WKT_PARAM).label('distance'),
263                         _locate_interpolation(t))\
264                 .where(t.c.linegeo.within_distance(WKT_PARAM, distance))\
265                 .where(t.c.startnumber != None)\
266                 .order_by('distance')\
267                 .limit(1)
268
269         if parent_place_id is not None:
270             sql = sql.where(t.c.parent_place_id == parent_place_id)
271
272         inner = sql.subquery('ipol')
273
274         sql = sa.select(inner.c.place_id, inner.c.osm_id,
275                         inner.c.parent_place_id, inner.c.address,
276                         _interpolated_housenumber(inner),
277                         _interpolated_position(inner),
278                         inner.c.postcode, inner.c.country_code,
279                         inner.c.distance)
280
281         if self.has_geometries():
282             sub = sql.subquery('geom')
283             sql = self._add_geometry_columns(sa.select(sub), sub.c.centroid)
284
285         return (await self.conn.execute(sql, self.bind_params)).one_or_none()
286
287     async def _find_tiger_number_for_street(self, parent_place_id: int) -> Optional[SaRow]:
288         t = self.conn.t.tiger
289
290         def _base_query() -> SaSelect:
291             inner = sa.select(t,
292                               t.c.linegeo.ST_Distance(WKT_PARAM).label('distance'),
293                               _locate_interpolation(t))\
294                       .where(t.c.linegeo.within_distance(WKT_PARAM, 0.001))\
295                       .where(t.c.parent_place_id == parent_place_id)\
296                       .order_by('distance')\
297                       .limit(1)\
298                       .subquery('tiger')
299
300             return sa.select(inner.c.place_id,
301                              inner.c.parent_place_id,
302                              _interpolated_housenumber(inner),
303                              _interpolated_position(inner),
304                              inner.c.postcode,
305                              inner.c.distance)
306
307         sql: SaLambdaSelect
308         if self.has_geometries():
309             sub = _base_query().subquery('geom')
310             sql = self._add_geometry_columns(sa.select(sub), sub.c.centroid)
311         else:
312             sql = sa.lambda_stmt(_base_query)
313
314         return (await self.conn.execute(sql, self.bind_params)).one_or_none()
315
316     async def lookup_street_poi(self) -> Tuple[Optional[SaRow], RowFunc]:
317         """ Find a street or POI/address for the given WKT point.
318         """
319         log().section('Reverse lookup on street/address level')
320         row_func: RowFunc = nres.create_from_placex_row
321         distance = 0.006
322
323         result = None
324         hnr_distance = None
325         parent_street = None
326         for row in await self._find_closest_street_or_pois(distance, 0.001):
327             if result is None:
328                 log().var_dump('Closest result', row)
329                 result = row
330                 if self.max_rank > 27 \
331                         and self.layer_enabled(DataLayer.ADDRESS) \
332                         and result.rank_address <= 27:
333                     parent_street = result.place_id
334                     distance = 0.001
335                 else:
336                     distance = row.distance
337             # If the closest result was a street but an address was requested,
338             # see if we can refine the result with a housenumber closeby.
339             elif parent_street is not None \
340                     and row.rank_address > 27 \
341                     and row.best_distance < 0.001 \
342                     and (hnr_distance is None or hnr_distance > row.best_distance) \
343                     and row.parent_place_id == parent_street:
344                 log().var_dump('Housenumber to closest result', row)
345                 result = row
346                 hnr_distance = row.best_distance
347                 distance = row.distance
348             # If the closest object is inside an area, then check if there is
349             # a POI nearby and return that with preference.
350             elif result.osm_type != 'N' and result.rank_search > 27 \
351                     and result.distance == 0 \
352                     and row.best_inside:
353                 log().var_dump('POI near closest result area', row)
354                 result = row
355                 break  # it can't get better than that, everything else is farther away
356
357         # For the US also check the TIGER data, when no housenumber/POI was found.
358         if result is not None and parent_street is not None and hnr_distance is None \
359                 and result.country_code == 'us':
360             log().comment('Find TIGER housenumber for street')
361             addr_row = await self._find_tiger_number_for_street(parent_street)
362             log().var_dump('Result (street Tiger housenumber)', addr_row)
363
364             if addr_row is not None:
365                 row_func = cast(RowFunc,
366                                 functools.partial(nres.create_from_tiger_row,
367                                                   osm_type=row.osm_type,
368                                                   osm_id=row.osm_id))
369                 result = addr_row
370
371         # Check for an interpolation that is either closer than our result
372         # or belongs to a close street found.
373         # No point in doing this when the result is already inside a building,
374         # i.e. when the distance is already 0.
375         if self.max_rank > 27 and self.layer_enabled(DataLayer.ADDRESS) and distance > 0:
376             log().comment('Find interpolation for street')
377             addr_row = await self._find_interpolation_for_street(parent_street, distance)
378             log().var_dump('Result (street interpolation)', addr_row)
379             if addr_row is not None:
380                 return addr_row, nres.create_from_osmline_row
381
382         return result, row_func
383
384     async def _lookup_area_address(self) -> Optional[SaRow]:
385         """ Lookup large addressable areas for the given WKT point.
386         """
387         log().comment('Reverse lookup by larger address area features')
388         t = self.conn.t.placex
389
390         def _base_query() -> SaSelect:
391             # The inner SQL brings results in the right order, so that
392             # later only a minimum of results needs to be checked with ST_Contains.
393             inner = sa.select(t, sa.literal(0.0).label('distance'))\
394                       .where(t.c.rank_search.between(5, MAX_RANK_PARAM))\
395                       .where(t.c.rank_address != 5)\
396                       .where(t.c.rank_address != 11)\
397                       .where(t.c.geometry.intersects(WKT_PARAM))\
398                       .where(sa.func.PlacexGeometryReverseLookuppolygon())\
399                       .order_by(sa.desc(t.c.rank_search))\
400                       .limit(50)\
401                       .subquery('area')
402
403             return _select_from_placex(inner, False)\
404                 .where(inner.c.geometry.ST_Contains(WKT_PARAM))\
405                 .order_by(sa.desc(inner.c.rank_search))\
406                 .limit(1)
407
408         sql: SaLambdaSelect = sa.lambda_stmt(_base_query)
409         if self.has_geometries():
410             sql = self._add_geometry_columns(sql, sa.literal_column('area.geometry'))
411
412         address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
413         log().var_dump('Result (area)', address_row)
414
415         if address_row is not None and address_row.rank_search < self.max_rank:
416             log().comment('Search for better matching place nodes inside the area')
417
418             address_rank = address_row.rank_search
419             address_id = address_row.place_id
420
421             def _place_inside_area_query() -> SaSelect:
422                 inner = \
423                     sa.select(t, t.c.geometry.ST_Distance(WKT_PARAM).label('distance'))\
424                     .where(t.c.rank_search > address_rank)\
425                     .where(t.c.rank_search <= MAX_RANK_PARAM)\
426                     .where(t.c.indexed_status == 0)\
427                     .where(sa.func.IntersectsReverseDistance(t, WKT_PARAM))\
428                     .order_by(sa.desc(t.c.rank_search))\
429                     .limit(50)\
430                     .subquery('places')
431
432                 touter = t.alias('outer')
433                 return _select_from_placex(inner, False)\
434                     .join(touter, touter.c.geometry.ST_Contains(inner.c.geometry))\
435                     .where(touter.c.place_id == address_id)\
436                     .where(sa.func.IsBelowReverseDistance(inner.c.distance, inner.c.rank_search))\
437                     .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
438                     .limit(1)
439
440             if self.has_geometries():
441                 sql = self._add_geometry_columns(_place_inside_area_query(),
442                                                  sa.literal_column('places.geometry'))
443             else:
444                 sql = sa.lambda_stmt(_place_inside_area_query)
445
446             place_address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
447             log().var_dump('Result (place node)', place_address_row)
448
449             if place_address_row is not None:
450                 return place_address_row
451
452         return address_row
453
454     async def _lookup_area_others(self) -> Optional[SaRow]:
455         t = self.conn.t.placex
456
457         inner = sa.select(t, t.c.geometry.ST_Distance(WKT_PARAM).label('distance'))\
458                   .where(t.c.rank_address == 0)\
459                   .where(t.c.rank_search.between(5, MAX_RANK_PARAM))\
460                   .where(t.c.name != None)\
461                   .where(t.c.indexed_status == 0)\
462                   .where(t.c.linked_place_id == None)\
463                   .where(self._filter_by_layer(t))\
464                   .where(t.c.geometry.intersects(sa.func.ST_Expand(WKT_PARAM, 0.007)))\
465                   .order_by(sa.desc(t.c.rank_search))\
466                   .order_by('distance')\
467                   .limit(50)\
468                   .subquery()
469
470         sql = _select_from_placex(inner, False)\
471             .where(sa.or_(sa.not_(inner.c.geometry.is_area()),
472                           inner.c.geometry.ST_Contains(WKT_PARAM)))\
473             .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
474             .limit(1)
475
476         if self.has_geometries():
477             sql = self._add_geometry_columns(sql, inner.c.geometry)
478
479         row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
480         log().var_dump('Result (non-address feature)', row)
481
482         return row
483
484     async def lookup_area(self) -> Optional[SaRow]:
485         """ Lookup large areas for the current search.
486         """
487         log().section('Reverse lookup by larger area features')
488
489         if self.layer_enabled(DataLayer.ADDRESS):
490             address_row = await self._lookup_area_address()
491         else:
492             address_row = None
493
494         if self.has_feature_layers():
495             other_row = await self._lookup_area_others()
496         else:
497             other_row = None
498
499         return _get_closest(address_row, other_row)
500
501     async def lookup_country_codes(self) -> List[str]:
502         """ Lookup the country for the current search.
503         """
504         log().section('Reverse lookup by country code')
505         t = self.conn.t.country_grid
506         sql = sa.select(t.c.country_code).distinct()\
507                 .where(t.c.geometry.ST_Contains(WKT_PARAM))
508
509         ccodes = [cast(str, r[0]) for r in await self.conn.execute(sql, self.bind_params)]
510         log().var_dump('Country codes', ccodes)
511         return ccodes
512
513     async def lookup_country(self, ccodes: List[str]) -> Tuple[Optional[SaRow], RowFunc]:
514         """ Lookup the country for the current search.
515         """
516         row_func = nres.create_from_placex_row
517         if not ccodes:
518             ccodes = await self.lookup_country_codes()
519
520         if not ccodes:
521             return None, row_func
522
523         t = self.conn.t.placex
524         if self.max_rank > 4:
525             log().comment('Search for place nodes in country')
526
527             def _base_query() -> SaSelect:
528                 inner = sa.select(t, t.c.geometry.ST_Distance(WKT_PARAM).label('distance'))\
529                           .where(t.c.rank_search > 4)\
530                           .where(t.c.rank_search <= MAX_RANK_PARAM)\
531                           .where(t.c.indexed_status == 0)\
532                           .where(t.c.country_code.in_(ccodes))\
533                           .where(sa.func.IntersectsReverseDistance(t, WKT_PARAM))\
534                           .order_by(sa.desc(t.c.rank_search))\
535                           .limit(50)\
536                           .subquery('area')
537
538                 return _select_from_placex(inner, False)\
539                     .where(sa.func.IsBelowReverseDistance(inner.c.distance, inner.c.rank_search))\
540                     .order_by(sa.desc(inner.c.rank_search), inner.c.distance)\
541                     .limit(1)
542
543             sql: SaLambdaSelect
544             if self.has_geometries():
545                 sql = self._add_geometry_columns(_base_query(),
546                                                  sa.literal_column('area.geometry'))
547             else:
548                 sql = sa.lambda_stmt(_base_query)
549
550             address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
551             log().var_dump('Result (addressable place node)', address_row)
552         else:
553             address_row = None
554
555         if address_row is None:
556             # Still nothing, then return a country with the appropriate country code.
557             def _country_base_query() -> SaSelect:
558                 return _select_from_placex(t)\
559                          .where(t.c.country_code.in_(ccodes))\
560                          .where(t.c.rank_address == 4)\
561                          .where(t.c.rank_search == 4)\
562                          .where(t.c.linked_place_id == None)\
563                          .order_by('distance')\
564                          .limit(1)
565
566             if self.has_geometries():
567                 sql = self._add_geometry_columns(_country_base_query(), t.c.geometry)
568             else:
569                 sql = sa.lambda_stmt(_country_base_query)
570
571             address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
572
573         if address_row is None:
574             # finally fall back to country table
575             t = self.conn.t.country_name
576             tgrid = self.conn.t.country_grid
577
578             sql = sa.select(tgrid.c.country_code,
579                             tgrid.c.geometry.ST_Centroid().ST_Collect().ST_Centroid()
580                                  .label('centroid'),
581                             tgrid.c.geometry.ST_Collect().ST_Expand(0).label('bbox'))\
582                     .where(tgrid.c.country_code.in_(ccodes))\
583                     .group_by(tgrid.c.country_code)
584
585             sub = sql.subquery('grid')
586             sql = sa.select(t.c.country_code,
587                             t.c.name.merge(t.c.derived_name).label('name'),
588                             sub.c.centroid, sub.c.bbox)\
589                     .join(sub, t.c.country_code == sub.c.country_code)\
590                     .order_by(t.c.country_code)\
591                     .limit(1)
592
593             sql = self._add_geometry_columns(sql, sub.c.centroid)
594
595             address_row = (await self.conn.execute(sql, self.bind_params)).one_or_none()
596             row_func = nres.create_from_country_row
597
598         return address_row, row_func
599
600     async def lookup(self, coord: AnyPoint) -> Optional[nres.ReverseResult]:
601         """ Look up a single coordinate. Returns the place information,
602             if a place was found near the coordinates or None otherwise.
603         """
604         log().function('reverse_lookup', coord=coord, params=self.params)
605
606         self.bind_params['wkt'] = f'POINT({coord[0]} {coord[1]})'
607
608         row: Optional[SaRow] = None
609         row_func: RowFunc = nres.create_from_placex_row
610
611         if self.max_rank >= 26:
612             row, tmp_row_func = await self.lookup_street_poi()
613             if row is not None:
614                 row_func = tmp_row_func
615
616         if row is None:
617             if self.restrict_to_country_areas:
618                 ccodes = await self.lookup_country_codes()
619                 if not ccodes:
620                     return None
621             else:
622                 ccodes = []
623
624             if self.max_rank > 4:
625                 row = await self.lookup_area()
626             if row is None and self.layer_enabled(DataLayer.ADDRESS):
627                 row, row_func = await self.lookup_country(ccodes)
628
629         if row is None:
630             return None
631
632         result = row_func(row, nres.ReverseResult)
633         result.distance = getattr(row,  'distance', 0)
634         if hasattr(row, 'bbox'):
635             result.bbox = Bbox.from_wkb(row.bbox)
636         await nres.add_result_details(self.conn, [result], self.params)
637
638         return result