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

5
.gitignore vendored
View file

@ -6,3 +6,8 @@ build/
dist/ dist/
.venv/ .venv/
*.geojson *.geojson
# Local NaPTAN downloads and generated spatial database
/Stops.csv
/NaPTAN.xml
/data/

View file

@ -109,33 +109,50 @@ when the server is running.
### UK Bus Stop Finder ### UK Bus Stop Finder
A mobile-friendly map for finding the `naptan:AtcoCode` recorded on UK bus A mobile-friendly map for finding ATCO codes using the Department for Transport's
stops in OpenStreetMap. Search by postcode, street, place, exact ATCO code, or [NaPTAN dataset](https://beta-naptan.dft.gov.uk/download). It covers active public
browser location. Postcode, street, and place searches show up to 20 transport stops in England, Scotland and Wales, including bus, rail, tram,
UK-only Nominatim matches for the user to choose from. Searches are preserved in shareable `?q=...` or ferry and airport access points; Northern Ireland is not included.
`?lat=...&lon=...` URLs, and `latitude, longitude` can be entered directly in
the search box. Selecting a stop shows its tags and stop-area-aware bus route
relations. Stop results identify their transport mode (such as bus, rail or
tram), and moving the map at zoom level 15 or closer loads stops in the visible
area. Wider map views deliberately do not query Overpass.
Postcode searches with exactly one Nominatim match skip the choice screen.
The current map bounds bias Nominatim's ranking without excluding UK matches
outside the visible area.
Selecting a stop replaces the search parameters with a shareable stop URL such Search by postcode, street, place, exact ATCO code, coordinates or browser
as `?node=485403163` (or `?way=...` / `?relation=...` for other OSM object location. Place searches use Nominatim, with the visible map as a ranking bias.
types). Refreshing that URL fetches and reopens the same stop. The map loads stops at zoom level 15 or closer. Selecting a stop shows its
NaPTAN fields and looks up bus routes in OpenStreetMap by matching its ATCO code.
OSM route coverage may be incomplete; NaPTAN itself does not contain routes or
live departures. An Overpass outage does not prevent NaPTAN stop searches.
UK stop queries use the Britain-and-Ireland Overpass instance at Searches have shareable `?q=...` and `?lat=...&lon=...` URLs; a selected NaPTAN
`overpass.atownsend.org.uk`, with `overpass.private.coffee` as an automatic stop uses `?naptan=010000056`. Existing `?node=...`, `?way=...` and
fallback when the primary service is unavailable. `?relation=...` OSM links still work.
Run it locally with: Download the national CSV from NaPTAN, then import it and start the app:
```sh
python3 -m venv .venv
.venv/bin/pip install -e ".[web]"
.venv/bin/uk-bus-stops-import-naptan Stops.csv
.venv/bin/flask --app uk_bus_stops.app run
``` ```
pip install -e ".[web]"
flask --app uk_bus_stops.app run The importer uses SQLite's built-in R-tree spatial index; no database server or
``` SpatiaLite extension is required. It preserves leading zeros in ATCO codes,
converts British National Grid coordinates when latitude/longitude are absent,
and reports imported, inactive/pending, invalid and converted row counts.
Only active records with usable positions are included. The original CSV fields
remain available in the detail panel. XML is not needed.
The default database is `data/naptan.sqlite3` in the project directory. Set
`NAPTAN_DATABASE=/absolute/path/naptan.sqlite3` for both import and serving, or
use `--database PATH` for the importer. The serving process needs read access.
To refresh, download a new CSV and rerun the same command. It builds a temporary
database alongside the destination and replaces the old snapshot atomically;
a failed import leaves the old snapshot intact. Downloads and databases are
excluded from git. The app returns a JSON 503 with import instructions if its
database is missing or unreadable.
NaPTAN is the default stop source. Set `UK_BUS_STOPS_SOURCE=osm` to retain
Overpass stop searches instead. OSM route queries use `overpass.atownsend.org.uk`,
with `overpass.private.coffee` as a fallback.
The production URL is `https://openstreetmap.tools/uk-bus-stops/`. The production URL is `https://openstreetmap.tools/uk-bus-stops/`.
The app's `/about` page documents shareable URL parameters, map behaviour, The app's `/about` page documents shareable URL parameters, map behaviour,

View file

@ -17,6 +17,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
web = [ web = [
"flask>=3.0", "flask>=3.0",
"pyproj>=3.4",
] ]
dev = [ dev = [
"mypy", "mypy",
@ -25,10 +26,12 @@ dev = [
"responses", "responses",
"types-requests", "types-requests",
"flask>=3.0", "flask>=3.0",
"pyproj>=3.4",
] ]
[project.scripts] [project.scripts]
osm-pt-geojson = "osm_geojson.pt.cli:cli" osm-pt-geojson = "osm_geojson.pt.cli:cli"
uk-bus-stops-import-naptan = "uk_bus_stops.naptan:main"
uk-bus-stops = "uk_bus_stops.app:main" uk-bus-stops = "uk_bus_stops.app:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View file

@ -1,4 +1,4 @@
"""Find UK bus stop ATCO codes using OpenStreetMap data.""" """Find UK bus stop ATCO codes using NaPTAN and OpenStreetMap data."""
from uk_bus_stops.app import create_app from uk_bus_stops.app import create_app

View file

@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
import os
import re import re
from pathlib import Path
from typing import Any from typing import Any
from flask import Flask, Response, jsonify, render_template, request from flask import Flask, Response, jsonify, render_template, request
@ -18,6 +20,7 @@ from uk_bus_stops.core import (
routes_for_stop, routes_for_stop,
stops_in_bounds, stops_in_bounds,
) )
from uk_bus_stops.naptan import NaptanError, NaptanStore, database_path
ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{9,16}$)(?=.*\d)[A-Za-z0-9]+$") ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{9,16}$)(?=.*\d)[A-Za-z0-9]+$")
COORDINATE_PATTERN = re.compile( COORDINATE_PATTERN = re.compile(
@ -54,9 +57,26 @@ def parse_viewbox() -> tuple[float, float, float, float] | None:
def create_app(test_config: dict[str, Any] | None = None) -> Flask: def create_app(test_config: dict[str, Any] | None = None) -> Flask:
"""Create and configure the bus stop finder application.""" """Create and configure the bus stop finder application."""
app = Flask(__name__) app = Flask(__name__)
app.config.from_mapping(
STOP_SOURCE=os.environ.get("UK_BUS_STOPS_SOURCE", "naptan"),
NAPTAN_DATABASE=str(database_path()),
)
if test_config: if test_config:
app.config.update(test_config) app.config.update(test_config)
if app.config["STOP_SOURCE"] not in ("naptan", "osm"):
raise ValueError("UK_BUS_STOPS_SOURCE must be naptan or osm")
store = NaptanStore(Path(app.config["NAPTAN_DATABASE"]))
use_naptan = app.config["STOP_SOURCE"] == "naptan"
find_nearby = store.nearby_stops if use_naptan else nearby_stops
find_bounds = store.stops_in_bounds if use_naptan else stops_in_bounds
find_code = store.find_atco_code if use_naptan else find_atco_code
@app.errorhandler(NaptanError)
def handle_naptan_error(exc: NaptanError) -> tuple[Response, int]:
"""Explain an unavailable local dataset using the standard error shape."""
return _error("naptan_unavailable", str(exc), 503)
@app.errorhandler(UpstreamError) @app.errorhandler(UpstreamError)
def handle_upstream_error(exc: UpstreamError) -> tuple[Response, int]: def handle_upstream_error(exc: UpstreamError) -> tuple[Response, int]:
"""Return the common JSON response for upstream OSM service failures.""" """Return the common JSON response for upstream OSM service failures."""
@ -89,10 +109,10 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
return jsonify({ return jsonify({
"kind": "location", "kind": "location",
"location": coordinate_location, "location": coordinate_location,
"stops": nearby_stops(lat, lon), "stops": find_nearby(lat, lon),
}) })
if ATCO_PATTERN.fullmatch(query): if ATCO_PATTERN.fullmatch(query):
stops = find_atco_code(query) stops = find_code(query)
return jsonify({"kind": "atco", "label": query.upper(), "stops": stops}) return jsonify({"kind": "atco", "label": query.upper(), "stops": stops})
locations = geocode(query, viewbox=viewbox) locations = geocode(query, viewbox=viewbox)
if not locations: if not locations:
@ -113,7 +133,31 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
return jsonify({ return jsonify({
"kind": "location", "kind": "location",
"location": {"lat": lat, "lon": lon, "label": label}, "location": {"lat": lat, "lon": lon, "label": label},
"stops": nearby_stops(lat, lon), "stops": find_nearby(lat, lon),
})
@app.get("/api/stop/naptan/<code>")
def naptan_detail(code: str) -> ResponseReturnValue:
"""Resolve a shareable NaPTAN stop by ATCO code."""
found = store.find_atco_code(code)
if not found:
return _error("stop_not_found", "That active NaPTAN stop was not found.", 404)
return jsonify({"stop": found[0]})
@app.get("/api/stop/naptan/<code>/routes")
def naptan_routes(code: str) -> ResponseReturnValue:
"""Use exact ATCO matches to retrieve OSM routes for a NaPTAN stop."""
if not ATCO_PATTERN.fullmatch(code) or not store.find_atco_code(code):
return _error("stop_not_found", "That active NaPTAN stop was not found.", 404)
matches = find_atco_code(code)
routes = {}
for stop in matches:
for route in routes_for_stop(stop["type"], stop["id"]):
routes[route["id"]] = route
return jsonify({
"routes": sorted(routes.values(), key=lambda route: (route.get("ref") or "", route["id"])),
"source": "OpenStreetMap",
"matched_stops": len(matches),
}) })
@app.get("/api/stop/<element_type>/<int:element_id>/routes") @app.get("/api/stop/<element_type>/<int:element_id>/routes")
@ -149,7 +193,7 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
lon_span = east - west lon_span = east - west
if lat_span > 0.15 or lon_span > 0.2 or lat_span * lon_span > 0.015: if lat_span > 0.15 or lon_span > 0.2 or lat_span * lon_span > 0.015:
return _error("area_too_large", "Zoom in further to load transport stops.", 400) return _error("area_too_large", "Zoom in further to load transport stops.", 400)
return jsonify({"kind": "map", "stops": stops_in_bounds(south, west, north, east)}) return jsonify({"kind": "map", "stops": find_bounds(south, west, north, east)})
return app return app

173
src/uk_bus_stops/naptan.py Normal file
View file

@ -0,0 +1,173 @@
"""Import NaPTAN CSV snapshots and search a local SQLite spatial index."""
from __future__ import annotations
import csv
import json
import math
import os
from pathlib import Path
import sqlite3
import tempfile
from contextlib import closing
from typing import Any
import click
from pyproj import Transformer
DEFAULT_DATABASE = Path(__file__).resolve().parents[2] / "data" / "naptan.sqlite3"
STOP_TYPES = {
"BCT": "Bus stop", "BCS": "Bus / coach station bay", "BCQ": "Bus / coach station (variable bay)",
"BCE": "Bus / coach station entrance", "BST": "Bus / coach station",
"RSE": "Railway station entrance", "RLY": "Railway station",
"RPL": "Railway platform", "TMU": "Tram / metro entrance",
"MET": "Tram / metro stop", "PLT": "Tram / metro platform",
"FER": "Ferry terminal", "FTD": "Ferry terminal entrance", "FBT": "Ferry berth",
"AIR": "Airport entrance", "GAT": "Airport interchange", "STR": "Shared taxi rank",
"TXR": "Taxi rank", "SDA": "Pick-up / set-down point",
"LSE": "Cable car entrance", "LCB": "Cable car station", "LPL": "Cable car platform",
}
class NaptanError(Exception):
"""Report an unavailable or invalid local NaPTAN dataset."""
def database_path() -> Path:
"""Return the configured database path, shared by the app and importer."""
return Path(os.environ.get("NAPTAN_DATABASE", str(DEFAULT_DATABASE)))
def import_csv(source: Path, destination: Path) -> dict[str, int]:
"""Build a complete snapshot beside the old database and replace it atomically."""
destination = destination.resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
transformer = Transformer.from_crs(27700, 4326, always_xy=True)
counts = {"imported": 0, "inactive": 0, "invalid": 0, "converted": 0}
fd, filename = tempfile.mkstemp(prefix=".naptan-", suffix=".sqlite3", dir=destination.parent)
os.close(fd)
temporary = Path(filename)
try:
with source.open(encoding="utf-8-sig", newline="") as stream, closing(sqlite3.connect(temporary)) as db:
reader = csv.DictReader(stream)
required = {"ATCOCode", "CommonName", "StopType", "Status", "Latitude", "Longitude", "Easting", "Northing"}
if not required.issubset(reader.fieldnames or []):
raise NaptanError("The file is not a NaPTAN Stops.csv export (missing required columns).")
db.executescript("""
CREATE TABLE stops (
id INTEGER PRIMARY KEY, code TEXT UNIQUE COLLATE NOCASE,
lat REAL NOT NULL, lon REAL NOT NULL, fields TEXT NOT NULL
);
CREATE VIRTUAL TABLE positions USING rtree(id, min_lon, max_lon, min_lat, max_lat);
""")
with db:
for row in reader:
if row["Status"].strip().lower() != "active":
counts["inactive"] += 1
continue
converted = False
try:
if not row["ATCOCode"].strip():
raise ValueError("Missing ATCO code")
if row["Latitude"] and row["Longitude"]:
lat, lon = float(row["Latitude"]), float(row["Longitude"])
else:
if row.get("GridType", "") not in ("", "UKOS"):
raise ValueError("Unsupported grid")
east, north = float(row["Easting"]), float(row["Northing"])
if not (0 < east <= 700000 and 0 < north <= 1300000):
raise ValueError("Invalid British National Grid position")
lon, lat = transformer.transform(east, north)
converted = True
if not (49 <= lat <= 61.5 and -9 <= lon <= 2.5):
raise ValueError("Position outside Great Britain")
except (ValueError, TypeError):
counts["invalid"] += 1
continue
cursor = db.execute(
"INSERT INTO stops(code, lat, lon, fields) VALUES (?, ?, ?, ?)",
(row["ATCOCode"].strip(), lat, lon, json.dumps({key: value for key, value in row.items() if value}, ensure_ascii=False, separators=(",", ":"))),
)
db.execute("INSERT INTO positions VALUES (?, ?, ?, ?, ?)", (cursor.lastrowid, lon, lon, lat, lat))
counts["imported"] += 1
counts["converted"] += int(converted)
if not counts["imported"]:
raise NaptanError("No active stops with valid coordinates were found; database unchanged.")
temporary.chmod(0o644)
os.replace(temporary, destination)
except (OSError, sqlite3.Error, csv.Error) as exc:
raise NaptanError(f"Could not import NaPTAN: {exc}") from exc
finally:
temporary.unlink(missing_ok=True)
return counts
class NaptanStore:
"""Read one indexed snapshot with a separate read-only connection per query."""
def __init__(self, path: Path) -> None:
"""Store the database location without opening it during app startup."""
self.path = path
def _query(self, sql: str, parameters: tuple[Any, ...]) -> list[dict[str, Any]]:
"""Execute a query and convert records to the shared stop response format."""
try:
with closing(sqlite3.connect(self.path.resolve().as_uri() + "?mode=ro", uri=True)) as db:
rows = db.execute(sql, parameters).fetchall()
except sqlite3.Error as exc:
raise NaptanError("NaPTAN data is unavailable. Import Stops.csv with uk-bus-stops-import-naptan.") from exc
stops = []
for code, lat, lon, fields in rows:
tags = json.loads(fields)
stops.append({
"type": "naptan", "source": "NaPTAN", "id": code, "atco_code": code,
"lat": lat, "lon": lon, "name": tags.get("CommonName") or "Unnamed stop",
"transport_type": STOP_TYPES.get(tags.get("StopType"), "Transport stop"),
"indicator": tags.get("Indicator"), "bearing": tags.get("Bearing"),
"tags": {key: value for key, value in tags.items() if value},
})
return stops
def find_atco_code(self, code: str) -> list[dict[str, Any]]:
"""Look up an exact ATCO code without changing its leading zeros."""
return self._query("SELECT code, lat, lon, fields FROM stops WHERE code = ?", (code,))
def stops_in_bounds(self, south: float, west: float, north: float, east: float) -> list[dict[str, Any]]:
"""Search the R-tree, then filter rounding at the exact viewport edges."""
return self._query("""
SELECT s.code, s.lat, s.lon, s.fields FROM positions p
JOIN stops s ON s.id = p.id
WHERE p.max_lat >= ? AND p.min_lat <= ? AND p.max_lon >= ? AND p.min_lon <= ?
AND s.lat BETWEEN ? AND ? AND s.lon BETWEEN ? AND ?
ORDER BY s.code
""", (south, north, west, east, south, north, west, east))
def nearby_stops(self, lat: float, lon: float, radius: int = 1000) -> list[dict[str, Any]]:
"""Filter indexed candidates by great-circle distance and sort nearest first."""
delta_lat = math.degrees(radius / 6371000)
delta_lon = delta_lat / max(math.cos(math.radians(lat)), 0.000001)
candidates = self.stops_in_bounds(lat - delta_lat, lon - delta_lon, lat + delta_lat, lon + delta_lon)
def distance(stop: dict[str, Any]) -> float:
"""Return spherical distance in metres from the requested coordinate."""
a = math.sin(math.radians(stop["lat"] - lat) / 2) ** 2
a += math.cos(math.radians(lat)) * math.cos(math.radians(stop["lat"])) * math.sin(math.radians(stop["lon"] - lon) / 2) ** 2
return 2 * 6371000 * math.asin(min(1, math.sqrt(a)))
return sorted((stop for stop in candidates if distance(stop) <= radius), key=distance)
@click.command()
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--database", type=click.Path(dir_okay=False, path_type=Path), default=database_path, show_default="NAPTAN_DATABASE or data/naptan.sqlite3")
def main(source: Path, database: Path) -> None:
"""Import a downloaded Stops.csv, replacing the previous NaPTAN snapshot."""
try:
counts = import_csv(source, database)
except NaptanError as exc:
raise click.ClickException(str(exc)) from exc
click.echo(json.dumps(counts))
if __name__ == "__main__":
main()

View file

@ -145,8 +145,10 @@ function restoreSearchFromUrl() {
} }
} }
/** Parse shareable OSM node, way, or relation parameters. */ /** Parse shareable NaPTAN codes or OSM node, way and relation IDs. */
function parseStopReference(params) { function parseStopReference(params) {
const atco = params.get('naptan');
if (atco && /^[a-z0-9]+$/i.test(atco)) return {type: 'naptan', id: atco};
for (const type of ['node', 'way', 'relation']) { for (const type of ['node', 'way', 'relation']) {
const id = params.get(type); const id = params.get(type);
if (id && /^\d+$/.test(id)) return {type, id: Number(id)}; if (id && /^\d+$/.test(id)) return {type, id: Number(id)};
@ -154,9 +156,9 @@ function parseStopReference(params) {
return null; return null;
} }
/** Build a mounted-path-safe API URL for a particular OSM stop. */ /** Build a mounted-path-safe API URL for a particular stop. */
function stopApiUrl(template, type, id) { function stopApiUrl(template, type, id) {
return template.replace('/TYPE/', `/${type}/`).replace('/0', `/${id}`); return template.replace('/TYPE/', `/${type}/`).replace('/0', `/${encodeURIComponent(id)}`);
} }
/** Fetch and display the stop named by a shareable URL. */ /** Fetch and display the stop named by a shareable URL. */
@ -294,6 +296,7 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection =
empty.className = 'small text-muted'; empty.className = 'small text-muted';
empty.textContent = data.kind === 'map' empty.textContent = data.kind === 'map'
? 'No transport stops were found in this map area.' ? 'No transport stops were found in this map area.'
: data.kind === 'atco' ? 'No stop was found with that ATCO code.'
: 'No transport stops were found within 1 km.'; : 'No transport stops were found within 1 km.';
list.appendChild(empty); list.appendChild(empty);
if (fitMap && data.location) { if (fitMap && data.location) {
@ -364,10 +367,15 @@ function showLocationImmediately(locationResult) {
function markerColour(stop) { function markerColour(stop) {
const type = stop.transport_type || ''; const type = stop.transport_type || '';
if (type.includes('Railway')) return '#6f42c1'; if (type.includes('Railway')) return '#6f42c1';
if (type.includes('Tram')) return '#dc3545'; if (type.includes('Tram') || type.includes('Light rail')) return '#dc3545';
if (type.includes('Underground')) return '#0d6efd'; if (type.includes('Underground')) return '#0d6efd';
if (type.includes('Ferry')) return '#0dcaf0'; if (type.includes('Ferry')) return '#0dcaf0';
if (type.includes('Airport')) return '#334155';
if (/taxi/i.test(type)) return '#ca8a04';
if (type.includes('Cable car')) return '#d63384';
if (type.includes('Pick-up')) return '#795548';
if (type.includes('Bus')) return '#198754'; if (type.includes('Bus')) return '#198754';
if (stop.type === 'naptan') return '#6c757d';
return stop.atco_code ? '#198754' : '#fd7e14'; return stop.atco_code ? '#198754' : '#fd7e14';
} }
@ -489,8 +497,12 @@ async function selectStop(stop, updateUrl = true) {
byId('stop-indicator').textContent = stop.indicator ? `Stop ${stop.indicator}` : ''; byId('stop-indicator').textContent = stop.indicator ? `Stop ${stop.indicator}` : '';
show('stop-indicator', Boolean(stop.indicator)); show('stop-indicator', Boolean(stop.indicator));
byId('coordinates').textContent = `${Number(stop.lat).toFixed(6)}, ${Number(stop.lon).toFixed(6)}`; byId('coordinates').textContent = `${Number(stop.lat).toFixed(6)}, ${Number(stop.lon).toFixed(6)}`;
byId('osm-link').href = osmUrl(stop); const isNaptan = stop.type === 'naptan';
byId('edit-link').href = osmUrl(stop, true); byId('stop-source').textContent = isNaptan ? 'Source: NaPTAN · Department for Transport' : 'Source: OpenStreetMap';
byId('tags-heading').textContent = isNaptan ? 'NaPTAN stop data' : 'Other OpenStreetMap tags';
show('osm-link', !isNaptan);
byId('osm-link').href = isNaptan ? '#' : osmUrl(stop);
byId('edit-link').href = isNaptan ? '#' : osmUrl(stop, true);
byId('atco-code').textContent = stop.atco_code || ''; byId('atco-code').textContent = stop.atco_code || '';
show('atco-present', Boolean(stop.atco_code)); show('atco-present', Boolean(stop.atco_code));
show('atco-missing', !stop.atco_code); show('atco-missing', !stop.atco_code);
@ -513,11 +525,17 @@ async function selectStop(stop, updateUrl = true) {
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Could not load routes.'); if (!response.ok) throw new Error(data.message || 'Could not load routes.');
if (selectedStop !== stop) return;
renderRoutes(data.routes); renderRoutes(data.routes);
if (isNaptan && !data.routes.length) {
byId('route-list').textContent = data.matched_stops
? 'No bus routes are recorded for the matching OpenStreetMap stop. Route coverage may be incomplete.'
: 'No OpenStreetMap stop matches this ATCO code, so route information is unavailable.';
}
} catch (error) { } catch (error) {
byId('route-list').textContent = error.message; if (selectedStop === stop) byId('route-list').textContent = error.message;
} finally { } finally {
show('route-loading', false); if (selectedStop === stop) show('route-loading', false);
} }
} }

View file

@ -22,18 +22,17 @@
<h1>About the UK Bus Stop Finder</h1> <h1>About the UK Bus Stop Finder</h1>
<p class="lead"> <p class="lead">
This tool makes it easier to find the ATCO code recorded for a UK public This tool makes it easier to find the ATCO code recorded for a UK public
transport stop in OpenStreetMap. ATCO codes are used by journey-planning transport stop in NaPTAN, the Department for Transport stop dataset. ATCO codes are used by journey-planning
services and integrations such as Home Assistant bus departure displays. services and integrations such as Home Assistant bus departure displays.
</p> </p>
<p> <p>
Search by postcode, street, place, ATCO code, or coordinates. Select a Search by postcode, street, place, ATCO code, or coordinates. Select a
result to see its code, direction, coordinates, OSM tags, and associated result to see its code, direction, coordinates and source fields. Bus routes
bus route relations. Stops without an ATCO code are still shown and link come from OpenStreetMap, matched by ATCO code, and may be incomplete.
to the OpenStreetMap editor.
</p> </p>
<h2 id="urls">Shareable URL parameters</h2> <h2 id="urls">Shareable URL parameters</h2>
<p>Use one search form at a time; selecting a stop replaces search parameters with its OSM object ID.</p> <p>Use one search form at a time; selecting a stop replaces search parameters with its NaPTAN ATCO code (or OSM object ID).</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered align-middle share-table"> <table class="table table-bordered align-middle share-table">
<thead><tr><th>Parameters</th><th>Meaning</th><th>Example</th></tr></thead> <thead><tr><th>Parameters</th><th>Meaning</th><th>Example</th></tr></thead>
@ -48,6 +47,11 @@
<td>Decimal latitude and longitude. Both are required. The map opens around this point.</td> <td>Decimal latitude and longitude. Both are required. The map opens around this point.</td>
<td><a href="{{ url_for('index', lat='51.4545', lon='-2.5879') }}"><code>{{ url_for('index') }}?lat=51.4545&amp;lon=-2.5879</code></a></td> <td><a href="{{ url_for('index', lat='51.4545', lon='-2.5879') }}"><code>{{ url_for('index') }}?lat=51.4545&amp;lon=-2.5879</code></a></td>
</tr> </tr>
<tr>
<td><code>naptan</code></td>
<td>A NaPTAN ATCO code. Opens the active stop and its surroundings.</td>
<td><a href="{{ url_for('index', naptan='010000056') }}"><code>{{ url_for('index') }}?naptan=010000056</code></a></td>
</tr>
<tr> <tr>
<td><code>node</code></td> <td><code>node</code></td>
<td>An OpenStreetMap node ID. The stop opens with nearby stops visible.</td> <td>An OpenStreetMap node ID. The stop opens with nearby stops visible.</td>
@ -78,24 +82,28 @@
<li>After moving the map, results are sorted from the new map centre.</li> <li>After moving the map, results are sorted from the new map centre.</li>
<li>Text searches use the visible map as a Nominatim ranking preference, but matches outside the view remain eligible.</li> <li>Text searches use the visible map as a Nominatim ranking preference, but matches outside the view remain eligible.</li>
<li>Stops in the visible area load automatically at zoom level 15 or closer.</li> <li>Stops in the visible area load automatically at zoom level 15 or closer.</li>
<li>Wider views do not query Overpass, to avoid requesting an excessively large area.</li> <li>Wider views do not load stops, to keep the map readable.</li>
<li>Selecting a stop highlights it and refreshes surrounding markers without closing its details.</li> <li>Selecting a stop highlights it and refreshes surrounding markers without closing its details.</li>
</ul> </ul>
<h2 id="data">Data and availability</h2> <h2 id="data">Data and availability</h2>
<p> <p>
Stop and route data comes from Stops come from a locally imported <a href="https://beta-naptan.dft.gov.uk/download">NaPTAN download</a>
<a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>. published by the Department for Transport. Only active stops with usable coordinates
Place searches use <a href="https://nominatim.org/">Nominatim</a>. Transport are shown, covering England, Scotland and Wales, not Northern Ireland.
queries use the Britain-and-Ireland Overpass instance operated at The snapshot is refreshed by the site operator; it is not a live departure feed.
<a href="https://overpass.atownsend.org.uk/">overpass.atownsend.org.uk</a>, Contains public sector information licensed under the
with <a href="https://overpass.private.coffee/">Private.coffee</a> as a fallback. <a href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/">Open Government Licence v3.0</a>.
</p> </p>
<p> <p>
Public Overpass services can occasionally be busy. OSM data may be incomplete Maps and bus routes use <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors' data</a>.
or out of date, and this finder is not an official source of NaPTAN data. Place searches use <a href="https://nominatim.org/">Nominatim</a>.
If a code or stop is missing, use the edit link on its detail panel and follow Selecting a NaPTAN stop looks up OSM stops with the same ATCO code, then their bus route relations.
OpenStreetMap's editing and source requirements. A missing match or empty route list does not mean no buses serve the stop.
Route queries use <a href="https://overpass.atownsend.org.uk/">overpass.atownsend.org.uk</a>,
with <a href="https://overpass.private.coffee/">Private.coffee</a> as a fallback.
These public services may occasionally be busy. Operators can also configure OSM as the stop source;
existing OSM stop links continue to work.
</p> </p>
<h2 id="privacy">Location and privacy</h2> <h2 id="privacy">Location and privacy</h2>
@ -122,6 +130,8 @@
<tr><td><code>GET {{ url_for('search') }}?q=…</code></td><td>Search a place, coordinates, or ATCO code.</td></tr> <tr><td><code>GET {{ url_for('search') }}?q=…</code></td><td>Search a place, coordinates, or ATCO code.</td></tr>
<tr><td><code>GET {{ url_for('stops') }}?lat=…&amp;lon=…</code></td><td>Stops within 1 km of a point.</td></tr> <tr><td><code>GET {{ url_for('stops') }}?lat=…&amp;lon=…</code></td><td>Stops within 1 km of a point.</td></tr>
<tr><td><code>GET {{ url_for('bounded_stops') }}?south=…&amp;west=…&amp;north=…&amp;east=…</code></td><td>Stops in a safely limited map area.</td></tr> <tr><td><code>GET {{ url_for('bounded_stops') }}?south=…&amp;west=…&amp;north=…&amp;east=…</code></td><td>Stops in a safely limited map area.</td></tr>
<tr><td><code>GET {{ url_for('naptan_detail', code='010000056') }}</code></td><td>Active NaPTAN stop by ATCO code.</td></tr>
<tr><td><code>GET {{ url_for('naptan_routes', code='010000056') }}</code></td><td>OSM bus routes matched by ATCO code.</td></tr>
<tr><td><code>GET {{ url_for('stop_detail', element_type='node', element_id=485403178) }}</code></td><td>One stop by OSM type and ID.</td></tr> <tr><td><code>GET {{ url_for('stop_detail', element_type='node', element_id=485403178) }}</code></td><td>One stop by OSM type and ID.</td></tr>
<tr><td><code>GET {{ url_for('stop_routes', element_type='node', element_id=485403178) }}</code></td><td>Stop-area-aware bus routes serving a stop.</td></tr> <tr><td><code>GET {{ url_for('stop_routes', element_type='node', element_id=485403178) }}</code></td><td>Stop-area-aware bus routes serving a stop.</td></tr>
</tbody> </tbody>

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Find the ATCO code and route information for UK bus stops using OpenStreetMap."> <meta name="description" content="Find the ATCO code and route information for UK bus stops using NaPTAN and OpenStreetMap.">
<title>UK Bus Stop ATCO Code Finder</title> <title>UK Bus Stop ATCO Code Finder</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
@ -61,6 +61,7 @@
<article id="stop-detail" class="d-none" aria-live="polite"> <article id="stop-detail" class="d-none" aria-live="polite">
<button id="back-button" class="btn btn-link btn-sm px-0 mb-2" type="button">← All nearby stops</button> <button id="back-button" class="btn btn-link btn-sm px-0 mb-2" type="button">← All nearby stops</button>
<h2 id="stop-name" class="h5 mb-1"></h2> <h2 id="stop-name" class="h5 mb-1"></h2>
<p id="stop-source" class="small text-muted mb-1"></p>
<p id="stop-type" class="transport-type mb-1"></p> <p id="stop-type" class="transport-type mb-1"></p>
<p id="stop-bearing" class="small fw-semibold mb-1 d-none"></p> <p id="stop-bearing" class="small fw-semibold mb-1 d-none"></p>
<p id="stop-indicator" class="text-muted small mb-3 d-none"></p> <p id="stop-indicator" class="text-muted small mb-3 d-none"></p>
@ -85,13 +86,13 @@
<p><a id="osm-link" target="_blank" rel="noopener">View stop on OpenStreetMap ↗</a></p> <p><a id="osm-link" target="_blank" rel="noopener">View stop on OpenStreetMap ↗</a></p>
<section class="mb-3"> <section class="mb-3">
<h3 class="h6">Bus routes serving this stop</h3> <h3 class="h6">Bus routes in OpenStreetMap</h3>
<div id="route-loading" class="small text-muted">Loading routes…</div> <div id="route-loading" class="small text-muted">Loading routes…</div>
<div id="route-list"></div> <div id="route-list"></div>
</section> </section>
<details> <details>
<summary class="small fw-semibold">Other OpenStreetMap tags</summary> <summary id="tags-heading" class="small fw-semibold">Other OpenStreetMap tags</summary>
<dl id="tag-list" class="tag-list small mt-2"></dl> <dl id="tag-list" class="tag-list small mt-2"></dl>
</details> </details>
</article> </article>

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() @pytest.fixture()
def client() -> Any: def client() -> Any:
"""Return a Flask test client.""" """Return a Flask test client."""
return create_app({"TESTING": True}).test_client() return create_app({"TESTING": True, "STOP_SOURCE": "osm"}).test_client()
@responses.activate @responses.activate

View file

@ -320,3 +320,33 @@ def test_mobile_postcode_is_fitted_above_results_sheet(
}""") }""")
assert 0 < position["markerY"] < position["visibleBottom"] assert 0 < position["markerY"] < position["visibleBottom"]
page.close() 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()