192 lines
4.4 KiB
Go
192 lines
4.4 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"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 supported by the ECMWF endpoint.
|
|
hourly := []string{
|
|
"temperature_2m",
|
|
"pressure_msl",
|
|
"wind_speed_10m",
|
|
"wind_gusts_10m",
|
|
"wind_direction_10m",
|
|
"precipitation",
|
|
"cloud_cover",
|
|
"relative_humidity_1000hPa",
|
|
}
|
|
|
|
u, _ := url.Parse("https://api.open-meteo.com/v1/ecmwf")
|
|
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("wind_speed_unit", "ms")
|
|
q.Set("temperature_unit", "celsius")
|
|
q.Set("precipitation_unit", "mm")
|
|
u.RawQuery = q.Encode()
|
|
|
|
safeURL := *u
|
|
safeQuery := safeURL.Query()
|
|
if safeQuery.Has("apikey") {
|
|
safeQuery.Set("apikey", "redacted")
|
|
}
|
|
safeURL.RawQuery = safeQuery.Encode()
|
|
log.Printf("open-meteo request url=%s", safeURL.String())
|
|
|
|
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 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
var apiErr struct {
|
|
Error bool `json:"error"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Reason != "" {
|
|
return nil, fmt.Errorf("open-meteo HTTP %d: %s", resp.StatusCode, apiErr.Reason)
|
|
}
|
|
if len(body) > 0 {
|
|
return nil, fmt.Errorf("open-meteo HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
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_1000hPa"])
|
|
if rh == nil {
|
|
rh = floatArray(hr["relative_humidity_2m"])
|
|
}
|
|
msl := floatArray(hr["pressure_msl"])
|
|
if msl == nil {
|
|
msl = floatArray(hr["surface_pressure"])
|
|
}
|
|
ws := floatArray(hr["wind_speed_10m"])
|
|
gust := floatArray(hr["wind_gusts_10m"])
|
|
wd := floatArray(hr["wind_direction_10m"])
|
|
precip := floatArray(hr["precipitation"])
|
|
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: nil,
|
|
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
|
|
}
|