first commit

This commit is contained in:
2026-01-26 12:40:47 +11:00
commit adaa57f9e2
17 changed files with 1382 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
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
}

View File

@@ -0,0 +1,34 @@
package providers
import "time"
type Site struct {
Name string
Latitude float64
Longitude float64
}
type HourlyForecastPoint struct {
TS time.Time
TempC *float64
RH *float64
PressureMSLH *float64
WindMS *float64
WindGustMS *float64
WindDirDeg *float64
PrecipMM *float64
PrecipProb *float64
CloudCover *float64
}
type ForecastResult struct {
RetrievedAt time.Time
Model string
Hourly []HourlyForecastPoint
Raw map[string]any
}
type Provider interface {
Name() string
Fetch(ctxDone <-chan struct{}, site Site, model string) (*ForecastResult, error)
}

View File

@@ -0,0 +1,99 @@
package providers
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
// WU PWS upload protocol endpoint. :contentReference[oaicite:1]{index=1}
const wuEndpoint = "https://rtupdate.wunderground.com/weatherstation/updateweatherstation.php"
type WundergroundClient struct {
HTTP *http.Client
}
type WUUpload struct {
StationID string
StationKey string
// Units required by WU:
// tempf (F), wind mph, baromin (inHg), rain inches.
TempC float64
Humidity float64
WindDirDeg float64
WindAvgMS float64
WindMaxMS float64
UVI float64
SolarWm2 *float64 // your payload is lux; leave nil unless you add W/m^2 later
RainLastHourMM float64
DailyRainMM float64
DateUTC string // "now" recommended
}
func (c *WundergroundClient) Upload(ctx context.Context, u WUUpload) (string, error) {
if c.HTTP == nil {
c.HTTP = &http.Client{Timeout: 10 * time.Second}
}
q := url.Values{}
q.Set("ID", u.StationID)
q.Set("PASSWORD", u.StationKey)
q.Set("action", "updateraw")
q.Set("dateutc", u.DateUTC) // "now" ok :contentReference[oaicite:2]{index=2}
// Required-ish observation fields (only set what we have)
q.Set("tempf", fmt.Sprintf("%.2f", cToF(u.TempC)))
q.Set("humidity", fmt.Sprintf("%.0f", u.Humidity))
q.Set("winddir", fmt.Sprintf("%.0f", u.WindDirDeg))
q.Set("windspeedmph", fmt.Sprintf("%.2f", msToMph(u.WindAvgMS)))
q.Set("windgustmph", fmt.Sprintf("%.2f", msToMph(u.WindMaxMS)))
// Rain (inches)
q.Set("rainin", fmt.Sprintf("%.4f", mmToIn(u.RainLastHourMM)))
q.Set("dailyrainin", fmt.Sprintf("%.4f", mmToIn(u.DailyRainMM)))
// UV index
q.Set("UV", fmt.Sprintf("%.2f", u.UVI))
// NOTE: your WS90 payload provides light in lux, not W/m^2.
// WU expects solarradiation in W/m^2, so we omit it unless you add a conversion/actual sensor field.
if u.SolarWm2 != nil {
q.Set("solarradiation", fmt.Sprintf("%.2f", *u.SolarWm2))
}
// Optional: a software identifier
q.Set("softwaretype", "go-weatherstation-go")
reqURL := wuEndpoint + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// WU typically returns plain text like "success"
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("wunderground upload HTTP %d", resp.StatusCode)
}
// Read small body
buf := make([]byte, 2048)
n, _ := resp.Body.Read(buf)
return strconv.Quote(string(buf[:n])), nil
}
func cToF(c float64) float64 { return (c * 9.0 / 5.0) + 32.0 }
func msToMph(ms float64) float64 { return ms * 2.2369362920544 }
func mmToIn(mm float64) float64 { return mm / 25.4 }