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:
2026-04-06 10:02:16 +02:00
commit 99613c52ae
19 changed files with 3924 additions and 0 deletions

View 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
}