Add configurable override duration and hard-stop thresholds
Override duration (web UI): - Dropdown on Ein button: 30min / 1h / 2h / 4h - Engine notified immediately via ApplyOverride() — no waiting for next SyncHardwareState cycle - Aus button always uses 1h lockout (keeps consumer off for 1h) Hard-stop thresholds that cancel active overrides: - SOC emergency brake: now also clears ManualOverride flag so EMS resumes full control after the safety shutdown - override_max_import_w (default 800W): if grid import exceeds this while an override is active, override is cancelled immediately (no hysteresis delay — protection is instant) Config: ems.override_max_import_w (0 = disabled) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,9 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo
|
||||
// --- SOC emergency brake ---
|
||||
actions = append(actions, e.socEmergencyBrake(soc, now)...)
|
||||
|
||||
// --- Override hard stop: cancel override on excessive import ---
|
||||
actions = append(actions, e.overrideHardStop(gridW)...)
|
||||
|
||||
// --- WW window shutdown ---
|
||||
// If WW is active but we're outside the allowed time window, reset immediately.
|
||||
if cs := e.consumers[ConsumerWW]; cs.Active && !wwWindow {
|
||||
@@ -400,7 +403,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
|
||||
}
|
||||
|
||||
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
|
||||
// is no longer met, ignoring minimum runtimes.
|
||||
// is no longer met, ignoring minimum runtimes and manual overrides.
|
||||
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
var actions []Action
|
||||
allowed := e.allowedConsumers(soc)
|
||||
@@ -416,9 +419,12 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
e.logger.Warn("SOC emergency brake",
|
||||
"consumer", c,
|
||||
"soc", soc,
|
||||
"was_override", cs.ManualOverride,
|
||||
)
|
||||
|
||||
cs.Active = false
|
||||
cs.ManualOverride = false // EMS takes back full control after emergency
|
||||
cs.OverrideUntil = time.Time{}
|
||||
a := Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
@@ -433,6 +439,69 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
return actions
|
||||
}
|
||||
|
||||
// overrideHardStop cancels active overrides when grid import exceeds the
|
||||
// configured hard-stop threshold. Called before normal shutdown logic so
|
||||
// there is no hysteresis delay — protection is immediate.
|
||||
func (e *Engine) overrideHardStop(gridW float64) []Action {
|
||||
limit := e.cfg.EMS.OverrideMaxImportW
|
||||
if limit <= 0 || gridW <= limit {
|
||||
return nil
|
||||
}
|
||||
|
||||
var actions []Action
|
||||
for c, cs := range e.consumers {
|
||||
if !cs.Active || !cs.ManualOverride {
|
||||
continue
|
||||
}
|
||||
|
||||
e.logger.Warn("override hard stop: import exceeds limit",
|
||||
"consumer", c,
|
||||
"grid_w", gridW,
|
||||
"limit_w", limit,
|
||||
)
|
||||
|
||||
cs.Active = false
|
||||
cs.ManualOverride = false
|
||||
cs.OverrideUntil = time.Time{}
|
||||
a := Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("override cancelled: import %.0fW > limit %.0fW", gridW, limit),
|
||||
}
|
||||
if c == ConsumerWW {
|
||||
a.TargetTempC = e.cfg.Strategic.WWBaseC
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// ApplyOverride directly sets a consumer's state and override lockout.
|
||||
// Called from the web UI override handler so the engine state is consistent
|
||||
// immediately, without waiting for the next SyncHardwareState cycle.
|
||||
func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duration) {
|
||||
cs, ok := e.consumers[consumer]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
cs.Active = on
|
||||
cs.ManualOverride = true
|
||||
cs.OverrideUntil = now.Add(duration)
|
||||
cs.LowPowerCycles = 0
|
||||
if on {
|
||||
cs.ActivatedAt = now
|
||||
} else {
|
||||
cs.ActivatedAt = time.Time{}
|
||||
}
|
||||
e.logger.Info("manual override applied",
|
||||
"consumer", consumer,
|
||||
"on", on,
|
||||
"duration", duration,
|
||||
"until", cs.OverrideUntil.Format("15:04"),
|
||||
)
|
||||
}
|
||||
|
||||
// isHeatingPeriod returns true if the current month is within the heating season.
|
||||
func (e *Engine) isHeatingPeriod(now time.Time) bool {
|
||||
month := int(now.Month())
|
||||
|
||||
Reference in New Issue
Block a user