// 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 }