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:
bdeb1337 committed 2026-10-11 19:29:58 +02:00
commit d7342d6096
42 files changed
+7219

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
package weather
import (
"context"
"errors"
"fmt"
"math"
"net/url"
"strings"
"time"
)
// metNo fetches the MET Norway (yr.no) forecast, a free fallback with
// global coverage. Outside the Nordics it has no gusts or rain chance, so
// gusts are estimated from wind speed and the rain chance is left unknown.
func (c *Client) metNo(ctx context.Context, lat, lon float64) (Forecast, error) {
q := url.Values{"lat": {fmt.Sprintf("%.2f", lat)}, "lon": {fmt.Sprintf("%.2f", lon)}}
var raw struct {
Properties struct {
Timeseries []struct {
Time time.Time `json:"time"`
Data struct {
Instant struct {
Details map[string]float64 `json:"details"`
} `json:"instant"`
Next1 *struct {
Summary struct {
Symbol string `json:"symbol_code"`
} `json:"summary"`
Details map[string]float64 `json:"details"`
} `json:"next_1_hours"`
} `json:"data"`
} `json:"timeseries"`
} `json:"properties"`
}
u := or(c.MetNoURL, "https://api.met.no/weatherapi/locationforecast/2.0/complete") + "?" + q.Encode()
if err := c.getJSON(ctx, u, &raw); err != nil {
return Forecast{}, err
}
now := time.Now()
if c.now != nil {
now = c.now()
}
f := Forecast{Source: "MET Norway"}
f.Sunrise, f.Sunset = sunTimes(now, lat, lon)
if f.Sunset.Before(now) {
f.Sunrise, f.Sunset = sunTimes(now.Add(24*time.Hour), lat, lon)
}
for _, t := range raw.Properties.Timeseries {
if t.Time.Before(now.Truncate(time.Hour)) || t.Data.Next1 == nil {
continue
}
if len(f.Hours) == 24 {
break
}
d, n := t.Data.Instant.Details, t.Data.Next1.Details
h := Hour{
Time: t.Time.Local(),
Temp: d["air_temperature"],
Feels: d["air_temperature"],
Precip: n["precipitation_amount"],
PrecipProb: -1,
Code: symbolCode(t.Data.Next1.Summary.Symbol),
Wind: d["wind_speed"] * 3.6,
WindDirection: int(math.Round(d["wind_from_direction"])),
IsDay: isDay(t.Time, lat, lon),
}
if v, ok := d["apparent_air_temperature"]; ok {
h.Feels = v
}
if v, ok := n["probability_of_precipitation"]; ok {
h.PrecipProb = int(math.Round(v))
}
if v, ok := d["wind_speed_of_gust"]; ok {
h.Gusts = v * 3.6
} else {
h.Gusts = h.Wind * 1.5 // typical gust factor over open land
f.GustsEstimated = true
}
f.Hours = append(f.Hours, h)
}
if len(f.Hours) == 0 {
return Forecast{}, errors.New("met norway: no hourly forecast")
}
f.Now = f.Hours[0]
f.Now.Time = now.Truncate(time.Minute)
return f, nil
}
// symbolCode maps a MET Norway symbol ("lightrainshowers_day") to the
// closest WMO weather code.
func symbolCode(sym string) int {
base, _, _ := strings.Cut(sym, "_")
switch {
case strings.Contains(base, "thunder"):
return 95
case strings.Contains(base, "sleet"):
if strings.HasPrefix(base, "light") {
return 66
}
return 67
case strings.HasSuffix(base, "snowshowers"):
if strings.HasPrefix(base, "heavy") {
return 86
}
return 85
case strings.HasSuffix(base, "rainshowers"):
switch {
case strings.HasPrefix(base, "light"):
return 80
case strings.HasPrefix(base, "heavy"):
return 82
}
return 81
}
if code, ok := map[string]int{
"clearsky": 0, "fair": 1, "partlycloudy": 2, "cloudy": 3, "fog": 45,
"lightrain": 61, "rain": 63, "heavyrain": 65,
"lightsnow": 71, "snow": 73, "heavysnow": 75,
}[base]; ok {
return code
}
return 3 // unknown: say overcast rather than promise sunshine
}
func isDay(t time.Time, lat, lon float64) bool {
rise, set := sunTimes(t, lat, lon)
return !t.Before(rise) && t.Before(set)
}
// sunTimes computes sunrise and sunset for t's UTC date with the standard
// sunrise equation (good to a minute or two), in local time.
func sunTimes(t time.Time, lat, lon float64) (rise, set time.Time) {
const rad = math.Pi / 180
y, m, d := t.UTC().Date()
noon := float64(time.Date(y, m, d, 12, 0, 0, 0, time.UTC).Unix())/86400 + 2440587.5 // Julian date
n := math.Round(noon - 2451545.0) // days since J2000
jStar := n - lon/360
M := math.Mod(357.5291+0.98560028*jStar, 360)
C := 1.9148*math.Sin(M*rad) + 0.02*math.Sin(2*M*rad) + 0.0003*math.Sin(3*M*rad)
lambda := math.Mod(M+C+180+102.9372, 360)
transit := 2451545.0 + jStar + 0.0053*math.Sin(M*rad) - 0.0069*math.Sin(2*lambda*rad)
sinDec := math.Sin(lambda*rad) * math.Sin(23.4397*rad)
cosDec := math.Cos(math.Asin(sinDec))
cosW := (math.Sin(-0.833*rad) - math.Sin(lat*rad)*sinDec) / (math.Cos(lat*rad) * cosDec)
cosW = math.Max(-1, math.Min(1, cosW)) // polar day/night: clamp
w := math.Acos(cosW) / rad
toTime := func(j float64) time.Time {
return time.Unix(int64(math.Round((j-2440587.5)*86400)), 0).Local()
}
return toTime(transit - w/360), toTime(transit + w/360)
}