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:
2026-04-06 10:43:49 +02:00
parent 4530b672d2
commit 7a971c4c41
5 changed files with 117 additions and 16 deletions

View File

@@ -151,12 +151,13 @@ func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
// EMSConfig holds operational settings for the EMS daemon.
type EMSConfig struct {
PollInterval string `yaml:"poll_interval"`
ListenAddr string `yaml:"listen_addr"`
LogLevel string `yaml:"log_level"`
StateFile string `yaml:"state_file"`
RecoveryTimeout string `yaml:"recovery_timeout"`
OverrideTimeout string `yaml:"override_timeout"`
PollInterval string `yaml:"poll_interval"`
ListenAddr string `yaml:"listen_addr"`
LogLevel string `yaml:"log_level"`
StateFile string `yaml:"state_file"`
RecoveryTimeout string `yaml:"recovery_timeout"`
OverrideTimeout string `yaml:"override_timeout"`
OverrideMaxImportW float64 `yaml:"override_max_import_w"` // cancel override if grid import exceeds this (0 = disabled)
}
func (e *EMSConfig) PollIntervalParsed() time.Duration {

View File

@@ -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())

View File

@@ -534,6 +534,17 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
.consumer-detail.active { color: #16a34a; font-weight: 500; }
.consumer-detail.override { color: #d97706; font-weight: 500; }
.dur-select {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0.3rem 0.4rem;
font-size: 0.75rem;
background: #f9fafb;
color: #374151;
cursor: pointer;
flex-shrink: 0;
}
.override-btn {
border: none;
border-radius: 8px;
@@ -673,13 +684,20 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
<span class="power-badge">{{formatW .LivePowerW}}</span>
{{end}}
{{if .CanOverride}}
<form method="post" action="/override">
<form method="post" action="/override" style="display:flex;align-items:center;gap:0.4rem;flex-shrink:0">
<input type="hidden" name="consumer" value="{{.ConsumerKey}}">
{{if .Active}}
<input type="hidden" name="state" value="off">
<input type="hidden" name="duration" value="1h">
<button type="submit" class="override-btn turn-off">Aus</button>
{{else}}
<input type="hidden" name="state" value="on">
<select name="duration" class="dur-select">
<option value="30m">30 min</option>
<option value="1h" selected>1 Std</option>
<option value="2h">2 Std</option>
<option value="4h">4 Std</option>
</select>
<button type="submit" class="override-btn turn-on">Ein</button>
{{end}}
</form>