Implement proactive forecast-driven car charging strategy
- Replace reactive export-threshold wallbox activation with proactive logic: WallboxA/B activate when forecast ≥ mid AND PV ≥ threshold AND SOC ≥ 35%, without requiring grid export surplus - Add WallboxB no-car detection via grid-delta probe (no PM available): after probe window, if grid shift < GridDeltaThreshW → no car, retry after configured timeout - Add EOD soft stop: after 16:00, stop proactive car charging if remaining PV estimate can't cover battery deficit to 90% by sunset - WW boost no longer requires export threshold; dynamic setpoint uses tank top temp + hysteresis + boost delta, capped at 60°C - Proactive wallboxes bypass import-hysteresis shutdown; SOC emergency brake uses SOCFloor (5%) instead of standard AllConsumers gate - Add WWTopTempC to SystemState (ww_top_temp metric from DHW cylinder) - Add BatteryConfig (capacity_kwh), CarChargingConfig to config - Add WWMaxSetpointC, WWHysteresisC to StrategicConfig - Update Decide() signature: forecastKWh + sunsetTime parameters - Update all tests; add proactive charging test cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,11 +51,14 @@ type DeviceStatus struct {
|
||||
|
||||
// ConsumerState tracks the runtime state of a single consumer.
|
||||
type ConsumerState struct {
|
||||
Active bool
|
||||
ActivatedAt time.Time // when it was last turned on
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
LowPowerCycles int // consecutive cycles with power below minimum threshold
|
||||
Active bool
|
||||
ActivatedAt time.Time // when it was last turned on
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
LowPowerCycles int // consecutive cycles with power below minimum threshold
|
||||
ProactiveCharging bool // true if activated by proactive car-charging logic (bypasses import shutdown)
|
||||
ProbeStartGridW float64 // grid power snapshot at WallboxB activation (for no-car detection)
|
||||
NoCarRetryUntil time.Time // don't retry WallboxB proactive charging until this time
|
||||
}
|
||||
|
||||
// OverrideInfo is returned to callers that need to display or record override state.
|
||||
@@ -100,9 +103,11 @@ func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine {
|
||||
}
|
||||
|
||||
// Decide evaluates the current system state and returns a list of actions.
|
||||
// wwBoostC is the WW temperature boost in °C derived from the PV forecast
|
||||
// (0 = no forecast / forecast too low to warrant boosting).
|
||||
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action {
|
||||
//
|
||||
// - wwBoostC: WW temperature boost in °C from PV forecast (0 = no boost warranted)
|
||||
// - forecastKWh: today's forecast total in kWh (0 if forecasting disabled)
|
||||
// - sunsetTime: estimated time of sunset (used for EOD soft stop); zero = disabled
|
||||
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, forecastKWh float64, sunsetTime time.Time) []Action {
|
||||
var actions []Action
|
||||
|
||||
soc := state.BatterySOC
|
||||
@@ -114,10 +119,11 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
|
||||
e.logger.Debug("decision input",
|
||||
"grid_w", gridW,
|
||||
"soc", soc,
|
||||
"pv_w", state.PVProductionW,
|
||||
"forecast_kwh", forecastKWh,
|
||||
"heating_period", heatingPeriod,
|
||||
"ww_window", wwWindow,
|
||||
"ww_boost_c", wwBoostC,
|
||||
"allowed", allowed,
|
||||
)
|
||||
|
||||
// --- SOC emergency brake ---
|
||||
@@ -139,7 +145,14 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
|
||||
})
|
||||
}
|
||||
|
||||
// --- Shutdown logic (reverse priority order) ---
|
||||
// --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient ---
|
||||
actions = append(actions, e.checkEODSoftStop(state, now, sunsetTime)...)
|
||||
|
||||
// --- WallboxB no-car probe: check grid delta after activation ---
|
||||
actions = append(actions, e.checkWallboxBProbe(state, now)...)
|
||||
|
||||
// --- Shutdown logic (reverse priority order, import hysteresis) ---
|
||||
// Proactive wallboxes are skipped — EOD/probe/idle-cycles handle their stops.
|
||||
if gridW > e.cfg.Thresholds.ImportOffW {
|
||||
if e.hyst.ImportSinceAbove.IsZero() {
|
||||
e.hyst.ImportSinceAbove = now
|
||||
@@ -191,9 +204,17 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
|
||||
e.logger.Info("wallbox released: car not charging",
|
||||
"consumer", wb,
|
||||
"low_power_cycles", cs.LowPowerCycles,
|
||||
"proactive", cs.ProactiveCharging,
|
||||
)
|
||||
cs.Active = false
|
||||
cs.ProactiveCharging = false
|
||||
cs.LowPowerCycles = 0
|
||||
// For WallboxA: set retry timeout so proactive logic doesn't immediately re-activate
|
||||
if wb == ConsumerWallboxA && e.cfg.CarCharging.NoCarRetryMin > 0 {
|
||||
e.consumers[ConsumerWallboxA].NoCarRetryUntil = now.Add(
|
||||
time.Duration(e.cfg.CarCharging.NoCarRetryMin) * time.Minute,
|
||||
)
|
||||
}
|
||||
actions = append(actions, Action{
|
||||
Consumer: wb,
|
||||
TurnOn: false,
|
||||
@@ -203,51 +224,245 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
|
||||
}
|
||||
|
||||
// --- Turn-on logic (priority order) ---
|
||||
if gridW <= 0 {
|
||||
// P1: SG-Ready (heating period only)
|
||||
if heatingPeriod {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
|
||||
// P2: WW boost (time window + forecast required)
|
||||
if wwWindow && wwBoostC > 0 {
|
||||
actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...)
|
||||
}
|
||||
// P1: Proactive car charging — forecast-driven, no export threshold required.
|
||||
// Mutual exclusion (A vs B) is enforced inside evaluateCarCharging.
|
||||
actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...)
|
||||
|
||||
// P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active.
|
||||
// Uses per-phase export check if available, otherwise falls back to total.
|
||||
// Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA.
|
||||
if !e.consumers[ConsumerWallboxB].Active {
|
||||
phaseGridW := gridW // fallback: total grid power
|
||||
if state.PhaseL1PowerW != 0 || state.PhaseL2PowerW != 0 || state.PhaseL3PowerW != 0 {
|
||||
phaseGridW = min3(state.PhaseL1PowerW, state.PhaseL2PowerW, state.PhaseL3PowerW)
|
||||
}
|
||||
threshold := e.cfg.Thresholds.WallboxAExportW
|
||||
if e.cfg.Thresholds.WallboxAPhaseExportW != 0 {
|
||||
threshold = e.cfg.Thresholds.WallboxAPhaseExportW
|
||||
}
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerWallboxA, phaseGridW, threshold,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
// P2: WW boost — time window + forecast, no export threshold, no car charging.
|
||||
// Only runs if no wallbox is active (car charging takes priority).
|
||||
if wwWindow && wwBoostC > 0 &&
|
||||
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
|
||||
actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...)
|
||||
}
|
||||
|
||||
// P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active.
|
||||
// Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB.
|
||||
if !e.consumers[ConsumerWallboxA].Active {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
// P3: SG-Ready — reactive, export-threshold based, heating period only.
|
||||
// Only runs if no wallbox is active (car charging takes priority).
|
||||
if heatingPeriod &&
|
||||
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// evaluateCarCharging implements proactive forecast-driven car charging.
|
||||
// Tries WallboxA first (has PM for car detection), then WallboxB (grid-delta probe).
|
||||
// Does not require export surplus — just sufficient PV production and a good forecast.
|
||||
func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh float64, now time.Time) []Action {
|
||||
cc := e.cfg.CarCharging
|
||||
|
||||
// Skip if proactive charging is not configured (thresholds must be set)
|
||||
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forecast must be available and meet the minimum threshold (mid = worthwhile day)
|
||||
if forecastKWh == 0 || forecastKWh < float64(e.cfg.Strategic.ForecastMidKWh) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SOC must be above the minimum for proactive charging
|
||||
if state.BatterySOC < float64(cc.MinSOC) {
|
||||
return nil
|
||||
}
|
||||
|
||||
csA := e.consumers[ConsumerWallboxA]
|
||||
csB := e.consumers[ConsumerWallboxB]
|
||||
|
||||
// --- Try WallboxA (has PM, preferred) ---
|
||||
if !csA.Active && !csB.Active {
|
||||
// Respect no-car retry timeout (set after idle-cycles detection)
|
||||
if !csA.NoCarRetryUntil.IsZero() && now.Before(csA.NoCarRetryUntil) {
|
||||
e.logger.Debug("WallboxA proactive: skipping, in no-car retry window",
|
||||
"retry_until", csA.NoCarRetryUntil.Format("15:04"),
|
||||
)
|
||||
// Fall through to WallboxB below
|
||||
} else if state.PVProductionW >= cc.PVThresholdAW {
|
||||
// Respect manual override
|
||||
if csA.ManualOverride && now.Before(csA.OverrideUntil) {
|
||||
return nil
|
||||
}
|
||||
e.logger.Info("proactive: activating WallboxA",
|
||||
"pv_w", state.PVProductionW,
|
||||
"threshold_w", cc.PVThresholdAW,
|
||||
"soc", state.BatterySOC,
|
||||
"forecast_kwh", forecastKWh,
|
||||
)
|
||||
csA.Active = true
|
||||
csA.ActivatedAt = now
|
||||
csA.ProactiveCharging = true
|
||||
csA.LowPowerCycles = 0
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWallboxA)
|
||||
return []Action{{
|
||||
Consumer: ConsumerWallboxA,
|
||||
TurnOn: true,
|
||||
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdAW, forecastKWh, state.BatterySOC),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Try WallboxB (no PM, uses grid-delta probe) ---
|
||||
// Only if WallboxA is not active and B is not already running.
|
||||
if !csA.Active && !csB.Active {
|
||||
if csB.ManualOverride && now.Before(csB.OverrideUntil) {
|
||||
return nil
|
||||
}
|
||||
if !csB.NoCarRetryUntil.IsZero() && now.Before(csB.NoCarRetryUntil) {
|
||||
e.logger.Debug("WallboxB proactive: skipping, in no-car retry window",
|
||||
"retry_until", csB.NoCarRetryUntil.Format("15:04"),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if state.PVProductionW >= cc.PVThresholdBW {
|
||||
e.logger.Info("proactive: activating WallboxB (grid-delta probe)",
|
||||
"pv_w", state.PVProductionW,
|
||||
"threshold_w", cc.PVThresholdBW,
|
||||
"soc", state.BatterySOC,
|
||||
"forecast_kwh", forecastKWh,
|
||||
)
|
||||
csB.Active = true
|
||||
csB.ActivatedAt = now
|
||||
csB.ProactiveCharging = true
|
||||
csB.ProbeStartGridW = state.GridPowerW
|
||||
csB.LowPowerCycles = 0
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWallboxB)
|
||||
return []Action{{
|
||||
Consumer: ConsumerWallboxB,
|
||||
TurnOn: true,
|
||||
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdBW, forecastKWh, state.BatterySOC),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkWallboxBProbe checks whether a car is actually charging on WallboxB after activation.
|
||||
// WallboxB has no PM, so we use grid-delta: if the grid power hasn't shifted by at least
|
||||
// GridDeltaThreshW within the probe window, no car is connected → deactivate and set retry.
|
||||
func (e *Engine) checkWallboxBProbe(state collector.SystemState, now time.Time) []Action {
|
||||
cs := e.consumers[ConsumerWallboxB]
|
||||
if !cs.Active || !cs.ProactiveCharging || cs.ActivatedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
cc := e.cfg.CarCharging
|
||||
probeDuration := e.cfg.Hysteresis.MinRuntimeWallboxParsed() / 2 // half the min runtime
|
||||
if now.Sub(cs.ActivatedAt) < probeDuration {
|
||||
return nil // probe window not reached yet
|
||||
}
|
||||
|
||||
// Already probed if ProbeStartGridW is zero after first probe (reset after detection)
|
||||
if cs.ProbeStartGridW == 0 {
|
||||
return nil // already concluded
|
||||
}
|
||||
|
||||
gridDelta := cs.ProbeStartGridW - state.GridPowerW // negative = more import = car charging
|
||||
// A 4kW wallbox causes a grid shift of ~4000W (or large export reduction).
|
||||
// Positive gridDelta means we're importing more than at probe start (expected with car charging).
|
||||
// We check: grid increased by at least threshold (car drawing power).
|
||||
if gridDelta >= cc.GridDeltaThreshW || -gridDelta >= cc.GridDeltaThreshW {
|
||||
// Either significantly more import or less export = car detected
|
||||
e.logger.Info("WallboxB probe: car detected via grid delta",
|
||||
"probe_start_w", cs.ProbeStartGridW,
|
||||
"current_w", state.GridPowerW,
|
||||
"delta_w", gridDelta,
|
||||
)
|
||||
cs.ProbeStartGridW = 0 // mark probe as concluded
|
||||
return nil
|
||||
}
|
||||
|
||||
// No meaningful grid shift → no car connected
|
||||
e.logger.Info("WallboxB probe: no car detected, deactivating",
|
||||
"probe_start_w", cs.ProbeStartGridW,
|
||||
"current_w", state.GridPowerW,
|
||||
"delta_w", gridDelta,
|
||||
"threshold_w", cc.GridDeltaThreshW,
|
||||
)
|
||||
cs.Active = false
|
||||
cs.ProactiveCharging = false
|
||||
cs.ProbeStartGridW = 0
|
||||
if cc.NoCarRetryMin > 0 {
|
||||
cs.NoCarRetryUntil = now.Add(time.Duration(cc.NoCarRetryMin) * time.Minute)
|
||||
}
|
||||
return []Action{{
|
||||
Consumer: ConsumerWallboxB,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("no-car probe: grid delta %.0fW < %.0fW", gridDelta, cc.GridDeltaThreshW),
|
||||
}}
|
||||
}
|
||||
|
||||
// checkEODSoftStop implements the end-of-day battery protection.
|
||||
// After CarCharging.EODTime, if the remaining estimated PV production is insufficient
|
||||
// to fill the battery to the EOD target by sunset, proactive car charging is stopped.
|
||||
func (e *Engine) checkEODSoftStop(state collector.SystemState, now time.Time, sunsetTime time.Time) []Action {
|
||||
cc := e.cfg.CarCharging
|
||||
if cc.EODTime == "" || cc.EODSOCTarget == 0 || e.cfg.Battery.CapacityKWh == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
eodTime := cc.EODTimeParsed(now)
|
||||
if now.Before(eodTime) {
|
||||
return nil // too early for EOD check
|
||||
}
|
||||
|
||||
// Determine sunset reference
|
||||
if sunsetTime.IsZero() || sunsetTime.Before(now) {
|
||||
return nil // no valid sunset time, skip
|
||||
}
|
||||
|
||||
hoursToSunset := sunsetTime.Sub(now).Hours()
|
||||
if hoursToSunset <= 0 {
|
||||
hoursToSunset = 0
|
||||
}
|
||||
|
||||
// Estimate remaining PV production (current watt × hours to sunset)
|
||||
remainingPVkWh := (state.PVProductionW / 1000.0) * hoursToSunset
|
||||
|
||||
// Battery energy needed to reach target SOC
|
||||
socDeficitKWh := (float64(cc.EODSOCTarget)/100.0 - state.BatterySOC/100.0) * e.cfg.Battery.CapacityKWh
|
||||
if socDeficitKWh <= 0 {
|
||||
return nil // already at or above target SOC
|
||||
}
|
||||
|
||||
// House base load consumption during remaining time
|
||||
houseKWh := (e.cfg.Forecast.BaseLoadW / 1000.0) * hoursToSunset
|
||||
|
||||
// If remaining PV can't cover the battery deficit plus house load, stop charging
|
||||
if remainingPVkWh >= socDeficitKWh+houseKWh {
|
||||
return nil // enough PV remaining
|
||||
}
|
||||
|
||||
e.logger.Info("EOD soft stop: remaining PV insufficient to reach target SOC",
|
||||
"remaining_pv_kwh", fmt.Sprintf("%.2f", remainingPVkWh),
|
||||
"soc_deficit_kwh", fmt.Sprintf("%.2f", socDeficitKWh),
|
||||
"house_kwh", fmt.Sprintf("%.2f", houseKWh),
|
||||
"hours_to_sunset", fmt.Sprintf("%.1f", hoursToSunset),
|
||||
"current_soc", state.BatterySOC,
|
||||
"target_soc", cc.EODSOCTarget,
|
||||
)
|
||||
|
||||
var stopActions []Action
|
||||
for _, wb := range []Consumer{ConsumerWallboxB, ConsumerWallboxA} {
|
||||
cs := e.consumers[wb]
|
||||
if !cs.Active || !cs.ProactiveCharging {
|
||||
continue
|
||||
}
|
||||
cs.Active = false
|
||||
cs.ProactiveCharging = false
|
||||
stopActions = append(stopActions, Action{
|
||||
Consumer: wb,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("EOD soft stop: %.1fkWh PV remaining < %.1fkWh needed", remainingPVkWh, socDeficitKWh+houseKWh),
|
||||
})
|
||||
}
|
||||
return stopActions
|
||||
}
|
||||
|
||||
// min3 returns the minimum of three float64 values.
|
||||
func min3(a, b, c float64) float64 {
|
||||
if b < a {
|
||||
@@ -372,6 +587,13 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
||||
continue
|
||||
}
|
||||
|
||||
// Proactive wallboxes are not shut down by import hysteresis.
|
||||
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
|
||||
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
|
||||
e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c)
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-consumer accepted import tolerance: if the current import is within
|
||||
// the configured tolerance for this wallbox, skip shutdown.
|
||||
var acceptedImportW float64
|
||||
@@ -423,6 +645,8 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action {
|
||||
|
||||
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
|
||||
// is no longer met, ignoring minimum runtimes and manual overrides.
|
||||
// Proactive car-charging wallboxes use the SOCFloor threshold instead of the
|
||||
// standard SOC gates, allowing charging down to a lower limit during solar hours.
|
||||
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
var actions []Action
|
||||
allowed := e.allowedConsumers(soc)
|
||||
@@ -431,6 +655,15 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
// Proactive wallboxes: only emergency-brake at SOCFloor
|
||||
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
|
||||
floor := float64(e.cfg.CarCharging.SOCFloor)
|
||||
if floor > 0 && soc >= floor {
|
||||
continue // still above floor, keep charging
|
||||
}
|
||||
}
|
||||
|
||||
if allowed[c] {
|
||||
continue
|
||||
}
|
||||
@@ -444,6 +677,7 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
cs.Active = false
|
||||
cs.ManualOverride = false // EMS takes back full control after emergency
|
||||
cs.OverrideUntil = time.Time{}
|
||||
cs.ProactiveCharging = false
|
||||
a := Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
@@ -567,50 +801,51 @@ func (e *Engine) minRuntime(c Consumer) time.Duration {
|
||||
}
|
||||
|
||||
// evaluateWWTurnOn checks whether WW boost should be activated.
|
||||
// Prerequisites (time window and forecast) are already verified by the caller.
|
||||
func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
|
||||
// Prerequisites (time window, forecast > 0, no car charging) are already verified by the caller.
|
||||
// No export threshold is required — the heat pump compressor load is covered by PV.
|
||||
// Dynamic setpoint: current tank top + hysteresis + boost delta, capped at WWMaxSetpointC.
|
||||
func (e *Engine) evaluateWWTurnOn(state collector.SystemState, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
|
||||
cs := e.consumers[ConsumerWW]
|
||||
if cs.Active {
|
||||
return nil
|
||||
}
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
if !allowed[ConsumerWW] {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
if gridW > e.cfg.Thresholds.WWExportW {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok {
|
||||
e.hyst.ExportSinceAbove[ConsumerWW] = now
|
||||
// Dynamic setpoint: set high enough above current temp to trigger heating immediately.
|
||||
// Viessmann switchOn threshold = setpoint - hysteresis.
|
||||
// Target = current_top + hysteresis + boost_delta, capped at max.
|
||||
hysteresis := e.cfg.Strategic.WWHysteresisC
|
||||
if hysteresis == 0 {
|
||||
hysteresis = 5 // safe default
|
||||
}
|
||||
exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW])
|
||||
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
|
||||
return nil
|
||||
maxSetpoint := e.cfg.Strategic.WWMaxSetpointC
|
||||
if maxSetpoint == 0 {
|
||||
maxSetpoint = 60 // safe default
|
||||
}
|
||||
targetTemp := state.WWTopTempC + hysteresis + wwBoostC
|
||||
if targetTemp > maxSetpoint {
|
||||
targetTemp = maxSetpoint
|
||||
}
|
||||
|
||||
targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC
|
||||
e.logger.Info("activating WW boost",
|
||||
"grid_w", gridW,
|
||||
"ww_top_c", state.WWTopTempC,
|
||||
"ww_boost_c", wwBoostC,
|
||||
"target_temp_c", targetTemp,
|
||||
"export_duration", exportDuration,
|
||||
)
|
||||
|
||||
cs.Active = true
|
||||
cs.ActivatedAt = now
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
|
||||
return []Action{{
|
||||
Consumer: ConsumerWW,
|
||||
TurnOn: true,
|
||||
TargetTempC: targetTemp,
|
||||
Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC),
|
||||
Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC),
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user