diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index 2ae1d5d..bb14f16 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -98,6 +98,8 @@ forecast: 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" + base_load_w: 1000 # typical house consumption used for surplus estimate (W) + min_surplus_w: 1800 # min export surplus to trigger wallbox — matches wallbox_a threshold # EMS operational settings ems: diff --git a/internal/config/config.go b/internal/config/config.go index b0d2330..2757483 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -131,12 +131,14 @@ 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 - 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 + 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 + FetchWindowStart string `yaml:"fetch_window_start"` // e.g. "07:00" + FetchWindowEnd string `yaml:"fetch_window_end"` // e.g. "19:00" + BaseLoadW float64 `yaml:"base_load_w"` // typical house consumption for surplus estimate (W) + MinSurplusW float64 `yaml:"min_surplus_w"` // min export needed to trigger wallbox charging (W) } func (f *ForecastConfig) FetchIntervalParsed() time.Duration { diff --git a/internal/forecast/forecast.go b/internal/forecast/forecast.go index e25fc5c..6aa0095 100644 --- a/internal/forecast/forecast.go +++ b/internal/forecast/forecast.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" "net/http" + "sort" + "strings" "sync" "time" @@ -14,9 +16,11 @@ import ( // Result holds the fetched forecast for a single day. type Result struct { - Date time.Time - TotalKWh float64 - FetchedAt time.Time + 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. @@ -151,6 +155,7 @@ func parseTimeOfDay(s string, ref time.Time) (time.Time, error) { // 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 { @@ -201,11 +206,72 @@ func (c *Client) fetch(ctx context.Context, day time.Time) (Result, error) { 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") +} diff --git a/internal/status/status.go b/internal/status/status.go index f57f2e9..16a26b5 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -136,6 +136,13 @@ type forecastView struct { Quality string } +type chargingAdviceView struct { + Show bool + PlugInBy string // e.g. "11:30" + WindowStart string // e.g. "12:00" + WindowEnd string // e.g. "16:00" +} + type pageData struct { LastUpdate time.Time ErrMsg string @@ -150,6 +157,7 @@ type pageData struct { HasPhaseData bool Phases []phaseView Forecast forecastView + ChargingAdvice chargingAdviceView Consumers []consumerView } @@ -253,6 +261,25 @@ func (s *Store) Handler() http.HandlerFunc { Icon: s.fcResult.QualityIcon(s.strategic), Quality: s.fcResult.Quality(s.strategic), } + + // Charging advice: show when surplus window is ahead and no wallbox is active + ws := s.fcResult.SurplusWindowStart + we := s.fcResult.SurplusWindowEnd + now := time.Now() + wallboxActive := s.consumers[engine.ConsumerWallboxA].active || + s.consumers[engine.ConsumerWallboxB].active + if !ws.IsZero() && now.Before(ws) && !wallboxActive { + plugBy := ws.Add(-30 * time.Minute) + if plugBy.Before(now) { + plugBy = ws // less than 30 min to window — just show window start + } + data.ChargingAdvice = chargingAdviceView{ + Show: true, + PlugInBy: plugBy.Format("15:04"), + WindowStart: ws.Format("15:04"), + WindowEnd: we.Format("15:04"), + } + } } for _, c := range consumerOrder { rec := s.consumers[c] @@ -415,6 +442,33 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } margin-top: 0.1rem; } +/* Charging advice card */ +.advice-card { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 14px; + padding: 0.85rem 1rem; + margin-bottom: 1.25rem; +} +.advice-title { + font-size: 0.78rem; + font-weight: 700; + color: #1d4ed8; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.4rem; +} +.advice-main { + font-size: 1rem; + font-weight: 700; + color: #1e3a8a; +} +.advice-sub { + font-size: 0.78rem; + color: #3b82f6; + margin-top: 0.2rem; +} + /* Live power badge on consumers */ .power-badge { font-size: 0.72rem; @@ -583,6 +637,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; } +{{if .ChargingAdvice.Show}} +