package providers import ( "context" "encoding/json" "fmt" "net/http" "net/url" "time" ) type OpenMeteo struct { Client *http.Client } func (o *OpenMeteo) Name() string { return "open_meteo" } func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*ForecastResult, error) { if o.Client == nil { o.Client = &http.Client{Timeout: 15 * time.Second} } // Hourly fields that are useful for bias-correction / training hourly := []string{ "temperature_2m", "relative_humidity_2m", "pressure_msl", "wind_speed_10m", "wind_gusts_10m", "wind_direction_10m", "precipitation", "precipitation_probability", "cloud_cover", } u, _ := url.Parse("https://api.open-meteo.com/v1/forecast") q := u.Query() q.Set("latitude", fmt.Sprintf("%.6f", site.Latitude)) q.Set("longitude", fmt.Sprintf("%.6f", site.Longitude)) q.Set("hourly", join(hourly)) q.Set("timezone", "UTC") q.Set("models", model) // e.g. "ecmwf" q.Set("forecast_days", "7") // keep it short; you can increase later u.RawQuery = q.Encode() ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { <-ctxDone; cancel() }() req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) resp, err := o.Client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return nil, fmt.Errorf("open-meteo HTTP %d", resp.StatusCode) } var raw map[string]any if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, err } // Parse the hourly arrays hr, ok := raw["hourly"].(map[string]any) if !ok { return nil, fmt.Errorf("unexpected open-meteo payload: missing hourly") } times, err := parseTimeArray(hr["time"]) if err != nil { return nil, err } // Helpers pull float arrays (may be absent) temp := floatArray(hr["temperature_2m"]) rh := floatArray(hr["relative_humidity_2m"]) msl := floatArray(hr["pressure_msl"]) ws := floatArray(hr["wind_speed_10m"]) gust := floatArray(hr["wind_gusts_10m"]) wd := floatArray(hr["wind_direction_10m"]) precip := floatArray(hr["precipitation"]) pprob := floatArray(hr["precipitation_probability"]) cloud := floatArray(hr["cloud_cover"]) points := make([]HourlyForecastPoint, 0, len(times)) for i := range times { points = append(points, HourlyForecastPoint{ TS: times[i], TempC: idx(temp, i), RH: idx(rh, i), PressureMSLH: idx(msl, i), WindMS: idx(ws, i), WindGustMS: idx(gust, i), WindDirDeg: idx(wd, i), PrecipMM: idx(precip, i), PrecipProb: idx(pprob, i), CloudCover: idx(cloud, i), }) } return &ForecastResult{ RetrievedAt: time.Now().UTC(), Model: model, Hourly: points, Raw: raw, }, nil } func join(items []string) string { out := "" for i, s := range items { if i > 0 { out += "," } out += s } return out } func parseTimeArray(v any) ([]time.Time, error) { arr, ok := v.([]any) if !ok { return nil, fmt.Errorf("hourly.time not array") } out := make([]time.Time, 0, len(arr)) for _, x := range arr { s, _ := x.(string) t, err := time.Parse("2006-01-02T15:04", s) // open-meteo uses ISO without seconds if err != nil { return nil, err } out = append(out, t.UTC()) } return out, nil } func floatArray(v any) []float64 { arr, ok := v.([]any) if !ok { return nil } out := make([]float64, 0, len(arr)) for _, x := range arr { switch n := x.(type) { case float64: out = append(out, n) case int: out = append(out, float64(n)) default: // if null or unexpected, use NaN sentinel? We'll instead skip by storing nil via idx() out = append(out, 0) } } return out } func idx(a []float64, i int) *float64 { if a == nil || i < 0 || i >= len(a) { return nil } v := a[i] return &v }