Use NaPTAN data for the UK stop finder

Import active NaPTAN stops into a local SQLite spatial index, including conversion of British National Grid coordinates. Add local searches, shareable stop links, source details and transport mode colours while retaining OSM route lookup by ATCO code.

Closes #1
This commit is contained in:
Edward Betts 2026-09-18 18:04:55 +01:00
parent 8ed7b8d112
commit 29afb4f115
12 changed files with 503 additions and 55 deletions

147
tests/test_naptan.py Normal file
View file

@ -0,0 +1,147 @@
"""Offline NaPTAN import, spatial lookup and API regression tests."""
from __future__ import annotations
import csv
from pathlib import Path
import pytest
import responses
from uk_bus_stops import core
from uk_bus_stops.app import create_app
from uk_bus_stops.naptan import NaptanError, NaptanStore, import_csv
@pytest.fixture()
def csv_file(tmp_path: Path) -> Path:
"""Write representative stop records, including grid-only and invalid data."""
path = tmp_path / "Stops.csv"
fields = ["ATCOCode", "CommonName", "StopType", "Status", "Latitude", "Longitude", "Easting", "Northing", "GridType", "Bearing", "Indicator"]
records = [
["0100BRP90317", "Temple Meads Stn", "BCT", "active", "51.44827", "-2.58302", "359581", "172304", "UKOS", "SE", "T1"],
["0100GRID001", "Gare café", "RSE", "active", "", "", "359581", "172304", "", "", ""],
["0100INACTIVE", "Closed", "BCT", "inactive", "51.448", "-2.583", "", "", "", "", ""],
["0100INVALID", "Invalid", "BCT", "active", "nan", "0", "", "", "", "", ""],
["0100CORNER", "Outside radius", "FER", "active", "51.456", "-2.571", "", "", "", "", ""],
["0100EAST001", "East", "BCT", "active", "51.44827", "-2.57302", "", "", "", "", ""],
["0100NORTH01", "North", "BCT", "active", "51.45627", "-2.58302", "", "", "", "", ""],
]
with path.open("w", encoding="utf-8-sig", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(fields)
writer.writerows(records)
return path
@pytest.fixture()
def database(csv_file: Path, tmp_path: Path) -> Path:
"""Import an isolated database shared by query and API tests."""
path = tmp_path / "naptan.sqlite3"
assert import_csv(csv_file, path) == {"imported": 5, "inactive": 1, "invalid": 1, "converted": 1}
return path
def test_import_and_exact_lookup(database: Path) -> None:
"""Preserve codes, accents, transport metadata and converted grid positions."""
store = NaptanStore(database)
stop = store.find_atco_code("0100brp90317")[0]
assert stop["id"] == "0100BRP90317"
assert stop["source"] == "NaPTAN"
assert stop["bearing"] == "SE"
assert stop["indicator"] == "T1"
grid = store.find_atco_code("0100GRID001")[0]
assert grid["name"] == "Gare café"
assert grid["transport_type"] == "Railway station entrance"
assert grid["lat"] == pytest.approx(stop["lat"], abs=0.0001)
assert grid["lon"] == pytest.approx(stop["lon"], abs=0.0001)
assert store.find_atco_code("0100INACTIVE") == []
assert store.find_atco_code("' OR 1=1 --") == []
def test_spatial_search(database: Path) -> None:
"""Radius excludes box corners and orders longitude distances correctly."""
store = NaptanStore(database)
nearby = store.nearby_stops(51.44827, -2.58302)
assert [stop["id"] for stop in nearby][-2:] == ["0100EAST001", "0100NORTH01"]
assert "0100CORNER" not in {stop["id"] for stop in nearby}
assert len(store.stops_in_bounds(51.448, -2.584, 51.449, -2.582)) == 2
assert store.stops_in_bounds(51.44828, -2.583021, 51.44829, -2.583019) == []
assert store.nearby_stops(0, 0) == []
def test_atomic_refresh_and_failed_import(database: Path, csv_file: Path) -> None:
"""Invalid refreshes preserve the old database; valid refreshes replace it."""
original = csv_file.read_text(encoding="utf-8-sig")
csv_file.write_text("invalid\n")
with pytest.raises(NaptanError):
import_csv(csv_file, database)
store = NaptanStore(database)
assert store.find_atco_code("0100BRP90317")[0]["name"] == "Temple Meads Stn"
csv_file.write_text(original.replace("Temple Meads Stn", "Updated name"))
import_csv(csv_file, database)
assert store.find_atco_code("0100BRP90317")[0]["name"] == "Updated name"
assert not list(database.parent.glob(".naptan-*"))
@responses.activate
def test_naptan_api_needs_no_network(database: Path) -> None:
"""Coordinate, viewport, code and shared stop requests all use local data."""
client = create_app({"TESTING": True, "STOP_SOURCE": "naptan", "NAPTAN_DATABASE": str(database)}).test_client()
for url in (
"/api/search?q=0100brp90317",
"/api/search?q=51.44827,-2.58302",
"/api/stops?lat=51.44827&lon=-2.58302",
"/api/stops/in-bounds?south=51.448&west=-2.584&north=51.449&east=-2.582",
):
response = client.get(url)
assert response.status_code == 200
assert response.json["stops"][0]["source"] == "NaPTAN"
response = client.get("/api/stop/naptan/0100brp90317")
assert response.status_code == 200
assert response.json["stop"]["id"] == "0100BRP90317"
assert client.get("/api/stop/naptan/missing").status_code == 404
assert len(responses.calls) == 0
def test_missing_database(tmp_path: Path) -> None:
"""An unconfigured installation explains how to import instead of creating a DB."""
database = tmp_path / "missing.sqlite3"
client = create_app({"TESTING": True, "NAPTAN_DATABASE": str(database), "STOP_SOURCE": "naptan"}).test_client()
response = client.get("/api/stops?lat=51.45&lon=-2.58")
assert response.status_code == 503
assert response.json["error"] == "naptan_unavailable"
assert not database.exists()
@responses.activate
def test_naptan_routes_match_atco_and_deduplicate(database: Path) -> None:
"""Multiple matching OSM objects contribute one combined route list."""
responses.get(core.OVERPASS_URL, json={"elements": [
{"type": "node", "id": 1, "lat": 51.448, "lon": -2.583},
{"type": "way", "id": 2, "center": {"lat": 51.448, "lon": -2.583}},
]})
for _ in range(2):
responses.get(core.OVERPASS_URL, json={"elements": [
{"id": 12, "tags": {"ref": "A1", "route": "bus"}},
]})
client = create_app({"TESTING": True, "NAPTAN_DATABASE": str(database)}).test_client()
response = client.get("/api/stop/naptan/0100BRP90317/routes")
assert response.status_code == 200
assert response.json["matched_stops"] == 2
assert [route["ref"] for route in response.json["routes"]] == ["A1"]
assert "0100BRP90317" in responses.calls[0].request.url
@responses.activate
def test_naptan_routes_without_osm_match(database: Path) -> None:
"""A missing OSM match has a distinct result from a service failure."""
responses.get(core.OVERPASS_URL, json={"elements": []})
client = create_app({"TESTING": True, "NAPTAN_DATABASE": str(database)}).test_client()
assert client.get("/api/stop/naptan/0100BRP90317/routes").json == {
"routes": [], "source": "OpenStreetMap", "matched_stops": 0,
}
responses.replace(responses.GET, core.OVERPASS_URL, status=503)
responses.get(core.OVERPASS_FALLBACK_URLS[0], status=503)
assert client.get("/api/stop/naptan/0100BRP90317/routes").status_code == 502
assert client.get("/api/stop/naptan/0100BRP90317").status_code == 200

View file

@ -14,7 +14,7 @@ from uk_bus_stops.app import ATCO_PATTERN, create_app, parse_coordinates
@pytest.fixture()
def client() -> Any:
"""Return a Flask test client."""
return create_app({"TESTING": True}).test_client()
return create_app({"TESTING": True, "STOP_SOURCE": "osm"}).test_client()
@responses.activate

View file

@ -320,3 +320,33 @@ def test_mobile_postcode_is_fitted_above_results_sheet(
}""")
assert 0 < position["markerY"] < position["visibleBottom"]
page.close()
def test_naptan_shared_stop_and_osm_routes(chromium_browser: Any, flask_url: str) -> None:
"""NaPTAN links survive reload and show source fields plus matched OSM routes."""
page = chromium_browser.new_page(viewport={"width": 1280, "height": 800})
errors: list[str] = []
page.on("pageerror", lambda error: errors.append(str(error)))
stop = {
"type": "naptan", "source": "NaPTAN", "id": "0100BRP90317",
"lat": 51.44827, "lon": -2.58302, "name": "Temple Meads Stn",
"atco_code": "0100BRP90317", "indicator": "T1", "bearing": "SE",
"transport_type": "Bus stop", "tags": {"CommonName": "Temple Meads Stn", "Status": "active"},
}
page.route("**/api/stop/naptan/0100BRP90317", lambda route: route.fulfill(json={"stop": stop}))
page.route("**/api/stop/naptan/0100BRP90317/routes", lambda route: route.fulfill(json={
"matched_stops": 1, "routes": [{"id": 12, "ref": "A1", "name": "Airport bus"}],
}))
page.route("**/api/stops/in-bounds?*", lambda route: route.fulfill(json={"kind": "map", "stops": [stop]}))
page.goto(f"{flask_url}/?naptan=0100BRP90317")
expect = playwright_api.expect
for _ in range(2):
expect(page.locator("#stop-name")).to_have_text("Temple Meads Stn")
expect(page.locator("#stop-source")).to_contain_text("NaPTAN")
expect(page.locator("#tags-heading")).to_have_text("NaPTAN stop data")
expect(page.locator("#osm-link")).not_to_be_visible()
expect(page.locator("#route-list")).to_contain_text("A1")
assert "naptan=0100BRP90317" in page.url
page.reload()
assert errors == []
page.close()