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)
|
||||
}
|
||||
Reference in new issue
Block a user