Files
workout-suggester/internal/routes/suggest.go
T
bdeb1337 d7342d6096 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.
2026-10-11 19:29:58 +02:00

136 lines
3.7 KiB
Go

package routes
import (
"encoding/xml"
"fmt"
"io"
"math"
"slices"
)
// Suggestion is a way to use a route for a workout of a given distance.
type Suggestion struct {
Route *Route
Plan string // "loop", "laps", "out-and-back" or "section"
Laps int // for "laps"
TotalKm float64 // what the user will actually cover on the route
FromYouKm float64 // to the nearest point of the route
JoinAt Point
}
// Describe says in a few words how to use the route.
func (s Suggestion) Describe() string {
switch s.Plan {
case "laps":
return fmt.Sprintf("%d laps of the %.1f km loop", s.Laps, s.Route.LengthKm)
case "out-and-back":
return fmt.Sprintf("out and back, %.1f km each way", s.Route.LengthKm)
case "section":
return fmt.Sprintf("a %.0f km stretch (out and back) of this %.0f km route", s.TotalKm, s.Route.LengthKm)
}
if s.Route.Generated {
return "from your door and back"
}
if s.Route.Loop {
return fmt.Sprintf("the full %.1f km loop", s.Route.LengthKm)
}
return fmt.Sprintf("the full %.1f km route", s.Route.LengthKm)
}
// maxApproachKm is how far we'd send someone to reach a route's start.
var maxApproachKm = map[Kind]float64{Bike: 6, Foot: 3}
// Suggest picks up to n routes of a kind that fit targetKm, starting from p.
func Suggest(a *Area, kind Kind, targetKm float64, p Point, n int) []Suggestion {
type scored struct {
Suggestion
cost float64
}
var all []scored
for i := range a.Routes {
r := &a.Routes[i]
if r.Kind != kind {
continue
}
if r.LengthKm > 3*targetKm {
continue // long-distance trails (Streek-GR & co) aren't a workout route
}
join, from := r.Closest(p)
if from > maxApproachKm[kind] {
continue
}
s := Suggestion{Route: r, Plan: "loop", Laps: 1, TotalKm: r.LengthKm, FromYouKm: from, JoinAt: join}
switch {
case r.Loop && r.LengthKm < 0.75*targetKm:
s.Laps = min(4, max(1, int(math.Round(targetKm/r.LengthKm))))
if s.Laps > 1 {
s.Plan = "laps"
}
s.TotalKm = r.LengthKm * float64(s.Laps)
case !r.Loop && 2*r.LengthKm <= 1.3*targetKm:
s.Plan, s.TotalKm = "out-and-back", 2*r.LengthKm
case r.LengthKm > 1.3*targetKm:
s.Plan, s.TotalKm = "section", max(targetKm/2, targetKm-2*from)
}
// Getting to the start and back is part of the workout.
cost := math.Abs(s.TotalKm+2*from-targetKm)/targetKm + 0.03*from
if !r.Loop {
cost += 0.3 // loops bring you home
}
if s.Plan == "section" {
cost += 0.15
}
if s.Laps > 2 {
cost += 0.1 // laps get dull
}
all = append(all, scored{s, cost})
}
slices.SortFunc(all, func(x, y scored) int {
switch {
case x.cost < y.cost:
return -1
case x.cost > y.cost:
return 1
}
return 0
})
var out []Suggestion
for _, s := range all[:min(n, len(all))] {
out = append(out, s.Suggestion)
}
return out
}
// WriteGPX writes a route as a GPX track, one segment per OSM way, ready for
// a watch, bike computer or OsmAnd.
func WriteGPX(w io.Writer, r *Route) error {
type trkpt struct {
Lat float64 `xml:"lat,attr"`
Lon float64 `xml:"lon,attr"`
}
type trkseg struct {
Points []trkpt `xml:"trkpt"`
}
type gpx struct {
XMLName xml.Name `xml:"gpx"`
Version string `xml:"version,attr"`
Creator string `xml:"creator,attr"`
Xmlns string `xml:"xmlns,attr"`
Name string `xml:"trk>name"`
Segs []trkseg `xml:"trk>trkseg"`
}
g := gpx{Version: "1.1", Creator: "workout-suggester (data © OpenStreetMap contributors, ODbL)",
Xmlns: "http://www.topografix.com/GPX/1/1", Name: r.Name}
for _, way := range r.Ways {
var seg trkseg
for _, p := range way {
seg.Points = append(seg.Points, trkpt{p.Lat, p.Lon})
}
g.Segs = append(g.Segs, seg)
}
io.WriteString(w, xml.Header)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
return enc.Encode(g)
}