diff options
Diffstat (limited to 'internal/openmeteo/openmeteo.go')
| -rw-r--r-- | internal/openmeteo/openmeteo.go | 385 |
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 +} |
