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.
371 lines
11 KiB
Go
371 lines
11 KiB
Go
// 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]
|
||
}
|