Add ambient temperature gate to heating period detection

Adds heating_min_ambient_c (default 15°C) to season config. When outdoor
temperature is at or above this threshold, SG-Ready is suppressed even if
the calendar month is within the heating season. Prevents unnecessary heat
pump boost activation on warm spring/autumn days.

Logic: heating active = in_heating_month AND ambient < threshold
Zero value (unset) disables the temperature gate (calendar-only, old behaviour).

New test: TestHeatingPeriodAmbientSuppression covers warm-day suppression,
cold-day pass-through, and summer month independence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:13:05 +02:00
parent cc33add507
commit 0b51ce5240
4 changed files with 55 additions and 9 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"