From 5f22df3e0fb055449014d7f0b9d89e5fe014b92a Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Mon, 6 Apr 2026 10:19:29 +0200 Subject: [PATCH] =?UTF-8?q?Refresh=20forecast=20up=20to=203=C3=97=20per=20?= =?UTF-8?q?day=20within=20daylight=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forecast.solar updates estimates as the day progresses, so a single morning fetch can be stale by afternoon. New behaviour: - Re-fetch every 4h within 07:00–19:00 window (3 fetches/day) - Outside the window always serve the cached value - Free tier limit is 12 req/day — 3 fetches is well within budget - First startup outside window still fetches if cache is empty Config: forecast.fetch_interval, fetch_window_start, fetch_window_end Co-Authored-By: Claude Sonnet 4.6 --- configs/ems-config.yaml | 3 +++ internal/config/config.go | 23 +++++++++++++----- internal/forecast/forecast.go | 44 +++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index 9f70f42..2ae1d5d 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -95,6 +95,9 @@ forecast: declination: 20 # panel tilt in degrees azimuth: 5 # degrees from south (south=0, west=90, east=-90) kwp: 7.0 # installed peak power + fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches) + fetch_window_start: "07:00" + fetch_window_end: "19:00" # EMS operational settings ems: diff --git a/internal/config/config.go b/internal/config/config.go index ad06744..b0d2330 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -128,12 +128,23 @@ type SeasonConfig struct { // ForecastConfig holds forecast.solar API parameters. type ForecastConfig struct { - Enabled bool `yaml:"enabled"` - Lat float64 `yaml:"lat"` - Lon float64 `yaml:"lon"` - Declination int `yaml:"declination"` // panel tilt in degrees - Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90) - KWp float64 `yaml:"kwp"` // installed peak power + Enabled bool `yaml:"enabled"` + Lat float64 `yaml:"lat"` + Lon float64 `yaml:"lon"` + Declination int `yaml:"declination"` // panel tilt in degrees + Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90) + KWp float64 `yaml:"kwp"` // installed peak power + FetchInterval string `yaml:"fetch_interval"` // how often to re-fetch during the day (e.g. "4h") + FetchWindowStart string `yaml:"fetch_window_start"` // e.g. "07:00" — no fetches before this + FetchWindowEnd string `yaml:"fetch_window_end"` // e.g. "19:00" — no fetches after this +} + +func (f *ForecastConfig) FetchIntervalParsed() time.Duration { + d, err := time.ParseDuration(f.FetchInterval) + if err != nil || d <= 0 { + return 24 * time.Hour // safe default: once per day + } + return d } // EMSConfig holds operational settings for the EMS daemon. diff --git a/internal/forecast/forecast.go b/internal/forecast/forecast.go index eb6dfed..e25fc5c 100644 --- a/internal/forecast/forecast.go +++ b/internal/forecast/forecast.go @@ -71,6 +71,9 @@ func NewClient(cfg config.ForecastConfig, strategic config.StrategicConfig, logg } // 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 { @@ -84,17 +87,31 @@ func (c *Client) Today(ctx context.Context) (Result, error) { now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - if cached != nil && cached.Date.Equal(today) { + // 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 { - // Return stale cache rather than nothing, if we have it 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 } @@ -108,6 +125,29 @@ func (c *Client) Today(ctx context.Context) (Result, error) { 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 {