Add EV charging advice notification to status page
Uses hourly PV forecast data (watts per hour from forecast.solar) to find the first contiguous block of hours where: PV production - base_load_w >= min_surplus_w Shows a blue advice card on the status page: "Auto einstecken bis HH:MM Uhr" "Erwartetes Überschuss-Fenster: HH:MM – HH:MM Uhr" Card is hidden when: - No surplus window found (weak forecast day) - Window has already started or passed - A wallbox is currently active (car already charging) Config: forecast.base_load_w (1000W), forecast.min_surplus_w (1800W) Timestamp parsing handles both "HH:MM:SS" and "HH:MM" key formats. Today: surplus window 11:00–13:00 (24.3 kWh forecast). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +16,11 @@ import (
|
||||
|
||||
// Result holds the fetched forecast for a single day.
|
||||
type Result struct {
|
||||
Date time.Time
|
||||
TotalKWh float64
|
||||
FetchedAt time.Time
|
||||
Date time.Time
|
||||
TotalKWh float64
|
||||
FetchedAt time.Time
|
||||
SurplusWindowStart time.Time // first hour where PV surplus exceeds wallbox threshold
|
||||
SurplusWindowEnd time.Time // last hour of that contiguous block
|
||||
}
|
||||
|
||||
// Quality returns a human-readable label based on configured thresholds.
|
||||
@@ -151,6 +155,7 @@ func parseTimeOfDay(s string, ref time.Time) (time.Time, error) {
|
||||
// forecastResponse is the forecast.solar API response structure.
|
||||
type forecastResponse struct {
|
||||
Result struct {
|
||||
Watts map[string]float64 `json:"watts"` // instantaneous W per hour slot
|
||||
WattHoursDay map[string]float64 `json:"watt_hours_day"`
|
||||
} `json:"result"`
|
||||
Message struct {
|
||||
@@ -201,11 +206,72 @@ func (c *Client) fetch(ctx context.Context, day time.Time) (Result, error) {
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Compute surplus window if thresholds are configured
|
||||
if c.cfg.MinSurplusW > 0 {
|
||||
result.SurplusWindowStart, result.SurplusWindowEnd =
|
||||
computeSurplusWindow(fr.Result.Watts, day, c.cfg.BaseLoadW, c.cfg.MinSurplusW)
|
||||
}
|
||||
|
||||
c.logger.Info("forecast fetched",
|
||||
"date", dateKey,
|
||||
"kwh", result.TotalKWh,
|
||||
"quality", result.Quality(c.strategic),
|
||||
"surplus_start", formatOptionalTime(result.SurplusWindowStart),
|
||||
"surplus_end", formatOptionalTime(result.SurplusWindowEnd),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// computeSurplusWindow finds the first contiguous block of hours in the hourly
|
||||
// forecast where PV production minus house base load meets the minimum surplus
|
||||
// threshold (i.e. enough export to charge a car).
|
||||
// Returns zero times if no such window exists.
|
||||
func computeSurplusWindow(watts map[string]float64, day time.Time, baseLoadW, minSurplusW float64) (start, end time.Time) {
|
||||
type slot struct {
|
||||
t time.Time
|
||||
w float64
|
||||
}
|
||||
|
||||
dayStr := day.Format("2006-01-02")
|
||||
var slots []slot
|
||||
|
||||
for k, v := range watts {
|
||||
if !strings.HasPrefix(k, dayStr) {
|
||||
continue
|
||||
}
|
||||
// forecast.solar may return timestamps with or without seconds
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", k, day.Location())
|
||||
if err != nil {
|
||||
t, err = time.ParseInLocation("2006-01-02 15:04", k, day.Location())
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
slots = append(slots, slot{t, v})
|
||||
}
|
||||
|
||||
sort.Slice(slots, func(i, j int) bool {
|
||||
return slots[i].t.Before(slots[j].t)
|
||||
})
|
||||
|
||||
for _, s := range slots {
|
||||
if s.w-baseLoadW >= minSurplusW {
|
||||
if start.IsZero() {
|
||||
start = s.t
|
||||
}
|
||||
end = s.t.Add(time.Hour)
|
||||
} else if !start.IsZero() {
|
||||
break // first contiguous block ended
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func formatOptionalTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.Format("15:04")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user