summaryrefslogtreecommitdiff
path: root/internal/openmeteo/openmeteo.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 13:04:10 +0200
commite14a7db4ffe3c4e0f15f6b37a980501a8d74d26b (patch)
tree670ef0897839871a64d3a3bb2e17e242e7d6c385 /internal/openmeteo/openmeteo.go
downloadprognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.tar.gz
prognosis-e14a7db4ffe3c4e0f15f6b37a980501a8d74d26b.zip
Initial commit: prognosis, the Go implementation
An hour-by-hour forecast for the terminal, with official IMGW warnings for Polish locations. Replaces the Python version, whose cache file format it keeps so the two can coexist until this reaches parity. Open-Meteo provides the forecast, geocoding and pollen; GUGiK turns coordinates into a TERYT powiat code; IMGW supplies the warnings, filtered to that powiat rather than the whole country. Only the two lookups that never change are cached. Forecasts never are. Silence is never allowed to read as all-clear: "no warnings in force" and "the check failed" are reported as distinct states. Place names are resolved without guessing. A name matching several places is refused with a numbered list carrying each candidate's region and coordinates, and -pick N chooses one and remembers it. A stray positional beside -l is an error, so an unquoted "Wiry, PL" cannot silently resolve to somewhere else. The cache is written one entry per line with sorted keys, and treated as disposable but not worthless: an entry that will not parse is skipped and the rest kept, and a file that will not parse at all is moved to cache.json.bad rather than overwritten. No third-party dependencies. `make ci` is the gate: gofmt clean, vet, tests.
Diffstat (limited to 'internal/openmeteo/openmeteo.go')
-rw-r--r--internal/openmeteo/openmeteo.go385
1 files changed, 385 insertions, 0 deletions
diff --git a/internal/openmeteo/openmeteo.go b/internal/openmeteo/openmeteo.go
new file mode 100644
index 0000000..b5220f4
--- /dev/null
+++ b/internal/openmeteo/openmeteo.go
@@ -0,0 +1,385 @@
+// Package openmeteo talks to Open-Meteo's forecast, air-quality and geocoding
+// APIs. None of them needs a key.
+package openmeteo
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/lukaszkasprzak/prognosis/internal/cache"
+)
+
+// Endpoints are variables, not constants, so tests can point them at a local
+// server serving recorded fixtures instead of the live APIs.
+var (
+ forecastURL = "https://api.open-meteo.com/v1/forecast"
+ airURL = "https://air-quality-api.open-meteo.com/v1/air-quality"
+ geoURL = "https://geocoding-api.open-meteo.com/v1/search"
+)
+
+const (
+ // Both APIs reject anything larger; the air-quality one is the stricter.
+ MaxForecastDays = 16
+ MaxAirDays = 7
+)
+
+// now is the clock, replaceable in tests: the window these functions return
+// depends on the hour, so a real clock would make the tests pass or fail
+// depending on when they ran.
+var now = time.Now
+
+// Timeout bounds every request.
+var Timeout = 15 * time.Second
+
+// Row is one hour of forecast. Vals is keyed by Open-Meteo field name, so a
+// column added to the config needs no change here.
+type Row struct {
+ When time.Time
+ Vals map[string]float64
+ Code int
+}
+
+// Val returns a field, and whether it was present.
+func (r Row) Val(field string) (float64, bool) {
+ v, ok := r.Vals[field]
+ return v, ok
+}
+
+// Data is a forecast reduced to the requested window.
+type Data struct {
+ TZ string
+ Rows []Row
+ Sun map[string][2]string // date -> {sunrise, sunset} as HH:MM
+ Daily map[string]float64
+}
+
+// DailyFields are the once-a-day figures shown in the summary line.
+var DailyFields = []string{
+ "temperature_2m_max", "temperature_2m_min", "precipitation_sum",
+ "precipitation_hours", "daylight_duration", "sunshine_duration",
+}
+
+func get(rawURL string, params url.Values, into any) error {
+ host := ""
+ if u, err := url.Parse(rawURL); err == nil {
+ host = u.Host
+ }
+ client := &http.Client{Timeout: Timeout}
+ req, err := http.NewRequest("GET", rawURL+"?"+params.Encode(), nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("User-Agent", "prognosis/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("cannot reach %s: %w", host, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("%s returned HTTP %d", host, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("reading from %s: %w", host, err)
+ }
+ if err := json.Unmarshal(body, into); err != nil {
+ return fmt.Errorf("bad response from %s: %w", host, err)
+ }
+ return nil
+}
+
+type geoResponse struct {
+ Results []struct {
+ Name string `json:"name"`
+ Country string `json:"country_code"`
+ Admin1 string `json:"admin1"`
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+ } `json:"results"`
+}
+
+// Candidate is one place the geocoder matched. Admin1 is the region, which is
+// usually the only thing telling two places of the same name apart.
+type Candidate struct {
+ Geo cache.Geo
+ Admin1 string
+}
+
+// Geocode resolves a place name to every candidate the geocoder returned, best
+// match first. Coordinates are kept for all of them: a caller that only knows
+// the names of the alternatives cannot offer a choice between them, it can only
+// guess.
+func Geocode(place string) ([]Candidate, error) {
+ var r geoResponse
+ err := get(geoURL, url.Values{
+ "name": {place},
+ "count": {"5"},
+ "format": {"json"},
+ }, &r)
+ if err != nil {
+ return nil, err
+ }
+ if len(r.Results) == 0 {
+ return nil, fmt.Errorf("no place called %q found by Open-Meteo's geocoder", place)
+ }
+ out := make([]Candidate, 0, len(r.Results))
+ for _, hit := range r.Results {
+ g := cache.Geo{Lat: hit.Latitude, Lon: hit.Longitude, Country: hit.Country}
+ g.Label = hit.Name
+ if hit.Country != "" {
+ g.Label += ", " + hit.Country
+ }
+ out = append(out, Candidate{Geo: g, Admin1: hit.Admin1})
+ }
+ return out, nil
+}
+
+type forecastResponse struct {
+ TZAbbrev string `json:"timezone_abbreviation"`
+ UTCOff int `json:"utc_offset_seconds"`
+ Hourly map[string]any `json:"hourly"`
+ Daily map[string]any `json:"daily"`
+ Error bool `json:"error"`
+ Reason string `json:"reason"`
+ _ map[string]float64 // keeps the shape obvious
+}
+
+// unitParams maps our units setting onto Open-Meteo's, so rounding is done by
+// the provider rather than by us.
+func unitParams(units string) url.Values {
+ v := url.Values{}
+ switch units {
+ case "imperial":
+ v.Set("temperature_unit", "fahrenheit")
+ v.Set("wind_speed_unit", "mph")
+ v.Set("precipitation_unit", "inch")
+ case "si":
+ v.Set("wind_speed_unit", "ms")
+ }
+ return v
+}
+
+// Forecast fetches `hours` hours from the current hour, in the location's own
+// timezone, requesting only the fields asked for.
+func Forecast(lat, lon float64, hours int, units string, fields []string) (*Data, error) {
+ need := append([]string{"weather_code"}, fields...)
+ sort.Strings(need)
+ need = dedupe(need)
+
+ days := hours/24 + 2 // we start partway through today
+ if days > MaxForecastDays {
+ days = MaxForecastDays
+ }
+ params := url.Values{
+ "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)},
+ "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)},
+ "hourly": {strings.Join(need, ",")},
+ "daily": {"sunrise,sunset," + strings.Join(DailyFields, ",")},
+ "forecast_days": {strconv.Itoa(days)},
+ "timezone": {"auto"},
+ }
+ for k, vs := range unitParams(units) {
+ params[k] = vs
+ }
+
+ var r forecastResponse
+ if err := get(forecastURL, params, &r); err != nil {
+ return nil, err
+ }
+ if r.Error {
+ return nil, fmt.Errorf("open-meteo: %s", r.Reason)
+ }
+
+ times := stringSlice(r.Hourly["time"])
+ if len(times) == 0 {
+ return nil, fmt.Errorf("Open-Meteo returned no hourly data")
+ }
+ start := WindowStart(times, r.UTCOff)
+
+ d := &Data{TZ: r.TZAbbrev, Sun: map[string][2]string{}, Daily: map[string]float64{}}
+ end := start + hours
+ if end > len(times) {
+ end = len(times)
+ }
+ series := map[string][]float64{}
+ for _, f := range need {
+ series[f] = floatSlice(r.Hourly[f])
+ }
+ for i := start; i < end; i++ {
+ when, err := time.Parse("2006-01-02T15:04", times[i])
+ if err != nil {
+ continue
+ }
+ row := Row{When: when, Vals: map[string]float64{}}
+ for f, vals := range series {
+ if i < len(vals) {
+ row.Vals[f] = vals[i]
+ }
+ }
+ if v, ok := row.Vals["weather_code"]; ok {
+ row.Code = int(v)
+ }
+ d.Rows = append(d.Rows, row)
+ }
+ if len(d.Rows) == 0 {
+ return nil, fmt.Errorf("no forecast hours left in the returned window")
+ }
+
+ dates := stringSlice(r.Daily["time"])
+ rises, sets := stringSlice(r.Daily["sunrise"]), stringSlice(r.Daily["sunset"])
+ for i, day := range dates {
+ if i < len(rises) && i < len(sets) && len(rises[i]) >= 16 && len(sets[i]) >= 16 {
+ d.Sun[day] = [2]string{rises[i][11:16], sets[i][11:16]}
+ }
+ }
+ for _, f := range DailyFields {
+ if vals := floatSlice(r.Daily[f]); len(vals) > 0 {
+ d.Daily[f] = vals[0]
+ }
+ }
+ return d, nil
+}
+
+// Pollen returns the peak per species over the window ahead.
+func Pollen(lat, lon float64, hours int, species []string) (map[string]float64, error) {
+ if len(species) == 0 {
+ return map[string]float64{}, nil
+ }
+ fields := make([]string, 0, len(species))
+ for _, s := range species {
+ fields = append(fields, s+"_pollen")
+ }
+ days := hours/24 + 2
+ if days > MaxAirDays {
+ days = MaxAirDays
+ }
+ var r struct {
+ UTCOff int `json:"utc_offset_seconds"`
+ Hourly map[string]any `json:"hourly"`
+ }
+ err := get(airURL, url.Values{
+ "latitude": {strconv.FormatFloat(lat, 'f', 4, 64)},
+ "longitude": {strconv.FormatFloat(lon, 'f', 4, 64)},
+ "hourly": {strings.Join(fields, ",")},
+ "forecast_days": {strconv.Itoa(days)},
+ "timezone": {"auto"},
+ }, &r)
+ if err != nil {
+ return nil, err
+ }
+
+ times := stringSlice(r.Hourly["time"])
+ start := WindowStart(times, r.UTCOff)
+ // Pollen peaks around midday, so a three-hour request would understate the
+ // day. Always look at least twelve hours ahead.
+ span := hours
+ if span < 12 {
+ span = 12
+ }
+ end := start + span
+ if end > len(times) {
+ end = len(times)
+ }
+
+ peaks := map[string]float64{}
+ for _, s := range species {
+ vals := floatSlice(r.Hourly[s+"_pollen"])
+ have := presentSlice(r.Hourly[s+"_pollen"])
+ found := false
+ best := 0.0
+ for i := start; i < end && i < len(vals); i++ {
+ if i < len(have) && !have[i] {
+ continue // null: no reading here
+ }
+ if !found || vals[i] > best {
+ best, found = vals[i], true
+ }
+ }
+ if found {
+ peaks[s] = best
+ }
+ }
+ return peaks, nil
+}
+
+// WindowStart is the index of the first timestamp at or after now, in the
+// location's timezone.
+//
+// Open-Meteo's hourly arrays begin at 00:00 local, so anything that slices from
+// the front reports the small hours of this morning rather than the hours
+// ahead. The offset comes from the response so this stays correct for a place
+// in another timezone.
+func WindowStart(times []string, utcOffsetSeconds int) int {
+ cut := now().UTC().Add(time.Duration(utcOffsetSeconds) * time.Second).Truncate(time.Hour)
+ for i, t := range times {
+ when, err := time.Parse("2006-01-02T15:04", t)
+ if err != nil {
+ continue
+ }
+ if !when.Before(cut) {
+ return i
+ }
+ }
+ return 0
+}
+
+func dedupe(in []string) []string {
+ seen := map[string]bool{}
+ out := in[:0]
+ for _, s := range in {
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func stringSlice(v any) []string {
+ raw, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]string, 0, len(raw))
+ for _, x := range raw {
+ s, _ := x.(string)
+ out = append(out, s)
+ }
+ return out
+}
+
+func floatSlice(v any) []float64 {
+ raw, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]float64, 0, len(raw))
+ for _, x := range raw {
+ f, _ := x.(float64)
+ out = append(out, f)
+ }
+ return out
+}
+
+// presentSlice reports, per index, whether the JSON value was non-null. Pollen
+// is null outside Europe, and treating that as 0.0 would report a confident
+// "grass 0.0 none" where the truth is "no data".
+func presentSlice(v any) []bool {
+ raw, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]bool, 0, len(raw))
+ for _, x := range raw {
+ _, isNum := x.(float64)
+ out = append(out, isNum)
+ }
+ return out
+}