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:
203
internal/config/config.go
Normal file
203
internal/config/config.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user