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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user