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:
42 files changed
+7219
No files matched your search
@@ -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
@@ -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
@@ -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}
|
||||
Vendored
+1
@@ -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
@@ -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"]}]
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user