Add EV charging advice notification to status page
Uses hourly PV forecast data (watts per hour from forecast.solar) to find the first contiguous block of hours where: PV production - base_load_w >= min_surplus_w Shows a blue advice card on the status page: "Auto einstecken bis HH:MM Uhr" "Erwartetes Überschuss-Fenster: HH:MM – HH:MM Uhr" Card is hidden when: - No surplus window found (weak forecast day) - Window has already started or passed - A wallbox is currently active (car already charging) Config: forecast.base_load_w (1000W), forecast.min_surplus_w (1800W) Timestamp parsing handles both "HH:MM:SS" and "HH:MM" key formats. Today: surplus window 11:00–13:00 (24.3 kWh forecast). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,8 @@ forecast:
|
|||||||
fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches)
|
fetch_interval: "4h" # re-fetch during the day (free tier: 12 req/day → 3 fetches)
|
||||||
fetch_window_start: "07:00"
|
fetch_window_start: "07:00"
|
||||||
fetch_window_end: "19: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 operational settings
|
||||||
ems:
|
ems:
|
||||||
|
|||||||
@@ -131,12 +131,14 @@ type ForecastConfig struct {
|
|||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Lat float64 `yaml:"lat"`
|
Lat float64 `yaml:"lat"`
|
||||||
Lon float64 `yaml:"lon"`
|
Lon float64 `yaml:"lon"`
|
||||||
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")
|
FetchInterval string `yaml:"fetch_interval"` // how often to re-fetch during the day
|
||||||
FetchWindowStart string `yaml:"fetch_window_start"` // e.g. "07:00" — no fetches before this
|
FetchWindowStart string `yaml:"fetch_window_start"` // e.g. "07:00"
|
||||||
FetchWindowEnd string `yaml:"fetch_window_end"` // e.g. "19:00" — no fetches after this
|
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 {
|
func (f *ForecastConfig) FetchIntervalParsed() time.Duration {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,9 +16,11 @@ import (
|
|||||||
|
|
||||||
// Result holds the fetched forecast for a single day.
|
// Result holds the fetched forecast for a single day.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
TotalKWh float64
|
TotalKWh float64
|
||||||
FetchedAt time.Time
|
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.
|
// 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.
|
// forecastResponse is the forecast.solar API response structure.
|
||||||
type forecastResponse struct {
|
type forecastResponse struct {
|
||||||
Result struct {
|
Result struct {
|
||||||
|
Watts map[string]float64 `json:"watts"` // instantaneous W per hour slot
|
||||||
WattHoursDay map[string]float64 `json:"watt_hours_day"`
|
WattHoursDay map[string]float64 `json:"watt_hours_day"`
|
||||||
} `json:"result"`
|
} `json:"result"`
|
||||||
Message struct {
|
Message struct {
|
||||||
@@ -201,11 +206,72 @@ func (c *Client) fetch(ctx context.Context, day time.Time) (Result, error) {
|
|||||||
FetchedAt: time.Now(),
|
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",
|
c.logger.Info("forecast fetched",
|
||||||
"date", dateKey,
|
"date", dateKey,
|
||||||
"kwh", result.TotalKWh,
|
"kwh", result.TotalKWh,
|
||||||
"quality", result.Quality(c.strategic),
|
"quality", result.Quality(c.strategic),
|
||||||
|
"surplus_start", formatOptionalTime(result.SurplusWindowStart),
|
||||||
|
"surplus_end", formatOptionalTime(result.SurplusWindowEnd),
|
||||||
)
|
)
|
||||||
|
|
||||||
return result, nil
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ type forecastView struct {
|
|||||||
Quality string
|
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 {
|
type pageData struct {
|
||||||
LastUpdate time.Time
|
LastUpdate time.Time
|
||||||
ErrMsg string
|
ErrMsg string
|
||||||
@@ -150,6 +157,7 @@ type pageData struct {
|
|||||||
HasPhaseData bool
|
HasPhaseData bool
|
||||||
Phases []phaseView
|
Phases []phaseView
|
||||||
Forecast forecastView
|
Forecast forecastView
|
||||||
|
ChargingAdvice chargingAdviceView
|
||||||
Consumers []consumerView
|
Consumers []consumerView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +261,25 @@ func (s *Store) Handler() http.HandlerFunc {
|
|||||||
Icon: s.fcResult.QualityIcon(s.strategic),
|
Icon: s.fcResult.QualityIcon(s.strategic),
|
||||||
Quality: s.fcResult.Quality(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 {
|
for _, c := range consumerOrder {
|
||||||
rec := s.consumers[c]
|
rec := s.consumers[c]
|
||||||
@@ -415,6 +442,33 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
margin-top: 0.1rem;
|
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 */
|
/* Live power badge on consumers */
|
||||||
.power-badge {
|
.power-badge {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -583,6 +637,16 @@ h1 { font-size: 1.4rem; font-weight: 700; color: #14532d; }
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .ChargingAdvice.Show}}
|
||||||
|
<div class="advice-card">
|
||||||
|
<div class="advice-title">🔌 Ladeempfehlung</div>
|
||||||
|
<div class="advice-main">Auto einstecken bis {{.ChargingAdvice.PlugInBy}} Uhr</div>
|
||||||
|
<div class="advice-sub">
|
||||||
|
Erwartetes Überschuss-Fenster: {{.ChargingAdvice.WindowStart}} – {{.ChargingAdvice.WindowEnd}} Uhr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="section-title">Verbraucher</div>
|
<div class="section-title">Verbraucher</div>
|
||||||
|
|
||||||
{{range .Consumers}}
|
{{range .Consumers}}
|
||||||
|
|||||||
Reference in New Issue
Block a user