Files
EMS/internal/config/config.go
Lutz Finsterle db46fcf0c6 Add SG-Ready startup grace period and daily PV yield tracking
SG-Ready startup grace period (sg_ready_startup_min: 10):
Heat pump compressor takes ~9 minutes to start after receiving the
SG-Ready signal. Previously, idle detection fired after only 3×2min=6min,
turning off SG-Ready before the compressor had time to respond. The grace
period suppresses idle detection for the configured duration after activation.
0 = disabled (tests and unconfigured deployments are unaffected).

Daily PV yield (photovoltaic_production_cumulated_currentDay):
Reads actual kWh produced today from Prometheus (Wh → kWh, ÷1000).
Logged as pv_today_kwh each cycle. Shown on status page in two places:
- PV card sub-line: "Heute: 31.9 kWh"
- Forecast card alongside forecast: "Ist: 31.9 kWh"
Immediately makes forecast vs reality visible (today: forecast 15.9,
actual 31.9 kWh — explains why SOC bypass triggered).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 21:00:57 +02:00

261 lines
10 KiB
Go

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"`
Battery BatteryConfig `yaml:"battery"`
CarCharging CarChargingConfig `yaml:"car_charging"`
Cars map[string]CarProfile `yaml:"cars"`
EMS EMSConfig `yaml:"ems"`
}
// BatteryConfig holds physical battery properties.
type BatteryConfig struct {
CapacityKWh float64 `yaml:"capacity_kwh"` // usable battery capacity in kWh
}
// CarChargingConfig holds parameters for the proactive car charging strategy.
type CarChargingConfig struct {
MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 25)
SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5)
SOCHighBypassPct int `yaml:"soc_high_bypass_pct"` // activate without forecast check when SOC ≥ this % (0 → default 90%)
EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90)
EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00")
NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection
PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W)
PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W)
GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W)
}
func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time {
var h, m int
fmt.Sscanf(c.EODTime, "%d:%d", &h, &m)
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location())
}
// CarProfile holds the display name and battery capacity of a known vehicle.
type CarProfile struct {
Name string `yaml:"name"`
BatteryKWh float64 `yaml:"battery_kwh"`
}
// 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
SGReadyStartupMin int `yaml:"sg_ready_startup_min"` // grace period after SG-Ready activation before idle detection starts (min, 0 = disabled)
WallboxAAcceptedImportW float64 `yaml:"wallbox_a_accepted_import_w"` // tolerate this much grid import while WallboxA is running (0 = disabled)
WallboxBAcceptedImportW float64 `yaml:"wallbox_b_accepted_import_w"` // tolerate this much grid import while WallboxB is running (0 = disabled)
}
// 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)
WWMaxSetpointC float64 `yaml:"ww_max_setpoint_c"` // absolute maximum WW setpoint (°C), e.g. 60
WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5
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 and ambient temperature.
type SeasonConfig struct {
HeatingStartMonth int `yaml:"heating_start_month"`
HeatingEndMonth int `yaml:"heating_end_month"`
HeatingMinAmbientC float64 `yaml:"heating_min_ambient_c"` // above this: non-heating regardless of month (0 = disabled)
}
// 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
FetchInterval string `yaml:"fetch_interval"` // how often to re-fetch during the day
FetchWindowStart string `yaml:"fetch_window_start"` // e.g. "07:00"
FetchWindowEnd string `yaml:"fetch_window_end"` // e.g. "19:00"
BaseLoadW float64 `yaml:"base_load_w"` // typical house consumption for surplus estimate (W)
MinSurplusW float64 `yaml:"min_surplus_w"` // min export needed to trigger wallbox charging (W)
}
func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
d, err := time.ParseDuration(f.FetchInterval)
if err != nil || d <= 0 {
return 24 * time.Hour // safe default: once per day
}
return d
}
// 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"`
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
MonitorOnlyFile string `yaml:"monitor_only_file"` // flag file path: presence = monitor-only mode active
WWBoostDisableFile string `yaml:"ww_boost_disable_file"` // flag file path: presence = WW boost disabled
TripGoalFile string `yaml:"trip_goal_file"` // persisted active trip goal
SessionLogFile string `yaml:"session_log_file"` // JSONL log of completed charge sessions
}
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
}