diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index 4c91edcc5a6..f741f0eeffd 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -166,12 +166,13 @@ func (c Messaging) IsConfigured() bool { } type Tariffs struct { - Currency string - Grid config.Typed - FeedIn config.Typed - Co2 config.Typed - Planner config.Typed - Solar []config.Typed + Currency string + Grid config.Typed + FeedIn config.Typed + Co2 config.Typed + Planner config.Typed + Solar []config.Typed + Temperature config.Typed } func (c Tariffs) IsConfigured() bool { @@ -179,20 +180,21 @@ func (c Tariffs) IsConfigured() bool { } type TariffRefs struct { - Grid string `json:"grid"` - FeedIn string `json:"feedIn"` - Co2 string `json:"co2"` - Planner string `json:"planner"` - Solar []string `json:"solar"` + Grid string `json:"grid"` + FeedIn string `json:"feedIn"` + Co2 string `json:"co2"` + Planner string `json:"planner"` + Solar []string `json:"solar"` + Temperature string `json:"temperature"` } func (refs TariffRefs) IsConfigured() bool { - return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0 + return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0 || refs.Temperature != "" } func (refs TariffRefs) Used() iter.Seq[string] { return func(yield func(string) bool) { - for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner}, refs.Solar...) { + for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner, refs.Temperature}, refs.Solar...) { if ref != "" { if !yield(ref) { return diff --git a/api/tariff.go b/api/tariff.go index ca6063b6846..641e53b38a9 100644 --- a/api/tariff.go +++ b/api/tariff.go @@ -12,6 +12,7 @@ const ( TariffTypePriceForecast TariffTypeCo2 TariffTypeSolar + TariffTypeTemperature // outdoor temperature forecast in °C ) type TariffUsage int @@ -23,4 +24,5 @@ const ( TariffUsageGrid TariffUsagePlanner TariffUsageSolar + TariffUsageTemperature // outdoor temperature forecast ) diff --git a/api/tarifftype_enumer.go b/api/tarifftype_enumer.go index 12272d9bc8b..093d309dee9 100644 --- a/api/tarifftype_enumer.go +++ b/api/tarifftype_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solar" +const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solartemperature" -var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44} +var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44, 55} -const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solar" +const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solartemperature" func (i TariffType) String() string { i -= 1 @@ -30,9 +30,10 @@ func _TariffTypeNoOp() { _ = x[TariffTypePriceForecast-(3)] _ = x[TariffTypeCo2-(4)] _ = x[TariffTypeSolar-(5)] + _ = x[TariffTypeTemperature-(6)] } -var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar} +var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar, TariffTypeTemperature} var _TariffTypeNameToValueMap = map[string]TariffType{ _TariffTypeName[0:11]: TariffTypePriceStatic, @@ -45,6 +46,8 @@ var _TariffTypeNameToValueMap = map[string]TariffType{ _TariffTypeLowerName[36:39]: TariffTypeCo2, _TariffTypeName[39:44]: TariffTypeSolar, _TariffTypeLowerName[39:44]: TariffTypeSolar, + _TariffTypeName[44:55]: TariffTypeTemperature, + _TariffTypeLowerName[44:55]: TariffTypeTemperature, } var _TariffTypeNames = []string{ @@ -53,6 +56,7 @@ var _TariffTypeNames = []string{ _TariffTypeName[23:36], _TariffTypeName[36:39], _TariffTypeName[39:44], + _TariffTypeName[44:55], } // TariffTypeString retrieves an enum value from the enum constants string name. diff --git a/api/tariffusage_enumer.go b/api/tariffusage_enumer.go index 2eadfda9024..f82a9c3e366 100644 --- a/api/tariffusage_enumer.go +++ b/api/tariffusage_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _TariffUsageName = "co2feedingridplannersolar" +const _TariffUsageName = "co2feedingridplannersolartemperature" -var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25} +var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25, 36} -const _TariffUsageLowerName = "co2feedingridplannersolar" +const _TariffUsageLowerName = "co2feedingridplannersolartemperature" func (i TariffUsage) String() string { i -= 1 @@ -30,9 +30,10 @@ func _TariffUsageNoOp() { _ = x[TariffUsageGrid-(3)] _ = x[TariffUsagePlanner-(4)] _ = x[TariffUsageSolar-(5)] + _ = x[TariffUsageTemperature-(6)] } -var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedIn, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar} +var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedIn, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar, TariffUsageTemperature} var _TariffUsageNameToValueMap = map[string]TariffUsage{ _TariffUsageName[0:3]: TariffUsageCo2, @@ -45,6 +46,8 @@ var _TariffUsageNameToValueMap = map[string]TariffUsage{ _TariffUsageLowerName[13:20]: TariffUsagePlanner, _TariffUsageName[20:25]: TariffUsageSolar, _TariffUsageLowerName[20:25]: TariffUsageSolar, + _TariffUsageName[25:36]: TariffUsageTemperature, + _TariffUsageLowerName[25:36]: TariffUsageTemperature, } var _TariffUsageNames = []string{ @@ -53,6 +56,7 @@ var _TariffUsageNames = []string{ _TariffUsageName[9:13], _TariffUsageName[13:20], _TariffUsageName[20:25], + _TariffUsageName[25:36], } // TariffUsageString retrieves an enum value from the enum constants string name. diff --git a/cmd/setup.go b/cmd/setup.go index 847322414c5..4b4dbbd086d 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1050,6 +1050,7 @@ func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tari eg.Go(func() error { return configureTariff(conf.Co2, refs.Co2, &tariffs.Co2) }) eg.Go(func() error { return configureTariff(conf.Planner, refs.Planner, &tariffs.Planner) }) eg.Go(func() error { return configureSolarTariffs(conf.Solar, refs.Solar, &tariffs.Solar) }) + eg.Go(func() error { return configureTariff(conf.Temperature, refs.Temperature, &tariffs.Temperature) }) if err := eg.Wait(); err != nil { return &tariffs, &ClassError{ClassTariff, err} } diff --git a/core/metrics/db.go b/core/metrics/db.go index 4520842bfb9..ac8aa3b0ebb 100644 --- a/core/metrics/db.go +++ b/core/metrics/db.go @@ -1,6 +1,7 @@ package metrics import ( + "database/sql" "errors" "time" @@ -9,8 +10,17 @@ import ( "gorm.io/gorm" ) +const ( + MeterTypeHousehold = 1 + MeterTypeLoadpoint = 2 + + // LoadpointNameHousehold is the identifier for household base load (non-loadpoint consumption) + LoadpointNameHousehold = "_household" +) + type meter struct { Meter int `json:"meter" gorm:"column:meter;uniqueIndex:meter_ts"` + Loadpoint string `json:"loadpoint" gorm:"column:loadpoint;uniqueIndex:meter_ts;default:'_household'"` // loadpoint name: "_household" for base load, or loadpoint title for heaters Timestamp time.Time `json:"ts" gorm:"column:ts;uniqueIndex:meter_ts"` Value float64 `json:"val" gorm:"column:val"` } @@ -23,29 +33,52 @@ func init() { }) } -// Persist stores 15min consumption in Wh +// Persist stores 15min consumption in Wh for household total func Persist(ts time.Time, value float64) error { return db.Instance.Create(meter{ - Meter: 1, + Meter: MeterTypeHousehold, + Loadpoint: LoadpointNameHousehold, Timestamp: ts.Truncate(15 * time.Minute), Value: value, }).Error } -// Profile returns a 15min average meter profile in Wh. +// PersistLoadpoint stores 15min consumption in Wh for a specific loadpoint +func PersistLoadpoint(loadpointName string, ts time.Time, value float64) error { + return db.Instance.Create(meter{ + Meter: MeterTypeLoadpoint, + Loadpoint: loadpointName, + Timestamp: ts.Truncate(15 * time.Minute), + Value: value, + }).Error +} + +// Profile returns a 15min average meter profile in Wh for household total. // Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values. func Profile(from time.Time) (*[96]float64, error) { + return profileQuery(MeterTypeHousehold, LoadpointNameHousehold, from) +} + +// LoadpointProfile returns a 15min average meter profile in Wh for a specific loadpoint. +// Profile is sorted by timestamp starting at 00:00. It is guaranteed to contain 96 15min values. +func LoadpointProfile(loadpointName string, from time.Time) (*[96]float64, error) { + return profileQuery(MeterTypeLoadpoint, loadpointName, from) +} + +// profileQuery is the internal implementation for querying meter profiles +func profileQuery(meterType int, loadpointName string, from time.Time) (*[96]float64, error) { db, err := db.Instance.DB() if err != nil { return nil, err } // Use 'localtime' in strftime to fix https://github.com/evcc-io/evcc/discussions/23759 - rows, err := db.Query(`SELECT min(ts) AS ts, avg(val) AS val + var rows *sql.Rows + rows, err = db.Query(`SELECT min(ts) AS ts, avg(val) AS val FROM meters - WHERE meter = ? AND ts >= ? + WHERE meter = ? AND loadpoint = ? AND ts >= ? GROUP BY strftime("%H:%M", ts, 'localtime') - ORDER BY strftime("%H:%M", ts, 'localtime') ASC`, 1, from, + ORDER BY strftime("%H:%M", ts, 'localtime') ASC`, meterType, loadpointName, from, ) if err != nil { return nil, err diff --git a/core/site.go b/core/site.go index e1f5a6532a7..a6265ce52de 100644 --- a/core/site.go +++ b/core/site.go @@ -60,10 +60,12 @@ type Site struct { log *util.Logger // configuration - Title string `mapstructure:"title"` // UI title - Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany. - ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin - Meters MetersConfig `mapstructure:"meters"` // Meter references + Title string `mapstructure:"title"` // UI title + Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany. + ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin + Meters MetersConfig `mapstructure:"meters"` // Meter references + HeatingThreshold float64 `mapstructure:"heatingThreshold"` // Temperature threshold for heating (°C) + HeatingCoefficient float64 `mapstructure:"heatingCoefficient"` // Heating load adjustment coefficient per degree // meters circuit api.Circuit // Circuit @@ -91,6 +93,10 @@ type Site struct { householdEnergy *meterEnergy householdSlotStart time.Time + // per-loadpoint energy tracking for heating devices + loadpointEnergy map[int]*meterEnergy + loadpointSlotStart map[int]time.Time + // cached state gridPower float64 // Grid power pvPower float64 // PV power @@ -140,6 +146,16 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif site.prioritizer = prioritizer.New(log) site.stats = NewStats() + // initialize per-loadpoint energy tracking for heating devices + site.loadpointEnergy = make(map[int]*meterEnergy) + site.loadpointSlotStart = make(map[int]time.Time) + for i, lp := range loadpoints { + if hasFeature(lp.charger, api.Heating) { + site.loadpointEnergy[i] = &meterEnergy{clock: clock.New()} + site.loadpointSlotStart[i] = time.Time{} + } + } + // upload telemetry on shutdown if telemetry.Enabled() { shutdown.Register(func() { @@ -800,6 +816,39 @@ func (site *Site) updateHomeConsumption(homePower float64) { } } +// updateLoadpointConsumption tracks energy consumption for a specific loadpoint (heating devices) +func (site *Site) updateLoadpointConsumption(lpID int, power float64) { + lpEnergy, exists := site.loadpointEnergy[lpID] + if !exists { + return // not a tracked heating loadpoint + } + + lpEnergy.AddPower(power) + + now := lpEnergy.clock.Now() + if site.loadpointSlotStart[lpID].IsZero() { + site.loadpointSlotStart[lpID] = now + return + } + + slotDuration := 15 * time.Minute + slotStart := now.Truncate(slotDuration) + + if slotStart.After(site.loadpointSlotStart[lpID]) { + // next slot has started + if slotStart.Sub(site.loadpointSlotStart[lpID]) >= slotDuration { + // more or less full slot + site.log.DEBUG.Printf("15min loadpoint %d consumption: %.0fWh", lpID, lpEnergy.Accumulated) + if err := metrics.PersistLoadpoint(site.loadpoints[lpID].GetTitle(), site.loadpointSlotStart[lpID], lpEnergy.Accumulated); err != nil { + site.log.ERROR.Printf("persist loadpoint %d consumption: %v", lpID, err) + } + } + + site.loadpointSlotStart[lpID] = slotStart + lpEnergy.Accumulated = 0 + } +} + // sitePower returns // - the net power exported by the site minus a residual margin // (negative values mean grid: export, battery: charging @@ -875,11 +924,17 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 { sum float64 ) - for _, lp := range site.loadpoints { + for i, lp := range site.loadpoints { + lpID := i // capture loop variable for goroutine wg.Go(func() { power := lp.UpdateChargePowerAndCurrents() site.prioritizer.UpdateChargePowerFlexibility(lp, rates) + // track heating loadpoint consumption + if _, isHeating := site.loadpointEnergy[lpID]; isHeating && power > 0 { + site.updateLoadpointConsumption(lpID, power) + } + mu.Lock() sum += power mu.Unlock() diff --git a/core/site_optimizer.go b/core/site_optimizer.go index 732e99c2ae2..33d747f0c9e 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -512,16 +512,27 @@ 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 := metrics.Profile(now.BeginningOfDay().AddDate(0, 0, -30)) + from := now.BeginningOfDay().AddDate(0, 0, -7) + + // kWh average over last 7 days - base load (excludes loadpoints) + // Note: metrics.Profile() returns meter=1 which is calculated as: + // gridPower + pvPower + batteryPower - totalChargePower + // So it already excludes all loadpoint consumption + gt_base, err := metrics.Profile(from) if err != nil { return nil, err } + // Query heater profile (sum of all heating loadpoints) + gt_heater_raw := site.extractHeaterProfile(from, time.Now()) + if gt_heater_raw != nil && len(gt_heater_raw) > 0 { + site.log.DEBUG.Printf("home profile: extracted heater profile with %d slots", len(gt_heater_raw)) + } + // max 4 days slots := make([]float64, 0, minLen+1) for len(slots) <= minLen+24*4 { // allow for prorating first day - slots = append(slots, profile[:]...) + slots = append(slots, gt_base[:]...) } res := profileSlotsFromNow(slots) @@ -532,12 +543,220 @@ func (site *Site) homeProfile(minLen int) ([]float64, error) { res = res[:minLen] } + // If no heating devices or no heater data, return base load only + if gt_heater_raw == nil || len(gt_heater_raw) == 0 { + site.log.DEBUG.Println("home profile: no heating devices or heater data, returning base load only") + // convert to Wh + return lo.Map(res, func(v float64, i int) float64 { + return v * 1e3 + }), nil + } + + // Prepare heater profile with same length as base profile + heaterSlots := make([]float64, 0, minLen+1) + for len(heaterSlots) <= minLen+24*4 { + heaterSlots = append(heaterSlots, gt_heater_raw[:]...) + } + gt_heater := profileSlotsFromNow(heaterSlots) + if len(gt_heater) > len(res) { + gt_heater = gt_heater[:len(res)] + } + + // Try to apply temperature correction to heater profile + // If correction cannot be applied (missing weather tariff, thresholds, etc.), + // applyTemperatureCorrection returns the uncorrected profile + site.log.DEBUG.Println("home profile: attempting temperature correction on heater profile") + gt_heater_corrected := site.applyTemperatureCorrection(gt_heater) + + // Merge: final = base + heater (corrected or uncorrected) + // Since base already excludes loadpoints, we always add the heating consumption + // This ensures total household consumption includes heating even if correction fails + gt_final := make([]float64, len(res)) + for i := range res { + if i < len(gt_heater_corrected) { + gt_final[i] = res[i] + gt_heater_corrected[i] + } else { + gt_final[i] = res[i] + } + } + // convert to Wh - return lo.Map(res, func(v float64, i int) float64 { + return lo.Map(gt_final, func(v float64, i int) float64 { return v * 1e3 }), nil } +// applyTemperatureCorrection adjusts the household load profile based on outdoor temperature. +// +// The correction is gated on the 24h average actual temperature of the past 24 hours: +// if that average is at or above heatingThreshold, heating is considered off and no +// correction is applied to any slot. +// +// When heating is active (past 24h avg < threshold), for each future slot i: +// 1. Looks up the forecast temperature T_future at that slot's wall-clock time +// 2. Computes the average historical temperature T_past_avg at the same hour-of-day +// from the past 7 days of Open-Meteo data already present in the rates slice +// 3. Applies: load[i] = load[i] * (1 + coeff * (T_past_avg - T_future)) +// A positive delta (tomorrow colder than historical average) increases the load estimate. +// A negative delta (tomorrow warmer) decreases it. +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 + } + + threshold := site.HeatingThreshold + coeff := site.HeatingCoefficient + + // Require explicit configuration - no defaults + // If either value is not configured (0), skip temperature correction + if threshold == 0 || coeff == 0 { + site.log.DEBUG.Println("temperature correction: heatingThreshold or heatingCoefficient not configured, skipping correction") + return profile + } + + currentTime := time.Now() + + // Compute the 24h average actual temperature from the past 24 hours. + // Uses past rates (Start < now) within the last 24h window. + yesterday := currentTime.Add(-24 * time.Hour) + var pastSum24h float64 + var pastCount24h int + for _, r := range rates { + if !r.Start.Before(yesterday) && r.Start.Before(currentTime) { + pastSum24h += r.Value + pastCount24h++ + } + } + if pastCount24h == 0 { + return profile + } + pastAvg24h := pastSum24h / float64(pastCount24h) + + // If the past 24h average actual temperature is at or above the heating threshold, + // heating is considered off — no correction needed. + if pastAvg24h >= threshold { + return profile + } + + // Pre-compute average historical temperature per hour-of-day (0..23) from past rates. + // Past rates are those whose Start is before the current time. + 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]) + } + } + + 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) + + // Find the forecast temperature for this slot by direct timestamp match + // Both weather data and profiles are on 15-minute slots, so direct matching works + var tFuture float64 + found := false + for _, r := range rates { + if r.Start.Equal(ts) { + tFuture = r.Value + found = true + break + } + } + if !found { + continue + } + + h := ts.UTC().Hour() + tPastAvg := pastTempAvg[h] + + // delta > 0: tomorrow colder than historical average → load increases + // delta < 0: tomorrow warmer → load decreases + delta := tPastAvg - tFuture + result[i] = profile[i] * (1 + coeff*delta) + } + + return result +} + +// getHeatingLoadpoints returns the indices of all loadpoints with api.Heating feature +func (site *Site) getHeatingLoadpoints() []int { + var heatingLPs []int + for i, lp := range site.loadpoints { + if hasFeature(lp.charger, api.Heating) { + heatingLPs = append(heatingLPs, i) + } + } + return heatingLPs +} + +// extractHeaterProfile queries and aggregates consumption from all heating loadpoints +// Returns nil if no heating devices are configured +func (site *Site) extractHeaterProfile(from, to time.Time) []float64 { + heatingLPs := site.getHeatingLoadpoints() + if len(heatingLPs) == 0 { + site.log.DEBUG.Println("heater profile: no heating loadpoints configured") + return nil // no heating devices + } + + site.log.DEBUG.Printf("heater profile: querying %d heating loadpoint(s)", len(heatingLPs)) + + // Query each heating loadpoint's profile + profiles := make([][]float64, 0, len(heatingLPs)) + for _, lpID := range heatingLPs { + profile, err := metrics.LoadpointProfile(site.loadpoints[lpID].GetTitle(), from) + if err == nil && profile != nil { + site.log.DEBUG.Printf("heater profile: loadpoint %d has %d slots of data", lpID, len(profile)) + profiles = append(profiles, profile[:]) + } else { + site.log.DEBUG.Printf("heater profile: loadpoint %d has no historical data", lpID) + } + } + + if len(profiles) == 0 { + site.log.DEBUG.Println("heater profile: no historical data available from any heating loadpoint") + return nil // no data available + } + + // Sum profiles slot-by-slot + result := sumProfiles(profiles) + site.log.DEBUG.Printf("heater profile: aggregated %d heating loadpoint(s) into %d slots", len(profiles), len(result)) + return result +} + +// sumProfiles sums multiple energy profiles slot-by-slot +func sumProfiles(profiles [][]float64) []float64 { + if len(profiles) == 0 { + return nil + } + + // Use the length of the first profile as reference + 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 +} + // profileSlotsFromNow strips away any slots before "now". // The profile contains 48 15min slots (00:00-23:45) that repeat for multiple days. func profileSlotsFromNow(profile []float64) []float64 { diff --git a/tariff/tariff.go b/tariff/tariff.go index 0add9df1a42..a46b2776458 100644 --- a/tariff/tariff.go +++ b/tariff/tariff.go @@ -119,6 +119,10 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval if t.typ == api.TariffTypeSolar { periodStart = beginningOfDay() } + if t.typ == api.TariffTypeTemperature { + // Keep 7 days of historical data (in 15-minute intervals) for temperature correction algorithm + periodStart = time.Now().AddDate(0, 0, -7) + } mergeRatesAfter(t.data, data, periodStart) once.Do(func() { close(done) }) diff --git a/tariff/tariffs.go b/tariff/tariffs.go index 3f88a199469..90effd100ba 100644 --- a/tariff/tariffs.go +++ b/tariff/tariffs.go @@ -8,8 +8,8 @@ import ( ) type Tariffs struct { - Currency currency.Unit - Grid, FeedIn, Co2, Planner, Solar api.Tariff + Currency currency.Unit + Grid, FeedIn, Co2, Planner, Solar, Temperature api.Tariff } // At returns the rate at the given time @@ -83,6 +83,9 @@ func (t *Tariffs) Get(u api.TariffUsage) api.Tariff { case api.TariffUsageSolar: return t.Solar + case api.TariffUsageTemperature: + return t.Temperature + default: return nil } diff --git a/templates/definition/tariff/open-meteo-temperature.yaml b/templates/definition/tariff/open-meteo-temperature.yaml new file mode 100644 index 00000000000..34895cae02e --- /dev/null +++ b/templates/definition/tariff/open-meteo-temperature.yaml @@ -0,0 +1,48 @@ +template: open-meteo-temperature +products: + - brand: Open-Meteo Temperature +requirements: + description: + en: Free Weather API [open-meteo.com](https://open-meteo.com) for outdoor temperature data in 15-minute intervals. Open-Meteo is an open-source weather API and offers free access for non-commercial use. No API key required. + de: Freie Wetter-API [open-meteo.com](https://open-meteo.com) für Außentemperaturdaten in 15-Minuten-Intervallen. Open-Meteo ist eine Open-Source-Wetter-API und bietet kostenlosen Zugriff für nicht-kommerzielle Nutzung. Kein API-Schlüssel erforderlich. + evcc: ["skiptest"] +group: solar +params: + - name: latitude + description: + en: Latitude + de: Breitengrad + type: float + required: true + help: + en: Geographic latitude in decimal degrees (e.g., 48.1 for Munich) + de: Geografischer Breitengrad in Dezimalgrad (z.B. 48.1 für München) + - name: longitude + description: + en: Longitude + de: Längengrad + type: float + required: true + help: + en: Geographic longitude in decimal degrees (e.g., 11.6 for Munich) + de: Geografischer Längengrad in Dezimalgrad (z.B. 11.6 für München) + - name: interval + default: 1h + advanced: true +render: | + type: custom + tariff: temperature + forecast: + source: http + uri: https://api.open-meteo.com/v1/forecast?latitude={{ .latitude }}&longitude={{ .longitude }}&minutely_15=temperature_2m&past_days=7&forecast_days=3&timezone=UTC + jq: | + [ .minutely_15.time as $times + | .minutely_15.temperature_2m as $temps + | range(0; ($times | length)) + | { + start: ($times[.] + ":00Z"), + end: (($times[.] + ":00Z" | fromdateiso8601) + 900 | todateiso8601), + value: $temps[.] + } + ] | tostring + interval: {{ .interval }}