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>
278 lines
7.2 KiB
Go
278 lines
7.2 KiB
Go
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:00–19: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")
|
||
}
|