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.
325 lines
8.0 KiB
Go
325 lines
8.0 KiB
Go
// 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
|
||
}
|