diff --git a/api/feature.go b/api/feature.go index 32cb8351caf..9bc5935123a 100644 --- a/api/feature.go +++ b/api/feature.go @@ -4,19 +4,21 @@ type Feature int //go:generate go tool enumer -type Feature -text const ( - _ Feature = iota - CoarseCurrent // charger - IntegratedDevice // charger - always connected - no vehicle, no charging sessions - SwitchDevice // charger - no current control - heat pumps or switch sockets - Heating // charger - heating device - soc ist temperature (°C) - Continuous // charger - heating device where disabled means "normal operation" - Average // tariff - Cacheable // tariff - Offline // vehicle - Retryable // vehicle - Streaming // vehicle - WelcomeCharge // vehicle - ClimaterDisabled // vehicle - ignore climater state for charge control - AutodetectDisabled // vehicle - do not try to identify vehicle by status - WakeUpDisabled // vehicle - do not send wake-up calls + _ Feature = iota + CoarseCurrent // charger + IntegratedDevice // charger - always connected - no vehicle, no charging sessions + SwitchDevice // charger - no current control - heat pumps or switch sockets + Heating // charger - heating device - soc ist temperature (°C) + DemandProfileWeekly // charger - demand forecast: same weekday last week (warm water) + DemandProfileTemperature // charger - demand forecast: daily avg scaled by outdoor temp (room heating) + Continuous // charger - heating device where disabled means "normal operation" + Average // tariff + Cacheable // tariff + Offline // vehicle + Retryable // vehicle + Streaming // vehicle + WelcomeCharge // vehicle + ClimaterDisabled // vehicle - ignore climater state for charge control + AutodetectDisabled // vehicle - do not try to identify vehicle by status + WakeUpDisabled // vehicle - do not send wake-up calls ) diff --git a/api/feature_enumer.go b/api/feature_enumer.go index b07256fe2a8..0ecc70fd61d 100644 --- a/api/feature_enumer.go +++ b/api/feature_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _FeatureName = "CoarseCurrentIntegratedDeviceSwitchDeviceHeatingContinuousAverageCacheableOfflineRetryableStreamingWelcomeChargeClimaterDisabledAutodetectDisabledWakeUpDisabled" +const _FeatureName = "CoarseCurrentIntegratedDeviceSwitchDeviceHeatingDemandProfileWeeklyDemandProfileTemperatureContinuousAverageCacheableOfflineRetryableStreamingWelcomeChargeClimaterDisabledAutodetectDisabledWakeUpDisabled" -var _FeatureIndex = [...]uint8{0, 13, 29, 41, 48, 58, 65, 74, 81, 90, 99, 112, 128, 146, 160} +var _FeatureIndex = [...]uint8{0, 13, 29, 41, 48, 67, 91, 101, 108, 117, 124, 133, 142, 155, 171, 189, 203} -const _FeatureLowerName = "coarsecurrentintegrateddeviceswitchdeviceheatingcontinuousaveragecacheableofflineretryablestreamingwelcomechargeclimaterdisabledautodetectdisabledwakeupdisabled" +const _FeatureLowerName = "coarsecurrentintegrateddeviceswitchdeviceheatingdemandprofileweeklydemandprofiletemperaturecontinuousaveragecacheableofflineretryablestreamingwelcomechargeclimaterdisabledautodetectdisabledwakeupdisabled" func (i Feature) String() string { i -= 1 @@ -29,19 +29,21 @@ func _FeatureNoOp() { _ = x[IntegratedDevice-(2)] _ = x[SwitchDevice-(3)] _ = x[Heating-(4)] - _ = x[Continuous-(5)] - _ = x[Average-(6)] - _ = x[Cacheable-(7)] - _ = x[Offline-(8)] - _ = x[Retryable-(9)] - _ = x[Streaming-(10)] - _ = x[WelcomeCharge-(11)] - _ = x[ClimaterDisabled-(12)] - _ = x[AutodetectDisabled-(13)] - _ = x[WakeUpDisabled-(14)] + _ = x[DemandProfileWeekly-(5)] + _ = x[DemandProfileTemperature-(6)] + _ = x[Continuous-(7)] + _ = x[Average-(8)] + _ = x[Cacheable-(9)] + _ = x[Offline-(10)] + _ = x[Retryable-(11)] + _ = x[Streaming-(12)] + _ = x[WelcomeCharge-(13)] + _ = x[ClimaterDisabled-(14)] + _ = x[AutodetectDisabled-(15)] + _ = x[WakeUpDisabled-(16)] } -var _FeatureValues = []Feature{CoarseCurrent, IntegratedDevice, SwitchDevice, Heating, Continuous, Average, Cacheable, Offline, Retryable, Streaming, WelcomeCharge, ClimaterDisabled, AutodetectDisabled, WakeUpDisabled} +var _FeatureValues = []Feature{CoarseCurrent, IntegratedDevice, SwitchDevice, Heating, DemandProfileWeekly, DemandProfileTemperature, Continuous, Average, Cacheable, Offline, Retryable, Streaming, WelcomeCharge, ClimaterDisabled, AutodetectDisabled, WakeUpDisabled} var _FeatureNameToValueMap = map[string]Feature{ _FeatureName[0:13]: CoarseCurrent, @@ -52,26 +54,30 @@ var _FeatureNameToValueMap = map[string]Feature{ _FeatureLowerName[29:41]: SwitchDevice, _FeatureName[41:48]: Heating, _FeatureLowerName[41:48]: Heating, - _FeatureName[48:58]: Continuous, - _FeatureLowerName[48:58]: Continuous, - _FeatureName[58:65]: Average, - _FeatureLowerName[58:65]: Average, - _FeatureName[65:74]: Cacheable, - _FeatureLowerName[65:74]: Cacheable, - _FeatureName[74:81]: Offline, - _FeatureLowerName[74:81]: Offline, - _FeatureName[81:90]: Retryable, - _FeatureLowerName[81:90]: Retryable, - _FeatureName[90:99]: Streaming, - _FeatureLowerName[90:99]: Streaming, - _FeatureName[99:112]: WelcomeCharge, - _FeatureLowerName[99:112]: WelcomeCharge, - _FeatureName[112:128]: ClimaterDisabled, - _FeatureLowerName[112:128]: ClimaterDisabled, - _FeatureName[128:146]: AutodetectDisabled, - _FeatureLowerName[128:146]: AutodetectDisabled, - _FeatureName[146:160]: WakeUpDisabled, - _FeatureLowerName[146:160]: WakeUpDisabled, + _FeatureName[48:67]: DemandProfileWeekly, + _FeatureLowerName[48:67]: DemandProfileWeekly, + _FeatureName[67:91]: DemandProfileTemperature, + _FeatureLowerName[67:91]: DemandProfileTemperature, + _FeatureName[91:101]: Continuous, + _FeatureLowerName[91:101]: Continuous, + _FeatureName[101:108]: Average, + _FeatureLowerName[101:108]: Average, + _FeatureName[108:117]: Cacheable, + _FeatureLowerName[108:117]: Cacheable, + _FeatureName[117:124]: Offline, + _FeatureLowerName[117:124]: Offline, + _FeatureName[124:133]: Retryable, + _FeatureLowerName[124:133]: Retryable, + _FeatureName[133:142]: Streaming, + _FeatureLowerName[133:142]: Streaming, + _FeatureName[142:155]: WelcomeCharge, + _FeatureLowerName[142:155]: WelcomeCharge, + _FeatureName[155:171]: ClimaterDisabled, + _FeatureLowerName[155:171]: ClimaterDisabled, + _FeatureName[171:189]: AutodetectDisabled, + _FeatureLowerName[171:189]: AutodetectDisabled, + _FeatureName[189:203]: WakeUpDisabled, + _FeatureLowerName[189:203]: WakeUpDisabled, } var _FeatureNames = []string{ @@ -79,16 +85,18 @@ var _FeatureNames = []string{ _FeatureName[13:29], _FeatureName[29:41], _FeatureName[41:48], - _FeatureName[48:58], - _FeatureName[58:65], - _FeatureName[65:74], - _FeatureName[74:81], - _FeatureName[81:90], - _FeatureName[90:99], - _FeatureName[99:112], - _FeatureName[112:128], - _FeatureName[128:146], - _FeatureName[146:160], + _FeatureName[48:67], + _FeatureName[67:91], + _FeatureName[91:101], + _FeatureName[101:108], + _FeatureName[108:117], + _FeatureName[117:124], + _FeatureName[124:133], + _FeatureName[133:142], + _FeatureName[142:155], + _FeatureName[155:171], + _FeatureName[171:189], + _FeatureName[189:203], } // FeatureString retrieves an enum value from the enum constants string name. diff --git a/core/metrics/collector.go b/core/metrics/collector.go index ac55601bcee..299dbcd6339 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -170,6 +170,10 @@ func (c *Collector) EnergyProfile(from time.Time) (*[96]float64, error) { return energyProfile(c.entity, from) } +func (c *Collector) EnergyProfileWeekday() (*[96]float64, error) { + return energyProfileWeekday(c.entity) +} + // LastSlotEnergy returns the energy in kWh of the most recently completed // 15min slot, or false when it has not been persisted (boot, data gap) or // contains recovered downtime energy. diff --git a/core/metrics/db_profile.go b/core/metrics/db_profile.go index 2986be8a2f6..28adcc2d5b2 100644 --- a/core/metrics/db_profile.go +++ b/core/metrics/db_profile.go @@ -10,8 +10,9 @@ import ( var ErrIncomplete = errors.New("meter profile incomplete") -// energyProfile returns a 15min average meter profile in Wh. The profile -// is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values. +// energyProfile returns a 15min average meter profile in kWh, averaged across all +// days in [from, now). Groups by time-of-day (96 slots). Returns ErrIncomplete if +// fewer than 96 slots are present. func energyProfile(entity entity, from time.Time) (*[96]float64, error) { db, err := db.Instance.DB() if err != nil { @@ -61,3 +62,62 @@ func energyProfile(entity entity, from time.Time) (*[96]float64, error) { return (*[96]float64)(res), nil } + +// energyProfileWeekday returns the actual 96-slot 15min energy profile (kWh) for the +// same weekday as now, taken from 7 days ago. No averaging — one day is the forecast. +// Returns ErrIncomplete if fewer than 96 slots are present for that day. +func energyProfileWeekday(entity entity) (*[96]float64, error) { + database, err := db.Instance.DB() + if err != nil { + return nil, err + } + + // same weekday, 7 days back: covers exactly 00:00–23:45 of that day + weekdayNum := int(time.Now().Weekday()) // 0=Sunday + rows, err := database.Query(`SELECT ts, COALESCE(energy, 0) AS energy + FROM meters + WHERE meter = ? AND COALESCE(recovered, 0) = 0 + AND strftime('%w', ts, 'unixepoch', 'localtime') = ? + AND ts >= ? + AND ts < ? + ORDER BY ts ASC`, + entity.Id, + weekdayNum, + time.Now().AddDate(0, 0, -7).Truncate(24*time.Hour).Unix(), + time.Now().AddDate(0, 0, -6).Truncate(24*time.Hour).Unix(), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var prev time.Time + res := make([]float64, 0, 96) + + for rows.Next() { + var ts SqlTime + var val float64 + + if err := rows.Scan(&ts, &val); err != nil { + return nil, err + } + + // interpolate single missing value + if !prev.IsZero() && time.Time(ts).Sub(prev) == 2*tariff.SlotDuration { + res = append(res, (val+res[len(res)-1])/2) + } + prev = time.Time(ts) + + res = append(res, val) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if len(res) != 96 { + return nil, ErrIncomplete + } + + return (*[96]float64)(res), nil +} diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 85ff2b828f6..4736a699cc5 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net/http" "os" "slices" @@ -770,16 +771,16 @@ func loadpointProfile(lp loadpoint.API, minLen int) []float64 { // homeProfile returns the home base load in Wh func (site *Site) homeProfile(minLen int) ([]float64, error) { - // kWh over last 30 days - profile, err := site.collectors[metrics.Home].EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -30)) + // base load (excludes loadpoints) - averaged over last 30 days + gt_base, err := site.collectors[metrics.Home].EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -30)) if err != nil { return nil, err } - // max 4 days + // max 4 days of base slots (allow for prorating first day) slots := make([]float64, 0, minLen+1) - for len(slots) <= minLen+24*4 { // allow for prorating first day - slots = append(slots, profile[:]...) + for len(slots) <= minLen+24*4 { + slots = append(slots, gt_base[:]...) } res := profileSlotsFromNow(slots) @@ -790,10 +791,207 @@ func (site *Site) homeProfile(minLen int) ([]float64, error) { res = res[:minLen] } + tempProfile, weeklyProfile := site.extractHeaterProfiles() + + if len(tempProfile) == 0 && len(weeklyProfile) == 0 { + site.log.DEBUG.Println("home profile: no heating devices, returning base load only") + return lo.Map(res, func(v float64, i int) float64 { return v * 1e3 }), nil + } + + gt_final := make([]float64, len(res)) + copy(gt_final, res) + + // DemandProfileTemperature: daily-averaged profile scaled by outdoor temp forecast + if len(tempProfile) > 0 { + p := tileAndTrim(tempProfile, minLen) + site.log.DEBUG.Printf("home profile: applying temperature correction to %d slots", len(p)) + p = site.applyTemperatureCorrection(p) + for i := range gt_final { + if i < len(p) { + gt_final[i] += p[i] + } + } + } + + // DemandProfileWeekly: same-weekday-last-week actual profile, tiled as-is + if len(weeklyProfile) > 0 { + p := tileAndTrim(weeklyProfile, minLen) + site.log.DEBUG.Printf("home profile: adding weekly demand profile with %d slots", len(p)) + for i := range gt_final { + if i < len(p) { + gt_final[i] += p[i] + } + } + } + // convert to Wh - return lo.Map(res, func(v float64, i int) float64 { - return v * 1e3 - }), nil + return lo.Map(gt_final, func(v float64, i int) float64 { return v * 1e3 }), nil +} + +// tileAndTrim repeats profile until it covers minLen slots, then trims and aligns to now. +func tileAndTrim(profile []float64, minLen int) []float64 { + slots := make([]float64, 0, minLen+1) + for len(slots) <= minLen+24*4 { + slots = append(slots, profile...) + } + res := profileSlotsFromNow(slots) + if len(res) > minLen { + res = res[:minLen] + } + return res +} + +// extractHeaterProfiles returns aggregated per-strategy heating profiles. +// tempProfile: loadpoints with DemandProfileTemperature (daily avg, scaled by outdoor temp). +// weeklyProfile: loadpoints with DemandProfileWeekly (same weekday last week). +func (site *Site) extractHeaterProfiles() (tempProfile, weeklyProfile []float64) { + var tempProfiles, weeklyProfiles [][]float64 + + for i, lp := range site.loadpoints { + if lp.chargeEnergy == nil || !hasFeature(lp.charger, api.Heating) { + continue + } + + if hasFeature(lp.charger, api.DemandProfileTemperature) { + profile, err := lp.chargeEnergy.EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -7)) + switch { + case err == nil: + site.log.DEBUG.Printf("heater profile: loadpoint %d: temperature strategy, %d slots", i, len(profile)) + tempProfiles = append(tempProfiles, profile[:]) + case errors.Is(err, metrics.ErrIncomplete): + site.log.DEBUG.Printf("heater profile: loadpoint %d: temperature strategy, insufficient data", i) + default: + site.log.DEBUG.Printf("heater profile: loadpoint %d: temperature strategy, no data (%v)", i, err) + } + } + + if hasFeature(lp.charger, api.DemandProfileWeekly) { + profile, err := lp.chargeEnergy.EnergyProfileWeekday() + switch { + case err == nil: + site.log.DEBUG.Printf("heater profile: loadpoint %d: weekly strategy, %d slots", i, len(profile)) + weeklyProfiles = append(weeklyProfiles, profile[:]) + case errors.Is(err, metrics.ErrIncomplete): + site.log.DEBUG.Printf("heater profile: loadpoint %d: weekly strategy, insufficient data", i) + default: + site.log.DEBUG.Printf("heater profile: loadpoint %d: weekly strategy, no data (%v)", i, err) + } + } + } + + if len(tempProfiles) > 0 { + tempProfile = sumProfiles(tempProfiles) + site.log.DEBUG.Printf("heater profile: aggregated %d temperature loadpoint(s)", len(tempProfiles)) + } + if len(weeklyProfiles) > 0 { + weeklyProfile = sumProfiles(weeklyProfiles) + site.log.DEBUG.Printf("heater profile: aggregated %d weekly loadpoint(s)", len(weeklyProfiles)) + } + + return tempProfile, weeklyProfile +} + +func sumProfiles(profiles [][]float64) []float64 { + if len(profiles) == 0 { + return nil + } + + result := make([]float64, len(profiles[0])) + for _, profile := range profiles { + for i := 0; i < len(result) && i < len(profile); i++ { + result[i] += profile[i] + } + } + return result +} + +// applyTemperatureCorrection adjusts heating load based on temperature forecast. +// Uses formula: load[i] = load_avg[i] × ((T_room − T_forecast[i]) / (T_room − T_past_avg[h])) +func (site *Site) applyTemperatureCorrection(profile []float64) []float64 { + weatherTariff := site.GetTariff(api.TariffUsageTemperature) + if weatherTariff == nil { + return profile + } + + rates, err := weatherTariff.Rates() + if err != nil || len(rates) == 0 { + return profile + } + + const ( + tRoom = 21.0 + heatingStopThreshold = 18.0 + ) + + currentTime := time.Now() + + // compute average historical temperature per hour-of-day + pastTempSum := make([]float64, 24) + pastTempCount := make([]int, 24) + for _, r := range rates { + if r.Start.Before(currentTime) { + h := r.Start.UTC().Hour() + pastTempSum[h] += r.Value + pastTempCount[h]++ + } + } + pastTempAvg := make([]float64, 24) + for h := range 24 { + if pastTempCount[h] > 0 { + pastTempAvg[h] = pastTempSum[h] / float64(pastTempCount[h]) + } + } + + ratesByTime := make(map[time.Time]float64, len(rates)) + for _, r := range rates { + ratesByTime[r.Start] = r.Value + } + + result := make([]float64, len(profile)) + copy(result, profile) + + slotStart := currentTime.Truncate(tariff.SlotDuration) + for i := range profile { + ts := slotStart.Add(time.Duration(i) * tariff.SlotDuration) + + tFuture, found := ratesByTime[ts] + if !found { + continue + } + + h := ts.UTC().Hour() + + if pastTempCount[h] == 0 { + site.log.DEBUG.Printf("temperature correction: no historical data for hour %d, skipping slot %s", h, ts.Format("15:04")) + continue + } + + tPastAvg := pastTempAvg[h] + + denominator := tRoom - tPastAvg + numerator := tRoom - tFuture + + if math.Abs(denominator) < 0.5 || tFuture >= heatingStopThreshold { + continue + } + + correctionFactor := numerator / denominator + + // Clamp to reasonable range to prevent extreme corrections from bad data + const minCorrectionFactor = 0.5 // -50% (warmer than expected) + const maxCorrectionFactor = 2.0 // +100% (colder than expected) + correctionFactor = math.Max(minCorrectionFactor, math.Min(maxCorrectionFactor, correctionFactor)) + + oldValue := profile[i] + result[i] = oldValue * correctionFactor + + if i < 3 && oldValue != 0 { + site.log.DEBUG.Printf("temperature correction sample: slot %s (hour %d): forecast=%.1f°C, hist_avg=%.1f°C, factor=%.3f, load: %.0fWh -> %.0fWh (%.1f%%)", + ts.Format("15:04"), h, tFuture, tPastAvg, correctionFactor, oldValue*1e3, result[i]*1e3, ((result[i]/oldValue)-1)*100) + } + } + + return result } // profileSlotsFromNow strips away any slots before "now". diff --git a/core/site_optimizer_temperature_test.go b/core/site_optimizer_temperature_test.go new file mode 100644 index 00000000000..01c47733af7 --- /dev/null +++ b/core/site_optimizer_temperature_test.go @@ -0,0 +1,99 @@ +package core + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/tariff" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestApplyTemperatureCorrection(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + now := time.Now().Truncate(15 * time.Minute) + + mockTariff := api.NewMockTariff(ctrl) + + rates := []api.Rate{} + + // Past 7 days - create slots for ALL hours to ensure historical data exists + for day := -7; day < 0; day++ { + for hour := range 24 { + for slot := range 4 { + baseTime := now.AddDate(0, 0, day).Truncate(24 * time.Hour) + rates = append(rates, api.Rate{ + Start: baseTime.Add(time.Duration(hour)*time.Hour + time.Duration(slot)*15*time.Minute), + End: baseTime.Add(time.Duration(hour)*time.Hour + time.Duration(slot+1)*15*time.Minute), + Value: 10.0, + }) + } + } + } + + // Future forecast: 8 slots (2 hours) + // First hour: 5°C, Second hour: 15°C + for i := range 8 { + temp := 5.0 + if i >= 4 { + temp = 15.0 + } + rates = append(rates, api.Rate{ + Start: now.Add(time.Duration(i) * 15 * time.Minute), + End: now.Add(time.Duration(i+1) * 15 * time.Minute), + Value: temp, + }) + } + + mockTariff.EXPECT().Rates().Return(rates, nil).AnyTimes() + + site := &Site{ + log: util.NewLogger("test"), + tariffs: &tariff.Tariffs{Temperature: mockTariff}, + } + + profile := []float64{2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0} + + result := site.applyTemperatureCorrection(profile) + + require.Len(t, result, 8) + + // Verify correction is applied: first hour should increase, second hour should decrease + assert.Greater(t, result[0], 2.0, "first hour should increase (colder forecast)") + assert.Less(t, result[4], 2.0, "second hour should decrease (warmer forecast)") + assert.Greater(t, result[0], result[4], "first hour should be higher than second hour") +} + +func TestSumProfiles(t *testing.T) { + tc := []struct { + name string + profiles [][]float64 + expected []float64 + }{ + { + name: "two profiles same length", + profiles: [][]float64{ + {1.0, 2.0, 3.0}, + {4.0, 5.0, 6.0}, + }, + expected: []float64{5.0, 7.0, 9.0}, + }, + { + name: "empty profiles", + profiles: [][]float64{}, + expected: nil, + }, + } + + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + result := sumProfiles(tc.profiles) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/util/templates/includes/heatpumpswitch.tpl b/util/templates/includes/heatpumpswitch.tpl index e94ce1a60e6..55a5bc9bc2b 100644 --- a/util/templates/includes/heatpumpswitch.tpl +++ b/util/templates/includes/heatpumpswitch.tpl @@ -1,6 +1,7 @@ {{ define "heatpumpswitch" }} features: - continuous +- demandprofiletemperature - heating - integrateddevice - switchdevice