Files
EMS/internal/forecast/forecast.go
Lutz Finsterle 0020f1268b 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>
2026-04-06 10:30:58 +02:00

278 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package forecast
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/tb/ems/internal/config"
)
// Result holds the fetched forecast for a single day.
type Result struct {
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.
func (r Result) Quality(cfg config.StrategicConfig) string {
switch {
case r.TotalKWh >= cfg.ForecastHighKWh:
return "Sehr gut"
case r.TotalKWh >= cfg.ForecastMidKWh:
return "Gut"
case r.TotalKWh >= 5:
return "Mittel"
default:
return "Schwach"
}
}
// QualityIcon returns a weather icon for the forecast quality.
func (r Result) QualityIcon(cfg config.StrategicConfig) string {
switch {
case r.TotalKWh >= cfg.ForecastHighKWh:
return "☀️"
case r.TotalKWh >= cfg.ForecastMidKWh:
return "🌤️"
case r.TotalKWh >= 5:
return "⛅"
default:
return "☁️"
}
}
// Client fetches daily PV forecasts from forecast.solar and caches the result.
type Client struct {
cfg config.ForecastConfig
strategic config.StrategicConfig
httpClient *http.Client
logger *slog.Logger
mu sync.RWMutex
cached *Result
}
// NewClient creates a new forecast client.
func NewClient(cfg config.ForecastConfig, strategic config.StrategicConfig, logger *slog.Logger) *Client {
return &Client{
cfg: cfg,
strategic: strategic,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
logger: logger,
}
}
// Today returns the forecast for today, fetching from the API if needed.
// The cache is refreshed at most once per FetchInterval, and only within the
// configured fetch window (e.g. 07:0019:00). Outside the window the cached
// value is always returned unchanged.
// Returns a zero Result and no error if forecasting is disabled.
func (c *Client) Today(ctx context.Context) (Result, error) {
if !c.cfg.Enabled {
return Result{}, nil
}
c.mu.RLock()
cached := c.cached
c.mu.RUnlock()
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// Outside fetch window: serve cache as-is (even if stale)
if !c.inFetchWindow(now) {
if cached != nil {
return *cached, nil
}
// No cache yet and outside window — try anyway (first startup)
}
// Within window: re-fetch if cache is missing, from a previous day, or older than the interval
interval := c.cfg.FetchIntervalParsed()
cacheValid := cached != nil &&
cached.Date.Equal(today) &&
now.Sub(cached.FetchedAt) < interval
if cacheValid {
return *cached, nil
}
result, err := c.fetch(ctx, today)
if err != nil {
if cached != nil {
c.logger.Warn("forecast fetch failed, using stale cache",
"error", err,
"cached_date", cached.Date.Format("2006-01-02"),
"fetched_at", cached.FetchedAt.Format("15:04"),
)
return *cached, nil
}
return Result{}, err
}
c.mu.Lock()
c.cached = &result
c.mu.Unlock()
return result, nil
}
// inFetchWindow returns true if now is within the configured fetch window.
// If no window is configured, always returns true.
func (c *Client) inFetchWindow(now time.Time) bool {
if c.cfg.FetchWindowStart == "" || c.cfg.FetchWindowEnd == "" {
return true
}
start, err1 := parseTimeOfDay(c.cfg.FetchWindowStart, now)
end, err2 := parseTimeOfDay(c.cfg.FetchWindowEnd, now)
if err1 != nil || err2 != nil {
return true // misconfigured → don't block
}
return now.After(start) && now.Before(end)
}
// parseTimeOfDay parses "HH:MM" and returns a time.Time on the same day as ref.
func parseTimeOfDay(s string, ref time.Time) (time.Time, error) {
var h, m int
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
return time.Time{}, fmt.Errorf("invalid time %q: %w", s, err)
}
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location()), nil
}
// 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 {
Code int `json:"code"`
Text string `json:"text"`
} `json:"message"`
}
func (c *Client) fetch(ctx context.Context, day time.Time) (Result, error) {
url := fmt.Sprintf(
"https://api.forecast.solar/estimate/%.4f/%.4f/%d/%d/%.1f",
c.cfg.Lat, c.cfg.Lon,
c.cfg.Declination, c.cfg.Azimuth,
c.cfg.KWp,
)
c.logger.Debug("fetching forecast", "url", url)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Result{}, fmt.Errorf("creating request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return Result{}, fmt.Errorf("fetching forecast: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Result{}, fmt.Errorf("forecast.solar returned status %d", resp.StatusCode)
}
var fr forecastResponse
if err := json.NewDecoder(resp.Body).Decode(&fr); err != nil {
return Result{}, fmt.Errorf("decoding response: %w", err)
}
dateKey := day.Format("2006-01-02")
wh, ok := fr.Result.WattHoursDay[dateKey]
if !ok {
return Result{}, fmt.Errorf("no forecast data for %s", dateKey)
}
result := Result{
Date: day,
TotalKWh: wh / 1000.0,
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")
}