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") }