// 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 } // AirHourly fetches per-hour values for air-quality fields, keyed by the local // timestamp the API reports ("2006-01-02T15:04") and then by field. // // Keyed by time rather than by index because this is a different endpoint from // the forecast: nothing guarantees the two arrays start at the same hour, and // merging by position would silently shift a column by an hour. // // A field the API withholds is absent rather than zero, so a column shows blank // instead of a confident wrong number. func AirHourly(lat, lon float64, hours int, fields []string) (map[string]map[string]float64, error) { out := map[string]map[string]float64{} if len(fields) == 0 { return out, nil } days := hours/24 + 2 if days > MaxAirDays { days = MaxAirDays } var r struct { 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"]) for _, f := range fields { vals := floatSlice(r.Hourly[f]) have := presentSlice(r.Hourly[f]) for i, t := range times { if i >= len(vals) || (i < len(have) && !have[i]) { continue } if out[t] == nil { out[t] = map[string]float64{} } out[t][f] = vals[i] } } return out, nil }