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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user