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>
298 lines
8.2 KiB
Go
298 lines
8.2 KiB
Go
// Package trip manages trip-mode charging goals and session learning.
|
|
// A Goal records the user's intent ("car ready by HH:MM") and is persisted
|
|
// across restarts. Completed sessions are appended to a JSONL log so the
|
|
// charge rate estimate improves over time.
|
|
package trip
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
sessionHistory = 10 // sessions used for rate averaging
|
|
minSessionsForLearning = 3 // below this: fall back to rated power
|
|
chargingBuffer = 15 * time.Minute
|
|
minSessionKWh = 0.1 // ignore sessions shorter than this
|
|
minSessionDuration = 5 * time.Minute
|
|
)
|
|
|
|
// Goal is a user-entered trip charging goal, persisted to disk.
|
|
type Goal struct {
|
|
Wallbox string `json:"wallbox"` // "wallbox_a" or "wallbox_b"
|
|
CarName string `json:"car_name"` // display label
|
|
BatteryKWh float64 `json:"battery_kwh"`
|
|
CurrentSOC float64 `json:"current_soc"` // % at time of entry
|
|
Deadline time.Time `json:"deadline"` // car must be ready by this time
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ChargingStartedAt time.Time `json:"charging_started_at,omitempty"` // set when trip mode activates the wallbox
|
|
}
|
|
|
|
// EnergyNeededKWh returns the energy required to charge from CurrentSOC to 100%.
|
|
func (g *Goal) EnergyNeededKWh() float64 {
|
|
return (100.0 - g.CurrentSOC) / 100.0 * g.BatteryKWh
|
|
}
|
|
|
|
// ChargeDuration estimates the time required at the given charge rate.
|
|
func (g *Goal) ChargeDuration(rateKW float64) time.Duration {
|
|
hours := g.EnergyNeededKWh() / rateKW
|
|
return time.Duration(hours * float64(time.Hour))
|
|
}
|
|
|
|
// StartTime returns when charging must begin to meet the deadline (including buffer).
|
|
func (g *Goal) StartTime(rateKW float64) time.Time {
|
|
return g.Deadline.Add(-(g.ChargeDuration(rateKW) + chargingBuffer))
|
|
}
|
|
|
|
// ShouldStartNow returns true if charging should begin immediately.
|
|
func (g *Goal) ShouldStartNow(now time.Time, rateKW float64) bool {
|
|
return !now.Before(g.StartTime(rateKW))
|
|
}
|
|
|
|
// IsTight returns true if the start time has already passed (deadline at risk).
|
|
func (g *Goal) IsTight(now time.Time, rateKW float64) bool {
|
|
return g.StartTime(rateKW).Before(now) && g.ChargingStartedAt.IsZero()
|
|
}
|
|
|
|
// Session records a completed charging session for rate learning.
|
|
type Session struct {
|
|
Timestamp time.Time `json:"ts"`
|
|
Wallbox string `json:"wallbox"`
|
|
DurationMin float64 `json:"duration_min"`
|
|
KWhDelivered float64 `json:"kwh_delivered"`
|
|
AvgKW float64 `json:"avg_kw"`
|
|
}
|
|
|
|
// Manager coordinates trip goals, session energy accumulation, and rate learning.
|
|
// All methods are safe for concurrent use (HTTP handler vs control loop).
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
goal *Goal
|
|
goalFile string
|
|
sessionFile string
|
|
|
|
// per-wallbox energy accumulation for the in-progress charging session
|
|
accEnergy map[string]float64
|
|
accStart map[string]time.Time
|
|
|
|
// previous active state per wallbox — used to detect active→inactive transitions
|
|
prevActive map[string]bool
|
|
}
|
|
|
|
// New creates a Manager and loads any persisted goal from disk.
|
|
func New(goalFile, sessionFile string) *Manager {
|
|
m := &Manager{
|
|
goalFile: goalFile,
|
|
sessionFile: sessionFile,
|
|
accEnergy: make(map[string]float64),
|
|
accStart: make(map[string]time.Time),
|
|
prevActive: make(map[string]bool),
|
|
}
|
|
m.loadGoal()
|
|
return m
|
|
}
|
|
|
|
func (m *Manager) loadGoal() {
|
|
if m.goalFile == "" {
|
|
return
|
|
}
|
|
data, err := os.ReadFile(m.goalFile)
|
|
if err != nil {
|
|
return // no file = no active goal
|
|
}
|
|
var g Goal
|
|
if json.Unmarshal(data, &g) == nil {
|
|
m.goal = &g
|
|
}
|
|
}
|
|
|
|
// ActiveGoal returns a snapshot of the current goal, or nil if none is set.
|
|
func (m *Manager) ActiveGoal() *Goal {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.goal == nil {
|
|
return nil
|
|
}
|
|
g := *m.goal
|
|
return &g
|
|
}
|
|
|
|
// SetGoal persists and activates a new trip goal.
|
|
func (m *Manager) SetGoal(g Goal) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.goal = &g
|
|
return m.saveGoalLocked()
|
|
}
|
|
|
|
// ClearGoal removes the active trip goal from memory and disk.
|
|
func (m *Manager) ClearGoal() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.goal = nil
|
|
if m.goalFile == "" {
|
|
return nil
|
|
}
|
|
err := os.Remove(m.goalFile)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) saveGoalLocked() error {
|
|
if m.goalFile == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(m.goalFile), 0755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(m.goal, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(m.goalFile, data, 0644)
|
|
}
|
|
|
|
// MarkChargingStarted records when trip-mode charging began in the persisted goal.
|
|
func (m *Manager) MarkChargingStarted(now time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.goal == nil || !m.goal.ChargingStartedAt.IsZero() {
|
|
return nil // already marked, or no goal
|
|
}
|
|
m.goal.ChargingStartedAt = now
|
|
return m.saveGoalLocked()
|
|
}
|
|
|
|
// Tick must be called once per cycle. It:
|
|
// 1. Accumulates energy from active PM readings into the running session total.
|
|
// 2. Detects active→inactive wallbox transitions and finalises those sessions.
|
|
//
|
|
// Returns the list of wallboxes whose sessions just completed this cycle.
|
|
// The caller uses this to auto-clear a trip goal when its wallbox finishes.
|
|
func (m *Manager) Tick(
|
|
activeStates map[string]bool,
|
|
powerReadingsW map[string]float64,
|
|
intervalMin float64,
|
|
) []string {
|
|
completed := m.tickLocked(activeStates, powerReadingsW, intervalMin)
|
|
for _, wb := range completed {
|
|
m.finalizeSession(wb)
|
|
}
|
|
return completed
|
|
}
|
|
|
|
func (m *Manager) tickLocked(activeStates map[string]bool, powerW map[string]float64, intervalMin float64) []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
for wb, active := range activeStates {
|
|
if active {
|
|
if _, ok := m.accStart[wb]; !ok {
|
|
m.accStart[wb] = time.Now()
|
|
m.accEnergy[wb] = 0
|
|
}
|
|
if pw := powerW[wb]; pw > 0 {
|
|
m.accEnergy[wb] += pw / 1000.0 * (intervalMin / 60.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
var completed []string
|
|
for wb, active := range activeStates {
|
|
if m.prevActive[wb] && !active {
|
|
completed = append(completed, wb)
|
|
}
|
|
m.prevActive[wb] = active
|
|
}
|
|
return completed
|
|
}
|
|
|
|
// finalizeSession writes a completed session record to the log file.
|
|
// Called without m.mu held.
|
|
func (m *Manager) finalizeSession(wallbox string) {
|
|
m.mu.Lock()
|
|
start, ok := m.accStart[wallbox]
|
|
kwh := m.accEnergy[wallbox]
|
|
delete(m.accStart, wallbox)
|
|
delete(m.accEnergy, wallbox)
|
|
m.mu.Unlock()
|
|
|
|
if !ok {
|
|
return
|
|
}
|
|
duration := time.Since(start)
|
|
if kwh < minSessionKWh || duration < minSessionDuration {
|
|
return // too short / too little energy — ignore
|
|
}
|
|
|
|
s := Session{
|
|
Timestamp: time.Now(),
|
|
Wallbox: wallbox,
|
|
DurationMin: math.Round(duration.Minutes()*10) / 10,
|
|
KWhDelivered: math.Round(kwh*100) / 100,
|
|
AvgKW: math.Round(kwh/duration.Hours()*100) / 100,
|
|
}
|
|
_ = m.appendSession(s)
|
|
}
|
|
|
|
func (m *Manager) appendSession(s Session) error {
|
|
if m.sessionFile == "" {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(m.sessionFile), 0755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(m.sessionFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return json.NewEncoder(f).Encode(s)
|
|
}
|
|
|
|
// LearnedRateKW returns the average charging rate (kW) from recent sessions
|
|
// for the given wallbox. Falls back to fallbackKW if fewer than
|
|
// minSessionsForLearning exist.
|
|
func (m *Manager) LearnedRateKW(wallbox string, fallbackKW float64) float64 {
|
|
sessions := m.recentSessions(wallbox, sessionHistory)
|
|
if len(sessions) < minSessionsForLearning {
|
|
return fallbackKW
|
|
}
|
|
var total float64
|
|
for _, s := range sessions {
|
|
total += s.AvgKW
|
|
}
|
|
return total / float64(len(sessions))
|
|
}
|
|
|
|
func (m *Manager) recentSessions(wallbox string, n int) []Session {
|
|
if m.sessionFile == "" {
|
|
return nil
|
|
}
|
|
f, err := os.Open(m.sessionFile)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
var matched []Session
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
var s Session
|
|
if json.Unmarshal(sc.Bytes(), &s) == nil && s.Wallbox == wallbox {
|
|
matched = append(matched, s)
|
|
}
|
|
}
|
|
if len(matched) <= n {
|
|
return matched
|
|
}
|
|
return matched[len(matched)-n:]
|
|
}
|