// 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:] }