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.
154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
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)
|
|
}
|