mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-08-26 17:31:37 +00:00
perf(offroute): query_radius uses k-NN <-> ordering for index-supported nearest-neighbor
The old ST_DWithin(50km) scan returned ~226k candidate points near dense areas before sorting (~3-10s/call). Replace with PostGIS k-NN ordering: ORDER BY geom::geography <-> point LIMIT k, which the existing GiST index on (geom::geography) walks nearest-first and stops after K rows. SELECT still computes ST_Distance AS distance_m so callers see real meters. radius_km is kept as a Python soft cap applied after fetch (drops rows beyond it), preserving the caller expanded-radius fallback (now effectively a no-op). 2 tests: SQL contains <-> + LIMIT and no ST_DWithin; radius_km soft cap filters beyond-cap rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
23ed09afdf
commit
c8e2568f64
2 changed files with 68 additions and 14 deletions
|
|
@ -255,23 +255,31 @@ class EntryPointIndex:
|
|||
limit: int = 50
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Find entry points within radius_km of (lat, lon).
|
||||
Uses PostGIS ST_DWithin with geography cast for meter-accurate distance.
|
||||
Find the nearest entry points to (lat, lon), ordered by true geodesic distance.
|
||||
|
||||
Uses PostGIS k-NN ordering (the geography ``<->`` operator), which is
|
||||
index-assisted by the GiST index on ``(geom::geography)``: it walks the index
|
||||
nearest-first and stops after ``limit`` rows, instead of scanning every point
|
||||
inside a radius (the old ST_DWithin approach returned ~226k candidates near
|
||||
dense areas before sorting). ``radius_km`` is retained as a *soft cap* applied
|
||||
in Python after the fetch — rows beyond it are dropped, so callers'
|
||||
expanded-radius fallback still works (though it is now effectively a no-op,
|
||||
since k-NN already returns the globally nearest K regardless of radius).
|
||||
"""
|
||||
if not self.table_exists():
|
||||
return []
|
||||
|
||||
conn = self._get_conn()
|
||||
radius_m = radius_km * 1000
|
||||
|
||||
# Build query with optional highway filter
|
||||
# SELECT distance_m needs the point; optional highway filter; ORDER BY <-> needs
|
||||
# the point again; then LIMIT. Param order follows the placeholders top-to-bottom.
|
||||
highway_filter = ""
|
||||
params = [lon, lat, lon, lat, radius_m]
|
||||
params = [lon, lat]
|
||||
if valid_highways:
|
||||
placeholders = ','.join(['%s'] * len(valid_highways))
|
||||
highway_filter = f"AND highway_class IN ({placeholders})"
|
||||
highway_filter = f"WHERE highway_class IN ({placeholders})"
|
||||
params.extend(list(valid_highways))
|
||||
params.append(limit)
|
||||
params.extend([lon, lat, limit])
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
|
|
@ -286,19 +294,18 @@ class EntryPointIndex:
|
|||
ST_SetSRID(ST_Point(%s, %s), 4326)::geography
|
||||
) as distance_m
|
||||
FROM entry_points
|
||||
WHERE ST_DWithin(
|
||||
geom::geography,
|
||||
ST_SetSRID(ST_Point(%s, %s), 4326)::geography,
|
||||
%s
|
||||
)
|
||||
{highway_filter}
|
||||
ORDER BY distance_m
|
||||
ORDER BY geom::geography <-> ST_SetSRID(ST_Point(%s, %s), 4326)::geography
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(query, params)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
|
||||
# radius_km soft cap (backward compat): drop rows beyond it.
|
||||
radius_m = radius_km * 1000
|
||||
return [r for r in rows if r["distance_m"] <= radius_m]
|
||||
|
||||
def build_index(self, osm_pbf_path: Path = None) -> Dict:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -512,3 +512,50 @@ def test_has_entry_points_empty(monkeypatch):
|
|||
def test_has_entry_points_rows(monkeypatch):
|
||||
idx = _bare_index(monkeypatch, table_exists=True, row=(True,))
|
||||
assert idx.has_entry_points() is True
|
||||
|
||||
|
||||
# ── EntryPointIndex.query_radius — k-NN <-> ordering + radius soft cap ─────
|
||||
|
||||
class _FakeCurQ:
|
||||
def __init__(self, rows, capture):
|
||||
self._rows, self._capture = rows, capture
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
def execute(self, q, params=None):
|
||||
self._capture["query"] = q
|
||||
self._capture["params"] = params
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeConnQ:
|
||||
def __init__(self, rows, capture):
|
||||
self._rows, self._capture = rows, capture
|
||||
def cursor(self, cursor_factory=None):
|
||||
return _FakeCurQ(self._rows, self._capture)
|
||||
|
||||
|
||||
def test_query_radius_uses_knn_sql(monkeypatch):
|
||||
cap = {}
|
||||
monkeypatch.setattr(EntryPointIndex, "table_exists", lambda self: True)
|
||||
monkeypatch.setattr(EntryPointIndex, "_get_conn", lambda self: _FakeConnQ([], cap))
|
||||
idx = object.__new__(EntryPointIndex)
|
||||
idx.query_radius(44.1, -115.0, 50, limit=10)
|
||||
assert "<->" in cap["query"]
|
||||
assert "LIMIT" in cap["query"]
|
||||
assert "ST_DWithin" not in cap["query"] # radius scan removed
|
||||
|
||||
|
||||
def test_query_radius_soft_cap_filters_beyond_radius(monkeypatch):
|
||||
rows = [
|
||||
{"id": 1, "distance_m": 100.0},
|
||||
{"id": 2, "distance_m": 50000.0},
|
||||
{"id": 3, "distance_m": 200000.0}, # beyond 50km cap -> dropped
|
||||
]
|
||||
monkeypatch.setattr(EntryPointIndex, "table_exists", lambda self: True)
|
||||
monkeypatch.setattr(EntryPointIndex, "_get_conn", lambda self: _FakeConnQ(rows, {}))
|
||||
idx = object.__new__(EntryPointIndex)
|
||||
out = idx.query_radius(44.1, -115.0, 50, limit=10) # 50 km = 50000 m cap
|
||||
assert [r["id"] for r in out] == [1, 2]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue