Files
bdeb1337 d7342d6096 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.
2026-10-11 19:29:58 +02:00

234 lines
6.3 KiB
Go

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
}