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,233 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BRouter's round trips come out about this many times longer than the
|
||||
// roundTripDistance asked for (it's closer to a radius than a length).
|
||||
const brouterStretch = 5.1
|
||||
|
||||
var brouterProfile = map[Kind]string{Foot: "hiking-mountain", Bike: "trekking"}
|
||||
|
||||
// Loops asks BRouter for round trips of about targetKm that start and end
|
||||
// at p, heading out in three directions. The first heads out into the wind
|
||||
// (windFrom, in degrees) so the way home is the easy part. The start point
|
||||
// is rounded to ~100 m before it leaves the machine.
|
||||
func (c *Client) Loops(ctx context.Context, kind Kind, p Point, targetKm float64, windFrom int) ([]Suggestion, error) {
|
||||
if c.BRouterURL == "" || c.BRouterURL == "-" || targetKm <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
p = Point{math.Round(p.Lat*1000) / 1000, math.Round(p.Lon*1000) / 1000}
|
||||
key := fmt.Sprintf("%s_%.3f_%.3f_%.1f_%d", kind, p.Lat, p.Lon, targetKm, windFrom/30)
|
||||
c.mu.Lock()
|
||||
c.initLocked()
|
||||
if hit, ok := c.loops[key]; ok && time.Since(hit.at) < cacheFor {
|
||||
c.mu.Unlock()
|
||||
return hit.s, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
type result struct {
|
||||
r *Route
|
||||
err error
|
||||
}
|
||||
results := make([]result, 3)
|
||||
var wg sync.WaitGroup
|
||||
for i := range results {
|
||||
wg.Go(func() {
|
||||
dir := (windFrom + i*120) % 360
|
||||
r, err := c.roundTrip(ctx, kind, p, targetKm, dir)
|
||||
results[i] = result{r, err}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var out []Suggestion
|
||||
var errs []error
|
||||
for _, res := range results {
|
||||
if res.err != nil {
|
||||
errs = append(errs, res.err)
|
||||
continue
|
||||
}
|
||||
out = append(out, Suggestion{Route: res.r, Plan: "loop", Laps: 1, TotalKm: res.r.LengthKm, JoinAt: p})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("brouter: %w", errors.Join(errs...))
|
||||
}
|
||||
// Closest to the target first; into-the-wind wins ties within 10%.
|
||||
slices.SortStableFunc(out, func(a, b Suggestion) int {
|
||||
da, db := math.Abs(a.TotalKm-targetKm), math.Abs(b.TotalKm-targetKm)
|
||||
if math.Abs(da-db) < 0.1*targetKm {
|
||||
return 0
|
||||
}
|
||||
if da < db {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
|
||||
c.mu.Lock()
|
||||
c.loops[key] = cachedLoops{out, time.Now()}
|
||||
for _, s := range out {
|
||||
c.byID[s.Route.ID] = s.Route
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type cachedLoops struct {
|
||||
s []Suggestion
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *Client) roundTrip(ctx context.Context, kind Kind, p Point, targetKm float64, dir int) (*Route, error) {
|
||||
q := url.Values{
|
||||
"lonlats": {fmt.Sprintf("%.3f,%.3f", p.Lon, p.Lat)},
|
||||
"profile": {brouterProfile[kind]},
|
||||
"alternativeidx": {"0"},
|
||||
"format": {"geojson"},
|
||||
"engineMode": {"4"}, // round trip
|
||||
"roundTripDistance": {strconv.Itoa(int(targetKm * 1000 / brouterStretch))},
|
||||
"direction": {strconv.Itoa(dir)},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BRouterURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
hc := c.HTTP
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
r, err := parseBRouter(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Kind, r.Heading = kind, dir
|
||||
r.ID = c.nextLoopID()
|
||||
r.Name = fmt.Sprintf("%.1f km loop heading %s", r.LengthKm, compass(dir))
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// nextLoopID hands out negative ids so generated loops never collide with
|
||||
// OpenStreetMap relation ids.
|
||||
func (c *Client) nextLoopID() int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.loopSeq--
|
||||
return c.loopSeq
|
||||
}
|
||||
|
||||
func parseBRouter(r io.Reader) (*Route, error) {
|
||||
var out struct {
|
||||
Features []struct {
|
||||
Properties struct {
|
||||
Length string `json:"track-length"`
|
||||
Ascent string `json:"filtered ascend"`
|
||||
Messages [][]string `json:"messages"`
|
||||
} `json:"properties"`
|
||||
Geometry struct {
|
||||
Coordinates [][]float64 `json:"coordinates"` // lon, lat, elevation
|
||||
} `json:"geometry"`
|
||||
} `json:"features"`
|
||||
}
|
||||
if err := json.NewDecoder(r).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode brouter: %w", err)
|
||||
}
|
||||
if len(out.Features) == 0 || len(out.Features[0].Geometry.Coordinates) < 2 {
|
||||
return nil, errors.New("brouter: empty route")
|
||||
}
|
||||
f := out.Features[0]
|
||||
m, _ := strconv.Atoi(f.Properties.Length)
|
||||
ascent, _ := strconv.Atoi(f.Properties.Ascent)
|
||||
pts := make([]Point, len(f.Geometry.Coordinates))
|
||||
for i, c := range f.Geometry.Coordinates {
|
||||
pts[i] = Point{c[1], c[0]}
|
||||
}
|
||||
return &Route{
|
||||
LengthKm: math.Round(float64(m)/100) / 10,
|
||||
Loop: true,
|
||||
Generated: true,
|
||||
AscentM: ascent,
|
||||
PavedPct: pavedPct(f.Properties.Messages),
|
||||
Ways: [][]Point{simplify(pts, 0.015)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
pavedSurfaces = []string{"asphalt", "paved", "concrete", "concrete:plates", "concrete:lanes", "paving_stones", "sett", "metal", "wood"}
|
||||
unpavedHighways = []string{"track", "path", "bridleway"}
|
||||
)
|
||||
|
||||
// pavedPct estimates how much of the route is paved from BRouter's per-
|
||||
// segment way tags: the surface tag when there is one, else the road type.
|
||||
func pavedPct(msgs [][]string) int {
|
||||
if len(msgs) < 2 {
|
||||
return -1
|
||||
}
|
||||
dist, tags := slices.Index(msgs[0], "Distance"), slices.Index(msgs[0], "WayTags")
|
||||
if dist < 0 || tags < 0 {
|
||||
return -1
|
||||
}
|
||||
var paved, total float64
|
||||
for _, row := range msgs[1:] {
|
||||
if len(row) <= max(dist, tags) {
|
||||
continue
|
||||
}
|
||||
d, _ := strconv.ParseFloat(row[dist], 64)
|
||||
kv := map[string]string{}
|
||||
for _, f := range strings.Fields(row[tags]) {
|
||||
k, v, _ := strings.Cut(f, "=")
|
||||
kv[k] = v
|
||||
}
|
||||
total += d
|
||||
if s, ok := kv["surface"]; ok {
|
||||
if slices.Contains(pavedSurfaces, s) {
|
||||
paved += d
|
||||
}
|
||||
} else if !slices.Contains(unpavedHighways, kv["highway"]) {
|
||||
paved += d
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return -1
|
||||
}
|
||||
return int(math.Round(100 * paved / total))
|
||||
}
|
||||
|
||||
func compass(deg int) string {
|
||||
dirs := []string{"north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"}
|
||||
return dirs[((deg%360+360)%360+22)/45%8]
|
||||
}
|
||||
|
||||
// IntoWind reports whether a loop heads out roughly into the wind, so the
|
||||
// way home has a tailwind.
|
||||
func (r *Route) IntoWind(windFrom int) bool {
|
||||
if !r.Generated {
|
||||
return false
|
||||
}
|
||||
d := ((r.Heading-windFrom)%360 + 360) % 360
|
||||
return d <= 45 || d >= 315
|
||||
}
|
||||
Reference in new issue
Block a user