diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index 704e7b6..aafddab 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -86,6 +86,7 @@ consumers: season: heating_start_month: 10 # October heating_end_month: 4 # April + heating_min_ambient_c: 15 # above 15°C outdoor temp: no SG-Ready even within heating months # PV forecast — forecast.solar (free, no API key required) forecast: diff --git a/internal/config/config.go b/internal/config/config.go index 0c06578..bd41840 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,10 +120,11 @@ type StrategicConfig struct { ScheduleOff string `yaml:"schedule_off"` } -// SeasonConfig defines the heating season by month range. +// 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"` + 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. diff --git a/internal/engine/engine.go b/internal/engine/engine.go index eab86e0..ff60122 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -107,7 +107,7 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo soc := state.BatterySOC gridW := state.GridPowerW // positive = import, negative = export - heatingPeriod := e.isHeatingPeriod(now) + heatingPeriod := e.isHeatingPeriod(now, state.AmbientTempC) wwWindow := e.isWWWindow(now) allowed := e.allowedConsumers(soc) @@ -502,17 +502,37 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio ) } -// isHeatingPeriod returns true if the current month is within the heating season. -func (e *Engine) isHeatingPeriod(now time.Time) bool { +// isHeatingPeriod returns true if heating is appropriate given the current month +// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient +// temperatures above that threshold suppress SG-Ready even within the heating months. +func (e *Engine) isHeatingPeriod(now time.Time, ambientC float64) bool { month := int(now.Month()) start := e.cfg.Season.HeatingStartMonth end := e.cfg.Season.HeatingEndMonth // Handles wrap-around: e.g. October(10) to April(4) + var inMonth bool if start > end { - return month >= start || month <= end + inMonth = month >= start || month <= end + } else { + inMonth = month >= start && month <= end } - return month >= start && month <= end + + if !inMonth { + return false + } + + // Temperature override: warm day within heating months → not a heating day + threshold := e.cfg.Season.HeatingMinAmbientC + if threshold > 0 && ambientC >= threshold { + e.logger.Debug("heating period suppressed by ambient temperature", + "ambient_c", ambientC, + "threshold_c", threshold, + ) + return false + } + + return true } // minRuntime returns the minimum runtime for a consumer. diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index f80e2e5..d1daa38 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -208,6 +208,7 @@ func TestShutdownReverseOrder(t *testing.T) { func TestHeatingPeriodDetection(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) + cold := 5.0 // well below any threshold — pure calendar test tests := []struct { month time.Month @@ -230,13 +231,36 @@ func TestHeatingPeriodDetection(t *testing.T) { for _, tt := range tests { t.Run(tt.month.String(), func(t *testing.T) { date := time.Date(2025, tt.month, 15, 12, 0, 0, 0, time.UTC) - if got := eng.isHeatingPeriod(date); got != tt.expected { + if got := eng.isHeatingPeriod(date, cold); got != tt.expected { t.Errorf("month %s: got %v, want %v", tt.month, got, tt.expected) } }) } } +func TestHeatingPeriodAmbientSuppression(t *testing.T) { + cfg := testConfig() + cfg.Season.HeatingMinAmbientC = 15.0 + eng := NewEngine(cfg, testLogger()) + + // Winter month, but warm day — should be suppressed + warmWinterDay := time.Date(2025, time.January, 15, 12, 0, 0, 0, time.UTC) + if eng.isHeatingPeriod(warmWinterDay, 18.0) { + t.Error("expected heating period suppressed when ambient (18°C) >= threshold (15°C)") + } + + // Winter month, cold day — should be active + if !eng.isHeatingPeriod(warmWinterDay, 8.0) { + t.Error("expected heating period active when ambient (8°C) < threshold (15°C)") + } + + // Summer month, cold day — still not heating (month gate takes precedence) + summerDay := time.Date(2025, time.July, 15, 12, 0, 0, 0, time.UTC) + if eng.isHeatingPeriod(summerDay, 5.0) { + t.Error("expected heating period inactive in summer regardless of temperature") + } +} + func TestWallboxMutualExclusion(t *testing.T) { cfg := testConfig() cfg.Hysteresis.ExportOnDuration = "0s"