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,324 @@
|
||||
// Package coach decides which workouts the weather allows. The scoring is
|
||||
// plain rules so the numbers are always right; the language model only
|
||||
// explains the result and fills in the workout itself.
|
||||
package coach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// Activity is something to do.
|
||||
type Activity string
|
||||
|
||||
const (
|
||||
Any Activity = ""
|
||||
Run Activity = "run"
|
||||
Ride Activity = "ride"
|
||||
Walk Activity = "walk"
|
||||
Indoor Activity = "indoor"
|
||||
)
|
||||
|
||||
// Outdoor lists the activities we score against the weather.
|
||||
var Outdoor = []Activity{Run, Ride, Walk}
|
||||
|
||||
// Label is a human name with an emoji.
|
||||
func (a Activity) Label() string {
|
||||
switch a {
|
||||
case Run:
|
||||
return "🏃 Run"
|
||||
case Ride:
|
||||
return "🚴 Ride"
|
||||
case Walk:
|
||||
return "🥾 Walk / hike"
|
||||
case Indoor:
|
||||
return "🏠 Indoor workout"
|
||||
}
|
||||
return "✨ Surprise me"
|
||||
}
|
||||
|
||||
// Noun is the activity as a word for prompts.
|
||||
func (a Activity) Noun() string {
|
||||
switch a {
|
||||
case Run:
|
||||
return "running"
|
||||
case Ride:
|
||||
return "cycling"
|
||||
case Walk:
|
||||
return "walking/hiking"
|
||||
case Indoor:
|
||||
return "an indoor workout (HIIT, bodyweight or gym)"
|
||||
}
|
||||
return "anything"
|
||||
}
|
||||
|
||||
// RouteKind is the kind of signposted route that suits the activity.
|
||||
func (a Activity) RouteKind() routes.Kind {
|
||||
if a == Ride {
|
||||
return routes.Bike
|
||||
}
|
||||
return routes.Foot
|
||||
}
|
||||
|
||||
// Intensity is how hard the user wants to go.
|
||||
type Intensity string
|
||||
|
||||
const (
|
||||
Easy Intensity = "easy"
|
||||
Moderate Intensity = "moderate"
|
||||
Hard Intensity = "hard"
|
||||
)
|
||||
|
||||
// Request is what the user asked for.
|
||||
type Request struct {
|
||||
Activity Activity
|
||||
Intensity Intensity
|
||||
Minutes int
|
||||
Note string
|
||||
}
|
||||
|
||||
// Score is how good the weather is for an activity, 0–100, with the reasons
|
||||
// points were taken off.
|
||||
type Score struct {
|
||||
Activity Activity
|
||||
Value int
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
// Verdict buckets a score.
|
||||
func (s Score) Verdict() string {
|
||||
switch {
|
||||
case s.Value >= 75:
|
||||
return "great"
|
||||
case s.Value >= 50:
|
||||
return "fine"
|
||||
case s.Value >= 30:
|
||||
return "meh"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// Later is a noticeably better window later today.
|
||||
type Later struct {
|
||||
Start time.Time
|
||||
Activity Activity
|
||||
Score int
|
||||
}
|
||||
|
||||
// Assessment is the full verdict for a request.
|
||||
type Assessment struct {
|
||||
Scores []Score // now, one per outdoor activity
|
||||
Pick Activity
|
||||
Later *Later
|
||||
TargetKm float64 // 0 for indoor
|
||||
Pace string
|
||||
}
|
||||
|
||||
// IndoorBelow is the best outdoor score under which we send people inside.
|
||||
const IndoorBelow = 40
|
||||
|
||||
// Assess scores every outdoor activity for the user's time window and picks one.
|
||||
func Assess(f weather.Forecast, req Request) Assessment {
|
||||
n := windowHours(f, req.Minutes)
|
||||
now := f.Hours[:min(n, len(f.Hours))]
|
||||
var a Assessment
|
||||
for _, act := range Outdoor {
|
||||
a.Scores = append(a.Scores, score(act, now))
|
||||
}
|
||||
a.Pick = pick(a.Scores, req)
|
||||
a.Later = later(f, req, n, a.Scores)
|
||||
a.TargetKm, a.Pace = target(a.Pick, req.Intensity, req.Minutes)
|
||||
return a
|
||||
}
|
||||
|
||||
// ScoreOf returns the score for one activity.
|
||||
func (a Assessment) ScoreOf(act Activity) Score {
|
||||
for _, s := range a.Scores {
|
||||
if s.Activity == act {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return Score{Activity: act}
|
||||
}
|
||||
|
||||
func windowHours(f weather.Forecast, minutes int) int {
|
||||
return max(1, int(math.Ceil(float64(f.Now.Time.Minute()+minutes)/60)))
|
||||
}
|
||||
|
||||
func pick(scores []Score, req Request) Activity {
|
||||
if req.Activity == Indoor {
|
||||
return Indoor
|
||||
}
|
||||
byAct := map[Activity]int{}
|
||||
for _, s := range scores {
|
||||
byAct[s.Activity] = s.Value
|
||||
}
|
||||
if req.Activity != Any && byAct[req.Activity] >= 50 {
|
||||
return req.Activity
|
||||
}
|
||||
best, bestV := Indoor, -1
|
||||
for _, act := range Outdoor {
|
||||
v := byAct[act]
|
||||
if act == Walk { // a stroll is an easy workout
|
||||
v += map[Intensity]int{Easy: 5, Moderate: -10, Hard: -20}[req.Intensity]
|
||||
}
|
||||
if v > bestV {
|
||||
best, bestV = act, v
|
||||
}
|
||||
}
|
||||
if byAct[best] < IndoorBelow {
|
||||
return Indoor
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// later looks up to 12 hours ahead for a daylight window that is clearly
|
||||
// better than now for the picked (or wanted) activity.
|
||||
func later(f weather.Forecast, req Request, n int, nowScores []Score) *Later {
|
||||
acts := Outdoor
|
||||
if req.Activity != Any && req.Activity != Indoor {
|
||||
acts = []Activity{req.Activity}
|
||||
}
|
||||
bestNow := 0
|
||||
for _, s := range nowScores {
|
||||
if slices.Contains(acts, s.Activity) {
|
||||
bestNow = max(bestNow, s.Value)
|
||||
}
|
||||
}
|
||||
if bestNow >= 75 {
|
||||
return nil
|
||||
}
|
||||
for start := 1; start <= 12 && start+n <= len(f.Hours); start++ {
|
||||
win := f.Hours[start : start+n]
|
||||
if !win[0].IsDay {
|
||||
continue
|
||||
}
|
||||
for _, act := range acts {
|
||||
s := score(act, win)
|
||||
if s.Value >= 60 && s.Value >= bestNow+20 {
|
||||
return &Later{Start: win[0].Time, Activity: act, Score: s.Value}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// score applies simple, explainable rules to the worst hour in the window.
|
||||
func score(act Activity, hours []weather.Hour) Score {
|
||||
var (
|
||||
maxPrecip, maxGust, maxWind float64
|
||||
minFeels, maxFeels = math.Inf(1), math.Inf(-1)
|
||||
maxProb int
|
||||
thunder, ice, snow, fog bool
|
||||
dark bool
|
||||
)
|
||||
for _, h := range hours {
|
||||
maxPrecip = max(maxPrecip, h.Precip)
|
||||
maxGust = max(maxGust, h.Gusts)
|
||||
maxWind = max(maxWind, h.Wind)
|
||||
minFeels = min(minFeels, h.Feels)
|
||||
maxFeels = max(maxFeels, h.Feels)
|
||||
maxProb = max(maxProb, h.PrecipProb)
|
||||
thunder = thunder || h.Code >= 95
|
||||
ice = ice || h.Code == 56 || h.Code == 57 || h.Code == 66 || h.Code == 67
|
||||
snow = snow || (h.Code >= 71 && h.Code <= 77) || h.Code == 85 || h.Code == 86
|
||||
fog = fog || h.Code == 45 || h.Code == 48
|
||||
dark = dark || !h.IsDay
|
||||
}
|
||||
|
||||
s := Score{Activity: act, Value: 100}
|
||||
take := func(points int, why string) {
|
||||
if points > 0 {
|
||||
s.Value -= points
|
||||
s.Reasons = append(s.Reasons, why)
|
||||
}
|
||||
}
|
||||
// per activity: ride, run, walk
|
||||
by := func(ride, run, walk int) int {
|
||||
switch act {
|
||||
case Ride:
|
||||
return ride
|
||||
case Run:
|
||||
return run
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
if thunder {
|
||||
take(100, "thunderstorms: stay off open ground")
|
||||
}
|
||||
switch {
|
||||
case maxPrecip >= 2:
|
||||
take(by(70, 65, 65), fmt.Sprintf("heavy rain (up to %.1f mm/h)", maxPrecip))
|
||||
case maxPrecip >= 0.5:
|
||||
take(by(40, 30, 30), fmt.Sprintf("rain (up to %.1f mm/h)", maxPrecip))
|
||||
case maxPrecip >= 0.1:
|
||||
take(by(15, 5, 10), "light drizzle")
|
||||
case maxProb >= 60:
|
||||
take(10, fmt.Sprintf("%d%% chance of rain", maxProb))
|
||||
}
|
||||
switch {
|
||||
case maxGust >= 60:
|
||||
take(by(70, 40, 35), fmt.Sprintf("storm gusts up to %.0f km/h", maxGust))
|
||||
case maxGust >= 45:
|
||||
take(by(40, 15, 10), fmt.Sprintf("strong gusts up to %.0f km/h", maxGust))
|
||||
case maxGust >= 35:
|
||||
take(by(20, 5, 0), fmt.Sprintf("gusty, up to %.0f km/h", maxGust))
|
||||
}
|
||||
if maxWind >= 25 {
|
||||
take(by(15, 5, 0), fmt.Sprintf("steady %.0f km/h wind", maxWind))
|
||||
}
|
||||
switch {
|
||||
case minFeels < -5:
|
||||
take(by(50, 30, 35), fmt.Sprintf("bitter cold (feels like %.0f°C)", minFeels))
|
||||
case minFeels < 2:
|
||||
take(by(20, 5, 10), fmt.Sprintf("cold (feels like %.0f°C)", minFeels))
|
||||
}
|
||||
switch {
|
||||
case maxFeels > 30:
|
||||
take(by(15, 40, 20), fmt.Sprintf("hot (feels like %.0f°C)", maxFeels))
|
||||
case maxFeels > 26:
|
||||
take(by(5, 15, 5), fmt.Sprintf("warm (feels like %.0f°C)", maxFeels))
|
||||
}
|
||||
if ice {
|
||||
take(by(60, 40, 30), "freezing rain: slippery")
|
||||
} else if snow {
|
||||
take(by(50, 20, 10), "snow")
|
||||
}
|
||||
if fog {
|
||||
take(by(15, 5, 5), "fog: low visibility")
|
||||
}
|
||||
if dark {
|
||||
take(by(10, 5, 10), "dark: bring lights and something reflective")
|
||||
}
|
||||
s.Value = max(0, s.Value)
|
||||
return s
|
||||
}
|
||||
|
||||
// target turns time and intensity into a distance at a sensible pace.
|
||||
func target(act Activity, in Intensity, minutes int) (km float64, pace string) {
|
||||
i := map[Intensity]int{Easy: 0, Moderate: 1, Hard: 2}[in]
|
||||
h := float64(minutes) / 60
|
||||
switch act {
|
||||
case Run:
|
||||
minPerKm := []float64{6.5, 5.75, 5.0}[i]
|
||||
km = float64(minutes) / minPerKm
|
||||
pace = fmt.Sprintf("about %d:%02d min/km", int(minPerKm), int(math.Round((minPerKm-math.Floor(minPerKm))*60)))
|
||||
case Ride:
|
||||
kmh := []float64{18, 24, 28}[i]
|
||||
km = h * kmh
|
||||
pace = fmt.Sprintf("about %.0f km/h", kmh)
|
||||
case Walk:
|
||||
kmh := []float64{4.5, 5, 5.5}[i]
|
||||
km = h * kmh
|
||||
pace = fmt.Sprintf("about %.1f km/h", kmh)
|
||||
default:
|
||||
return 0, ""
|
||||
}
|
||||
return math.Round(km*2) / 2, pace
|
||||
}
|
||||
Reference in new issue
Block a user