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
|
declination: 20 # panel tilt in degrees
|
||||||
azimuth: 5 # degrees from south (south=0, west=90, east=-90)
|
azimuth: 5 # degrees from south (south=0, west=90, east=-90)
|
||||||
kwp: 7.0 # installed peak power
|
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 operational settings
|
||||||
ems:
|
ems:
|
||||||
|
|||||||
@@ -134,6 +134,17 @@ type ForecastConfig struct {
|
|||||||
Declination int `yaml:"declination"` // panel tilt in degrees
|
Declination int `yaml:"declination"` // panel tilt in degrees
|
||||||
Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90)
|
Azimuth int `yaml:"azimuth"` // degrees from south (south=0, west=90)
|
||||||
KWp float64 `yaml:"kwp"` // installed peak power
|
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.
|
// 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.
|
// 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.
|
// Returns a zero Result and no error if forecasting is disabled.
|
||||||
func (c *Client) Today(ctx context.Context) (Result, error) {
|
func (c *Client) Today(ctx context.Context) (Result, error) {
|
||||||
if !c.cfg.Enabled {
|
if !c.cfg.Enabled {
|
||||||
@@ -84,17 +87,31 @@ func (c *Client) Today(ctx context.Context) (Result, error) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
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
|
return *cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := c.fetch(ctx, today)
|
result, err := c.fetch(ctx, today)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return stale cache rather than nothing, if we have it
|
|
||||||
if cached != nil {
|
if cached != nil {
|
||||||
c.logger.Warn("forecast fetch failed, using stale cache",
|
c.logger.Warn("forecast fetch failed, using stale cache",
|
||||||
"error", err,
|
"error", err,
|
||||||
"cached_date", cached.Date.Format("2006-01-02"),
|
"cached_date", cached.Date.Format("2006-01-02"),
|
||||||
|
"fetched_at", cached.FetchedAt.Format("15:04"),
|
||||||
)
|
)
|
||||||
return *cached, nil
|
return *cached, nil
|
||||||
}
|
}
|
||||||
@@ -108,6 +125,29 @@ func (c *Client) Today(ctx context.Context) (Result, error) {
|
|||||||
return result, nil
|
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.
|
// forecastResponse is the forecast.solar API response structure.
|
||||||
type forecastResponse struct {
|
type forecastResponse struct {
|
||||||
Result struct {
|
Result struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user