guideCity/backend/app/services/nearby_search.py

83 lines
2.5 KiB
Python
Raw Normal View History

"""Nearby-place search with iterative radius expansion.
Sorts candidate places by distance ascending and grows the search radius by
``step_m`` until at least ``min_results`` places are found (or ``max_radius_m``
is hit, as a safety cap against runaway loops in sparsely-covered areas).
Reserved for a future v2 extension: ``speed``/``heading`` inputs to bias
results toward the user's direction of travel and to pick a shorter content
``length`` automatically when moving fast through a dense cluster of places.
Not implemented in this iteration callers should not pass them yet.
"""
from dataclasses import dataclass
from geoalchemy2 import Geometry
from sqlalchemy import Row, and_, cast, func, select
from sqlalchemy.orm import Session
from app.models.city import City
from app.models.enums import ContentLanguage, ContentLength
from app.models.place import Place
from app.models.place_content import PlaceContent
@dataclass
class NearbyResult:
search_radius_m: int
rows: list[Row]
def find_nearby(
db: Session,
*,
lat: float,
lon: float,
language: ContentLanguage,
length: ContentLength,
city_slug: str | None = None,
min_results: int = 5,
initial_radius_m: int = 300,
step_m: int = 200,
max_radius_m: int = 5000,
) -> NearbyResult:
city_id: int | None = None
if city_slug is not None:
city_id = db.scalar(select(City.id).where(City.slug == city_slug))
origin = func.ST_GeogFromText(f"SRID=4326;POINT({lon} {lat})")
distance_expr = func.ST_Distance(Place.location, origin)
base_stmt = (
select(
Place,
PlaceContent,
distance_expr.label("distance_m"),
func.ST_X(cast(Place.location, Geometry)).label("lon"),
func.ST_Y(cast(Place.location, Geometry)).label("lat"),
)
.join(
PlaceContent,
and_(
PlaceContent.place_id == Place.id,
PlaceContent.language == language,
PlaceContent.length == length,
),
)
.where(Place.is_active.is_(True))
.order_by(distance_expr)
)
if city_id is not None:
base_stmt = base_stmt.where(Place.city_id == city_id)
radius = initial_radius_m
rows: list[Row] = []
while True:
stmt = base_stmt.where(func.ST_DWithin(Place.location, origin, radius))
rows = db.execute(stmt).all()
if len(rows) >= min_results or radius >= max_radius_m:
break
radius += step_m
return NearbyResult(search_radius_m=radius, rows=rows)