Refresh forecast up to 3× per day within daylight window

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:19:29 +02:00
parent 79828a46c5
commit 5f22df3e0f
3 changed files with 62 additions and 8 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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: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 {
@@ -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 {