Add trip mode: self-learning EV charging scheduler

User enters car SOC + departure deadline; EMS computes when to start
charging and activates the wallbox at the right time (grid import OK
for trip mode). Charging rate is learned from completed PM sessions,
improving over time. Car profiles (Mini Cooper SE 50 kWh, BMW ix2 63
kWh) are config-driven and selectable per trip.

- internal/trip/trip.go: Goal/Session types, Manager with per-cycle
  Tick() that accumulates energy, detects session completion, learns
  avg charge rate from JSONL session log
- internal/config: CarProfile + Cars map, TripGoalFile, SessionLogFile
- internal/status: trip card (active goal) + trip form (set new goal),
  CarOption list, formatDay/formatDur helpers
- main.go: tripMgr lifecycle, /trip + /trip/cancel HTTP handlers,
  runCycle integration (ShouldStartNow → ApplyOverride, auto-clear)
- configs/ems-config.yaml: cars section, trip_goal_file, session_log_file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:51:41 +02:00
parent 00f8f3cdde
commit 406046c3a9
5 changed files with 709 additions and 20 deletions

196
main.go
View File

@@ -9,6 +9,8 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"syscall"
"time"
@@ -22,6 +24,7 @@ import (
"github.com/tb/ems/internal/metrics"
"github.com/tb/ems/internal/monitor"
"github.com/tb/ems/internal/status"
"github.com/tb/ems/internal/trip"
"github.com/tb/ems/internal/viessmann"
)
@@ -112,9 +115,22 @@ func main() {
)
}
// Trip mode manager (goal persistence + session learning)
tripMgr := trip.New(cfg.EMS.TripGoalFile, cfg.EMS.SessionLogFile)
if g := tripMgr.ActiveGoal(); g != nil {
logger.Info("trip goal loaded from disk",
"wallbox", g.Wallbox,
"car", g.CarName,
"deadline", g.Deadline.Format("02.01. 15:04"),
)
}
// Build sorted car option list for the status page form
carOptions := buildCarOptions(cfg.Cars)
// Status store (shared between HTTP handler and control loop)
wwConfigured := cfg.Viessmann.InstallationID != ""
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode)
statusStore := status.NewStore(*dryRun, wwConfigured, cfg.Strategic, monitorMode, tripMgr, carOptions)
// Metrics HTTP server
mux := http.NewServeMux()
@@ -125,6 +141,8 @@ func main() {
})
mux.HandleFunc("/override", overrideHandler(act, eng, logger))
mux.HandleFunc("/monitor", monitorHandler(monitorMode, logger))
mux.HandleFunc("/trip", tripSetHandler(tripMgr, cfg, logger))
mux.HandleFunc("/trip/cancel", tripCancelHandler(tripMgr, logger))
mux.HandleFunc("/", statusStore.Handler())
srv := &http.Server{
@@ -153,12 +171,12 @@ func main() {
logger.Info("EMS control loop started")
// Run once immediately
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode)
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr)
for {
select {
case <-ticker.C:
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode)
runCycle(ctx, coll, eng, act, fc, m, statusStore, cfg, cfg.EMS.StateFile, logger, *dryRun, monitorMode, tripMgr)
case sig := <-sigCh:
logger.Info("received signal, shutting down", "signal", sig)
@@ -185,8 +203,10 @@ func runCycle(
logger *slog.Logger,
dryRun bool,
monitorMode *monitor.Mode,
tripMgr *trip.Manager,
) {
now := time.Now()
pollMin := cfg.EMS.PollIntervalParsed().Minutes()
// Step 1: Collect current state from Prometheus
state, err := coll.Collect(ctx)
@@ -200,10 +220,12 @@ func runCycle(
writeHeartbeat(stateFile, logger)
// Step 1b: Read Shelly states and sync to engine (detects manual overrides).
// Partial results are fine — unreachable devices are logged inside ReadAllStates.
if shellyStates, err := act.ReadAllStates(ctx); err != nil {
// Keep shellyStates accessible for trip mode energy accumulation.
var shellyStates map[engine.Consumer]engine.DeviceStatus
if states, err := act.ReadAllStates(ctx); err != nil {
logger.Warn("all Shelly devices unreachable, skipping override detection", "error", err)
} else {
shellyStates = states
eng.SyncHardwareState(shellyStates, now, cfg.EMS.OverrideTimeoutParsed())
store.SyncDeviceStates(shellyStates)
}
@@ -213,8 +235,8 @@ func runCycle(
m.BatterySOC.Set(state.BatterySOC)
m.PVProductionW.Set(state.PVProductionW)
// Track energy flow (rough estimation based on 2-min intervals)
intervalHours := 2.0 / 60.0
// Track energy flow (rough estimation based on poll interval)
intervalHours := pollMin / 60.0
if state.GridPowerW > 0 {
m.GridImportKWh.Add(state.GridPowerW / 1000.0 * intervalHours)
} else {
@@ -233,6 +255,58 @@ func runCycle(
actions := eng.Decide(state, now, wwBoostC)
m.DecisionDuration.Observe(time.Since(decisionStart).Seconds())
// Step 3b: Trip mode — session energy tracking + wallbox activation
consumerStates := eng.ConsumerStates()
wallboxActive := map[string]bool{
engine.ConsumerWallboxA.String(): consumerStates[engine.ConsumerWallboxA],
engine.ConsumerWallboxB.String(): consumerStates[engine.ConsumerWallboxB],
}
wallboxPowerW := map[string]float64{}
if shellyStates != nil {
if s, ok := shellyStates[engine.ConsumerWallboxA]; ok {
wallboxPowerW[engine.ConsumerWallboxA.String()] = s.PowerW
}
if s, ok := shellyStates[engine.ConsumerWallboxB]; ok {
wallboxPowerW[engine.ConsumerWallboxB.String()] = s.PowerW
}
}
completed := tripMgr.Tick(wallboxActive, wallboxPowerW, pollMin)
// If a trip goal's wallbox just went idle → car is full, clear the goal
if goal := tripMgr.ActiveGoal(); goal != nil {
for _, wb := range completed {
if wb == goal.Wallbox {
logger.Info("trip mode: charging complete, clearing goal",
"wallbox", wb, "car", goal.CarName)
_ = tripMgr.ClearGoal()
}
}
}
// If trip goal's start time has arrived and wallbox is not yet active, force it on
if goal := tripMgr.ActiveGoal(); goal != nil {
consumer := tripConsumer(goal.Wallbox)
ratedKW := tripRatedKW(cfg, consumer)
learnedKW := tripMgr.LearnedRateKW(goal.Wallbox, ratedKW)
if goal.ShouldStartNow(now, learnedKW) && !consumerStates[consumer] {
actions = append(actions, engine.Action{
Consumer: consumer,
TurnOn: true,
Reason: fmt.Sprintf("trip mode: %s, bereit bis %s",
goal.CarName, goal.Deadline.Format("15:04")),
})
overrideDur := time.Until(goal.Deadline) + time.Hour
eng.ApplyOverride(consumer, true, overrideDur)
_ = tripMgr.MarkChargingStarted(now)
logger.Info("trip mode: activating wallbox",
"wallbox", goal.Wallbox,
"car", goal.CarName,
"learned_kw", learnedKW,
"deadline", goal.Deadline.Format("15:04"),
)
}
}
// Update consumer state metrics
m.UpdateConsumerStates(eng.ConsumerStates())
@@ -271,6 +345,114 @@ func runCycle(
}
}
// tripConsumer maps a wallbox config key to an engine Consumer.
func tripConsumer(wallbox string) engine.Consumer {
if wallbox == "wallbox_b" {
return engine.ConsumerWallboxB
}
return engine.ConsumerWallboxA
}
// tripRatedKW returns the configured rated power for the given consumer in kW.
func tripRatedKW(cfg *config.Config, c engine.Consumer) float64 {
if c == engine.ConsumerWallboxB {
return float64(cfg.Shelly.WallboxB.PowerW) / 1000.0
}
return float64(cfg.Shelly.WallboxA.PowerW) / 1000.0
}
// buildCarOptions returns a sorted list of car options for the status page form.
func buildCarOptions(cars map[string]config.CarProfile) []status.CarOption {
keys := make([]string, 0, len(cars))
for k := range cars {
keys = append(keys, k)
}
sort.Strings(keys)
opts := make([]status.CarOption, 0, len(keys))
for _, k := range keys {
c := cars[k]
opts = append(opts, status.CarOption{Key: k, Name: c.Name, BatteryKWh: c.BatteryKWh})
}
return opts
}
// tripSetHandler handles the trip mode form submission.
func tripSetHandler(tm *trip.Manager, cfg *config.Config, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
carKey := r.FormValue("car")
wallbox := r.FormValue("wallbox")
socStr := r.FormValue("current_soc")
deadlineStr := r.FormValue("deadline") // "2006-01-02T15:04"
car, ok := cfg.Cars[carKey]
if !ok {
http.Error(w, "unknown car", http.StatusBadRequest)
return
}
if wallbox != "wallbox_a" && wallbox != "wallbox_b" {
http.Error(w, "unknown wallbox", http.StatusBadRequest)
return
}
soc, err := strconv.ParseFloat(socStr, 64)
if err != nil || soc < 0 || soc >= 100 {
http.Error(w, "invalid SOC (must be 099)", http.StatusBadRequest)
return
}
deadline, err := time.ParseInLocation("2006-01-02T15:04", deadlineStr, time.Local)
if err != nil {
http.Error(w, "invalid deadline format", http.StatusBadRequest)
return
}
if deadline.Before(time.Now()) {
http.Error(w, "deadline is in the past", http.StatusBadRequest)
return
}
goal := trip.Goal{
Wallbox: wallbox,
CarName: car.Name,
BatteryKWh: car.BatteryKWh,
CurrentSOC: soc,
Deadline: deadline,
CreatedAt: time.Now(),
}
if err := tm.SetGoal(goal); err != nil {
logger.Error("failed to save trip goal", "error", err)
http.Error(w, "could not save goal — check logs", http.StatusInternalServerError)
return
}
logger.Info("trip goal set",
"wallbox", wallbox,
"car", car.Name,
"soc", soc,
"deadline", deadline.Format("02.01. 15:04"),
)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// tripCancelHandler clears the active trip goal.
func tripCancelHandler(tm *trip.Manager, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := tm.ClearGoal(); err != nil {
logger.Error("failed to clear trip goal", "error", err)
}
logger.Info("trip goal cancelled by user")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// monitorHandler toggles monitor-only mode on POST and redirects to the status page.
func monitorHandler(mode *monitor.Mode, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {