feat: workout-suggester, weather-aware workouts from a local Gemma
Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
This commit is contained in:
42 files changed
+7219
No files matched your search
@@ -0,0 +1,216 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// line returns a straight path of about km kilometres going east from p.
|
||||
func line(p Point, km float64) []Point {
|
||||
const kmPerDegLon = 70.0 // at ~51°N
|
||||
return []Point{p, {p.Lat, p.Lon + km/kmPerDegLon/2}, {p.Lat, p.Lon + km/kmPerDegLon}}
|
||||
}
|
||||
|
||||
const overpassJSON = `{"elements":[
|
||||
{"type":"relation","id":1,"tags":{"route":"foot","name":"Park loop","roundtrip":"yes"},
|
||||
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.7357}]}]},
|
||||
{"type":"relation","id":2,"tags":{"route":"bicycle","name":"River ride"},
|
||||
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.9857}]},{"type":"node"}]},
|
||||
{"type":"relation","id":3,"tags":{"route":"foot"},"members":[]},
|
||||
{"type":"node","id":9,"lat":51.051,"lon":3.701,"tags":{"rcn_ref":"56"}}
|
||||
]}`
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
a, err := parse(strings.NewReader(overpassJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Routes) != 2 {
|
||||
t.Fatalf("got %d routes, want 2 (unnamed one skipped): %+v", len(a.Routes), a.Routes)
|
||||
}
|
||||
park, ride := a.Routes[0], a.Routes[1]
|
||||
if park.Kind != Foot || !park.Loop || park.LengthKm < 2.4 || park.LengthKm > 2.6 {
|
||||
t.Errorf("park = %+v", park)
|
||||
}
|
||||
if ride.Kind != Bike || ride.Loop || ride.LengthKm < 19.5 || ride.LengthKm > 20.5 {
|
||||
t.Errorf("ride = %+v", ride)
|
||||
}
|
||||
j, km, ok := a.NearestJunction(Bike, Point{51.05, 3.70})
|
||||
if !ok || j.Ref != "56" || km > 0.2 {
|
||||
t.Errorf("junction = %+v %.2f %v", j, km, ok)
|
||||
}
|
||||
if _, _, ok := a.NearestJunction(Foot, Point{51.05, 3.70}); ok {
|
||||
t.Error("found a walking junction that isn't there")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest(t *testing.T) {
|
||||
home := Point{51.05, 3.70}
|
||||
a := &Area{Routes: []Route{
|
||||
{ID: 1, Name: "short loop", Kind: Foot, Loop: true, LengthKm: 5, Ways: [][]Point{line(home, 5)}},
|
||||
{ID: 2, Name: "perfect loop far away", Kind: Foot, Loop: true, LengthKm: 10, Ways: [][]Point{line(Point{51.2, 3.7}, 10)}},
|
||||
{ID: 3, Name: "long path", Kind: Foot, LengthKm: 30, Ways: [][]Point{line(home, 30)}},
|
||||
{ID: 4, Name: "bike loop", Kind: Bike, Loop: true, LengthKm: 10, Ways: [][]Point{line(home, 10)}},
|
||||
}}
|
||||
got := Suggest(a, Foot, 10, home, 3)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d suggestions, want 2 (one too far, one for bikes): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Route.ID != 1 || got[0].Plan != "laps" || got[0].Laps != 2 || got[0].TotalKm != 10 {
|
||||
t.Errorf("first = %+v, want 2 laps of the short loop", got[0])
|
||||
}
|
||||
if got[1].Plan != "section" || got[1].TotalKm != 10 {
|
||||
t.Errorf("second = %+v, want a 10 km section", got[1])
|
||||
}
|
||||
if s := got[0].Describe(); s != "2 laps of the 5.0 km loop" {
|
||||
t.Errorf("describe = %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearRetriesAndCaches(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
http.Error(w, "406", http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
if calls.Add(1) == 1 {
|
||||
http.Error(w, "busy", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, overpassJSON)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
c := &Client{URLs: []string{srv.URL}, CacheDir: dir}
|
||||
a, err := c.Near(context.Background(), 51.05, 3.70)
|
||||
if err != nil || len(a.Routes) != 2 {
|
||||
t.Fatalf("Near = %v, %v", a, err)
|
||||
}
|
||||
if _, ok := c.Route(2); !ok {
|
||||
t.Error("route 2 not remembered for GPX")
|
||||
}
|
||||
// Same ~2 km cell: no new request. A fresh client reads the disk cache.
|
||||
c.Near(context.Background(), 51.051, 3.701)
|
||||
(&Client{URLs: []string{srv.URL}, CacheDir: dir}).Near(context.Background(), 51.05, 3.70)
|
||||
if n := calls.Load(); n != 2 {
|
||||
t.Errorf("overpass called %d times, want 2 (one busy, one ok)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteGPX(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := &Route{Name: "Park & loop", Ways: [][]Point{{{51, 3}, {51.1, 3.1}}, {{51.2, 3.2}, {51.3, 3.3}}}}
|
||||
if err := WriteGPX(&buf, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var g struct {
|
||||
Name string `xml:"trk>name"`
|
||||
Segs []struct {
|
||||
Pts []struct{} `xml:"trkpt"`
|
||||
} `xml:"trk>trkseg"`
|
||||
}
|
||||
if err := xml.Unmarshal(buf.Bytes(), &g); err != nil {
|
||||
t.Fatal(err, buf.String())
|
||||
}
|
||||
if g.Name != "Park & loop" || len(g.Segs) != 2 || len(g.Segs[1].Pts) != 2 {
|
||||
t.Errorf("gpx = %+v\n%s", g, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoops(t *testing.T) {
|
||||
var dirs []string
|
||||
var mu sync.Mutex
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
mu.Lock()
|
||||
dirs = append(dirs, q.Get("direction"))
|
||||
mu.Unlock()
|
||||
if q.Get("engineMode") != "4" || q.Get("profile") != "hiking-mountain" || q.Get("lonlats") != "4.700,50.880" {
|
||||
http.Error(w, "bad query "+r.URL.RawQuery, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("roundTripDistance") != "1960" { // 10 km / 5.1
|
||||
http.Error(w, "bad distance "+q.Get("roundTripDistance"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("direction") == "330" {
|
||||
http.Error(w, "no route", http.StatusInternalServerError) // one direction failing is fine
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "testdata/brouter.json")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{BRouterURL: srv.URL}
|
||||
// The exact spot is rounded to ~100 m before it's sent.
|
||||
got, err := c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
if !slices.Equal(dirs, []string{"210", "330", "90"}) {
|
||||
t.Errorf("directions asked = %v, want into the wind (90) and ±120°", dirs)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d loops, want 2", len(got))
|
||||
}
|
||||
r := got[0].Route
|
||||
if !r.Generated || !r.Loop || r.LengthKm != 5.9 || r.AscentM != 17 || r.PavedPct < 0 || r.PavedPct > 100 || r.ID >= 0 {
|
||||
t.Errorf("loop = %+v", r)
|
||||
}
|
||||
if !strings.HasPrefix(r.Name, "5.9 km loop heading ") {
|
||||
t.Errorf("name = %q", r.Name)
|
||||
}
|
||||
if _, ok := c.Route(r.ID); !ok {
|
||||
t.Error("loop not remembered for GPX")
|
||||
}
|
||||
n := len(dirs)
|
||||
c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90)
|
||||
if len(dirs) != n {
|
||||
t.Error("second identical request should hit the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPavedPct(t *testing.T) {
|
||||
msgs := [][]string{
|
||||
{"Longitude", "Latitude", "Elevation", "Distance", "WayTags"},
|
||||
{"0", "0", "0", "300", "highway=residential"},
|
||||
{"0", "0", "0", "100", "highway=track surface=asphalt"},
|
||||
{"0", "0", "0", "400", "highway=path"},
|
||||
{"0", "0", "0", "200", "highway=footway surface=gravel"},
|
||||
}
|
||||
if got := pavedPct(msgs); got != 40 {
|
||||
t.Errorf("paved = %d%%, want 40%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntoWind(t *testing.T) {
|
||||
r := &Route{Generated: true, Heading: 270}
|
||||
if !r.IntoWind(250) || r.IntoWind(90) || (&Route{Heading: 270}).IntoWind(270) {
|
||||
t.Error("IntoWind wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSkipsLongTrails(t *testing.T) {
|
||||
home := Point{51.05, 3.70}
|
||||
a := &Area{Routes: []Route{
|
||||
{ID: 1, Name: "Streek-GR", Kind: Foot, LengthKm: 130, Ways: [][]Point{line(home, 130)}},
|
||||
{ID: 3, Name: "Streek-GR loop", Kind: Foot, Loop: true, LengthKm: 130, Ways: [][]Point{line(home, 130)}},
|
||||
{ID: 2, Name: "Park loop", Kind: Foot, Loop: true, LengthKm: 8, Ways: [][]Point{line(Point{51.06, 3.70}, 8)}},
|
||||
}}
|
||||
got := Suggest(a, Foot, 10, home, 3)
|
||||
if len(got) != 1 || got[0].Route.ID != 2 { // loop or not, 130 km is not a 10 km workout
|
||||
t.Errorf("got %+v, want only the park loop", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user