diff --git a/assets/js/components/Footer.stories.js b/assets/js/components/Footer.stories.js
index 7c9eee08d..f3b72efef 100644
--- a/assets/js/components/Footer.stories.js
+++ b/assets/js/components/Footer.stories.js
@@ -19,9 +19,9 @@ KeinUpdate.args = {
version: { installed: "0.40" },
savings: {
since: 82800,
- chargedTotal: 15231,
- chargedSelfConsumption: 12231,
- selfPercentage: 80.3,
+ totalCharged: 15231,
+ selfConsumptionCharged: 12231,
+ selfConsumptionPercent: 80.3,
},
};
@@ -32,9 +32,9 @@ Sponsor.args = {
},
savings: {
since: 82800,
- chargedTotal: 21000,
- chargedSelfConsumption: 12000,
- selfPercentage: 54,
+ totalCharged: 21000,
+ selfConsumptionCharged: 12000,
+ selfConsumptionPercent: 54,
},
sponsor: "naltatis",
};
@@ -48,9 +48,9 @@ UpdateVerfuegbar.args = {
},
savings: {
since: 82800,
- chargedTotal: 15231,
- chargedSelfConsumption: 15000,
- selfPercentage: 74,
+ totalCharged: 15231,
+ selfConsumptionCharged: 15000,
+ selfConsumptionPercent: 74,
},
};
@@ -64,9 +64,9 @@ Updater.args = {
},
savings: {
since: 82800,
- chargedTotal: 0,
- chargedSelfConsumption: 0,
- selfPercentage: 0,
+ totalCharged: 0,
+ selfConsumptionCharged: 0,
+ selfConsumptionPercent: 0,
},
};
@@ -79,9 +79,9 @@ Upgrade.args = {
},
savings: {
since: 82800,
- chargedTotal: 0,
- chargedSelfConsumption: 0,
- selfPercentage: 0,
+ totalCharged: 0,
+ selfConsumptionCharged: 0,
+ selfConsumptionPercent: 0,
},
};
@@ -94,8 +94,8 @@ Savings.args = {
},
savings: {
since: 82800,
- chargedTotal: 15231,
- chargedSelfConsumption: 12231,
- selfPercentage: 80.3,
+ totalCharged: 15231,
+ selfConsumptionCharged: 12231,
+ selfConsumptionPercent: 80.3,
},
};
diff --git a/assets/js/components/Savings.vue b/assets/js/components/Savings.vue
index 9497961d0..7ffc2af74 100644
--- a/assets/js/components/Savings.vue
+++ b/assets/js/components/Savings.vue
@@ -25,11 +25,22 @@
@@ -53,7 +64,7 @@
{{
$t("footer.savings.modalChartGrid", {
- grid: fmtKw(chargedGrid * 1000, true, false),
+ grid: fmtKw(gridCharged * 1000, true, false),
})
}}
@@ -62,21 +73,21 @@
class="chart d-flex justify-content-stretch mb-1 rounded overflow-hidden"
>
{{ percent }}%
{{ 100 - percent }}%
{{ $t("footer.savings.modalNoData") }}
@@ -85,15 +96,15 @@
{{ $t("footer.savings.modalSavingsPrice") }}:
- {{ pricePerKWh }}
+ {{ fmtPricePerKWh(effectivePrice, currency) }}
{{ $t("footer.savings.modalSavingsTotal") }}:
- {{ savingAmount }}
+ {{ fmtMoney(amount, currency) }}
@@ -153,36 +164,21 @@ export default {
components: { Sponsor },
mixins: [formatter],
props: {
- selfPercentage: Number,
+ selfConsumptionPercent: Number,
since: { type: Number, default: 0 },
sponsor: String,
- chargedTotal: { type: Number, default: 0 },
- chargedSelfConsumption: { type: Number, default: 0 },
- gridPrice: { type: Number, default: 0.3 },
- feedInPrice: { type: Number, default: 0.08 },
+ amount: { type: Number, default: 0 },
+ effectivePrice: { type: Number, default: 0 },
+ totalCharged: { type: Number, default: 0 },
+ gridCharged: { type: Number, default: 0 },
+ selfConsumptionCharged: { type: Number, default: 0 },
+ gridPrice: { type: Number },
+ feedInPrice: { type: Number },
currency: String,
},
computed: {
- chargedGrid() {
- return this.chargedTotal - this.chargedSelfConsumption;
- },
- defaultPrices() {
- const { gridPrice, feedInPrice } = this.$options.propsData;
- return gridPrice === undefined || feedInPrice === undefined;
- },
- savingAmount() {
- const priceDiff = (this.gridPrice - this.feedInPrice) / 100;
- const saving = this.chargedSelfConsumption * priceDiff;
- return this.fmtMoney(saving, this.currency);
- },
- pricePerKWh() {
- const total =
- this.chargedGrid * this.gridPrice + this.chargedSelfConsumption * this.feedInPrice;
- const perKWh = total / this.chargedTotal;
- return this.fmtPricePerKWh(perKWh || this.gridPrice, this.currency);
- },
percent() {
- return Math.round(this.selfPercentage) || 0;
+ return Math.round(this.selfConsumptionPercent) || 0;
},
},
};
diff --git a/assets/js/components/Sponsor.vue b/assets/js/components/Sponsor.vue
index dc540ee01..e2cf4a99a 100644
--- a/assets/js/components/Sponsor.vue
+++ b/assets/js/components/Sponsor.vue
@@ -12,12 +12,7 @@
{{ $t("footer.sponsor.confetti") }}
-
+
{{ $t("footer.sponsor.sticker") }}
diff --git a/assets/js/i18n/de.js b/assets/js/i18n/de.js
index f085d606b..7b35c98ba 100644
--- a/assets/js/i18n/de.js
+++ b/assets/js/i18n/de.js
@@ -20,14 +20,15 @@ export default {
},
savings: {
footerShort: "{percent}% Sonne",
- footerLong: "{percent}% Sonnenstrom ",
- modalTitle: "{total} kWh Strom geladen",
- modalChartGrid: "Netzstrom {grid} kWh",
- modalChartSelf: "Sonnenstrom {self} kWh",
- modalSavingsPrice: "Effektiver Strompreis",
+ footerLong: "{percent}% Sonnenenergie",
+ modalTitleShort: "{total} kWh geladen · {percent}% Sonne",
+ modalTitleLong: "{total} kWh geladen · {percent}% Sonnenenergie",
+ modalChartGrid: "Netz {grid} kWh",
+ modalChartSelf: "Sonne {self} kWh",
+ modalSavingsPrice: "Effektiver Energiepreis",
modalSavingsTotal: "Ersparnis gegenüber Netzbezug",
modalExplaination: "Berechnung",
- modalExplainationGrid: "Netzstrom {gridPrice}",
+ modalExplainationGrid: "Netz {gridPrice}",
modalExplainationFeedIn: "Einspeisung {feedInPrice}",
modalServerStart: "Seit Serverstart {since}.",
modalNoData: "noch nicht geladen",
@@ -35,12 +36,12 @@ export default {
experimentalText: "Unplausible Werte? Fragen zur Ansicht? Meld dich gerne in den",
},
sponsor: {
- thanks: "Danke für deine Unterstützung {sponsor}! Das hilft uns bei der Weiterentwicklung.",
+ thanks: "Danke für deine Unterstützung, {sponsor}! Das hilft uns bei der Weiterentwicklung.",
confetti: "Lust auf etwas Sponsor-Konfetti?",
supportUs:
"Wir möchten effizientes Zuhause-Laden zum Standard für möglichst viele Menschen machen. Helfe uns indem du die Weiterentwicklung und Pflege des Projekts unterstützt.",
sticker: "...oder evcc Sticker?",
- confettiPromise: "Es gibt auch Konfetti ;)",
+ confettiPromise: "Es gibt auch Sticker und digitales Konfetti ;)",
becomeSponsor: "Werde jetzt GitHub Sponsor",
},
},
diff --git a/assets/js/i18n/en.js b/assets/js/i18n/en.js
index d5aff9bda..25720acd5 100644
--- a/assets/js/i18n/en.js
+++ b/assets/js/i18n/en.js
@@ -21,7 +21,8 @@ export default {
savings: {
footerShort: "{percent}% solar",
footerLong: "{percent}% solar energy",
- modalTitle: "{total} kWh solar energy charged",
+ modalTitleShort: "{total} kWh charged · {percent}% solar",
+ modalTitleLong: "{total} kWh charged · {percent}% solar energy",
modalChartGrid: "Grid energy {grid} kWh",
modalChartSelf: "Solar energy {self} kWh",
modalSavingsPrice: "Effective energy price",
@@ -35,12 +36,12 @@ export default {
experimentalText: "Implausible values? Questions about this view? Feel free to join our ",
},
sponsor: {
- thanks: "Thanks for your support {sponsor}! It helps us with the further development.",
+ thanks: "Thanks for your support, {sponsor}! It helps us with the further development.",
confetti: "Ready for some sponsor confetti?",
supportUs:
"We want to make efficient home charging the standard for as many people as possible. Help us by supporting the further development and maintenance of the project.",
sticker: "...or evcc stickers?",
- confettiPromise: "There will be confetti ;)",
+ confettiPromise: "There will be stickers and digital confetti ;)",
becomeSponsor: "Become a GitHub Sponsor",
},
},
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index ee49c83e5..17ebf1d30 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -101,11 +101,13 @@ export default {
fmtPricePerKWh: function (amout = 0, currency = "EUR") {
let unit = currency;
let value = amout;
+ let maximumFractionDigits = 3;
if (["EUR", "USD"].includes(currency)) {
value *= 100;
unit = "ct";
+ maximumFractionDigits = 1;
}
- return `${this.$n(value, { style: "decimal" })} ${unit}/kWh`;
+ return `${this.$n(value, { style: "decimal", maximumFractionDigits })} ${unit}/kWh`;
},
fmtTimeAgo: function (elapsed) {
const units = {
diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue
index c3d611598..6f0004711 100644
--- a/assets/js/views/App.vue
+++ b/assets/js/views/App.vue
@@ -103,9 +103,12 @@ export default {
savings: function () {
return {
since: this.store.state.savingsSince,
- chargedTotal: this.store.state.savingsChargedTotal,
- chargedSelfConsumption: this.store.state.savingsChargedSelfConsumption,
- selfPercentage: this.store.state.savingsSelfPercentage,
+ totalCharged: this.store.state.savingsTotalCharged,
+ gridCharged: this.store.state.savingsGridCharged,
+ selfConsumptionCharged: this.store.state.savingsSelfConsumptionCharged,
+ amount: this.store.state.savingsAmount,
+ effectivePrice: this.store.state.savingsEffectivePrice,
+ selfConsumptionPercent: this.store.state.savingsSelfConsumptionPercent,
gridPrice: this.store.state.tariffGrid,
feedInPrice: this.store.state.tariffFeedIn,
currency: this.store.state.currency,
diff --git a/core/savings.go b/core/savings.go
index 41b767d81..c7caecf67 100644
--- a/core/savings.go
+++ b/core/savings.go
@@ -5,52 +5,76 @@ import (
"time"
"github.com/benbjohnson/clock"
+ "github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
)
-// Site is the main configuration container. A site can host multiple loadpoints.
-type Savings struct {
- log *util.Logger
+const DefaultGridPrice = 0.30
+const DefaultFeedInPrice = 0.08
- started time.Time // Boot time
- updated time.Time // Time of last charged value update
- chargedTotal float64 // Energy charged since startup (kWh)
- chargedSelfConsumption float64 // Self-produced energy charged since startup (kWh)
- Clock clock.Clock
+// publisher gives access to the site's publish function
+type publisher interface {
+ publish(key string, val interface{})
}
-func NewSavings() Savings {
+// Site is the main configuration container. A site can host multiple loadpoints.
+type Savings struct {
+ log *util.Logger
+ clock clock.Clock
+ tariffs tariff.Tariffs
+ started time.Time // Boot time
+ updated time.Time // Time of last charged value update
+ gridCharged float64 // Grid energy charged since startup (kWh)
+ gridCost float64 // Running total of charged grid energy cost (e.g. EUR)
+ selfConsumptionCharged float64 // Self-produced energy charged since startup (kWh)
+ selfConsumptionCost float64 // Running total of charged self-produced energy cost (e.g. EUR)
+ lastGridPrice float64 // Stores the last published grid price. Needed to detect price changes (Awattar, ..)
+}
+
+func NewSavings(tariffs tariff.Tariffs) *Savings {
clock := clock.New()
savings := &Savings{
log: util.NewLogger("savings"),
+ clock: clock,
+ tariffs: tariffs,
started: clock.Now(),
updated: clock.Now(),
- Clock: clock,
}
- return *savings
+ return savings
}
func (s *Savings) Since() time.Duration {
return time.Since(s.started)
}
-func (s *Savings) SelfPercentage() float64 {
- if s.chargedTotal == 0 {
+func (s *Savings) SelfConsumptionPercent() float64 {
+ if s.TotalCharged() == 0 {
return 0
}
- return 100 / s.chargedTotal * s.chargedSelfConsumption
+ return s.selfConsumptionCharged / s.TotalCharged() * 100
}
-func (s *Savings) ChargedTotal() float64 {
- return s.chargedTotal
+func (s *Savings) TotalCharged() float64 {
+ return s.gridCharged + s.selfConsumptionCharged
}
-func (s *Savings) ChargedSelfConsumption() float64 {
- return s.chargedSelfConsumption
+func (s *Savings) CostTotal() float64 {
+ return s.gridCost + s.selfConsumptionCost
}
-func (s *Savings) shareOfSelfProducedEnergy(gridPower float64, pvPower float64, batteryPower float64) float64 {
+func (s *Savings) EffectivePrice() float64 {
+ if s.TotalCharged() == 0 {
+ return s.currentGridPrice()
+ }
+ return s.CostTotal() / s.TotalCharged()
+}
+
+func (s *Savings) SavingsAmount() float64 {
+ return s.selfConsumptionCharged * (s.currentGridPrice() - s.currentFeedInPrice())
+}
+
+func (s *Savings) shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower float64) float64 {
batteryDischarge := math.Max(0, batteryPower)
batteryCharge := math.Min(0, batteryPower) * -1
pvConsumption := math.Min(pvPower, pvPower+gridPower-batteryCharge)
@@ -58,29 +82,60 @@ func (s *Savings) shareOfSelfProducedEnergy(gridPower float64, pvPower float64,
gridImport := math.Max(0, gridPower)
selfConsumption := math.Max(0, batteryDischarge+pvConsumption+batteryCharge)
- selfPercentage := 100 / (gridImport + selfConsumption) * selfConsumption
+ share := selfConsumption / (gridImport + selfConsumption)
- if math.IsNaN(selfPercentage) {
+ if math.IsNaN(share) {
return 0
}
- return selfPercentage
+ return share
}
-func (s *Savings) Update(gridPower float64, pvPower float64, batteryPower float64, chargePower float64) {
- now := s.Clock.Now()
-
- selfPercentage := s.shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower)
-
- updateDuration := now.Sub(s.updated)
-
- // assuming the charge power was constant over the duration -> rough estimate
- addedEnergy := updateDuration.Hours() * chargePower / 1000
-
- s.chargedTotal += addedEnergy
- s.chargedSelfConsumption += addedEnergy * (selfPercentage / 100)
- s.updated = now
-
- s.log.DEBUG.Printf("%.1fkWh charged since %s", s.chargedTotal, time.Since(s.started).Round(time.Second))
- s.log.DEBUG.Printf("%.1fkWh own energy (%.1f%%)", s.chargedSelfConsumption, s.SelfPercentage())
+func (s *Savings) currentGridPrice() float64 {
+ if s.tariffs.Grid != nil {
+ if gridPrice, err := s.tariffs.Grid.CurrentPrice(); err == nil {
+ return gridPrice
+ }
+ }
+ return DefaultGridPrice
+}
+
+func (s *Savings) currentFeedInPrice() float64 {
+ if s.tariffs.FeedIn != nil {
+ if gridPrice, err := s.tariffs.FeedIn.CurrentPrice(); err == nil {
+ return gridPrice
+ }
+ }
+ return DefaultFeedInPrice
+}
+
+func (s *Savings) Update(p publisher, gridPower, pvPower, batteryPower, chargePower float64) {
+ // assume charge power as constant over the duration -> rough kWh estimate
+ energyAdded := s.clock.Since(s.updated).Hours() * chargePower / 1e3
+
+ // nothing meaningfull changed, no need to update
+ if energyAdded == 0 && s.lastGridPrice == s.currentGridPrice() {
+ return
+ }
+
+ share := s.shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower)
+
+ addedSelfConsumption := energyAdded * share
+ addedGrid := energyAdded - addedSelfConsumption
+
+ s.gridCharged += addedGrid
+ s.gridCost += addedGrid * s.currentGridPrice()
+ s.selfConsumptionCharged += addedSelfConsumption
+ s.selfConsumptionCost += addedSelfConsumption * s.currentFeedInPrice()
+ s.lastGridPrice = s.currentGridPrice()
+ s.updated = s.clock.Now()
+
+ p.publish("savingsTotalCharged", s.TotalCharged())
+ p.publish("savingsGridCharged", s.gridCharged)
+ p.publish("savingsSelfConsumptionCharged", s.selfConsumptionCharged)
+ p.publish("savingsSelfConsumptionPercent", s.SelfConsumptionPercent())
+ p.publish("savingsEffectivePrice", s.EffectivePrice())
+ p.publish("savingsAmount", s.SavingsAmount())
+ p.publish("tariffGrid", s.currentGridPrice())
+ p.publish("tariffFeedIn", s.currentFeedInPrice())
}
diff --git a/core/savings_test.go b/core/savings_test.go
index 7ac02aa83..05db7d897 100644
--- a/core/savings_test.go
+++ b/core/savings_test.go
@@ -9,15 +9,24 @@ import (
"github.com/evcc-io/evcc/util"
)
-func assert(t *testing.T, s *Savings, total float64, self float64, percentage float64) {
- if !compareWithTolerane(s.ChargedTotal(), total) {
- t.Errorf("ChargedTotal was incorrect, got: %.3f, want: %.3f.", s.ChargedTotal(), total)
+func assertEnergy(t *testing.T, s *Savings, total, self, percentage float64) {
+ if !compareWithTolerane(s.TotalCharged(), total) {
+ t.Errorf("TotalCharged was incorrect, got: %.3f, want: %.3f.", s.TotalCharged(), total)
}
- if !compareWithTolerane(s.ChargedSelfConsumption(), self) {
- t.Errorf("ChargedSelfConsumption was incorrect, got: %.3f, want: %.3f.", s.ChargedSelfConsumption(), self)
+ if !compareWithTolerane(s.selfConsumptionCharged, self) {
+ t.Errorf("ChargedSelfConsumption was incorrect, got: %.3f, want: %.3f.", s.selfConsumptionCharged, self)
}
- if int(s.SelfPercentage()) != int(percentage) {
- t.Errorf("SelfPercentage was incorrect, got: %.1f, want: %.1f.", s.SelfPercentage(), percentage)
+ if int(s.SelfConsumptionPercent()) != int(percentage) {
+ t.Errorf("SelfConsumptionPercent was incorrect, got: %.1f, want: %.1f.", s.SelfConsumptionPercent(), percentage)
+ }
+}
+
+func assertPrices(t *testing.T, s *Savings, effectivePrice, savingsAmount float64) {
+ if !compareWithTolerane(s.EffectivePrice(), effectivePrice) {
+ t.Errorf("EffectivePrice was incorrect, got: %.3f, want: %.3f.", s.EffectivePrice(), effectivePrice)
+ }
+ if !compareWithTolerane(s.SavingsAmount(), savingsAmount) {
+ t.Errorf("SavingsAmount was incorrect, got: %.3f, want: %.3f.", s.SavingsAmount(), savingsAmount)
}
}
@@ -27,94 +36,189 @@ func compareWithTolerane(a, b float64) bool {
return diff < tolerance
}
+type StubPublisher struct{}
+
+func (p StubPublisher) publish(key string, val interface{}) {}
+
func TestSavingsWithChangingEnergySources(t *testing.T) {
- mockClock := clock.NewMock()
+ p := StubPublisher{}
+
+ clck := clock.NewMock()
s := &Savings{
- log: util.NewLogger("savings"),
- started: mockClock.Now(),
- updated: mockClock.Now(),
- Clock: mockClock,
+ log: util.NewLogger("foo"),
+ clock: clck,
+ started: clck.Now(),
+ updated: clck.Now(),
}
- s.Update(0, 0, 0, 0)
+ tc := []struct {
+ title string
+ grid, pv, battery, charge float64
+ total, self, percentage float64
+ }{
+ {"half grid, half pv",
+ 2500, 2500, 0, 5000,
+ 5, 2.5, 50},
+ {"full pv",
+ 0, 5000, 0, 5000,
+ 10, 7.5, 75},
+ {"full grid",
+ 5000, 0, 0, 5000,
+ 15, 7.5, 50},
+ {"half grid, half battery",
+ 2500, 0, 2500, 5000,
+ 20, 10, 50},
+ {"full pv, pv export",
+ -5000, 10000, 0, 5000,
+ 25, 15, 60},
+ {"full pv, pv export, battery charge",
+ -2500, 10000, -2500, 5000,
+ 30, 20, 66},
+ {"double charge speed, full grid",
+ 10000, 0, 0, 10000,
+ 40, 20, 50},
+ }
- // half grid, half pv
- mockClock.Add(time.Hour)
- s.Update(2500, 2500, 0, 5000)
- assert(t, s, 5, 2.5, 50)
+ s.Update(p, 0, 0, 0, 0)
- // full pv
- mockClock.Add(time.Hour)
- s.Update(0, 5000, 0, 5000)
- assert(t, s, 10, 7.5, 75)
+ for _, tc := range tc {
+ t.Logf("%+v", tc)
- // full grid
- mockClock.Add(time.Hour)
- s.Update(5000, 0, 0, 5000)
- assert(t, s, 15, 7.5, 50)
-
- // half grid, half battery
- mockClock.Add(time.Hour)
- s.Update(2500, 0, 2500, 5000)
- assert(t, s, 20, 10, 50)
-
- // full pv, pv export
- mockClock.Add(time.Hour)
- s.Update(-5000, 10000, 0, 5000)
- assert(t, s, 25, 15, 60)
-
- // full pv, pv export, battery charge
- mockClock.Add(time.Hour)
- s.Update(-2500, 10000, -2500, 5000)
- assert(t, s, 30, 20, 66)
-
- // double charge speed, full grid
- mockClock.Add(time.Hour)
- s.Update(10000, 0, 0, 10000)
- assert(t, s, 40, 20, 50)
+ clck.Add(time.Hour)
+ s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
+ assertEnergy(t, s, tc.total, tc.self, tc.percentage)
+ }
}
func TestSavingsWithDifferentTimespans(t *testing.T) {
- mockClock := clock.NewMock()
+ p := StubPublisher{}
+
+ clck := clock.NewMock()
s := &Savings{
- log: util.NewLogger("savings"),
- started: mockClock.Now(),
- updated: mockClock.Now(),
- Clock: mockClock,
+ log: util.NewLogger("foo"),
+ clock: clck,
+ started: clck.Now(),
+ updated: clck.Now(),
}
- s.Update(0, 0, 0, 0)
+ type tcStep = struct {
+ dt time.Duration
+ grid, pv, battery, charge float64
+ }
- // 10 second 11kW charging, full grid
- mockClock.Add(10 * time.Second)
- s.Update(11000, 0, 0, 11000)
- assert(t, s, 0.030556, 0, 0) // 30,555Wh
+ tc := []struct {
+ title string
+ steps []tcStep
+ total, self, percentage float64
+ }{
+ {"10 second 11kW charging, full grid",
+ []tcStep{
+ {10 * time.Second, 0, 0, 0, 11000},
+ },
+ 0.030556, 0, 0, // 30,555Wh
+ },
+ {"10 second 11kW charging, full grid",
+ []tcStep{
+ {10 * time.Second, 0, 0, 0, 11000},
+ },
+ 0.061111, 0, 0, // 61,111Wh
+ },
+ {"5x 2 second 11kW charging, full grid",
+ []tcStep{
+ {2 * time.Second, 0, 0, 0, 11000},
+ {2 * time.Second, 0, 0, 0, 11000},
+ {2 * time.Second, 0, 0, 0, 11000},
+ {2 * time.Second, 0, 0, 0, 11000},
+ {2 * time.Second, 0, 0, 0, 11000},
+ },
+ 0.092, 0, 0, // 91,666Wh
+ },
+ {"30 min 11kW charging, full grid",
+ []tcStep{
+ {30 * time.Minute, 0, 0, 0, 11000},
+ },
+ 5.592, 0, 0, // 5561,111Wh
+ },
+ {"4 hours 11kW charging, full pv",
+ []tcStep{
+ {4 * time.Hour, 0, 11000, 0, 11000},
+ },
+ 49.592, 44, 88,
+ },
+ }
- // 10 second 11kW charging, full grid
- mockClock.Add(10 * time.Second)
- s.Update(11000, 0, 0, 11000)
- assert(t, s, 0.061111, 0, 0) // 61,111Wh
+ s.Update(p, 0, 0, 0, 0)
- // 5x 2 second 11kW charging, full grid
- mockClock.Add(2 * time.Second)
- s.Update(11000, 0, 0, 11000)
- mockClock.Add(2 * time.Second)
- s.Update(11000, 0, 0, 11000)
- mockClock.Add(2 * time.Second)
- s.Update(11000, 0, 0, 11000)
- mockClock.Add(2 * time.Second)
- s.Update(11000, 0, 0, 11000)
- mockClock.Add(2 * time.Second)
- s.Update(11000, 0, 0, 11000)
- assert(t, s, 0.092, 0, 0) // 91,666Wh
+ for _, tc := range tc {
+ t.Logf("%+v", tc)
- // 30 min 11kW charging, full grid
- mockClock.Add(30 * time.Minute)
- s.Update(11000, 0, 0, 11000)
- assert(t, s, 5.592, 0, 0) // 5561,111Wh
+ for _, tc := range tc.steps {
+ clck.Add(tc.dt)
+ s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
+ }
- // 4 hours 11kW charging, full pv
- mockClock.Add(4 * time.Hour)
- s.Update(0, 11000, 0, 11000)
- assert(t, s, 49.592, 44, 88)
+ assertEnergy(t, s, tc.total, tc.self, tc.percentage)
+ }
+}
+
+func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
+ p := StubPublisher{}
+
+ clck := clock.NewMock()
+
+ type tcStep = struct {
+ dt time.Duration
+ grid, pv, battery, charge float64
+ }
+
+ tc := []struct {
+ title string
+ steps []tcStep
+ effectivePrice, savingsAmount float64
+ }{
+ {"1 hour, 10kW, full grid",
+ []tcStep{
+ {time.Hour, 10000, 0, 0, 10000},
+ },
+ 0.3, 0,
+ },
+ {"1 hour, 10kW, full pv",
+ []tcStep{
+ {time.Hour, 0, 10000, 0, 10000},
+ },
+ 0.08, 2.2,
+ },
+ {"1 hour, 10kW, full battery",
+ []tcStep{
+ {time.Hour, 0, 0, 10000, 10000},
+ },
+ 0.08, 2.2,
+ },
+
+ {"1 hour, 10kW, half grid, half pv",
+ []tcStep{
+ {time.Hour, 5000, 0, 5000, 10000},
+ },
+ 0.19, 1.1,
+ },
+ }
+
+ for _, tc := range tc {
+ t.Logf("%+v", tc)
+
+ s := &Savings{
+ log: util.NewLogger("foo"),
+ clock: clck,
+ started: clck.Now(),
+ updated: clck.Now(),
+ }
+ s.Update(p, 0, 0, 0, 0)
+
+ for _, tc := range tc.steps {
+ clck.Add(tc.dt)
+ s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
+ }
+
+ assertPrices(t, s, tc.effectivePrice, tc.savingsAmount)
+ }
}
diff --git a/core/site.go b/core/site.go
index 661d7d4ee..3a0dd25dd 100644
--- a/core/site.go
+++ b/core/site.go
@@ -45,7 +45,7 @@ type Site struct {
tariffs tariff.Tariffs // Tariff
loadpoints []*LoadPoint // Loadpoints
- savings Savings // Savings
+ savings *Savings // Savings
// cached state
gridPower float64 // Grid power
@@ -77,9 +77,9 @@ func NewSiteFromConfig(
}
Voltage = site.Voltage
- site.tariffs = tariffs
site.loadpoints = loadpoints
- site.savings = NewSavings()
+ site.tariffs = tariffs
+ site.savings = NewSavings(tariffs)
if site.Meters.GridMeterRef != "" {
site.gridMeter = cp.Meter(site.Meters.GridMeterRef)
@@ -393,24 +393,6 @@ func (site *Site) sitePower() (float64, error) {
sitePower := sitePower(site.gridPower, batteryPower, site.ResidualPower)
site.log.DEBUG.Printf("site power: %.0fW", sitePower)
- site.publish("savingsChargedTotal", site.savings.ChargedTotal())
- site.publish("savingsChargedSelfConsumption", site.savings.ChargedSelfConsumption())
- site.publish("savingsSelfPercentage", site.savings.SelfPercentage())
- site.publish("savingsSince", site.savings.Since())
-
- site.publish("currency", site.tariffs.Currency.String())
-
- if site.tariffs.Grid != nil {
- if gridPrice, err := site.tariffs.Grid.CurrentPrice(); err == nil {
- site.publish("tariffGrid", gridPrice)
- }
- }
- if site.tariffs.FeedIn != nil {
- if feedInPrice, err := site.tariffs.FeedIn.CurrentPrice(); err == nil {
- site.publish("tariffFeedIn", feedInPrice)
- }
- }
-
return sitePower, nil
}
@@ -441,12 +423,20 @@ func (site *Site) update(lp Updater) {
}
// update savings
+
// TODO: use a proper interface, use meter readings instead of current power for better results
- var totalChargePower = 0.0
+ var totalChargePower float64
for _, lp := range site.loadpoints {
totalChargePower += lp.chargePower
}
- site.savings.Update(site.gridPower, site.pvPower, site.batteryPower, totalChargePower)
+
+ site.savings.Update(site, site.gridPower, site.pvPower, site.batteryPower, totalChargePower)
+}
+
+// prepare publishes initial values
+func (site *Site) prepare() {
+ site.publish("currency", site.tariffs.Currency.String())
+ site.publish("savingsSince", site.savings.Since())
}
// Prepare attaches communication channels to site and loadpoints
@@ -454,6 +444,8 @@ func (site *Site) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Event)
site.uiChan = uiChan
site.lpUpdateChan = make(chan *LoadPoint, 1) // 1 capacity to avoid deadlock
+ site.prepare()
+
for id, lp := range site.loadpoints {
lpUIChan := make(chan util.Param)
lpPushChan := make(chan push.Event)
diff --git a/dist/css/index.a26a8c8b.css b/dist/css/index.77f18cb6.css
similarity index 89%
rename from dist/css/index.a26a8c8b.css
rename to dist/css/index.77f18cb6.css
index a31289c36..905f4295f 100644
--- a/dist/css/index.a26a8c8b.css
+++ b/dist/css/index.77f18cb6.css
@@ -1 +1 @@
-:root{--evcc-green:#66d85a;--evcc-dark-green:#3aba2c;--evcc-yellow:#ffe000;--evcc-grid:var(--bs-gray-dark);--evcc-self:var(--evcc-dark-green);--evcc-export:var(--evcc-yellow);--bs-primary:var(--evcc-dark-green);--bs-primary-rgb:58,186,44}.bg-primary{background-color:var(--evcc-dark-green)!important}a{color:var(--evcc-dark-green)}.progress-bar-animated{animation-direction:reverse}.bg-muted{opacity:.25}.btn-primary,.btn-primary:focus{background-color:var(--bs-gray-dark);border-color:var(--bs-gray-dark)}.btn-primary:active,.btn-primary:hover{background-color:var(--evcc-dark-green);border-color:var(--evcc-dark-green)}.btn-outline-primary{color:var(--bs-gray-dark);border-color:var(--bs-gray-dark)}.btn-outline-primary:hover{background-color:var(--evcc-dark-green);border-color:var(--evcc-dark-green)}.text-evcc{color:var(--evcc-dark-green)}.text-grid{color:var(--evcc-grid)}@media screen and (min-width:400px) and (max-width:574px){.d-xs-none{display:none!important}.d-xs-inline{display:inline!important}}.cursor-pointer{cursor:pointer}.v-popper__inner{margin:0 12px!important}.icon[data-v-7872ae76]{color:var(--evcc-dark-green)}.confetti-button[data-v-bcb1af48]{touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}@media (max-width:575px){.modal-dialog.modal-dialog-centered[data-v-68e21a90]{align-items:flex-end;margin-bottom:0}.modal.fade .modal-dialog[data-v-68e21a90]{transition:transform .4s ease;transform:translateY(150px)}.modal.show .modal-dialog[data-v-68e21a90]{transform:none}.modal-dialog-scrollable[data-v-68e21a90]{height:calc(100% - .5rem)}.modal-content[data-v-68e21a90]{border-radius:1rem 1rem 0 0}}.chart[data-v-68e21a90]{height:1.6rem}.chart-item--self[data-v-68e21a90]{background-color:var(--evcc-self)}.chart-item--grid[data-v-68e21a90]{background-color:var(--evcc-grid)}.chart-item--no-data[data-v-68e21a90]{background-color:var(--bs-gray)}.chart-item[data-v-68e21a90]{transition-property:width;transition-duration:.5s;transition-timing-function:linear}.logo[data-v-69de83c0]{width:85px}.app[data-v-69de83c0]{min-height:100vh}.title[data-v-69de83c0]{position:relative;top:.1rem}.battery[data-v-2dcca16c]{transform:translateX(.35rem) rotate(-90deg);transition-property:transform;transition-duration:.25s;transition-timing-function:ease}.power--in .battery[data-v-2dcca16c]{transform:translateX(.7rem) rotate(-90deg)}.power--out .battery[data-v-2dcca16c]{transform:translateX(0) rotate(-90deg)}.arrow[data-v-2dcca16c]{margin-left:-.2rem;opacity:0;transform:translateX(-.5rem);transition-property:opacity,transform;transition-duration:.25s;transition-timing-function:ease}.power--in .arrow[data-v-2dcca16c]{opacity:1;transform:translateX(-1rem) scaleX(1)}.power--out .arrow[data-v-2dcca16c]{opacity:1;transform:translateX(0) scaleX(1)}.grid[data-v-c8e84f48]{transform:translateX(.35rem);transition-property:transform;transition-duration:.25s;transition-timing-function:ease}.power-export .grid[data-v-c8e84f48]{transform:translateX(.9rem)}.power-import .grid[data-v-c8e84f48]{transform:translateX(-.1rem)}.arrow[data-v-c8e84f48]{margin-left:-.2rem;opacity:0;transform:translateX(-.5rem);transition-property:opacity,transform;transition-duration:.25s;transition-timing-function:ease}.power-export .arrow[data-v-c8e84f48]{opacity:1;transform:translateX(-.9rem) scaleX(1)}.power-import .arrow[data-v-c8e84f48]{opacity:1;transform:translateX(.1em) scaleX(1)}.label-bar[data-v-17da7e54]{width:0;margin:0;height:1.7rem;padding:.6rem 0;opacity:1;overflow:hidden}.label-bar-scale--hidden[data-v-17da7e54]{opacity:0}.label-bar-scale[data-v-17da7e54]{border:1px solid var(--bs-gray);height:.5rem;background:none;display:flex;justify-content:center;align-items:center;white-space:nowrap;border-radius:0;transition:border-radius .5s linear}.label-bar--top .label-bar-scale[data-v-17da7e54]{border-bottom:none}.label-bar--bottom .label-bar-scale[data-v-17da7e54]{border-top:none}.label-bar--top.label-bar--first .label-bar-scale[data-v-17da7e54]{border-top-left-radius:4px}.label-bar--top.label-bar--last .label-bar-scale[data-v-17da7e54]{border-top-right-radius:4px}.label-bar--bottom.label-bar--first .label-bar-scale[data-v-17da7e54]{border-bottom-left-radius:4px}.label-bar--bottom.label-bar--last .label-bar-scale[data-v-17da7e54]{border-bottom-right-radius:4px}.label-bar:not(.label-bar--first) .label-bar-scale[data-v-17da7e54]{border-left:none}.label-bar-icon[data-v-17da7e54]{background-color:#fff;color:var(--bs-gray);padding:0 .3rem;opacity:1}.label-bar--top .label-bar-icon[data-v-17da7e54]{margin-top:-6px}.label-bar--bottom .label-bar-icon[data-v-17da7e54]{margin-top:6px}.label-bar--hidden[data-v-17da7e54],.label-bar--hide-icon .label-bar-icon[data-v-17da7e54]{opacity:0}.site-progress[data-v-0c035ffc]{--height:38px;height:var(--height);margin:.25rem 0;border-radius:5px;display:flex;overflow:hidden}.site-progress-bar[data-v-0c035ffc]{display:flex;justify-content:center;align-items:center;overflow:hidden;position:relative;width:0}.visualization--ready .site-progress-bar[data-v-0c035ffc]{transition-property:width;transition-duration:.5s;transition-timing-function:linear}.grid-import[data-v-0c035ffc]{background-color:var(--evcc-grid);color:var(--bs-white)}.self-consumption[data-v-0c035ffc]{background-color:var(--evcc-self);color:var(--bs-white)}.pv-export[data-v-0c035ffc]{background-color:var(--evcc-export);color:var(--bs-dark)}.power[data-v-0c035ffc]{display:block;margin:0 .2rem;white-space:nowrap;overflow:hidden}.visualization--ready[data-v-0c035ffc] .label-bar{transition-property:width,opacity;transition-duration:.5s,.25s;transition-timing-function:linear,ease}.visualization--ready[data-v-0c035ffc] .label-bar-icon{transition:opacity .25s ease-in}.energyflow[data-v-c2b4f05e]{cursor:pointer}.color-grid[data-v-c2b4f05e]{color:var(--evcc-grid)}.color-self[data-v-c2b4f05e]{color:var(--evcc-self)}.color-export[data-v-c2b4f05e]{color:var(--evcc-export)}.details-icon[data-v-c2b4f05e]{text-align:center;width:30px;margin-right:.25rem;white-space:nowrap;flex-shrink:0}.btn[data-v-1ff39d22]{flex-basis:0;white-space:nowrap}.vehicle-soc[data-v-66f68a62]{--height:38px;--thumb-overlap:3px;--thumb-width:3px;--thumb-horizontal-padding:15px;--label-height:26px;position:relative;height:var(--height)}.progress[data-v-66f68a62]{height:100%;font-size:1rem}.progress-bar.bg-muted[data-v-66f68a62]{color:var(--white)}.bg-light[data-v-66f68a62]{color:var(--bs-gray-dark)}.target-label[data-v-66f68a62]{width:3em;margin-left:-1.5em;height:var(--label-height);position:absolute;top:calc(var(--label-height)*-1 + var(--thumb-overlap)*-1);text-align:center;color:var(--bs-gray-dark);font-size:1rem;opacity:1;transition:opacity .2s ease 1s}.target-slider[data-v-66f68a62]{-webkit-appearance:none;position:absolute;top:calc(var(--thumb-overlap)*-1);left:calc(var(--thumb-horizontal-padding)*-1);height:calc(100% + var(--thumb-overlap)*2);width:calc(100% + var(--thumb-horizontal-padding)*2);background:transparent}.target-slider[data-v-66f68a62]:focus{outline:none}.target-slider[data-v-66f68a62]::-webkit-slider-runnable-track{position:relative;background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-66f68a62]::-moz-range-track{background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-66f68a62]::-webkit-slider-thumb{-webkit-appearance:none;position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:var(--label-height) var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--bs-gray-dark);cursor:-webkit-grab;cursor:grab;border:none;opacity:1;-webkit-transition:opacity .2s ease 1s;transition:opacity .2s ease 1s;box-shadow:none}.target-slider[data-v-66f68a62]::-moz-range-thumb{position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:0 var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--bs-gray-dark);cursor:grab;border:none;opacity:1;-moz-transition:opacity .5s ease 1s;transition:opacity .5s ease 1s}.target--slider-hidden .target-label[data-v-66f68a62],.target--slider-hidden .target-slider[data-v-66f68a62]::-webkit-slider-thumb{opacity:0}.target--slider-hidden .target-label[data-v-66f68a62],.target--slider-hidden .target-slider[data-v-66f68a62]::-moz-range-thumb{opacity:0}.target:hover .target-label[data-v-66f68a62],.target:hover .target-slider[data-v-66f68a62]::-webkit-slider-thumb{opacity:1;transition-delay:0s}.target:hover .target-label[data-v-66f68a62],.target:hover .target-slider[data-v-66f68a62]::-moz-range-thumb{opacity:1;transition-delay:0s}.dots[data-v-60c12baf]{height:1em;width:1em}.dots-back[data-v-60c12baf],.dots-front[data-v-60c12baf]{display:block}.dots-back[data-v-60c12baf]{opacity:.25}.dots-front[data-v-60c12baf]{opacity:1}.direction-up .dots-front[data-v-60c12baf]{-webkit-animation:dots-vertical-data-v-60c12baf 2s steps(4,jump-none) infinite normal;animation:dots-vertical-data-v-60c12baf 2s steps(4,jump-none) infinite normal}.direction-down .dots-front[data-v-60c12baf]{animation:dots-vertical-data-v-60c12baf 2s steps(4,jump-none) infinite reverse}@-webkit-keyframes dots-vertical-data-v-60c12baf{25%{-webkit-clip-path:inset(100% 0 0 0);clip-path:inset(100% 0 0 0)}75%{-webkit-clip-path:inset(0 0 0 0);clip-path:inset(0 0 0 0)}}@keyframes dots-vertical-data-v-60c12baf{25%{-webkit-clip-path:inset(100% 0 0 0);clip-path:inset(100% 0 0 0)}75%{-webkit-clip-path:inset(0 0 0 0);clip-path:inset(0 0 0 0)}}.root[data-v-60c12baf] svg{display:block}
\ No newline at end of file
+:root{--evcc-green:#66d85a;--evcc-dark-green:#3aba2c;--evcc-yellow:#ffe000;--evcc-grid:var(--bs-gray-dark);--evcc-self:var(--evcc-dark-green);--evcc-export:var(--evcc-yellow);--bs-primary:var(--evcc-dark-green);--bs-primary-rgb:58,186,44}.bg-primary{background-color:var(--evcc-dark-green)!important}a{color:var(--evcc-dark-green)}.progress-bar-animated{animation-direction:reverse}.bg-muted{opacity:.25}.btn-primary,.btn-primary:focus{background-color:var(--bs-gray-dark);border-color:var(--bs-gray-dark)}.btn-primary:active,.btn-primary:hover{background-color:var(--evcc-dark-green);border-color:var(--evcc-dark-green)}.btn-outline-primary{color:var(--bs-gray-dark);border-color:var(--bs-gray-dark)}.btn-outline-primary:hover{background-color:var(--evcc-dark-green);border-color:var(--evcc-dark-green)}.text-evcc{color:var(--evcc-dark-green)}.text-grid{color:var(--evcc-grid)}@media screen and (min-width:400px) and (max-width:574px){.d-xs-none{display:none!important}.d-xs-inline{display:inline!important}}.cursor-pointer{cursor:pointer}.v-popper__inner{margin:0 12px!important}.icon[data-v-7872ae76]{color:var(--evcc-dark-green)}.confetti-button[data-v-6edb9fae]{touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}@media (max-width:575px){.modal-dialog.modal-dialog-centered[data-v-7e72516b]{align-items:flex-end;margin-bottom:0}.modal.fade .modal-dialog[data-v-7e72516b]{transition:transform .4s ease;transform:translateY(150px)}.modal.show .modal-dialog[data-v-7e72516b]{transform:none}.modal-dialog-scrollable[data-v-7e72516b]{height:calc(100% - .5rem)}.modal-content[data-v-7e72516b]{border-radius:1rem 1rem 0 0}}.chart[data-v-7e72516b]{height:1.6rem}.chart-item--self[data-v-7e72516b]{background-color:var(--evcc-self)}.chart-item--grid[data-v-7e72516b]{background-color:var(--evcc-grid)}.chart-item--no-data[data-v-7e72516b]{background-color:var(--bs-gray)}.chart-item[data-v-7e72516b]{transition-property:width;transition-duration:.5s;transition-timing-function:linear}.logo[data-v-3d7c9732]{width:85px}.app[data-v-3d7c9732]{min-height:100vh}.title[data-v-3d7c9732]{position:relative;top:.1rem}.battery[data-v-2dcca16c]{transform:translateX(.35rem) rotate(-90deg);transition-property:transform;transition-duration:.25s;transition-timing-function:ease}.power--in .battery[data-v-2dcca16c]{transform:translateX(.7rem) rotate(-90deg)}.power--out .battery[data-v-2dcca16c]{transform:translateX(0) rotate(-90deg)}.arrow[data-v-2dcca16c]{margin-left:-.2rem;opacity:0;transform:translateX(-.5rem);transition-property:opacity,transform;transition-duration:.25s;transition-timing-function:ease}.power--in .arrow[data-v-2dcca16c]{opacity:1;transform:translateX(-1rem) scaleX(1)}.power--out .arrow[data-v-2dcca16c]{opacity:1;transform:translateX(0) scaleX(1)}.grid[data-v-c8e84f48]{transform:translateX(.35rem);transition-property:transform;transition-duration:.25s;transition-timing-function:ease}.power-export .grid[data-v-c8e84f48]{transform:translateX(.9rem)}.power-import .grid[data-v-c8e84f48]{transform:translateX(-.1rem)}.arrow[data-v-c8e84f48]{margin-left:-.2rem;opacity:0;transform:translateX(-.5rem);transition-property:opacity,transform;transition-duration:.25s;transition-timing-function:ease}.power-export .arrow[data-v-c8e84f48]{opacity:1;transform:translateX(-.9rem) scaleX(1)}.power-import .arrow[data-v-c8e84f48]{opacity:1;transform:translateX(.1em) scaleX(1)}.label-bar[data-v-17da7e54]{width:0;margin:0;height:1.7rem;padding:.6rem 0;opacity:1;overflow:hidden}.label-bar-scale--hidden[data-v-17da7e54]{opacity:0}.label-bar-scale[data-v-17da7e54]{border:1px solid var(--bs-gray);height:.5rem;background:none;display:flex;justify-content:center;align-items:center;white-space:nowrap;border-radius:0;transition:border-radius .5s linear}.label-bar--top .label-bar-scale[data-v-17da7e54]{border-bottom:none}.label-bar--bottom .label-bar-scale[data-v-17da7e54]{border-top:none}.label-bar--top.label-bar--first .label-bar-scale[data-v-17da7e54]{border-top-left-radius:4px}.label-bar--top.label-bar--last .label-bar-scale[data-v-17da7e54]{border-top-right-radius:4px}.label-bar--bottom.label-bar--first .label-bar-scale[data-v-17da7e54]{border-bottom-left-radius:4px}.label-bar--bottom.label-bar--last .label-bar-scale[data-v-17da7e54]{border-bottom-right-radius:4px}.label-bar:not(.label-bar--first) .label-bar-scale[data-v-17da7e54]{border-left:none}.label-bar-icon[data-v-17da7e54]{background-color:#fff;color:var(--bs-gray);padding:0 .3rem;opacity:1}.label-bar--top .label-bar-icon[data-v-17da7e54]{margin-top:-6px}.label-bar--bottom .label-bar-icon[data-v-17da7e54]{margin-top:6px}.label-bar--hidden[data-v-17da7e54],.label-bar--hide-icon .label-bar-icon[data-v-17da7e54]{opacity:0}.site-progress[data-v-0c035ffc]{--height:38px;height:var(--height);margin:.25rem 0;border-radius:5px;display:flex;overflow:hidden}.site-progress-bar[data-v-0c035ffc]{display:flex;justify-content:center;align-items:center;overflow:hidden;position:relative;width:0}.visualization--ready .site-progress-bar[data-v-0c035ffc]{transition-property:width;transition-duration:.5s;transition-timing-function:linear}.grid-import[data-v-0c035ffc]{background-color:var(--evcc-grid);color:var(--bs-white)}.self-consumption[data-v-0c035ffc]{background-color:var(--evcc-self);color:var(--bs-white)}.pv-export[data-v-0c035ffc]{background-color:var(--evcc-export);color:var(--bs-dark)}.power[data-v-0c035ffc]{display:block;margin:0 .2rem;white-space:nowrap;overflow:hidden}.visualization--ready[data-v-0c035ffc] .label-bar{transition-property:width,opacity;transition-duration:.5s,.25s;transition-timing-function:linear,ease}.visualization--ready[data-v-0c035ffc] .label-bar-icon{transition:opacity .25s ease-in}.energyflow[data-v-c2b4f05e]{cursor:pointer}.color-grid[data-v-c2b4f05e]{color:var(--evcc-grid)}.color-self[data-v-c2b4f05e]{color:var(--evcc-self)}.color-export[data-v-c2b4f05e]{color:var(--evcc-export)}.details-icon[data-v-c2b4f05e]{text-align:center;width:30px;margin-right:.25rem;white-space:nowrap;flex-shrink:0}.btn[data-v-1ff39d22]{flex-basis:0;white-space:nowrap}.vehicle-soc[data-v-66f68a62]{--height:38px;--thumb-overlap:3px;--thumb-width:3px;--thumb-horizontal-padding:15px;--label-height:26px;position:relative;height:var(--height)}.progress[data-v-66f68a62]{height:100%;font-size:1rem}.progress-bar.bg-muted[data-v-66f68a62]{color:var(--white)}.bg-light[data-v-66f68a62]{color:var(--bs-gray-dark)}.target-label[data-v-66f68a62]{width:3em;margin-left:-1.5em;height:var(--label-height);position:absolute;top:calc(var(--label-height)*-1 + var(--thumb-overlap)*-1);text-align:center;color:var(--bs-gray-dark);font-size:1rem;opacity:1;transition:opacity .2s ease 1s}.target-slider[data-v-66f68a62]{-webkit-appearance:none;position:absolute;top:calc(var(--thumb-overlap)*-1);left:calc(var(--thumb-horizontal-padding)*-1);height:calc(100% + var(--thumb-overlap)*2);width:calc(100% + var(--thumb-horizontal-padding)*2);background:transparent}.target-slider[data-v-66f68a62]:focus{outline:none}.target-slider[data-v-66f68a62]::-webkit-slider-runnable-track{position:relative;background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-66f68a62]::-moz-range-track{background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-66f68a62]::-webkit-slider-thumb{-webkit-appearance:none;position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:var(--label-height) var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--bs-gray-dark);cursor:-webkit-grab;cursor:grab;border:none;opacity:1;-webkit-transition:opacity .2s ease 1s;transition:opacity .2s ease 1s;box-shadow:none}.target-slider[data-v-66f68a62]::-moz-range-thumb{position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:0 var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--bs-gray-dark);cursor:grab;border:none;opacity:1;-moz-transition:opacity .5s ease 1s;transition:opacity .5s ease 1s}.target--slider-hidden .target-label[data-v-66f68a62],.target--slider-hidden .target-slider[data-v-66f68a62]::-webkit-slider-thumb{opacity:0}.target--slider-hidden .target-label[data-v-66f68a62],.target--slider-hidden .target-slider[data-v-66f68a62]::-moz-range-thumb{opacity:0}.target:hover .target-label[data-v-66f68a62],.target:hover .target-slider[data-v-66f68a62]::-webkit-slider-thumb{opacity:1;transition-delay:0s}.target:hover .target-label[data-v-66f68a62],.target:hover .target-slider[data-v-66f68a62]::-moz-range-thumb{opacity:1;transition-delay:0s}.dots[data-v-60c12baf]{height:1em;width:1em}.dots-back[data-v-60c12baf],.dots-front[data-v-60c12baf]{display:block}.dots-back[data-v-60c12baf]{opacity:.25}.dots-front[data-v-60c12baf]{opacity:1}.direction-up .dots-front[data-v-60c12baf]{-webkit-animation:dots-vertical-data-v-60c12baf 2s steps(4,jump-none) infinite normal;animation:dots-vertical-data-v-60c12baf 2s steps(4,jump-none) infinite normal}.direction-down .dots-front[data-v-60c12baf]{animation:dots-vertical-data-v-60c12baf 2s steps(4,jump-none) infinite reverse}@-webkit-keyframes dots-vertical-data-v-60c12baf{25%{-webkit-clip-path:inset(100% 0 0 0);clip-path:inset(100% 0 0 0)}75%{-webkit-clip-path:inset(0 0 0 0);clip-path:inset(0 0 0 0)}}@keyframes dots-vertical-data-v-60c12baf{25%{-webkit-clip-path:inset(100% 0 0 0);clip-path:inset(100% 0 0 0)}75%{-webkit-clip-path:inset(0 0 0 0);clip-path:inset(0 0 0 0)}}.root[data-v-60c12baf] svg{display:block}
\ No newline at end of file
diff --git a/dist/index.html b/dist/index.html
index a17782f63..c3bc3e67e 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -1,5 +1,5 @@
-
evcc
\ No newline at end of file
+ };