Geolocation (distance + time)¶
Need to know how many km separate two points and how long the trip
takes by car, motorcycle, or bus — without paying for a maps API? The
tempest_fastapi_sdk.geo module solves it in two layers that share the same
schemas:
- Offline heuristic — pure math, zero dependencies, zero network. Straight-line distance (Haversine) scaled by a circuity factor, and time from the mode's average speed. Instant and approximate.
- Real routing —
OSRMBackendtalks to an OSRM server (open-source, free, self-hostable or the public demo server). Gives true road geometry.
Everything imports without the extra. Only OSRMBackend needs httpx:
No paid API
The offline layer makes no network calls at all. OSRM is free software —
use the public demo server or run your own
(docker run osrm/osrm-backend). No paid key on either path.
Straight-line distance¶
haversine_km takes two Coordinates (latitude/longitude in decimal
degrees, validated by LatitudeField/LongitudeField) and returns the
great-circle distance in km — the "as the crow flies" distance, no roads:
from tempest_fastapi_sdk.geo import Coordinate, haversine_km
sao_paulo = Coordinate(latitude=-23.5505, longitude=-46.6333)
rio = Coordinate(latitude=-22.9068, longitude=-43.1729)
km: float = haversine_km(sao_paulo, rio)
print(round(km, 1)) # 360.7
Offline estimate (distance + time per mode)¶
estimate_travel turns the straight line into a road estimate: it multiplies
the distance by the circuity factor (how much longer the real road is
than the straight line, ~1.3 by default) and computes the time from the car's
average speed, scaled by the mode factor.
from tempest_fastapi_sdk.geo import (
Coordinate,
TravelEstimate,
TravelMode,
estimate_travel,
)
origin = Coordinate(latitude=-23.5505, longitude=-46.6333)
destination = Coordinate(latitude=-23.5015, longitude=-46.6553)
by_car: TravelEstimate = estimate_travel(origin, destination, TravelMode.CAR)
by_bus: TravelEstimate = estimate_travel(origin, destination, TravelMode.BUS)
print(by_car.distance_km, by_car.duration_minutes) # 7.659817427032203 9.191780912438643
print(by_bus.duration_minutes) # larger (bus stops)
print(by_car.source) # "heuristic"
The defaults are tunable per call:
from tempest_fastapi_sdk.geo import Coordinate, TravelMode, estimate_travel
destination = Coordinate(latitude=-7.9899, longitude=-34.8386)
origin = Coordinate(latitude=-8.0476, longitude=-34.8770)
estimate_travel(
origin,
destination,
TravelMode.MOTORCYCLE,
circuity_factor=1.4, # windier road
car_speed_kmh=70.0, # highway leg
)
Motorcycle and bus derive from the car
A single map, DEFAULT_MODE_DURATION_FACTORS, defines how much slower or
faster each mode is versus the car (bus ~1.6x for stops, motorcycle
~0.95x). It scales both paths — the heuristic (via speed) and OSRM
(via duration) — so everything works even against a car-only profile.
Real routing with OSRM¶
OSRMBackend follows the SDK pattern: you inject the
httpx.AsyncClient (the SDK does not open or close connections for you) and
it returns the same TravelEstimate, now with source="osrm" and the real
road distance.
import httpx
from tempest_fastapi_sdk.geo import Coordinate, OSRMBackend, TravelMode
origin = Coordinate(latitude=-23.5505, longitude=-46.6333)
destination = Coordinate(latitude=-22.9068, longitude=-43.1729)
async def route() -> None:
"""Query the real route via an OSRM server."""
async with httpx.AsyncClient(timeout=10.0) as client:
backend = OSRMBackend(http_client=client) # public demo by default
estimate = await backend.route(origin, destination, mode=TravelMode.CAR)
print(estimate.distance_km, estimate.duration_minutes)
OSRMBackend satisfies the RoutingBackend Protocol, so you can swap it for
a mock in tests or another implementation without touching the call site.
Demo server = car only
The public demo (router.project-osrm.org) exposes only the car profile
and is rate-limited. Motorcycle and bus reuse the car distance and scale
the duration by the mode factor. For real motorcycle/bus profiles, run a
self-hosted OSRM with your own data and point base_url at it.
Choosing a layer¶
| You need... | Use |
|---|---|
| Fast, offline, "roughly" | estimate_travel (heuristic) |
| Real road distance/time | OSRMBackend.route |
| Just the straight line (radius, proximity) | haversine_km |
A common pattern: try OSRM and fall back to the heuristic if the network fails.
from tempest_fastapi_sdk.geo import OSRMBackend, TravelEstimate, estimate_travel
async def estimate(origin, destination, mode, client) -> TravelEstimate:
"""Real route when possible; otherwise the offline estimate."""
try:
return await OSRMBackend(http_client=client).route(
origin, destination, mode=mode
)
except RuntimeError:
return estimate_travel(origin, destination, mode)
Integrated example: delivery ETA (layered FastAPI)¶
A real service wants an endpoint that takes origin, destination, and mode and returns distance + time, trying OSRM's real route and falling back to the offline heuristic if the network fails. It follows the SDK's layered architecture (schema → service → controller → router → dependency).
Request/response schemas¶
# src/schemas/geo.py
from tempest_fastapi_sdk.geo import Coordinate, TravelEstimate, TravelMode
from tempest_fastapi_sdk.schemas.base import BaseSchema
class RouteRequestSchema(BaseSchema):
"""A route-estimate request between two points.
Attributes:
origin: Start coordinate.
destination: End coordinate.
mode: Desired travel mode.
"""
origin: Coordinate
destination: Coordinate
mode: TravelMode = TravelMode.CAR
# The response is the SDK's own TravelEstimate — nothing to redefine.
RouteResponseSchema = TravelEstimate
Service — business logic + fallback¶
# src/services/geo.py
from tempest_fastapi_sdk.geo import (
Coordinate,
RoutingBackend,
TravelEstimate,
TravelMode,
estimate_travel,
)
class GeoService:
"""Estimates travel distance and time between two points.
Uses a `RoutingBackend` (OSRM) for the real route and falls back to the
offline heuristic when the backend fails, so the endpoint never 5xxs
just because the routing server hiccuped.
"""
def __init__(self, routing: RoutingBackend) -> None:
"""Initialize the service.
Args:
routing: A routing backend (e.g. `OSRMBackend`).
"""
self.routing: RoutingBackend = routing
async def estimate(
self,
origin: Coordinate,
destination: Coordinate,
mode: TravelMode = TravelMode.CAR,
) -> TravelEstimate:
"""Estimate the trip, real route with an offline fallback.
Args:
origin: Start coordinate.
destination: End coordinate.
mode: Travel mode.
Returns:
The `TravelEstimate` — `source="osrm"` when the real route
answered, `source="heuristic"` on the fallback.
"""
try:
return await self.routing.route(origin, destination, mode=mode)
except RuntimeError:
return estimate_travel(origin, destination, mode)
Controller — thin pass-through (room for orchestration)¶
# src/controllers/geo.py
from src.schemas.geo import RouteRequestSchema
from src.services.geo import GeoService
from tempest_fastapi_sdk.geo import TravelEstimate
class GeoController:
"""Orchestrates `GeoService` for the routers."""
def __init__(self, service: GeoService) -> None:
"""Initialize the controller.
Args:
service: The geolocation service.
"""
self.service: GeoService = service
async def estimate_route(self, payload: RouteRequestSchema) -> TravelEstimate:
"""Estimate a route from the validated payload.
Args:
payload: Origin, destination and mode.
Returns:
The travel estimate.
"""
return await self.service.estimate(
payload.origin, payload.destination, payload.mode
)
Dependency — injects the shared httpx client¶
# src/api/dependencies/services.py
from collections.abc import AsyncIterator
import httpx
from fastapi import Depends
from src.controllers.geo import GeoController
from src.services.geo import GeoService
from tempest_fastapi_sdk.geo import OSRMBackend
async def get_geo_controller() -> AsyncIterator[GeoController]:
"""Provide a `GeoController` with a short-lived httpx client.
Yields:
A ready-to-use controller; the client closes when done.
"""
async with httpx.AsyncClient(timeout=10.0) as client:
backend = OSRMBackend(http_client=client)
yield GeoController(GeoService(backend))
Reuse the client across requests
Opening an httpx.AsyncClient per request is simple but costs
handshakes. In production, create one client in the app lifespan,
stash it on app.state, and inject it into OSRMBackend — the SDK
never closes the client you pass, so lifecycle is yours to own.
Router — HTTP only¶
# src/api/routers/geo.py
from fastapi import APIRouter, Depends
from src.api.dependencies.services import get_geo_controller
from src.controllers.geo import GeoController
from src.schemas.geo import RouteRequestSchema
from tempest_fastapi_sdk.geo import TravelEstimate
router = APIRouter(prefix="/api/geo", tags=["geo"])
@router.post("/estimate")
async def estimate_route(
payload: RouteRequestSchema,
controller: GeoController = Depends(get_geo_controller),
) -> TravelEstimate:
"""Estimate distance and time between two points per mode."""
return await controller.estimate_route(payload)
A POST /api/geo/estimate with origin/destination/mode returns
{"mode": "...", "distance_km": ..., "duration_minutes": ..., "source": ...}.
Radius filter and neighbours (in memory)¶
With no routing server, the geometry helpers filter and rank by
proximity. within_radius returns what's inside the radius; nearest
returns the k closest. Both take key= to extract a Coordinate from
your own objects:
from dataclasses import dataclass
from tempest_fastapi_sdk.geo import Coordinate, nearest, within_radius
@dataclass
class Store:
"""A store of yours, holding its coordinate in a field of its own."""
name: str
location: Coordinate
store_a = Store("Boa Viagem", Coordinate(latitude=-8.0476, longitude=-34.8770))
store_b = Store("Olinda", Coordinate(latitude=-7.9899, longitude=-34.8386))
store_c = Store("Jaboatão", Coordinate(latitude=-8.1130, longitude=-34.9060))
center = Coordinate(latitude=-23.55, longitude=-46.63)
stores = [store_a, store_b, store_c]
near = within_radius(center, stores, 5.0, key=lambda s: s.location)
top3 = nearest(center, stores, k=3, key=lambda s: s.location)
The radius is a cheap pre-filter
Straight-line distance underestimates road distance: use a radius a bit
larger than the target and refine with estimate_travel/OSRM only on
the finalists.
Radius search in the database (GeoRepositoryMixin)¶
To search a radius straight from the database, mix GeoPointMixin into
the model and GeoRepositoryMixin into the repository. nearby runs a
bounding-box pre-filter in SQL (indexed) and refines with Haversine in
Python:
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String
from tempest_fastapi_sdk import BaseModel, BaseRepository
from tempest_fastapi_sdk.geo import Coordinate, GeoPointMixin, GeoRepositoryMixin
class StoreModel(GeoPointMixin, BaseModel):
__tablename__ = "stores"
name: Mapped[str] = mapped_column(String(120))
class StoreRepository(GeoRepositoryMixin, BaseRepository[StoreModel]):
...
async def nearby_stores(repo: StoreRepository, center: Coordinate) -> list[StoreModel]:
# Active stores within 5 km, nearest first, at most 20.
return await repo.nearby(
center,
radius_km=5.0,
extra_filters={"is_active": True},
limit=20,
)
PostGIS when the volume grows
On Postgres + the PostGIS extension, swap in PostGISRepositoryMixin:
nearby pushes the filter and distance sort into the database via
ST_DWithin / ST_Distance — no extra Python dependency, same
signature.
Geocoding (address <-> coordinate)¶
NominatimBackend resolves address → coordinate (and reverse) via
OpenStreetMap Nominatim, for free. Injected httpx client, like OSRM:
import asyncio
import httpx
from tempest_fastapi_sdk.geo import Coordinate, NominatimBackend
async def main() -> None:
"""Run this example."""
async with httpx.AsyncClient() as client:
geocoder = NominatimBackend(http_client=client, user_agent="my-app/1.0")
hit = await geocoder.geocode("Av. Paulista, 1578, São Paulo")
if hit:
print(hit.coordinate, hit.display_name)
place = await geocoder.reverse(Coordinate(latitude=-23.561, longitude=-46.656))
asyncio.run(main())
Public Nominatim usage policy
nominatim.openstreetmap.org requires a descriptive User-Agent and
caps you at ~1 req/s. Self-host for scale.
Distance matrix and route geometry¶
OSRM does more than point-to-point: matrix computes N×M in one call
(dispatching, "nearest courier") and route(..., with_geometry=True)
returns the route line decoded into TravelEstimate.geometry:
import asyncio
import httpx
from tempest_fastapi_sdk.geo import Coordinate, OSRMBackend
store_a = Coordinate(latitude=-8.0476, longitude=-34.8770)
a = store_a
store_b = Coordinate(latitude=-7.9899, longitude=-34.8386)
b = store_b
client = httpx.AsyncClient()
destination = Coordinate(latitude=-7.9899, longitude=-34.8386)
destinations = [destination]
def draw_on_map(line: list[Coordinate]) -> None:
"""Render the route line on your map widget."""
origin = Coordinate(latitude=-8.0476, longitude=-34.8770)
origins = [origin]
backend = OSRMBackend(http_client=client)
async def main() -> None:
"""Run this example."""
matrix = await backend.matrix(origins, destinations) # DistanceMatrix
print(matrix.durations_minutes[0][2]) # time origin 0 -> destination 2
route = await backend.route(a, b, with_geometry=True)
draw_on_map(route.geometry) # list[Coordinate]
asyncio.run(main())
encode_polyline / decode_polyline convert the line to/from the compact
Google/OSRM format (precision 5 or 6), no dependency.
Geometry: projection, geofence, length¶
from tempest_fastapi_sdk.geo import (
Coordinate,
bounding_box,
destination_point,
initial_bearing,
path_length_km,
point_in_polygon,
polygon_area_km2,
)
point = Coordinate(latitude=-8.0476, longitude=-34.8770)
center = point
delivery_zone = bounding_box(point, radius_km=5)
zone_polygon = [
Coordinate(
latitude=delivery_zone.min_latitude,
longitude=delivery_zone.min_longitude,
),
Coordinate(
latitude=delivery_zone.min_latitude,
longitude=delivery_zone.max_longitude,
),
Coordinate(
latitude=delivery_zone.max_latitude,
longitude=delivery_zone.max_longitude,
),
Coordinate(
latitude=delivery_zone.max_latitude,
longitude=delivery_zone.min_longitude,
),
]
gps_points = [point, Coordinate(latitude=-7.9899, longitude=-34.8386)]
target = destination_point(center, bearing_degrees=90.0, distance_km=2.0) # 2 km east
heading = initial_bearing(center, target) # ~90.0
inside = delivery_zone.contains(point) # geofence: the cheap box test
in_polygon = point_in_polygon(point, zone_polygon) # geofence: the ring
area = polygon_area_km2(zone_polygon)
travelled = path_length_km(gps_points)
Brazil: UF centroid and CEP → coordinate¶
import asyncio
import httpx
from tempest_fastapi_sdk.geo import NominatimBackend, cep_to_coordinate, uf_centroid
geocoder = NominatimBackend(http_client=httpx.AsyncClient())
pin = uf_centroid("SP") # approximate state centre, offline
async def main() -> None:
"""Run this example."""
coord = await cep_to_coordinate("01310-100", geocoder=geocoder) # via Nominatim
asyncio.run(main())
Recap¶
haversine_km(a, b)— great-circle distance, pure, always available.bounding_box/within_radius/nearest— offline proximity;key=for your own objects.GeoPointMixin+GeoRepositoryMixin.nearby— radius search in the DB (PostGIS viaPostGISRepositoryMixin).NominatimBackend— address<->coordinate geocoding, free, injectedhttpx.OSRMBackend.matrix/route(with_geometry=True)— N×M matrix and route line;encode_polyline/decode_polyline.destination_point/initial_bearing/point_in_polygon/polygon_area_km2/path_length_km— offline geometry.uf_centroid/cep_to_coordinate— Brazil shortcuts.estimate_travel/OSRMBackend.route— distance + time (heuristic/osrm); car/motorcycle/bus/bicycle/pedestrian modes.