Files
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

371 lines
11 KiB
Go
Raw Permalink 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 weather fetches forecasts and place names from Open-Meteo
// (open source, no API key), with MET Norway and Nominatim as fallbacks.
// Coordinates are rounded to ~1 km before they leave the machine.
package weather
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Hour is the weather for one hour of the forecast.
type Hour struct {
Time time.Time
Temp float64 // °C
Feels float64 // apparent temperature, °C
PrecipProb int // %, or -1 when the source doesn't say
Precip float64 // mm
Code int // WMO weather code
Wind float64 // km/h
Gusts float64 // km/h
WindDirection int // degrees, where the wind comes from
IsDay bool
}
// Forecast is the current weather plus the next 24 hours.
type Forecast struct {
Now Hour
Hours []Hour // starting at the current hour
Sunrise time.Time
Sunset time.Time
Source string // who made the forecast, shown in the UI
GustsEstimated bool // the source had no gusts; they're derived from wind speed
}
// Place is a geocoded location.
type Place struct {
Name string
Lat float64
Lon float64
}
// Client fetches forecasts from Open-Meteo, falling back to MET Norway when
// Open-Meteo is down, and caches them for 15 minutes.
type Client struct {
ForecastURL string // default https://api.open-meteo.com/v1/forecast
GeocodeURL string // default https://geocoding-api.open-meteo.com/v1/search
MetNoURL string // default https://api.met.no/weatherapi/locationforecast/2.0/complete; "-" disables
NominatimURL string // default https://nominatim.openstreetmap.org/search; "-" disables
HTTP *http.Client
now func() time.Time // for tests; nil means time.Now
mu sync.Mutex
cache map[string]cached
}
type cached struct {
f Forecast
at time.Time
}
const (
cacheFor = 15 * time.Minute
staleFor = 3 * time.Hour // when every source is down, an older forecast beats none
userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)"
)
func (c *Client) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return http.DefaultClient
}
func or(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
// Round keeps two decimals (~1 km), which is plenty for weather.
func Round(v float64) float64 { return math.Round(v*100) / 100 }
// getJSON fetches u, retrying once when the server is busy (429/5xx).
func (c *Client) getJSON(ctx context.Context, u string, out any) error {
var err error
for attempt := range 2 {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
var retry bool
if retry, err = c.getOnce(ctx, u, out); !retry {
return err
}
}
return err
}
func (c *Client) getOnce(ctx context.Context, u string, out any) (retry bool, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, err
}
req.Header.Set("User-Agent", userAgent) // MET Norway and Nominatim require one
req.Header.Set("Accept", "application/json")
resp, err := c.http().Do(req)
if err != nil {
return ctx.Err() == nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
busy := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
return busy, fmt.Errorf("%s returned %s: %s", req.URL.Host, resp.Status, bytes.TrimSpace(msg))
}
return false, json.NewDecoder(resp.Body).Decode(out)
}
// Geocode finds a town or city by name, via Open-Meteo or else Nominatim.
func (c *Client) Geocode(ctx context.Context, name string) (Place, error) {
q := url.Values{"name": {name}, "count": {"1"}, "language": {"en"}, "format": {"json"}}
var out struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
} `json:"results"`
}
err := c.getJSON(ctx, or(c.GeocodeURL, "https://geocoding-api.open-meteo.com/v1/search")+"?"+q.Encode(), &out)
if err == nil {
if len(out.Results) == 0 {
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
}
r := out.Results[0]
n := r.Name
if r.Country != "" {
n += ", " + r.Country
}
return Place{Name: n, Lat: r.Lat, Lon: r.Lon}, nil
}
if c.NominatimURL == "-" {
return Place{}, err
}
p, err2 := c.nominatim(ctx, name)
if err2 != nil {
return Place{}, errors.Join(err, err2)
}
return p, nil
}
func (c *Client) nominatim(ctx context.Context, name string) (Place, error) {
q := url.Values{"q": {name}, "format": {"jsonv2"}, "limit": {"1"}, "accept-language": {"en"}}
var out []struct {
Name string `json:"name"`
Display string `json:"display_name"`
Lat string `json:"lat"`
Lon string `json:"lon"`
}
if err := c.getJSON(ctx, or(c.NominatimURL, "https://nominatim.openstreetmap.org/search")+"?"+q.Encode(), &out); err != nil {
return Place{}, err
}
if len(out) == 0 {
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
}
lat, err1 := strconv.ParseFloat(out[0].Lat, 64)
lon, err2 := strconv.ParseFloat(out[0].Lon, 64)
if err1 != nil || err2 != nil {
return Place{}, errors.New("nominatim: bad coordinates")
}
n := out[0].Name
if parts := strings.Split(out[0].Display, ", "); len(parts) > 1 {
n += ", " + parts[len(parts)-1] // country
}
return Place{Name: n, Lat: lat, Lon: lon}, nil
}
// Forecast returns current weather and the next 24 hours.
func (c *Client) Forecast(ctx context.Context, lat, lon float64) (Forecast, error) {
lat, lon = Round(lat), Round(lon)
key := fmt.Sprintf("%.2f,%.2f", lat, lon)
c.mu.Lock()
hit, ok := c.cache[key]
c.mu.Unlock()
if ok && time.Since(hit.at) < cacheFor {
return hit.f, nil
}
f, err := c.openMeteo(ctx, lat, lon)
if err != nil && c.MetNoURL != "-" {
var err2 error
if f, err2 = c.metNo(ctx, lat, lon); err2 != nil {
err = errors.Join(err, err2)
} else {
err = nil
}
}
if err != nil {
if ok && time.Since(hit.at) < staleFor {
hit.f.Source += fmt.Sprintf(", from %s", hit.at.Format("15:04"))
return hit.f, nil
}
return Forecast{}, err
}
c.mu.Lock()
if c.cache == nil {
c.cache = make(map[string]cached)
}
c.cache[key] = cached{f, time.Now()}
c.mu.Unlock()
return f, nil
}
func (c *Client) openMeteo(ctx context.Context, lat, lon float64) (Forecast, error) {
vars := "temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,wind_direction_10m,is_day"
q := url.Values{
"latitude": {strconv.FormatFloat(lat, 'f', 2, 64)},
"longitude": {strconv.FormatFloat(lon, 'f', 2, 64)},
"current": {vars},
"hourly": {vars + ",precipitation_probability"},
"daily": {"sunrise,sunset"},
"forecast_hours": {"24"},
"forecast_days": {"2"},
"timezone": {"auto"},
}
var raw apiResponse
if err := c.getJSON(ctx, or(c.ForecastURL, "https://api.open-meteo.com/v1/forecast")+"?"+q.Encode(), &raw); err != nil {
return Forecast{}, err
}
return raw.forecast()
}
type apiResponse struct {
UTCOffset int `json:"utc_offset_seconds"`
Current struct {
Time string `json:"time"`
Temp float64 `json:"temperature_2m"`
Feels float64 `json:"apparent_temperature"`
Precip float64 `json:"precipitation"`
Code int `json:"weather_code"`
Wind float64 `json:"wind_speed_10m"`
Gusts float64 `json:"wind_gusts_10m"`
Dir int `json:"wind_direction_10m"`
IsDay int `json:"is_day"`
} `json:"current"`
Hourly struct {
Time []string `json:"time"`
Temp []float64 `json:"temperature_2m"`
Feels []float64 `json:"apparent_temperature"`
Precip []float64 `json:"precipitation"`
PrecipProb []int `json:"precipitation_probability"`
Code []int `json:"weather_code"`
Wind []float64 `json:"wind_speed_10m"`
Gusts []float64 `json:"wind_gusts_10m"`
Dir []int `json:"wind_direction_10m"`
IsDay []int `json:"is_day"`
} `json:"hourly"`
Daily struct {
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
} `json:"daily"`
}
func (r apiResponse) forecast() (Forecast, error) {
loc := time.FixedZone("local", r.UTCOffset)
parse := func(s string) time.Time {
t, _ := time.ParseInLocation("2006-01-02T15:04", s, loc)
return t
}
h := r.Hourly
n := len(h.Time)
if n == 0 || len(h.Temp) != n || len(h.Feels) != n || len(h.Precip) != n || len(h.PrecipProb) != n ||
len(h.Code) != n || len(h.Wind) != n || len(h.Gusts) != n || len(h.Dir) != n || len(h.IsDay) != n {
return Forecast{}, errors.New("open-meteo: incomplete hourly forecast")
}
f := Forecast{Source: "Open-Meteo", Now: Hour{
Time: parse(r.Current.Time), Temp: r.Current.Temp, Feels: r.Current.Feels,
Precip: r.Current.Precip, Code: r.Current.Code, Wind: r.Current.Wind,
Gusts: r.Current.Gusts, WindDirection: r.Current.Dir, IsDay: r.Current.IsDay == 1,
}}
for i := range n {
f.Hours = append(f.Hours, Hour{
Time: parse(h.Time[i]), Temp: h.Temp[i], Feels: h.Feels[i], PrecipProb: h.PrecipProb[i],
Precip: h.Precip[i], Code: h.Code[i], Wind: h.Wind[i], Gusts: h.Gusts[i],
WindDirection: h.Dir[i], IsDay: h.IsDay[i] == 1,
})
}
// The current hour's rain chance is the best guess for "now".
f.Now.PrecipProb = f.Hours[0].PrecipProb
// Today's sunrise/sunset, or tomorrow's once today's sun has set.
for i := range r.Daily.Sunset {
f.Sunrise, f.Sunset = parse(r.Daily.Sunrise[i]), parse(r.Daily.Sunset[i])
if f.Sunset.After(f.Now.Time) {
break
}
}
return f, nil
}
// Describe turns a WMO weather code into words and an emoji.
func Describe(code int) (string, string) {
switch {
case code == 0:
return "clear sky", "☀️"
case code == 1:
return "mostly clear", "🌤️"
case code == 2:
return "partly cloudy", "⛅"
case code == 3:
return "overcast", "☁️"
case code == 45 || code == 48:
return "fog", "🌫️"
case code >= 51 && code <= 55:
return "drizzle", "🌦️"
case code == 56 || code == 57:
return "freezing drizzle", "🧊"
case code >= 61 && code <= 65:
return "rain", "🌧️"
case code == 66 || code == 67:
return "freezing rain", "🧊"
case code >= 71 && code <= 77:
return "snow", "🌨️"
case code >= 80 && code <= 82:
return "rain showers", "🌦️"
case code == 85 || code == 86:
return "snow showers", "🌨️"
case code >= 95:
return "thunderstorm", "⛈️"
}
return "unknown", "🌡️"
}
// Icon is Describe's emoji, with a moon for clear nights.
func (h Hour) Icon() string {
_, icon := Describe(h.Code)
if !h.IsDay && h.Code <= 1 {
return "🌙"
}
return icon
}
// Chance is the rain chance as text, "–" when unknown.
func (h Hour) Chance() string {
if h.PrecipProb < 0 {
return "–"
}
return fmt.Sprintf("%d%%", h.PrecipProb)
}
// Compass turns a wind direction in degrees into N, NE, E, ...
func Compass(deg int) string {
dirs := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
return dirs[((deg%360+360)%360+22)/45%8]
}