220 lines
4.5 KiB
Go
220 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go-weatherstation/internal/mqttingest"
|
|
"go-weatherstation/internal/providers"
|
|
)
|
|
|
|
type ForecastCache struct {
|
|
mu sync.RWMutex
|
|
res *providers.ForecastResult
|
|
}
|
|
|
|
func (c *ForecastCache) Set(res *providers.ForecastResult) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.res = copyForecast(res)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *ForecastCache) Get() *providers.ForecastResult {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
c.mu.RLock()
|
|
res := c.res
|
|
c.mu.RUnlock()
|
|
return res
|
|
}
|
|
|
|
func copyForecast(res *providers.ForecastResult) *providers.ForecastResult {
|
|
if res == nil {
|
|
return nil
|
|
}
|
|
out := &providers.ForecastResult{
|
|
RetrievedAt: res.RetrievedAt,
|
|
Model: res.Model,
|
|
}
|
|
if len(res.Hourly) > 0 {
|
|
out.Hourly = make([]providers.HourlyForecastPoint, len(res.Hourly))
|
|
copy(out.Hourly, res.Hourly)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func forecastSummary(res *providers.ForecastResult) string {
|
|
if res == nil || len(res.Hourly) == 0 {
|
|
return ""
|
|
}
|
|
|
|
start := res.Hourly[0].TS
|
|
end := res.Hourly[len(res.Hourly)-1].TS
|
|
parts := []string{
|
|
fmt.Sprintf("range=%s..%s", start.Format(time.RFC3339), end.Format(time.RFC3339)),
|
|
}
|
|
|
|
var (
|
|
minTemp, maxTemp float64
|
|
hasTemp bool
|
|
maxWind float64
|
|
hasWind bool
|
|
maxGust float64
|
|
hasGust bool
|
|
totalPrecip float64
|
|
hasPrecip bool
|
|
)
|
|
|
|
for _, pt := range res.Hourly {
|
|
if pt.TempC != nil {
|
|
if !hasTemp {
|
|
minTemp, maxTemp = *pt.TempC, *pt.TempC
|
|
hasTemp = true
|
|
} else {
|
|
if *pt.TempC < minTemp {
|
|
minTemp = *pt.TempC
|
|
}
|
|
if *pt.TempC > maxTemp {
|
|
maxTemp = *pt.TempC
|
|
}
|
|
}
|
|
}
|
|
|
|
if pt.WindMS != nil {
|
|
if !hasWind || *pt.WindMS > maxWind {
|
|
maxWind = *pt.WindMS
|
|
hasWind = true
|
|
}
|
|
}
|
|
|
|
if pt.WindGustMS != nil {
|
|
if !hasGust || *pt.WindGustMS > maxGust {
|
|
maxGust = *pt.WindGustMS
|
|
hasGust = true
|
|
}
|
|
}
|
|
|
|
if pt.PrecipMM != nil {
|
|
totalPrecip += *pt.PrecipMM
|
|
hasPrecip = true
|
|
}
|
|
}
|
|
|
|
if hasTemp {
|
|
parts = append(parts, fmt.Sprintf("temp_c(min=%.2f,max=%.2f)", minTemp, maxTemp))
|
|
}
|
|
if hasWind {
|
|
parts = append(parts, fmt.Sprintf("wind_ms(max=%.2f)", maxWind))
|
|
}
|
|
if hasGust {
|
|
parts = append(parts, fmt.Sprintf("gust_ms(max=%.2f)", maxGust))
|
|
}
|
|
if hasPrecip {
|
|
parts = append(parts, fmt.Sprintf("precip_mm(total=%.2f)", totalPrecip))
|
|
}
|
|
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func logForecastDeviation(cache *ForecastCache, snap mqttingest.Snapshot) {
|
|
res := cache.Get()
|
|
if res == nil || len(res.Hourly) == 0 {
|
|
return
|
|
}
|
|
|
|
fc, diff := nearestForecastPoint(res.Hourly, snap.TS)
|
|
if fc == nil || diff > 2*time.Hour {
|
|
return
|
|
}
|
|
|
|
parts := []string{
|
|
fmt.Sprintf("model=%s", res.Model),
|
|
fmt.Sprintf("obs_ts=%s", snap.TS.Format(time.RFC3339)),
|
|
fmt.Sprintf("fc_ts=%s", fc.TS.Format(time.RFC3339)),
|
|
fmt.Sprintf("dt=%s", diff.Round(time.Minute)),
|
|
}
|
|
|
|
if fc.TempC != nil {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"temp_c(obs=%.2f fc=%.2f d=%.2f)",
|
|
snap.P.TemperatureC, *fc.TempC, snap.P.TemperatureC-*fc.TempC,
|
|
))
|
|
}
|
|
|
|
if fc.WindMS != nil {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"wind_ms(obs=%.2f fc=%.2f d=%.2f)",
|
|
snap.P.WindAvgMS, *fc.WindMS, snap.P.WindAvgMS-*fc.WindMS,
|
|
))
|
|
}
|
|
|
|
if fc.WindGustMS != nil {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"gust_ms(obs=%.2f fc=%.2f d=%.2f)",
|
|
snap.P.WindMaxMS, *fc.WindGustMS, snap.P.WindMaxMS-*fc.WindGustMS,
|
|
))
|
|
}
|
|
|
|
if fc.WindDirDeg != nil {
|
|
dirDelta := angularDiffDeg(snap.P.WindDirDeg, *fc.WindDirDeg)
|
|
parts = append(parts, fmt.Sprintf(
|
|
"wind_dir_deg(obs=%.0f fc=%.0f d=%.0f)",
|
|
snap.P.WindDirDeg, *fc.WindDirDeg, dirDelta,
|
|
))
|
|
}
|
|
|
|
if fc.PrecipMM != nil {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"rain_mm(obs=%.2f fc=%.2f d=%.2f)",
|
|
snap.RainLastHourMM, *fc.PrecipMM, snap.RainLastHourMM-*fc.PrecipMM,
|
|
))
|
|
}
|
|
|
|
if len(parts) <= 4 {
|
|
return
|
|
}
|
|
|
|
log.Printf("forecast deviation %s", strings.Join(parts, " "))
|
|
}
|
|
|
|
func nearestForecastPoint(hourly []providers.HourlyForecastPoint, ts time.Time) (*providers.HourlyForecastPoint, time.Duration) {
|
|
if len(hourly) == 0 {
|
|
return nil, 0
|
|
}
|
|
|
|
bestIdx := 0
|
|
bestDiff := absDuration(ts.Sub(hourly[0].TS))
|
|
for i := 1; i < len(hourly); i++ {
|
|
diff := absDuration(ts.Sub(hourly[i].TS))
|
|
if diff < bestDiff {
|
|
bestIdx = i
|
|
bestDiff = diff
|
|
}
|
|
}
|
|
|
|
return &hourly[bestIdx], bestDiff
|
|
}
|
|
|
|
func absDuration(d time.Duration) time.Duration {
|
|
if d < 0 {
|
|
return -d
|
|
}
|
|
return d
|
|
}
|
|
|
|
func angularDiffDeg(a, b float64) float64 {
|
|
diff := math.Mod(math.Abs(a-b), 360.0)
|
|
if diff > 180.0 {
|
|
diff = 360.0 - diff
|
|
}
|
|
return diff
|
|
}
|