Fixed (2026-07-13): the plot cell's show(fig) used to fall through to a str() fallback (Figure(1400x650)) instead of rendering inline — see notes-browser/plotting ("Implementation notes") for how the fix works and where the tests live.
Fetches today's OwnTracks fixes directly from the PostgreSQL locations table on gravlax (via the [postgres] stunnel tunnel on local port 5432, read-only as owntracks_ro — see location-db/architecture), computes speed between consecutive points, and plots speed over time using seaborn, with a stop-detection strip derived from low-speed runs in the same data (no external station reference table). Run cells in order. Change days_ago in the fetch cell (default 0 = today) to look at a different day. For historical/offline analysis see movement which reads the SQLite database directly.
import psycopg2, datetime
# Change days_ago to 0 for today, 1 for yesterday, etc.
days_ago = 0
day_start = (datetime.datetime.now(datetime.timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
- datetime.timedelta(days=days_ago))
day_end = day_start + datetime.timedelta(days=1)
start = int(day_start.timestamp())
end = int(day_end.timestamp())
# 'who=john' maps to this device UUID via /etc/owntracks/config.json on gravlax
DEVICE_UUID = '53AECFB9-BA35-44B8-BB70-9A35D2B32500'
# Reads via the [postgres] stunnel tunnel (local :5432 -> gravlax:15432 -> owntracks Unix socket)
conn = psycopg2.connect(host='127.0.0.1', port=5432, dbname='owntracks', user='owntracks_ro')
cur = conn.cursor()
cur.execute('''
SELECT extract(epoch from received_at)::bigint, tst, lat, lon, acc, batt, topic
FROM locations
WHERE topic LIKE %s AND tst >= %s AND tst <= %s
ORDER BY tst ASC
''', (f'%{DEVICE_UUID}%', start, end))
rows = cur.fetchall()
cur.close()
conn.close()
locations = [
{'received_at': r[0], 'tst': r[1], 'lat': r[2], 'lon': r[3], 'acc': r[4], 'batt': r[5], 'topic': r[6]}
for r in rows
]
print(f'Fetched {len(locations)} fixes for {day_start.strftime("%Y-%m-%d")} (days_ago={days_ago}) via postgres')Fetched 431 fixes for 2026-07-17 (days_ago=0) via postgres
import math, pandas as pd, numpy as np
BST = datetime.timezone(datetime.timedelta(hours=1))
R = 6371000
def east_north_m(lat1, lon1, lat2, lon2):
p = math.pi / 180
mean_lat = (lat1 + lat2) / 2 * p
north = (lat2 - lat1) * p * R
east = (lon2 - lon1) * p * R * math.cos(mean_lat)
return east, north
rows = []
prev = None
for loc in locations:
t = datetime.datetime.fromtimestamp(loc['tst'], tz=BST)
speed_mph = east_m = north_m = None
if prev:
dt = loc['tst'] - prev['tst']
if 0 < dt < 300 and loc['acc'] <= 200:
east, north = east_north_m(prev['lat'], prev['lon'], loc['lat'], loc['lon'])
dist = math.hypot(east, north)
speed_mph = (dist / dt) * 2.23694
if speed_mph > 200:
speed_mph = east_m = north_m = None
else:
east_m, north_m = east, north
rows.append({'time': t, 'speed_mph': speed_mph, 'east_m': east_m, 'north_m': north_m})
prev = loc
df = pd.DataFrame(rows).dropna(subset=['speed_mph'])
print(f'{len(df)} speed points computed')409 speed points computed
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
import numpy as np, math, datetime
import psycopg2
RED = np.array([0.85, 0.08, 0.08])
BLUE = np.array([0.08, 0.08, 0.85])
GREY = np.array([0.55, 0.55, 0.55])
def direction_colour(east, north):
try:
e, n = float(east), float(north)
except (TypeError, ValueError):
return tuple(float(x) for x in GREY)
if not (math.isfinite(e) and math.isfinite(n)):
return tuple(float(x) for x in GREY)
mag = math.sqrt(e*e + n*n)
if mag < 1e-3:
return tuple(float(x) for x in GREY)
t = e / mag
rgb = GREY + t * (RED - GREY) if t >= 0 else GREY + (-t) * (BLUE - GREY)
return tuple(float(x) for x in rgb)
def hav_m(lat1, lon1, lat2, lon2):
R = 6371000; p = math.pi/180
a = math.sin((lat2-lat1)*p/2)**2 + math.cos(lat1*p)*math.cos(lat2*p)*math.sin((lon2-lon1)*p/2)**2
return 2*R*math.asin(math.sqrt(a))
# --- Match fixes against known stations (gazetteer table, via the
# [postgres] tunnel — see location-db/gazetteer). Query only the bounding
# box of today's route (with a margin) so we don't pull all 2606 stations
# every run, then do exact nearest-approach matching in Python.
lats = [loc['lat'] for loc in locations]
lons = [loc['lon'] for loc in locations]
margin = 0.05 # degrees, ~5 km
conn = psycopg2.connect(host='127.0.0.1', port=5432, dbname='owntracks', user='owntracks_ro')
cur = conn.cursor()
cur.execute('''
SELECT name, lat, lon FROM gazetteer
WHERE category = 'station'
AND geom && ST_Expand(ST_MakeEnvelope(%s, %s, %s, %s, 4326), %s)
''', (min(lons), min(lats), max(lons), max(lats), margin))
stations = cur.fetchall()
cur.close()
conn.close()
print(f'{len(stations)} candidate stations in the route bounding box')
STATION_THRESHOLD_M = 500
BST = datetime.timezone(datetime.timedelta(hours=1))
visits = {} # name -> (time_bst, dist_m)
for loc in locations:
if loc.get('acc', 999) > 300:
continue
for name, slat, slon in stations:
d = hav_m(loc['lat'], loc['lon'], slat, slon)
if d < STATION_THRESHOLD_M and (name not in visits or d < visits[name][1]):
t = datetime.datetime.fromtimestamp(loc['tst'], tz=BST)
visits[name] = (t, d)
label_map = sorted(((t, name) for name, (t, d) in visits.items()), key=lambda x: x[0])
print(f'Found {len(label_map)} station(s) near the route:')
for t, name in label_map:
print(f' {t.strftime("%H:%M")} {name}')
# --- Plot ---
colours = [direction_colour(r['east_m'], r['north_m']) for _, r in df.iterrows()]
times_num = mdates.date2num(df['time'].tolist())
widths = np.empty(len(times_num))
widths[:-1] = np.diff(times_num) * 0.85
widths[-1] = widths[-2] if len(widths) > 1 else 1/288
sns.set_theme(style='whitegrid')
fig, ax = plt.subplots(figsize=(14, 7.5))
ax.bar(times_num, df['speed_mph'].values, width=widths, color=colours, align='center', edgecolor='none')
ax.xaxis_date()
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
ax.set_ylim(bottom=0)
ax.set_ylabel('Speed (mph)')
ax.set_xlabel('Time (BST)')
ax.set_title(f'Speed over time — {day_start.strftime("%Y-%m-%d")}')
from matplotlib.patches import Patch
ax.legend(handles=[
Patch(color=tuple(RED), label='East'),
Patch(color=tuple(BLUE), label='West'),
Patch(color=tuple(GREY), label='N / S'),
Patch(color=direction_colour(1, 1), label='NE/SE'),
Patch(color=direction_colour(-1, 1), label='NW/SW'),
], loc='upper left', frameon=True, fontsize=8)
# Station labels above the plot: small font, rotated 45°, staggered across
# a few heights to cut down on overlap between neighbouring labels. x is a
# real date (data coords), y is axes-fraction (via get_xaxis_transform), so
# this works regardless of the speed scale.
trans = ax.get_xaxis_transform()
LEVELS = [1.05, 1.22, 1.39, 1.56]
for i, (t, name) in enumerate(label_map):
tn = mdates.date2num(t)
y = LEVELS[i % len(LEVELS)]
ax.axvline(tn, color='#999', linewidth=0.6, ymin=0.0, ymax=y, clip_on=False, alpha=0.5)
ax.annotate(
name, xy=(tn, y), xycoords=trans,
rotation=45, ha='left', va='bottom',
fontsize=6, color='#333', clip_on=False, annotation_clip=False,
)
# Headroom above the axes for the staggered labels.
fig.subplots_adjust(top=0.55, bottom=0.1)
show(fig)
plt.close(fig)2 candidate stations in the route bounding box
Found 0 station(s) near the route: