Files
workout-suggester/internal/coach/coach.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

325 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}