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)
}
+1
View File
@@ -0,0 +1 @@
{"latitude":51.052,"longitude":3.712,"generationtime_ms":0.3993511199951172,"utc_offset_seconds":7200,"timezone":"Europe/Brussels","timezone_abbreviation":"GMT+2","elevation":9.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h","wind_gusts_10m":"km/h","wind_direction_10m":"°","is_day":""},"current":{"time":"2026-10-11T16:30","interval":900,"temperature_2m":14.7,"apparent_temperature":11.7,"precipitation":0.00,"weather_code":0,"wind_speed_10m":12.2,"wind_gusts_10m":27.7,"wind_direction_10m":276,"is_day":1},"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h","wind_gusts_10m":"km/h","wind_direction_10m":"°","is_day":"","precipitation_probability":"%"},"hourly":{"time":["2026-10-11T16:00","2026-10-11T17:00","2026-10-11T18:00","2026-10-11T19:00","2026-10-11T20:00","2026-10-11T21:00","2026-10-11T22:00","2026-10-11T23:00","2026-10-12T00:00","2026-10-12T01:00","2026-10-12T02:00","2026-10-12T03:00","2026-10-12T04:00","2026-10-12T05:00","2026-10-12T06:00","2026-10-12T07:00","2026-10-12T08:00","2026-10-12T09:00","2026-10-12T10:00","2026-10-12T11:00","2026-10-12T12:00","2026-10-12T13:00","2026-10-12T14:00","2026-10-12T15:00"],"temperature_2m":[14.7,14.6,14.1,13.0,11.4,10.6,10.7,10.7,10.2,10.2,9.4,8.8,9.2,10.0,10.2,10.2,10.6,11.0,12.0,13.1,15.0,16.5,17.3,17.9],"apparent_temperature":[11.7,11.6,11.4,11.0,9.4,8.5,8.9,8.2,7.5,7.7,7.6,7.2,7.7,8.8,9.0,8.7,9.2,9.6,10.7,11.6,13.0,14.0,14.7,15.2],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.10,0.10,0.00,0.00,0.00,0.00,0.00,0.00],"weather_code":[0,0,0,0,0,0,3,0,0,0,0,0,3,3,3,3,51,51,2,3,0,2,2,1],"wind_speed_10m":[12.6,11.9,10.4,6.8,6.5,6.5,4.7,8.3,9.0,9.7,8.6,7.2,7.6,6.8,6.1,8.3,8.6,8.6,8.6,9.4,12.2,14.0,14.0,14.4],"wind_gusts_10m":[28.8,26.3,24.1,19.8,9.4,7.6,7.6,11.5,11.9,16.6,14.8,12.6,12.6,13.0,11.5,14.4,15.8,15.8,16.2,18.7,25.2,28.8,29.5,30.6],"wind_direction_10m":[274,277,276,249,206,177,166,179,186,182,189,169,184,175,139,142,141,141,152,171,184,184,190,187],"is_day":[1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1],"precipitation_probability":[12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601"},"daily":{"time":["2026-10-11","2026-10-12"],"sunrise":["2026-10-11T08:01","2026-10-12T08:03"],"sunset":["2026-10-11T19:01","2026-10-12T18:59"]}}
+1
View File
@@ -0,0 +1 @@
{"results":[{"id":2797656,"name":"Ghent","latitude":51.05,"longitude":3.71667,"elevation":10.0,"feature_code":"PPL","country_code":"BE","admin1_id":3337388,"admin2_id":2789733,"admin3_id":2797655,"admin4_id":2797657,"timezone":"Europe/Brussels","population":265086,"postcodes":["9000"],"country_id":2802361,"country":"Belgium","admin1":"Flanders","admin2":"East Flanders","admin3":"Arrondissement of Ghent","admin4":"Gent"}],"generationtime_ms":0.7789135}
+1
View File
@@ -0,0 +1 @@
{"type":"Feature","geometry":{"type":"Point","coordinates":[3.72,51.05,13]},"properties":{"meta":{"updated_at":"2026-10-11T13:19:22Z","units":{"air_pressure_at_sea_level":"hPa","air_temperature":"celsius","air_temperature_max":"celsius","air_temperature_min":"celsius","apparent_air_temperature":"celsius","cloud_area_fraction":"%","cloud_area_fraction_high":"%","cloud_area_fraction_low":"%","cloud_area_fraction_medium":"%","dew_point_temperature":"celsius","fog_area_fraction":"%","precipitation_amount":"mm","relative_humidity":"%","ultraviolet_index_clear_sky":"1","wind_from_direction":"degrees","wind_speed":"m/s"}},"timeseries":[{"time":"2026-10-11T16:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1014.1,"air_temperature":13.1,"apparent_air_temperature":12.5,"cloud_area_fraction":19.5,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":19.5,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.2,"fog_area_fraction":0.0,"relative_humidity":62.8,"ultraviolet_index_clear_sky":0.1,"wind_from_direction":283.8,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"fair_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":11.1,"air_temperature_min":8.0,"precipitation_amount":0.0}}}},{"time":"2026-10-11T17:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1014.6,"air_temperature":11.1,"apparent_air_temperature":10.8,"cloud_area_fraction":6.2,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":6.2,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.6,"fog_area_fraction":0.0,"relative_humidity":74.1,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":263.2,"wind_speed":1.6}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":10.3,"air_temperature_min":7.8,"precipitation_amount":0.0}}}},{"time":"2026-10-11T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.2,"air_temperature":10.3,"apparent_air_temperature":9.7,"cloud_area_fraction":2.3,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":2.3,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.5,"fog_area_fraction":0.0,"relative_humidity":78.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":247.4,"wind_speed":1.7}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.5,"air_temperature_min":7.6,"precipitation_amount":0.0}}}},{"time":"2026-10-11T19:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.6,"air_temperature":9.5,"apparent_air_temperature":8.8,"cloud_area_fraction":0.0,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.2,"fog_area_fraction":0.0,"relative_humidity":80.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":231.5,"wind_speed":1.8}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":7.4,"precipitation_amount":0.0}}}},{"time":"2026-10-11T20:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.9,"air_temperature":9.0,"apparent_air_temperature":8.1,"cloud_area_fraction":4.7,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":4.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.9,"fog_area_fraction":0.0,"relative_humidity":81.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":218.5,"wind_speed":1.9}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":8.3,"air_temperature_min":7.1,"precipitation_amount":0.0}}}},{"time":"2026-10-11T21:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1016.4,"air_temperature":8.3,"apparent_air_temperature":7.4,"cloud_area_fraction":0.8,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":0.8,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.6,"fog_area_fraction":0.0,"relative_humidity":83.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":204.9,"wind_speed":1.8}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hoLine truncated
+1
View File
@@ -0,0 +1 @@
[{"place_id":101749274,"licence":"Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright","osm_type":"relation","osm_id":897671,"lat":"51.0538286","lon":"3.7250121","category":"boundary","type":"administrative","place_rank":14,"importance":0.6930824855023677,"addresstype":"city","name":"Ghent","display_name":"Ghent, Gent, East Flanders, Flanders, Belgium","boundingbox":["50.9795422","51.1888910","3.5797616","3.8493413"]}]
+370
View File
@@ -0,0 +1,370 @@
// 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]
}
+164
View File
@@ -0,0 +1,164 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// testdata holds real Open-Meteo responses for Ghent.
func fakeOpenMeteo(t *testing.T, calls *atomic.Int32) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
switch r.URL.Path {
case "/v1/forecast":
if lat := r.URL.Query().Get("latitude"); lat != "51.05" {
t.Errorf("latitude sent = %q, want it rounded to 51.05", lat)
}
http.ServeFile(w, r, "testdata/forecast.json")
case "/v1/search":
http.ServeFile(w, r, "testdata/geocode.json")
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestForecast(t *testing.T) {
var calls atomic.Int32
srv := fakeOpenMeteo(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast"}
f, err := c.Forecast(context.Background(), 51.0512345, 3.7212345)
if err != nil {
t.Fatal(err)
}
if len(f.Hours) != 24 || f.Now.Time.IsZero() || f.Sunset.Before(f.Now.Time) {
t.Errorf("forecast = now %v, %d hours, sunset %v", f.Now.Time, len(f.Hours), f.Sunset)
}
if _, off := f.Now.Time.Zone(); off != 2*3600 {
t.Errorf("zone offset = %d, want Brussels summer time", off)
}
c.Forecast(context.Background(), 51.05, 3.72)
if calls.Load() != 1 {
t.Errorf("second call within 15 min should hit the cache, made %d requests", calls.Load())
}
}
func TestGeocode(t *testing.T) {
var calls atomic.Int32
srv := fakeOpenMeteo(t, &calls)
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search"}).Geocode(context.Background(), "Gent")
if err != nil || p.Name != "Ghent, Belgium" || p.Lat < 51 || p.Lat > 51.1 {
t.Errorf("geocode = %+v, %v", p, err)
}
}
func TestCompassAndIcons(t *testing.T) {
for deg, want := range map[int]string{0: "N", 44: "NE", 276: "W", 350: "N", -90: "W"} {
if got := Compass(deg); got != want {
t.Errorf("Compass(%d) = %s, want %s", deg, got, want)
}
}
if (Hour{Code: 0, IsDay: false}).Icon() != "🌙" || (Hour{Code: 63, IsDay: false}).Icon() != "🌧️" {
t.Error("night icons wrong")
}
}
// busy answers 503 like an overloaded Open-Meteo, and serves the fallbacks.
func busy(t *testing.T, openMeteoCalls *atomic.Int32) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/forecast", "/v1/search":
openMeteoCalls.Add(1)
http.Error(w, `{"reason":"The service is overloaded","error":true}`, http.StatusServiceUnavailable)
case "/metno":
if r.Header.Get("User-Agent") == "" {
http.Error(w, "403", http.StatusForbidden)
return
}
http.ServeFile(w, r, "testdata/metno.json")
case "/nominatim":
http.ServeFile(w, r, "testdata/nominatim.json")
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestForecastFallsBackToMetNo(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: srv.URL + "/metno",
now: func() time.Time { return time.Date(2026, 10, 11, 17, 10, 0, 0, time.UTC) }}
f, err := c.Forecast(context.Background(), 51.05, 3.72)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Errorf("open-meteo tried %d times, want 2 (one retry)", calls.Load())
}
if f.Source != "MET Norway" || !f.GustsEstimated || len(f.Hours) != 24 {
t.Errorf("source %q, estimated gusts %v, %d hours", f.Source, f.GustsEstimated, len(f.Hours))
}
h := f.Hours[0]
if h.PrecipProb != -1 || h.Chance() != "–" || h.Gusts <= h.Wind || h.Temp == 0 {
t.Errorf("first hour = %+v", h)
}
if !f.Hours[0].Time.Equal(time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC)) {
t.Errorf("first hour at %v, want 17:00 UTC", f.Hours[0].Time)
}
if !h.IsDay || f.Hours[1].IsDay { // sunset in Ghent is 19:01 CEST
t.Errorf("19:00 CEST should be day, 20:00 night: %v %v", h.IsDay, f.Hours[1].IsDay)
}
}
func TestStaleForecastBeatsNothing(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: "-"}
if _, err := c.Forecast(context.Background(), 51.05, 3.72); err == nil {
t.Fatal("expected an error with every source down and no cache")
}
c.cache = map[string]cached{"51.05,3.72": {Forecast{Source: "Open-Meteo"}, time.Now().Add(-time.Hour)}}
f, err := c.Forecast(context.Background(), 51.05, 3.72)
if err != nil || !strings.HasPrefix(f.Source, "Open-Meteo, from ") {
t.Errorf("stale forecast = %q, %v", f.Source, err)
}
}
func TestGeocodeFallsBackToNominatim(t *testing.T) {
var calls atomic.Int32
srv := busy(t, &calls)
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search", NominatimURL: srv.URL + "/nominatim"}).Geocode(context.Background(), "Gent")
if err != nil || !strings.HasSuffix(p.Name, ", Belgium") || p.Lat < 51 || p.Lat > 51.1 {
t.Errorf("geocode = %+v, %v", p, err)
}
}
func TestSunTimesMatchOpenMeteo(t *testing.T) {
// Open-Meteo says 08:01 / 19:01 CEST for Ghent on 11 Oct 2026.
cest := time.FixedZone("CEST", 2*3600)
rise, set := sunTimes(time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC), 51.05, 3.72)
for _, c := range []struct {
got, want time.Time
}{{rise, time.Date(2026, 10, 11, 8, 1, 0, 0, cest)}, {set, time.Date(2026, 10, 11, 19, 1, 0, 0, cest)}} {
if d := c.got.Sub(c.want); d < -3*time.Minute || d > 3*time.Minute {
t.Errorf("got %v, want %v", c.got.In(cest), c.want)
}
}
}
func TestSymbolCode(t *testing.T) {
for sym, want := range map[string]int{
"clearsky_night": 0, "partlycloudy_day": 2, "heavyrainshowers_day": 82, "lightrainshowers_night": 80,
"rain": 63, "lightsleet": 66, "heavyrainandthunder": 95, "snowshowers_day": 85, "somethingnew": 3,
} {
if got := symbolCode(sym); got != want {
t.Errorf("symbolCode(%q) = %d, want %d", sym, got, want)
}
}
}