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,135 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user