aboutsummaryrefslogtreecommitdiff
path: root/internal/openmeteo
diff options
context:
space:
mode:
Diffstat (limited to 'internal/openmeteo')
-rw-r--r--internal/openmeteo/openmeteo.go385
-rw-r--r--internal/openmeteo/openmeteo_test.go364
-rw-r--r--internal/openmeteo/testdata/forecast.json1
-rw-r--r--internal/openmeteo/testdata/geocode_ambiguous.json1
-rw-r--r--internal/openmeteo/testdata/pollen.json1
-rw-r--r--internal/openmeteo/testdata/pollen_nulls.json1
6 files changed, 753 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
+}
diff --git a/internal/openmeteo/openmeteo_test.go b/internal/openmeteo/openmeteo_test.go
new file mode 100644
index 0000000..758920c
--- /dev/null
+++ b/internal/openmeteo/openmeteo_test.go
@@ -0,0 +1,364 @@
+package openmeteo
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// serve returns a server replying with a recorded fixture, and records the
+// query it was asked for so tests can assert on the request as well as the
+// parsing.
+func serve(t *testing.T, fixture string, gotQuery *string) *httptest.Server {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", fixture))
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if gotQuery != nil {
+ *gotQuery = r.URL.RawQuery
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(body)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// fixtureStart is the first hour in the recorded forecast, so tests can pin the
+// clock relative to real recorded data.
+func fixtureStart(t *testing.T) time.Time {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", "forecast.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r struct {
+ Hourly struct {
+ Time []string `json:"time"`
+ } `json:"hourly"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ t.Fatal(err)
+ }
+ when, err := time.Parse("2006-01-02T15:04", r.Hourly.Time[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return when
+}
+
+func pinClock(t *testing.T, at time.Time) {
+ t.Helper()
+ old := now
+ now = func() time.Time { return at.UTC() }
+ t.Cleanup(func() { now = old })
+}
+
+// The hourly array begins at 00:00 local, so slicing from the front reports the
+// small hours of this morning rather than the hours ahead -- the bug that made
+// the Python version report the wrong pollen peak.
+//
+// It also pins the timezone contract: the clock is real UTC, and the offset in
+// the response converts it to the *location's* local time. That is what makes
+// "prognosis -l krakow" start at the right hour when run from another zone.
+func TestForecastWindowStartsAtTheCurrentLocalHourNotMidnight(t *testing.T) {
+ offset := fixtureOffset(t) // +02:00 for the recorded Polish forecast
+ if offset == 0 {
+ t.Skip("fixture has no UTC offset; the conversion cannot be exercised")
+ }
+ // 11:00 UTC is 13:00 in a +02:00 location.
+ utcNoon := fixtureStart(t).Add(13*time.Hour - time.Duration(offset)*time.Second)
+ pinClock(t, utcNoon)
+
+ srv := serve(t, "forecast.json", nil)
+ forecastURL = srv.URL
+ d, err := Forecast(50.0617, 19.9373, 6, "metric", []string{"temperature_2m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := d.Rows[0].When.Hour(); got != 13 {
+ t.Fatalf("first row is %02d:00, want 13:00 local (clock was %s UTC, offset %+ds)",
+ got, utcNoon.Format("15:04"), offset)
+ }
+ if len(d.Rows) != 6 {
+ t.Fatalf("got %d rows, want 6", len(d.Rows))
+ }
+ // Rows must be consecutive hours from there.
+ for i, r := range d.Rows {
+ if want := 13 + i; r.When.Hour() != want {
+ t.Fatalf("row %d is %02d:00, want %02d:00", i, r.When.Hour(), want)
+ }
+ }
+}
+
+// fixtureOffset is the recorded response's utc_offset_seconds.
+func fixtureOffset(t *testing.T) int {
+ t.Helper()
+ body, err := os.ReadFile(filepath.Join("testdata", "forecast.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r struct {
+ Offset int `json:"utc_offset_seconds"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ t.Fatal(err)
+ }
+ return r.Offset
+}
+
+func TestForecastRequestsOnlySelectedFields(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+
+ if _, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m", "wind_speed_10m"}); err != nil {
+ t.Fatal(err)
+ }
+ hourly := queryValue(t, query, "hourly")
+ for _, want := range []string{"temperature_2m", "wind_speed_10m", "weather_code"} {
+ if !strings.Contains(hourly, want) {
+ t.Errorf("hourly=%q is missing %q", hourly, want)
+ }
+ }
+ // A field nobody asked for costs response size for nothing.
+ for _, unwanted := range []string{"relative_humidity_2m", "pressure_msl", "uv_index"} {
+ if strings.Contains(hourly, unwanted) {
+ t.Errorf("hourly=%q requests %q, which was not selected", hourly, unwanted)
+ }
+ }
+}
+
+func TestForecastUnitsArePassedToTheProvider(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ cases := map[string]map[string]string{
+ "metric": {"temperature_unit": "", "wind_speed_unit": ""},
+ "imperial": {"temperature_unit": "fahrenheit", "wind_speed_unit": "mph"},
+ "si": {"wind_speed_unit": "ms"},
+ }
+ for units, want := range cases {
+ t.Run(units, func(t *testing.T) {
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, units, []string{"temperature_2m"}); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range want {
+ if got := queryValue(t, query, k); got != v {
+ t.Errorf("%s=%q, want %q", k, got, v)
+ }
+ }
+ })
+ }
+}
+
+func TestForecastRequestsEnoughDaysAndRespectsTheCap(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ for hours, wantDays := range map[int]string{12: "2", 24: "3", 48: "4", 400: "16"} {
+ var query string
+ srv := serve(t, "forecast.json", &query)
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, hours, "metric", []string{"temperature_2m"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := queryValue(t, query, "forecast_days"); got != wantDays {
+ t.Errorf("%d hours asked for forecast_days=%s, want %s", hours, got, wantDays)
+ }
+ }
+}
+
+func TestForecastParsesDailyAndSun(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ srv := serve(t, "forecast.json", nil)
+ forecastURL = srv.URL
+ d, err := Forecast(50, 21, 3, "metric", []string{"temperature_2m"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(d.Sun) == 0 {
+ t.Error("no sunrise/sunset parsed")
+ }
+ for _, day := range d.Sun {
+ if len(day[0]) != 5 || len(day[1]) != 5 {
+ t.Errorf("sun times must be HH:MM, got %v", day)
+ }
+ }
+ for _, f := range DailyFields {
+ if _, ok := d.Daily[f]; !ok {
+ t.Errorf("daily field %q missing", f)
+ }
+ }
+}
+
+func TestForecastErrors(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ t.Run("http error", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "nope", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, "metric", nil); err == nil {
+ t.Fatal("expected an error on HTTP 500")
+ }
+ })
+ t.Run("bad json", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("{not json"))
+ }))
+ defer srv.Close()
+ forecastURL = srv.URL
+ if _, err := Forecast(50, 21, 3, "metric", nil); err == nil {
+ t.Fatal("expected an error on malformed JSON")
+ }
+ })
+}
+
+// Pollen peaks around midday, so a 3-hour request must still look 12 hours
+// ahead or it understates the day for someone with an allergy.
+func TestPollenAlwaysLooksAtLeastTwelveHoursAhead(t *testing.T) {
+ start := fixtureStart(t)
+ pinClock(t, start.Add(6*time.Hour))
+ srv := serve(t, "pollen.json", nil)
+ airURL = srv.URL
+
+ short, err := Pollen(50, 21, 3, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ long, err := Pollen(50, 21, 12, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if short["grass"] != long["grass"] {
+ t.Fatalf("3-hour window gave %v but 12-hour gave %v; the short window must "+
+ "still cover 12 hours", short["grass"], long["grass"])
+ }
+}
+
+// Outside Europe the API returns nulls. Treating those as 0.0 reports a
+// confident "grass 0.0 none" where the truth is "no data".
+func TestPollenNullsAreAbsentNotZero(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ srv := serve(t, "pollen_nulls.json", nil)
+ airURL = srv.URL
+ peaks, err := Pollen(-54.8, -68.3, 12, []string{"grass"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v, ok := peaks["grass"]; ok {
+ t.Fatalf("grass reported as %v, but the fixture has no readings there", v)
+ }
+}
+
+func TestPollenCapsDaysAtTheAirQualityLimit(t *testing.T) {
+ pinClock(t, fixtureStart(t))
+ var query string
+ srv := serve(t, "pollen.json", &query)
+ airURL = srv.URL
+ if _, err := Pollen(50, 21, 15*24, []string{"grass"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := queryValue(t, query, "forecast_days"); got != "7" {
+ t.Fatalf("forecast_days=%s, want 7 (the air-quality API rejects more)", got)
+ }
+}
+
+func TestPollenWithNoSpeciesMakesNoRequest(t *testing.T) {
+ called := false
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ }))
+ defer srv.Close()
+ airURL = srv.URL
+ peaks, err := Pollen(50, 21, 12, nil)
+ if err != nil || len(peaks) != 0 {
+ t.Fatalf("got %v, %v", peaks, err)
+ }
+ if called {
+ t.Error("requested pollen despite no species being selected")
+ }
+}
+
+func TestGeocodeReportsAlternatives(t *testing.T) {
+ srv := serve(t, "geocode_ambiguous.json", nil)
+ geoURL = srv.URL
+ cands, err := Geocode("krakow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cands) < 2 {
+ t.Fatalf("got %d candidates, want several: the fixture is ambiguous", len(cands))
+ }
+ g := cands[0].Geo
+ if g.Country == "" || g.Label == "" {
+ t.Errorf("incomplete geo: %+v", g)
+ }
+ if !strings.Contains(g.Label, g.Country) {
+ t.Errorf("label %q should carry the country %q", g.Label, g.Country)
+ }
+}
+
+// Selecting a place other than the first one is impossible unless its
+// coordinates survive the call, which is exactly what the old API discarded.
+func TestGeocodeKeepsCoordinatesForEveryCandidate(t *testing.T) {
+ srv := serve(t, "geocode_ambiguous.json", nil)
+ geoURL = srv.URL
+ cands, err := Geocode("krakow")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i, c := range cands {
+ if c.Geo.Lat == 0 || c.Geo.Lon == 0 {
+ t.Errorf("candidate %d (%s) has no coordinates, so it cannot be picked", i+1, c.Geo.Label)
+ }
+ if c.Admin1 == "" {
+ t.Errorf("candidate %d (%s) has no region, so the list cannot tell duplicates apart", i+1, c.Geo.Label)
+ }
+ }
+}
+
+func TestGeocodeNoResultsIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte(`{"generationtime_ms":0.1}`))
+ }))
+ defer srv.Close()
+ geoURL = srv.URL
+ if _, err := Geocode("zzzznowhere"); err == nil {
+ t.Fatal("expected an error when nothing matched")
+ }
+}
+
+func TestWindowStartFallsBackToZeroWhenEverythingIsPast(t *testing.T) {
+ pinClock(t, time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
+ if got := WindowStart([]string{"2026-08-10T00:00", "2026-08-10T01:00"}, 0); got != 0 {
+ t.Fatalf("got %d, want 0", got)
+ }
+}
+
+func queryValue(t *testing.T, rawQuery, key string) string {
+ t.Helper()
+ for _, pair := range strings.Split(rawQuery, "&") {
+ k, v, _ := strings.Cut(pair, "=")
+ if k == key {
+ unescaped, err := urlUnescape(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return unescaped
+ }
+ }
+ return ""
+}
+
+func urlUnescape(s string) (string, error) { return url.QueryUnescape(s) }
diff --git a/internal/openmeteo/testdata/forecast.json b/internal/openmeteo/testdata/forecast.json
new file mode 100644
index 0000000..b8948f7
--- /dev/null
+++ b/internal/openmeteo/testdata/forecast.json
@@ -0,0 +1 @@
+{"latitude":50.061700,"longitude":19.937300,"generationtime_ms":1.4580488204956055,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","precipitation_probability":"%","weather_code":"wmo code"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"temperature_2m":[15.0,14.4,13.4,13.0,12.9,12.1,11.9,14.4,19.2,22.4,24.6,26.2,27.5,28.7,29.3,29.4,29.3,28.9,28.4,27.0,24.8,23.6,22.9,22.6,22.3,22.5,22.4,21.9,21.6,20.3,18.9,19.8,20.9,22.4,22.0,22.1,23.1,22.7,21.5,22.2,21.9,21.8,21.7,21.3,19.5,18.3,16.8,15.6],"apparent_temperature":[13.8,13.3,12.5,11.8,11.5,11.2,11.0,14.0,18.3,21.6,24.1,26.2,27.6,29.1,29.5,28.2,28.3,28.7,28.9,28.2,25.5,24.1,22.2,21.8,21.3,20.4,20.0,19.5,19.7,18.7,18.3,19.4,21.3,23.1,21.6,22.0,23.3,21.8,20.4,20.8,19.8,19.7,20.2,19.6,18.3,16.3,15.1,13.7],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00],"precipitation_probability":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,3,4,4,3,2,1,0,0,2,6,8,8,6,4,3,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"weather_code":[1,3,2,0,0,0,0,1,0,2,2,3,3,0,3,3,3,3,3,0,0,3,1,0,0,0,0,0,0,0,0,0,3,3,3,3,2,3,3,3,3,3,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","precipitation_hours":"h","daylight_duration":"s","sunshine_duration":"s"},"daily":{"time":["2026-08-10","2026-08-11"],"sunrise":["2026-08-10T05:15","2026-08-11T05:16"],"sunset":["2026-08-10T20:01","2026-08-11T19:59"],"temperature_2m_max":[29.4,23.1],"temperature_2m_min":[11.9,15.6],"precipitation_sum":[0.00,0.00],"precipitation_hours":[0.0,0.0],"daylight_duration":[53154.79,52962.40],"sunshine_duration":[43387.38,856.60]}} \ No newline at end of file
diff --git a/internal/openmeteo/testdata/geocode_ambiguous.json b/internal/openmeteo/testdata/geocode_ambiguous.json
new file mode 100644
index 0000000..62f9cda
--- /dev/null
+++ b/internal/openmeteo/testdata/geocode_ambiguous.json
@@ -0,0 +1 @@
+{"results":[{"id":3094802,"name":"Krakow","latitude":50.06143,"longitude":19.93658,"elevation":219.0,"feature_code":"PPLA","country_code":"PL","admin1_id":858786,"admin2_id":6690154,"admin3_id":7531791,"timezone":"Europe/Warsaw","population":804237,"country_id":798544,"country":"Poland","admin1":"Lesser Poland","admin2":"Kraków","admin3":"Kraków"},{"id":5258888,"name":"Krakow","latitude":44.76166,"longitude":-88.25149,"elevation":237.0,"feature_code":"PPL","country_code":"US","admin1_id":5279468,"admin2_id":5265518,"admin3_id":5248361,"timezone":"America/Chicago","population":354,"postcodes":["54137"],"country_id":6252001,"country":"United States","admin1":"Wisconsin","admin2":"Oconto","admin3":"Town of Chase"},{"id":2884850,"name":"Krakow am See","latitude":53.65142,"longitude":12.26748,"elevation":48.0,"feature_code":"PPLA4","country_code":"DE","admin1_id":2872567,"admin3_id":8648342,"admin4_id":6550687,"timezone":"Europe/Berlin","population":3450,"country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Rostock","admin4":"Krakow am See"},{"id":2884852,"name":"Krakow","latitude":54.12372,"longitude":12.78276,"elevation":18.0,"feature_code":"PPL","country_code":"DE","admin1_id":2872567,"admin3_id":2843324,"admin4_id":6548111,"timezone":"Europe/Berlin","country_id":2921044,"country":"Germany","admin1":"Mecklenburg-Vorpommern","admin3":"Landkreis Vorpommern-Rügen","admin4":"Drechow"},{"id":3094801,"name":"Krąków","latitude":51.72921,"longitude":18.51595,"elevation":136.0,"feature_code":"PPL","country_code":"PL","admin1_id":3337493,"admin2_id":7531009,"admin3_id":7533290,"timezone":"Europe/Warsaw","country_id":798544,"country":"Poland","admin1":"Łódź Voivodeship","admin2":"Sieradz County","admin3":"Warta"}],"generationtime_ms":0.44548512} \ No newline at end of file
diff --git a/internal/openmeteo/testdata/pollen.json b/internal/openmeteo/testdata/pollen.json
new file mode 100644
index 0000000..eba1a73
--- /dev/null
+++ b/internal/openmeteo/testdata/pollen.json
@@ -0,0 +1 @@
+{"latitude":50.0,"longitude":21.8,"generationtime_ms":0.19991397857666016,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":236.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³","birch_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00","2026-08-11T00:00","2026-08-11T01:00","2026-08-11T02:00","2026-08-11T03:00","2026-08-11T04:00","2026-08-11T05:00","2026-08-11T06:00","2026-08-11T07:00","2026-08-11T08:00","2026-08-11T09:00","2026-08-11T10:00","2026-08-11T11:00","2026-08-11T12:00","2026-08-11T13:00","2026-08-11T14:00","2026-08-11T15:00","2026-08-11T16:00","2026-08-11T17:00","2026-08-11T18:00","2026-08-11T19:00","2026-08-11T20:00","2026-08-11T21:00","2026-08-11T22:00","2026-08-11T23:00"],"grass_pollen":[7.2,6.7,5.4,2.6,1.7,2.6,2.6,6.2,6.3,6.5,6.9,6.6,5.5,4.7,4.5,4.7,5.0,4.9,5.5,6.0,10.6,6.4,6.3,6.5,6.4,5.4,5.4,6.0,5.4,4.9,4.8,4.6,4.6,3.8,3.4,3.5,3.5,3.8,3.9,3.8,3.7,4.4,4.2,4.9,5.8,5.8,6.6,8.0],"birch_pollen":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]}} \ No newline at end of file
diff --git a/internal/openmeteo/testdata/pollen_nulls.json b/internal/openmeteo/testdata/pollen_nulls.json
new file mode 100644
index 0000000..46effa0
--- /dev/null
+++ b/internal/openmeteo/testdata/pollen_nulls.json
@@ -0,0 +1 @@
+{"latitude":-54.8,"longitude":-68.299995,"generationtime_ms":0.09846687316894531,"utc_offset_seconds":-10800,"timezone":"America/Argentina/Ushuaia","timezone_abbreviation":"GMT-3","elevation":52.0,"hourly_units":{"time":"iso8601","grass_pollen":"grains/m³"},"hourly":{"time":["2026-08-10T00:00","2026-08-10T01:00","2026-08-10T02:00","2026-08-10T03:00","2026-08-10T04:00","2026-08-10T05:00","2026-08-10T06:00","2026-08-10T07:00","2026-08-10T08:00","2026-08-10T09:00","2026-08-10T10:00","2026-08-10T11:00","2026-08-10T12:00","2026-08-10T13:00","2026-08-10T14:00","2026-08-10T15:00","2026-08-10T16:00","2026-08-10T17:00","2026-08-10T18:00","2026-08-10T19:00","2026-08-10T20:00","2026-08-10T21:00","2026-08-10T22:00","2026-08-10T23:00"],"grass_pollen":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]}} \ No newline at end of file