Initial commit: EMS — Energie Management System
Complete self-consumption optimisation system for 7 kWp PV installation: - Prometheus collector (grid power, SOC, PV, per-phase, compressor) - Pure decision engine with SOC gates, hysteresis, priority ordering - Shelly Gen1/Gen2 actuator (SHA-256 Digest auth, PM power readback) - Viessmann OAuth2 client for DHW temperature control - PV forecast integration (forecast.solar) - Wallbox mutual exclusion (VX3 4.6 kW AC output constraint) - Car-not-charging detection via Shelly PM - Compressor idle → early SG-Ready release - Per-phase grid power for single-phase wallbox decisions - Manual override detection and web UI with override buttons - Full unit test coverage for decision engine - systemd service, Makefile, complete documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
171
internal/forecast/forecast.go
Normal file
171
internal/forecast/forecast.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package forecast
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
// Result holds the fetched forecast for a single day.
|
||||
type Result struct {
|
||||
Date time.Time
|
||||
TotalKWh float64
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// Quality returns a human-readable label based on configured thresholds.
|
||||
func (r Result) Quality(cfg config.StrategicConfig) string {
|
||||
switch {
|
||||
case r.TotalKWh >= cfg.ForecastHighKWh:
|
||||
return "Sehr gut"
|
||||
case r.TotalKWh >= cfg.ForecastMidKWh:
|
||||
return "Gut"
|
||||
case r.TotalKWh >= 5:
|
||||
return "Mittel"
|
||||
default:
|
||||
return "Schwach"
|
||||
}
|
||||
}
|
||||
|
||||
// QualityIcon returns a weather icon for the forecast quality.
|
||||
func (r Result) QualityIcon(cfg config.StrategicConfig) string {
|
||||
switch {
|
||||
case r.TotalKWh >= cfg.ForecastHighKWh:
|
||||
return "☀️"
|
||||
case r.TotalKWh >= cfg.ForecastMidKWh:
|
||||
return "🌤️"
|
||||
case r.TotalKWh >= 5:
|
||||
return "⛅"
|
||||
default:
|
||||
return "☁️"
|
||||
}
|
||||
}
|
||||
|
||||
// Client fetches daily PV forecasts from forecast.solar and caches the result.
|
||||
type Client struct {
|
||||
cfg config.ForecastConfig
|
||||
strategic config.StrategicConfig
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cached *Result
|
||||
}
|
||||
|
||||
// NewClient creates a new forecast client.
|
||||
func NewClient(cfg config.ForecastConfig, strategic config.StrategicConfig, logger *slog.Logger) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
strategic: strategic,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Today returns the forecast for today, fetching from the API if needed.
|
||||
// Returns a zero Result and no error if forecasting is disabled.
|
||||
func (c *Client) Today(ctx context.Context) (Result, error) {
|
||||
if !c.cfg.Enabled {
|
||||
return Result{}, nil
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
cached := c.cached
|
||||
c.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
if cached != nil && cached.Date.Equal(today) {
|
||||
return *cached, nil
|
||||
}
|
||||
|
||||
result, err := c.fetch(ctx, today)
|
||||
if err != nil {
|
||||
// Return stale cache rather than nothing, if we have it
|
||||
if cached != nil {
|
||||
c.logger.Warn("forecast fetch failed, using stale cache",
|
||||
"error", err,
|
||||
"cached_date", cached.Date.Format("2006-01-02"),
|
||||
)
|
||||
return *cached, nil
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.cached = &result
|
||||
c.mu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// forecastResponse is the forecast.solar API response structure.
|
||||
type forecastResponse struct {
|
||||
Result struct {
|
||||
WattHoursDay map[string]float64 `json:"watt_hours_day"`
|
||||
} `json:"result"`
|
||||
Message struct {
|
||||
Code int `json:"code"`
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
}
|
||||
|
||||
func (c *Client) fetch(ctx context.Context, day time.Time) (Result, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://api.forecast.solar/estimate/%.4f/%.4f/%d/%d/%.1f",
|
||||
c.cfg.Lat, c.cfg.Lon,
|
||||
c.cfg.Declination, c.cfg.Azimuth,
|
||||
c.cfg.KWp,
|
||||
)
|
||||
|
||||
c.logger.Debug("fetching forecast", "url", url)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("fetching forecast: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Result{}, fmt.Errorf("forecast.solar returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var fr forecastResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fr); err != nil {
|
||||
return Result{}, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
dateKey := day.Format("2006-01-02")
|
||||
wh, ok := fr.Result.WattHoursDay[dateKey]
|
||||
if !ok {
|
||||
return Result{}, fmt.Errorf("no forecast data for %s", dateKey)
|
||||
}
|
||||
|
||||
result := Result{
|
||||
Date: day,
|
||||
TotalKWh: wh / 1000.0,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
c.logger.Info("forecast fetched",
|
||||
"date", dateKey,
|
||||
"kwh", result.TotalKWh,
|
||||
"quality", result.Quality(c.strategic),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user