Refactor savings (#2226)

This commit is contained in:
andig 2022-01-09 12:20:38 +01:00 • committed by GitHub
parent 445bafcb8e
commit 8ba710ff5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 413 additions and 243 deletions

View file

@ -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,
},
};

View file

@ -25,11 +25,22 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{
$t("footer.savings.modalTitle", {
total: fmtKw(chargedTotal * 1000, true, false),
})
}}
<span class="d-block d-sm-none">
{{
$t("footer.savings.modalTitleShort", {
percent,
total: fmtKw(totalCharged * 1000, true, false),
})
}}
</span>
<span class="d-none d-sm-block">
{{
$t("footer.savings.modalTitleLong", {
percent,
total: fmtKw(totalCharged * 1000, true, false),
})
}}
</span>
</h5>
<button
type="button"
@ -45,7 +56,7 @@
<fa-icon icon="square" class="text-evcc"></fa-icon>
{{
$t("footer.savings.modalChartSelf", {
self: fmtKw(chargedSelfConsumption * 1000, true, false),
self: fmtKw(selfConsumptionCharged * 1000, true, false),
})
}}
</div>
@ -53,7 +64,7 @@
<fa-icon icon="square" class="text-grid"></fa-icon>
{{
$t("footer.savings.modalChartGrid", {
grid: fmtKw(chargedGrid * 1000, true, false),
grid: fmtKw(gridCharged * 1000, true, false),
})
}}
</div>
@ -62,21 +73,21 @@
class="chart d-flex justify-content-stretch mb-1 rounded overflow-hidden"
>
<div
v-if="chargedTotal > 0"
v-if="totalCharged > 0"
class="chart-item chart-item--self d-flex justify-content-center text-white flex-shrink-1"
:style="{ width: `${percent}%` }"
>
<span class="text-truncate"> {{ percent }}% </span>
</div>
<div
v-if="chargedTotal > 0"
v-if="totalCharged > 0"
class="chart-item chart-item--grid d-flex justify-content-center text-white flex-shrink-1"
:style="{ width: `${100 - percent}%` }"
>
<span class="text-truncate"> {{ 100 - percent }}% </span>
</div>
<div
v-if="chargedTotal === 0"
v-if="totalCharged === 0"
class="chart-item chart-item--no-data d-flex justify-content-center text-white w-100"
>
<span>{{ $t("footer.savings.modalNoData") }}</span>
@ -85,15 +96,15 @@
</div>
<p class="mb-3">
{{ $t("footer.savings.modalSavingsPrice") }}:
<strong>{{ pricePerKWh }}</strong>
<strong>{{ fmtPricePerKWh(effectivePrice, currency) }}</strong>
<br />
{{ $t("footer.savings.modalSavingsTotal") }}:
<strong>{{ savingAmount }}</strong>
<strong>{{ fmtMoney(amount, currency) }}</strong>
</p>
<p class="small text-muted mb-3">
<a
href="https://github.com/evcc-io/evcc/blob/master/README.md#energy-tariffs--savings-estimate"
href="https://docs.evcc.io/docs/guides/setup/#ersparnisberechnung"
target="_blank"
class="text-muted"
>
@ -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;
},
},
};

View file

@ -12,12 +12,7 @@
<fa-icon :icon="['fas', 'heart']" class="icon me-1 solid"></fa-icon>
{{ $t("footer.sponsor.confetti") }}
</button>
<a
v-if="false"
href="https://evcc.io/sticker"
target="_blank"
class="small text-muted text-center"
>
<a href="https://evcc.io/sticker" target="_blank" class="small text-muted text-center">
{{ $t("footer.sponsor.sticker") }}
</a>
</div>

View file

@ -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",
},
},

View file

@ -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",
},
},

View file

@ -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 = {

View file

@ -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,

View file

@ -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())
}

View file

@ -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)
}
}

View file

@ -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)

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -1,5 +1,5 @@
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.4692b1e2.css" rel="preload" as="style"><link href="css/index.a26a8c8b.css" rel="preload" as="style"><link href="js/chunk-vendors.323cc329.js" rel="preload" as="script"><link href="js/index.b0ecc7d3.js" rel="preload" as="script"><link href="css/chunk-vendors.4692b1e2.css" rel="stylesheet"><link href="css/index.a26a8c8b.css" rel="stylesheet"></head><body><script>window.evcc = {
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.4692b1e2.css" rel="preload" as="style"><link href="css/index.77f18cb6.css" rel="preload" as="style"><link href="js/chunk-vendors.323cc329.js" rel="preload" as="script"><link href="js/index.2b1f2a0a.js" rel="preload" as="script"><link href="css/chunk-vendors.4692b1e2.css" rel="stylesheet"><link href="css/index.77f18cb6.css" rel="stylesheet"></head><body><script>window.evcc = {
version: "[[.Version]]",
configured: "[[.Configured]]",
commit: "[[.Commit]]",
};</script><div id="app"></div><script src="js/chunk-vendors.323cc329.js"></script><script src="js/index.b0ecc7d3.js"></script></body></html>
};</script><div id="app"></div><script src="js/chunk-vendors.323cc329.js"></script><script src="js/index.2b1f2a0a.js"></script></body></html>

2
dist/js/index.2b1f2a0a.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/js/index.2b1f2a0a.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

63
package-lock.json generated
View file

@ -34290,7 +34290,8 @@
"@fortawesome/vue-fontawesome": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.6.tgz",
"integrity": "sha512-V3vT3flY15AKbUS31aZOP12awQI3aAzkr2B1KnqcHLmwrmy51DW3pwyBczKdypV8QxBZ8U68Hl2XxK2nudTxpg=="
"integrity": "sha512-V3vT3flY15AKbUS31aZOP12awQI3aAzkr2B1KnqcHLmwrmy51DW3pwyBczKdypV8QxBZ8U68Hl2XxK2nudTxpg==",
"requires": {}
},
"@gar/promisify": {
"version": "1.1.2",
@ -36267,7 +36268,8 @@
"version": "1.6.22",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-1.6.22.tgz",
"integrity": "sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg==",
"dev": true
"dev": true,
"requires": {}
},
"@mdx-js/util": {
"version": "1.6.22",
@ -38322,7 +38324,8 @@
"version": "4.5.15",
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-vuex/-/cli-plugin-vuex-4.5.15.tgz",
"integrity": "sha512-fqap+4HN+w+InDxlA3hZTOGE0tzBTgXhKLoDydhywqgmhQ1D9JA6Feh94ze6tG8DsWX58/ujYUqA8jAz17FJtg==",
"dev": true
"dev": true,
"requires": {}
},
"@vue/cli-service": {
"version": "4.5.15",
@ -38950,7 +38953,8 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@vue/preload-webpack-plugin/-/preload-webpack-plugin-1.1.2.tgz",
"integrity": "sha512-LIZMuJk38pk9U9Ur4YzHjlIyMuxPlACdBIHH9/nGYVTsaGKOSnSuELiE8vS9wa+dJpIYspYUOqk+L1Q4pgHQHQ==",
"dev": true
"dev": true,
"requires": {}
},
"@vue/reactivity-transform": {
"version": "3.2.26",
@ -39231,7 +39235,8 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"dev": true
"dev": true,
"requires": {}
},
"acorn-walk": {
"version": "7.2.0",
@ -39296,13 +39301,15 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
"dev": true
"dev": true,
"requires": {}
},
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true
"dev": true,
"requires": {}
},
"alphanum-sort": {
"version": "1.0.2",
@ -39791,7 +39798,8 @@
"version": "7.0.0-bridge.0",
"resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz",
"integrity": "sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==",
"dev": true
"dev": true,
"requires": {}
},
"babel-jest": {
"version": "24.9.0",
@ -40801,7 +40809,8 @@
"bootstrap": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.1.3.tgz",
"integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q=="
"integrity": "sha512-fcQztozJ8jToQWXxVuEyXWW+dSo8AiXWKwiSSrKWsRB/Qt+Ewwza+JWoLKiTuQLaEPhdNAJ7+Dosc9DOIqNy7Q==",
"requires": {}
},
"boxen": {
"version": "5.1.2",
@ -44070,13 +44079,15 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz",
"integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==",
"dev": true
"dev": true,
"requires": {}
},
"eslint-config-standard": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz",
"integrity": "sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==",
"dev": true
"dev": true,
"requires": {}
},
"eslint-import-resolver-node": {
"version": "0.3.6",
@ -44303,13 +44314,15 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-5.2.0.tgz",
"integrity": "sha512-SftLb1pUG01QYq2A/hGAWfDRXqYD82zE7j7TopDOyNdU+7SvvoXREls/+PRTY17vUXzXnZA/zfnyKgRH6x4JJw==",
"dev": true
"dev": true,
"requires": {}
},
"eslint-plugin-standard": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-standard/-/eslint-plugin-standard-5.0.0.tgz",
"integrity": "sha512-eSIXPc9wBM4BrniMzJRBm2uoVuXz2EPa+NXPk2+itrVt+r5SbKFERx/IgrK/HmfjddyKVz2f+j+7gBRvu19xLg==",
"dev": true
"dev": true,
"requires": {}
},
"eslint-plugin-vue": {
"version": "7.20.0",
@ -48008,7 +48021,8 @@
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.6.tgz",
"integrity": "sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==",
"dev": true
"dev": true,
"requires": {}
}
}
},
@ -48462,7 +48476,8 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
"integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==",
"dev": true
"dev": true,
"requires": {}
},
"jest-regex-util": {
"version": "26.0.0",
@ -50426,7 +50441,8 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.5.tgz",
"integrity": "sha512-YQEMMMCX3PYOWtUAQu8Fmz5/sH09s17eyQnDubwaAo8sWmnRTT1og96EFv1vL59l4nWfmtF3L91pqkuheVqRlA==",
"dev": true
"dev": true,
"requires": {}
},
"md5.js": {
"version": "1.3.5",
@ -53328,7 +53344,8 @@
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.5.1.tgz",
"integrity": "sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg==",
"dev": true
"dev": true,
"requires": {}
},
"react-dom": {
"version": "16.14.0",
@ -57043,13 +57060,15 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.2.1.tgz",
"integrity": "sha512-6+X1FLlIcjvFMAeAD/hcxDT8tmyrWnbSPMU0EnxQuDLIxokuFzWliXBiYZuGIx+mrAMLBw0WFfCkaPw8ebzAhw==",
"dev": true
"dev": true,
"requires": {}
},
"use-isomorphic-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz",
"integrity": "sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==",
"dev": true
"dev": true,
"requires": {}
},
"use-latest": {
"version": "1.2.0",
@ -58461,7 +58480,8 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/webpack-filter-warnings-plugin/-/webpack-filter-warnings-plugin-1.2.1.tgz",
"integrity": "sha512-Ez6ytc9IseDMLPo0qCuNNYzgtUl8NovOqjIq4uAU8LTD4uoa1w1KpZyyzFtLTEMZpkkOkLfL9eN+KGYdk1Qtwg==",
"dev": true
"dev": true,
"requires": {}
},
"webpack-hot-middleware": {
"version": "2.25.1",
@ -58723,7 +58743,8 @@
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.4.0.tgz",
"integrity": "sha512-IHVsKe2pjajSUIl4KYMQOdlyliovpEPquKkqbwswulszzI7r0SfQrxnXdWAEqOlDCLrVSJzo+O1hAwdog2sKSQ==",
"dev": true
"dev": true,
"requires": {}
},
"x-default-browser": {
"version": "0.4.0",