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:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tb/ems/internal/engine"
|
||||
"github.com/tb/ems/internal/forecast"
|
||||
"github.com/tb/ems/internal/monitor"
|
||||
"github.com/tb/ems/internal/trip"
|
||||
)
|
||||
|
||||
// consumerMeta holds static display info for each consumer.
|
||||
@@ -40,6 +41,13 @@ type consumerRecord struct {
|
||||
livePowerW float64 // from Shelly PM; 0 for non-PM devices
|
||||
}
|
||||
|
||||
// CarOption is a selectable car for the trip mode form.
|
||||
type CarOption struct {
|
||||
Key string
|
||||
Name string
|
||||
BatteryKWh float64
|
||||
}
|
||||
|
||||
// Store holds the latest EMS snapshot and is safe for concurrent use.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
@@ -52,10 +60,12 @@ type Store struct {
|
||||
consumers map[engine.Consumer]*consumerRecord
|
||||
fcResult *forecast.Result
|
||||
monitorMode *monitor.Mode
|
||||
tripMgr *trip.Manager
|
||||
carOptions []CarOption
|
||||
}
|
||||
|
||||
// NewStore creates a new status store.
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode) *Store {
|
||||
func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig, mode *monitor.Mode, tm *trip.Manager, cars []CarOption) *Store {
|
||||
consumers := make(map[engine.Consumer]*consumerRecord, len(consumerOrder))
|
||||
for _, c := range consumerOrder {
|
||||
consumers[c] = &consumerRecord{}
|
||||
@@ -66,6 +76,8 @@ func NewStore(dryRun bool, wwConfigured bool, strategic config.StrategicConfig,
|
||||
strategic: strategic,
|
||||
consumers: consumers,
|
||||
monitorMode: mode,
|
||||
tripMgr: tm,
|
||||
carOptions: cars,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +159,20 @@ type chargingAdviceView struct {
|
||||
WindowEnd string // e.g. "16:00"
|
||||
}
|
||||
|
||||
type tripView struct {
|
||||
Active bool
|
||||
CarName string
|
||||
WallboxLabel string // "Wallbox A" or "Wallbox B"
|
||||
CurrentSOC float64
|
||||
EnergyKWh float64 // energy needed
|
||||
DeadlineStr string // e.g. "morgen 07:00"
|
||||
StartsAtStr string // e.g. "heute 22:30" / "jetzt"
|
||||
DurationStr string // estimated charge time, e.g. "15h 45min"
|
||||
ChargingStarted bool
|
||||
ChargingSince string // e.g. "seit 2h 10min"
|
||||
IsTight bool // start time already passed, deadline at risk
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
LastUpdate time.Time
|
||||
ErrMsg string
|
||||
@@ -164,6 +190,8 @@ type pageData struct {
|
||||
Forecast forecastView
|
||||
ChargingAdvice chargingAdviceView
|
||||
Consumers []consumerView
|
||||
Trip tripView
|
||||
CarOptions []CarOption
|
||||
}
|
||||
|
||||
// SyncConsumerStates updates active flags for all consumers directly from the
|
||||
@@ -232,6 +260,7 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
}
|
||||
|
||||
monitorOnly := s.monitorMode != nil && s.monitorMode.IsActive()
|
||||
now := time.Now()
|
||||
|
||||
s.mu.RLock()
|
||||
l1, l2, l3 := s.state.PhaseL1PowerW, s.state.PhaseL2PowerW, s.state.PhaseL3PowerW
|
||||
@@ -273,7 +302,6 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
// Charging advice: show when surplus window is ahead and no wallbox is active
|
||||
ws := s.fcResult.SurplusWindowStart
|
||||
we := s.fcResult.SurplusWindowEnd
|
||||
now := time.Now()
|
||||
wallboxActive := s.consumers[engine.ConsumerWallboxA].active ||
|
||||
s.consumers[engine.ConsumerWallboxB].active
|
||||
if !ws.IsZero() && now.Before(ws) && !wallboxActive {
|
||||
@@ -317,6 +345,12 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Trip mode view (read outside store lock — tripMgr has its own lock)
|
||||
data.CarOptions = s.carOptions
|
||||
if s.tripMgr != nil {
|
||||
data.Trip = buildTripView(s.tripMgr, now)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
@@ -324,6 +358,74 @@ func (s *Store) Handler() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// buildTripView constructs the trip card data for the current cycle.
|
||||
func buildTripView(tm *trip.Manager, now time.Time) tripView {
|
||||
goal := tm.ActiveGoal()
|
||||
if goal == nil {
|
||||
return tripView{}
|
||||
}
|
||||
|
||||
// Use rated wallbox power as fallback (learned rate improves over sessions)
|
||||
// The actual rated power isn't available here, so use battery capacity / 10 as rough estimate.
|
||||
// The real rate is computed in runCycle; here we just format what's stored.
|
||||
rateKW := goal.BatteryKWh / 20.0 // conservative placeholder for display only
|
||||
startTime := goal.StartTime(rateKW)
|
||||
duration := goal.ChargeDuration(rateKW)
|
||||
|
||||
wbLabel := "Wallbox A"
|
||||
if goal.Wallbox == "wallbox_b" {
|
||||
wbLabel = "Wallbox B"
|
||||
}
|
||||
|
||||
tv := tripView{
|
||||
Active: true,
|
||||
CarName: goal.CarName,
|
||||
WallboxLabel: wbLabel,
|
||||
CurrentSOC: goal.CurrentSOC,
|
||||
EnergyKWh: goal.EnergyNeededKWh(),
|
||||
DeadlineStr: formatDay(goal.Deadline, now),
|
||||
DurationStr: formatDur(duration),
|
||||
IsTight: goal.IsTight(now, rateKW),
|
||||
}
|
||||
|
||||
if !goal.ChargingStartedAt.IsZero() {
|
||||
tv.ChargingStarted = true
|
||||
tv.ChargingSince = formatDur(now.Sub(goal.ChargingStartedAt))
|
||||
} else if goal.ShouldStartNow(now, rateKW) {
|
||||
tv.StartsAtStr = "jetzt"
|
||||
} else {
|
||||
tv.StartsAtStr = formatDay(startTime, now)
|
||||
}
|
||||
|
||||
return tv
|
||||
}
|
||||
|
||||
func formatDay(t, now time.Time) string {
|
||||
today := now.Format("2006-01-02")
|
||||
tomorrow := now.AddDate(0, 0, 1).Format("2006-01-02")
|
||||
switch t.Format("2006-01-02") {
|
||||
case today:
|
||||
return "heute " + t.Format("15:04")
|
||||
case tomorrow:
|
||||
return "morgen " + t.Format("15:04")
|
||||
default:
|
||||
return t.Format("02.01. 15:04")
|
||||
}
|
||||
}
|
||||
|
||||
func formatDur(d time.Duration) string {
|
||||
d = d.Round(time.Minute)
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if h == 0 {
|
||||
return fmt.Sprintf("%d min", m)
|
||||
}
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("%d h", h)
|
||||
}
|
||||
return fmt.Sprintf("%d h %d min", h, m)
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
@@ -395,6 +497,47 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
}
|
||||
.monitor-toggle-btn:hover { border-color: #9ca3af; color: #6b7280; }
|
||||
|
||||
/* Trip mode card */
|
||||
.trip-card {
|
||||
background: #f0fdf4; border: 1px solid #86efac;
|
||||
border-radius: 14px; padding: 0.85rem 1rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.trip-card.tight { background: #fff7ed; border-color: #fed7aa; }
|
||||
.trip-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.trip-title { font-size: 0.78rem; font-weight: 700; color: #15803d; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.trip-card.tight .trip-title { color: #9a3412; }
|
||||
.trip-cancel { background: none; border: 1px solid #d1d5db; color: #9ca3af; border-radius: 7px; padding: 0.2rem 0.55rem; font-size: 0.75rem; cursor: pointer; }
|
||||
.trip-cancel:hover { border-color: #9ca3af; color: #6b7280; }
|
||||
.trip-car { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
|
||||
.trip-detail { font-size: 0.78rem; color: #6b7280; }
|
||||
.trip-timing { font-size: 0.85rem; font-weight: 600; color: #15803d; margin-top: 0.35rem; }
|
||||
.trip-card.tight .trip-timing { color: #9a3412; }
|
||||
.trip-deadline { font-size: 0.78rem; color: #6b7280; margin-top: 0.1rem; }
|
||||
|
||||
/* Trip form */
|
||||
.trip-form-card {
|
||||
background: #fff; border-radius: 14px; padding: 0.85rem 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07); margin-top: 1.25rem;
|
||||
}
|
||||
.trip-form { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.5rem; }
|
||||
.trip-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.trip-select, .trip-number, .trip-datetime {
|
||||
border: 1px solid #d1d5db; border-radius: 8px; padding: 0.35rem 0.5rem;
|
||||
font-size: 0.82rem; background: #f9fafb; color: #374151;
|
||||
}
|
||||
.trip-select { flex: 1; min-width: 0; }
|
||||
.trip-number { width: 4.5rem; }
|
||||
.trip-datetime { flex: 1; min-width: 0; }
|
||||
.trip-label { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
|
||||
.trip-submit {
|
||||
background: #16a34a; color: #fff; border: none; border-radius: 8px;
|
||||
padding: 0.45rem 1.2rem; font-size: 0.85rem; font-weight: 700; cursor: pointer; align-self: flex-end;
|
||||
}
|
||||
.trip-submit:active { opacity: 0.8; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -770,5 +913,52 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Trip.Active}}
|
||||
<div class="trip-card{{if .Trip.IsTight}} tight{{end}}">
|
||||
<div class="trip-header">
|
||||
<span class="trip-title">🚗 Fahrt geplant</span>
|
||||
<form method="post" action="/trip/cancel">
|
||||
<button type="submit" class="trip-cancel">Abbrechen</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="trip-car">{{.Trip.CarName}} · {{.Trip.WallboxLabel}}</div>
|
||||
<div class="trip-detail">Ladebedarf: {{printf "%.1f" .Trip.EnergyKWh}} kWh ({{printf "%.0f" .Trip.CurrentSOC}}% → 100%) · ca. {{.Trip.DurationStr}}</div>
|
||||
{{if .Trip.ChargingStarted}}
|
||||
<div class="trip-timing">⚡ Lädt seit {{.Trip.ChargingSince}}</div>
|
||||
{{else if .Trip.IsTight}}
|
||||
<div class="trip-timing">⚠ Zeitfenster knapp — sofort einstecken!</div>
|
||||
{{else}}
|
||||
<div class="trip-timing">Laden startet: {{.Trip.StartsAtStr}}</div>
|
||||
{{end}}
|
||||
<div class="trip-deadline">Bereit bis: {{.Trip.DeadlineStr}}</div>
|
||||
</div>
|
||||
{{else if .CarOptions}}
|
||||
<div class="trip-form-card">
|
||||
<div class="section-title">🚗 Fahrtziel planen</div>
|
||||
<form method="post" action="/trip" class="trip-form">
|
||||
<div class="trip-row">
|
||||
<select name="wallbox" class="trip-select">
|
||||
<option value="wallbox_a">Wallbox A</option>
|
||||
<option value="wallbox_b">Wallbox B</option>
|
||||
</select>
|
||||
<select name="car" class="trip-select">
|
||||
{{range .CarOptions}}
|
||||
<option value="{{.Key}}">{{.Name}} ({{.BatteryKWh}} kWh)</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="trip-row">
|
||||
<span class="trip-label">Ladestand jetzt</span>
|
||||
<input type="number" name="current_soc" min="1" max="99" value="50" class="trip-number"> %
|
||||
</div>
|
||||
<div class="trip-row">
|
||||
<span class="trip-label">Bereit bis</span>
|
||||
<input type="datetime-local" name="deadline" class="trip-datetime" required>
|
||||
</div>
|
||||
<button type="submit" class="trip-submit">Planen</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
Reference in New Issue
Block a user