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

203
internal/config/config.go Normal file
View File

@@ -0,0 +1,203 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config is the top-level EMS configuration.
type Config struct {
Prometheus PrometheusConfig `yaml:"prometheus"`
Shelly ShellyConfig `yaml:"shelly"`
Viessmann ViessmannConfig `yaml:"viessmann"`
SOC SOCThresholds `yaml:"soc_thresholds"`
Hysteresis HysteresisConfig `yaml:"hysteresis"`
Thresholds PowerThresholds `yaml:"thresholds"`
Consumers ConsumersConfig `yaml:"consumers"`
Strategic StrategicConfig `yaml:"strategic"`
Season SeasonConfig `yaml:"season"`
Forecast ForecastConfig `yaml:"forecast"`
EMS EMSConfig `yaml:"ems"`
}
// PrometheusConfig holds Prometheus connection settings.
type PrometheusConfig struct {
URL string `yaml:"url"`
Metrics map[string]string `yaml:"metrics"`
}
// ShellyConfig holds all Shelly actuator addresses.
type ShellyConfig struct {
SGReady ShellyDevice `yaml:"sg_ready"`
WallboxA ShellyDevice `yaml:"wallbox_a"`
WallboxB ShellyDevice `yaml:"wallbox_b"`
}
// ShellyDevice represents a single Shelly device.
type ShellyDevice struct {
IP string `yaml:"ip"`
Gen int `yaml:"gen"`
PowerW int `yaml:"power_w"`
Password string `yaml:"password"` // optional; Gen2 uses HTTP Digest auth
}
// ViessmannConfig holds Viessmann API credentials (write access for WW temp).
type ViessmannConfig struct {
TokenFile string `yaml:"token_file"`
ClientID string `yaml:"client_id"`
InstallationID string `yaml:"installation_id"`
GatewaySerial string `yaml:"gateway_serial"`
DeviceID string `yaml:"device_id"`
}
// SOCThresholds defines the battery SOC levels that gate consumers.
type SOCThresholds struct {
BlockAll float64 `yaml:"block_all"`
SGReadyOnly float64 `yaml:"sg_ready_only"`
PlusWallboxA float64 `yaml:"plus_wallbox_a"`
AllConsumers float64 `yaml:"all_consumers"`
}
// HysteresisConfig defines timing parameters for switching decisions.
type HysteresisConfig struct {
ExportOnDuration string `yaml:"export_on_duration"`
ImportOffDuration string `yaml:"import_off_duration"`
MinRuntimeWallbox string `yaml:"min_runtime_wallbox"`
MinRuntimeSGReady string `yaml:"min_runtime_sg_ready"`
}
func (h *HysteresisConfig) ExportOnDurationParsed() time.Duration {
d, _ := time.ParseDuration(h.ExportOnDuration)
return d
}
func (h *HysteresisConfig) ImportOffDurationParsed() time.Duration {
d, _ := time.ParseDuration(h.ImportOffDuration)
return d
}
func (h *HysteresisConfig) MinRuntimeWallboxParsed() time.Duration {
d, _ := time.ParseDuration(h.MinRuntimeWallbox)
return d
}
func (h *HysteresisConfig) MinRuntimeSGReadyParsed() time.Duration {
d, _ := time.ParseDuration(h.MinRuntimeSGReady)
return d
}
// PowerThresholds defines the grid power levels that trigger switching.
// Export thresholds are negative (grid exports = negative grid power).
type PowerThresholds struct {
SGReadyExportW float64 `yaml:"sg_ready_export_w"`
WWExportW float64 `yaml:"ww_export_w"`
WallboxAExportW float64 `yaml:"wallbox_a_export_w"`
WallboxAPhaseExportW float64 `yaml:"wallbox_a_phase_export_w"` // per-phase export for single-phase WallboxA
WallboxBExportW float64 `yaml:"wallbox_b_export_w"`
ImportOffW float64 `yaml:"import_off_w"`
}
// ConsumersConfig holds per-consumer behavior thresholds.
type ConsumersConfig struct {
CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W)
WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W)
IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release
}
// StrategicConfig holds PV forecast based strategic settings.
type StrategicConfig struct {
ForecastHighKWh float64 `yaml:"forecast_high_kwh"`
ForecastMidKWh float64 `yaml:"forecast_mid_kwh"`
WWBoostHighC float64 `yaml:"ww_boost_high_c"`
WWBoostMidC float64 `yaml:"ww_boost_mid_c"`
WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C)
WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30"
WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00"
ScheduleOn string `yaml:"schedule_on"`
ScheduleOff string `yaml:"schedule_off"`
}
// SeasonConfig defines the heating season by month range.
type SeasonConfig struct {
HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_month"`
}
// ForecastConfig holds forecast.solar API parameters.
type ForecastConfig struct {
Enabled bool `yaml:"enabled"`
Lat float64 `yaml:"lat"`
Lon float64 `yaml:"lon"`
Declination int `yaml:"declination"` // panel tilt in degrees
Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90)
KWp float64 `yaml:"kwp"` // installed peak power
}
// EMSConfig holds operational settings for the EMS daemon.
type EMSConfig struct {
PollInterval string `yaml:"poll_interval"`
ListenAddr string `yaml:"listen_addr"`
LogLevel string `yaml:"log_level"`
StateFile string `yaml:"state_file"`
RecoveryTimeout string `yaml:"recovery_timeout"`
OverrideTimeout string `yaml:"override_timeout"`
}
func (e *EMSConfig) PollIntervalParsed() time.Duration {
d, _ := time.ParseDuration(e.PollInterval)
return d
}
func (e *EMSConfig) RecoveryTimeoutParsed() time.Duration {
d, err := time.ParseDuration(e.RecoveryTimeout)
if err != nil {
return time.Hour // safe default
}
return d
}
func (e *EMSConfig) OverrideTimeoutParsed() time.Duration {
d, err := time.ParseDuration(e.OverrideTimeout)
if err != nil {
return time.Hour // safe default
}
return d
}
// Load reads and parses the YAML config file at the given path.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &cfg, nil
}
func (c *Config) validate() error {
if c.Prometheus.URL == "" {
return fmt.Errorf("prometheus.url is required")
}
if c.EMS.PollInterval == "" {
return fmt.Errorf("ems.poll_interval is required")
}
if _, err := time.ParseDuration(c.EMS.PollInterval); err != nil {
return fmt.Errorf("ems.poll_interval %q: %w", c.EMS.PollInterval, err)
}
if c.EMS.ListenAddr == "" {
return fmt.Errorf("ems.listen_addr is required")
}
return nil
}