Replace savings with session based stats (#10341)

This commit is contained in:
Michael Geers 2023-10-21 10:58:07 +02:00 • committed by GitHub
parent eaea20ae7a
commit 58e1238e6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 574 additions and 441 deletions

26
assets/js/co2Reference.js Normal file
View file

@ -0,0 +1,26 @@
// Data source: gCO2eq/kWh
const source = "https://ourworldindata.org/grapher/carbon-intensity-electricity?tab=table";
// This is a manual selection of countries. If yours is missing, please add it with data from the source above.
const regions = [
{ name: "Australia", co2: 503 },
{ name: "Austria", co2: 158 },
{ name: "Canada", co2: 128 },
{ name: "Czech Republic", co2: 415 },
{ name: "Denmark", co2: 181 },
{ name: "Estonia", co2: 464 },
{ name: "Europe", co2: 278 },
{ name: "Finland", co2: 131 },
{ name: "France", co2: 85 },
{ name: "Germany", co2: 385 },
{ name: "Netherlands", co2: 356 },
{ name: "Norway", co2: 29 },
{ name: "Poland", co2: 635 },
{ name: "Sweden", co2: 45 },
{ name: "Switzerland", co2: 46 },
{ name: "United Kingdom", co2: 257 },
{ name: "United States", co2: 367 },
{ name: "World", co2: 436 },
];
export default { regions, source };

View file

@ -48,3 +48,9 @@ export default {
},
};
</script>
<style scoped>
span {
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<label class="position-relative d-block">
<select :value="selected" class="custom-select" @change="change">
<label class="position-relative d-block" :for="id">
<select :id="id" :value="selected" class="custom-select" @change="change">
<option
v-for="{ name, value, count, disabled } in options"
:key="value"
@ -20,6 +20,7 @@ export default {
props: {
options: { type: Array },
selected: { type: String },
id: { type: String },
},
emits: ["change"],
methods: {

View file

@ -24,7 +24,7 @@
<SavingsTile
class="text-accent3"
icon="eco"
icon="lightning"
:title="$t('footer.community.greenEnergy')"
:value="greenEnergy.value"
:valueFmt="fmtAnimation"

View file

@ -14,7 +14,7 @@
</CustomSelect>
</template>
<template #value>
<div data-testid="sessionInfoValue" @click="nextSessionInfo">
<div class="value" data-testid="sessionInfoValue" @click="nextSessionInfo">
<div :class="{ 'd-none d-sm-block': showSm }">{{ value }}</div>
<div v-if="showSm" class="d-block d-sm-none">{{ valueSm }}</div>
</div>
@ -155,4 +155,7 @@ export default {
user-select: none;
-webkit-user-select: none;
}
.value {
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -2,6 +2,7 @@
<div>
<button
class="btn btn-link pe-0 text-decoration-none evcc-default-text text-nowrap d-flex align-items-end"
data-testid="savings-button"
@click="openModal"
>
<span class="d-inline d-sm-none text-decoration-underline">{{
@ -65,18 +66,13 @@
<SavingsTile
class="text-accent1"
icon="sun"
data-testid="savings-tile-solar"
:title="$t('footer.savings.percentTitle')"
:value="selfConsumptionPercent"
:valueFmt="fmtAnimation"
:value="fmtNumber(solarPercentage, 1)"
unit="%"
:sub1="
$t('footer.savings.percentSelf', {
self: fmtKw(
selfConsumptionCharged * 1000,
true,
false,
0
),
self: fmtKw(solarCharged * 1000, true, false, 0),
})
"
:sub2="
@ -89,44 +85,101 @@
<SavingsTile
class="text-accent2"
icon="receivepayment"
data-testid="savings-tile-price"
:title="$t('footer.savings.priceTitle')"
:value="effectivePriceFormatted.value"
:unit="effectivePriceFormatted.unit"
:value="priceConfigured ? avgPriceFormatted.value : '__'"
:unit="avgPriceFormatted.unit"
:sub1="
$t('footer.savings.priceFeedIn', {
feedInPrice: fmtPricePerKWh(feedInPrice, currency),
})
"
:sub2="
$t('footer.savings.priceGrid', {
gridPrice: fmtPricePerKWh(gridPrice, currency),
})
priceConfigured
? `${fmtMoney(
(referenceGrid - avgPrice) * totalCharged,
currency,
false
)} ${fmtCurrencySymbol(currency)} ${$t(
'footer.savings.moneySaved'
)}`
: ''
"
/>
<SavingsTile
class="text-accent3"
icon="coinjar"
:title="$t('footer.savings.savingsTitle')"
:value="fmtMoney(amount, currency, amount < 100)"
:unit="fmtCurrencySymbol(currency)"
:sub1="$t('footer.savings.savingsComparedToGrid')"
:sub2="
$t('footer.savings.savingsTotalEnergy', {
total: fmtKw(totalCharged * 1000, true, false, 0),
})
icon="eco"
data-testid="savings-tile-co2"
:title="$t('footer.savings.co2Title')"
:value="co2Configured ? fmtNumber(avgCo2, 0) : '__'"
unit="g/kWh"
:sub1="
region && co2Configured
? `${fmtNumber(
((region.co2 - avgCo2) * totalCharged) /
1000,
0,
'kilogram'
)} ${$t('footer.savings.co2Saved')}`
: ''
"
/>
</div>
<p class="my-3 lh-2">
<small>
{{
$t("footer.savings.since", {
since: startDate,
})
}}
</small>
</p>
<div class="my-3 lh-2">
<div class="d-flex">
<label for="savingsPeriod" class="me-1">
{{ $t("footer.savings.periodLabel") }}
</label>
<CustomSelect
id="savingsPeriod"
:selected="period"
:options="periodOptions"
data-testid="savings-period-select"
@change="selectPeriod($event.target.value)"
>
<span class="text-decoration-underline evcc-gray">
{{ $t(`footer.savings.period.${period}`) }}
</span>
</CustomSelect>
</div>
<div
v-if="region"
class="d-flex flex-wrap"
data-testid="savings-reference"
>
<div class="me-1">
{{ $t("footer.savings.referenceLabel") }}
</div>
<div class="evcc-gray me-1">
{{
priceConfigured
? fmtPricePerKWh(referenceGrid, currency)
: "___"
}}
({{ $t("footer.savings.referenceGrid") }}),
</div>
<div class="evcc-gray d-flex">
<div class="me-1">⌀ {{ fmtCo2Medium(region.co2) }}</div>
<CustomSelect
class="me-1 evcc-gray"
:selected="region.name"
:options="regionOptions"
data-testid="savings-region-select"
@change="selectRegion($event.target.value)"
>
(<span class="text-decoration-underline">{{
region.name
}}</span
>)
</CustomSelect>
</div>
</div>
<div v-if="!priceConfigured || !co2Configured">
<a
href="https://docs.evcc.io/en/docs/reference/configuration/tariffs/"
class="evcc-gray"
target="_blank"
>
{{ $t("footer.savings.configurePriceCo2") }}
</a>
</div>
</div>
</div>
<div v-else class="my-4">
<LiveCommunity />
@ -149,41 +202,91 @@ import Sponsor from "./Sponsor.vue";
import SavingsTile from "./SavingsTile.vue";
import LiveCommunity from "./LiveCommunity.vue";
import TelemetrySettings from "./TelemetrySettings.vue";
import CustomSelect from "./CustomSelect.vue";
import co2Reference from "../co2Reference";
import settings from "../settings";
import api from "../api";
export default {
name: "Savings",
components: { Sponsor, SavingsTile, LiveCommunity, TelemetrySettings },
components: { Sponsor, SavingsTile, LiveCommunity, TelemetrySettings, CustomSelect },
mixins: [formatter],
props: {
selfConsumptionPercent: Number,
since: String,
stats: { type: Object, default: () => ({}) },
co2Configured: Boolean,
sponsor: String,
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,
},
data() {
return { communityView: false, telemetryEnabled: false };
return {
communityView: false,
telemetryEnabled: false,
period: settings.savingsPeriod || "30d",
selectedRegion: settings.savingsRegion || "Germany",
referenceGrid: undefined,
};
},
computed: {
percent() {
return Math.round(this.selfConsumptionPercent) || 0;
return Math.round(this.solarPercentage) || 0;
},
effectivePriceFormatted() {
const value = this.fmtPricePerKWh(this.effectivePrice, this.currency, false, false);
regionOptions() {
return co2Reference.regions.map((r) => ({
value: r.name,
name: `${r.name} (${this.fmtCo2Short(r.co2)})`,
}));
},
region() {
// previously selected region
if (this.selectedRegion) {
const result = co2Reference.regions.find((r) => r.name === this.selectedRegion);
if (result) {
return result;
}
}
// first region
return co2Reference.regions[0];
},
periodOptions() {
return ["30d", "365d", "total"].map((p) => ({
value: p,
name: this.$t(`footer.savings.period.${p}`),
}));
},
avgPriceFormatted() {
const value = this.fmtPricePerKWh(
this.currentStats.avgPrice,
this.currency,
false,
false
);
const unit = this.pricePerKWhUnit(this.currency);
return { value, unit };
},
startDate() {
if (this.since) {
return this.fmtDayMonthYear(new Date(this.since));
}
return "";
currentStats() {
return this.stats[this.period] || {};
},
totalCharged() {
return this.currentStats.chargedKWh;
},
solarPercentage() {
return this.currentStats.solarPercentage;
},
solarCharged() {
return (this.solarPercentage / 100) * this.totalCharged;
},
gridCharged() {
return this.totalCharged - this.solarCharged;
},
avgPrice() {
return this.currentStats.avgPrice;
},
avgCo2() {
return this.currentStats.avgCo2;
},
priceConfigured() {
return this.referenceGrid !== undefined;
},
},
methods: {
@ -196,12 +299,31 @@ export default {
openModal() {
const modal = Modal.getOrCreateInstance(document.getElementById("savingsModal"));
modal.show();
this.updateReferenceGrid();
},
fmtAnimation(number) {
let decimals = 0;
if (number < 100) decimals = 1;
if (number < 10) decimals = 2;
return this.fmtNumber(number, decimals);
selectPeriod(period) {
this.period = period;
settings.savingsPeriod = period;
},
selectRegion(region) {
this.selectedRegion = region;
settings.savingsRegion = region;
},
updateReferenceGrid: async function () {
try {
const res = await api.get(`tariff/grid`, {
validateStatus: (status) => status >= 200 && status < 500,
});
const { rates } = res.data.result;
this.referenceGrid =
rates.reduce((acc, slot) => {
acc += slot.price;
return acc;
}, 0) / rates.length;
} catch (e) {
this.referenceGrid = undefined;
console.error(e);
}
},
},
};

View file

@ -1,10 +1,10 @@
<template>
<div class="flex-shrink-1 d-flex mb-4 mb-lg-0 align-items-center align-items-lg-start">
<shopicon-regular-sun v-if="icon === 'sun'" class="tile-icon"></shopicon-regular-sun>
<shopicon-regular-coinjar
v-if="icon === 'coinjar'"
<shopicon-regular-lightning
v-if="icon === 'lightning'"
class="tile-icon"
></shopicon-regular-coinjar>
></shopicon-regular-lightning>
<shopicon-regular-receivepayment
v-if="icon === 'receivepayment'"
class="tile-icon"
@ -15,7 +15,7 @@
<div>
<p class="my-0 fw-bold text-truncate">{{ title }}</p>
<strong class="d-flex align-items-baseline lh-sm">
<span class="fs-1">
<span class="fs-1 value">
<AnimatedNumber v-if="valueFmt" :to="value" :format="valueFmt" />
<span v-else>{{ value }}</span>
</span>
@ -31,7 +31,7 @@
</template>
<script>
import "@h2d2/shopicons/es/regular/coinjar";
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/car3";
@ -67,4 +67,7 @@ export default {
.unit {
font-size: var(--bs-body-font-size);
}
.value {
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -80,13 +80,7 @@ export default {
auth: Object,
currency: String,
savingsAmount: Number,
savingsEffectivePrice: Number,
savingsGridCharged: Number,
savingsSelfConsumptionCharged: Number,
savingsSelfConsumptionPercent: Number,
savingsSince: String,
savingsTotalCharged: Number,
stats: Object,
tariffFeedIn: Number,
tariffGrid: Number,
tariffCo2: Number,
@ -142,15 +136,9 @@ export default {
},
sponsor: this.sponsor,
savings: {
since: this.savingsSince,
totalCharged: this.savingsTotalCharged,
gridCharged: this.savingsGridCharged,
selfConsumptionCharged: this.savingsSelfConsumptionCharged,
amount: this.savingsAmount,
effectivePrice: this.savingsEffectivePrice,
selfConsumptionPercent: this.savingsSelfConsumptionPercent,
gridPrice: this.tariffGrid,
feedInPrice: this.tariffFeedIn,
stats: this.stats,
co2Configured: this.tariffCo2 !== undefined,
priceConfigured: this.tariffGrid !== undefined,
currency: this.currency,
},
};

View file

@ -128,6 +128,10 @@ export default {
},
},
mounted() {
const $el = document.getElementById("topNavigatonDropdown");
if (!$el) {
return;
}
this.dropdown = new Dropdown(document.getElementById("topNavigatonDropdown"));
},
unmounted() {

View file

@ -7,6 +7,8 @@ const SETTINGS_HIDDEN_FEATURES = "settings_hidden_features";
const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
const SESSION_INFO = "session_info";
const SESSION_COLUMNS = "session_columns";
const SAVINGS_PERIOD = "savings_period";
const SAVINGS_REGION = "savings_region";
function read(key) {
return window.localStorage[key];
@ -56,6 +58,8 @@ const settings = reactive({
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
sessionInfo: readArray(SESSION_INFO),
sessionColumns: readArray(SESSION_COLUMNS),
savingsPeriod: read(SAVINGS_PERIOD),
savingsRegion: read(SAVINGS_REGION),
});
watch(() => settings.locale, save(SETTINGS_LOCALE));
@ -65,5 +69,7 @@ watch(() => settings.hiddenFeatures, saveBool(SETTINGS_HIDDEN_FEATURES));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.sessionInfo, saveArray(SESSION_INFO));
watch(() => settings.sessionColumns, saveArray(SESSION_COLUMNS));
watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
watch(() => settings.savingsRegion, save(SAVINGS_REGION));
export default settings;

View file

@ -25,15 +25,16 @@ func (em *EnergyMetrics) SetEnvironment(greenShare float64, effPrice, effCo2 *fl
em.currentCo2 = effCo2
}
// Update sets the a new value for the total amount of charged energy and updated metrics based on enviroment values
func (em *EnergyMetrics) Update(chargedKWh float64) {
// Update sets the a new value for the total amount of charged energy and updated metrics based on enviroment values. It returns the added totan and green energy
func (em *EnergyMetrics) Update(chargedKWh float64) (float64, float64) {
added := chargedKWh - em.totalKWh
// nothing changed or invalid lower value
if added <= 0 {
return
return 0, 0
}
em.totalKWh = chargedKWh
em.solarKWh += added * em.currentGreenShare
addedGreen := added * em.currentGreenShare
em.solarKWh += addedGreen
// optional values
if em.currentPrice != nil {
addedPrice := *em.currentPrice * added
@ -51,6 +52,7 @@ func (em *EnergyMetrics) Update(chargedKWh float64) {
}
em.co2 = &newCo2
}
return added, addedGreen
}
// Reset sets all calculations to initial values

View file

@ -20,6 +20,7 @@ import (
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/telemetry"
evbus "github.com/asaskevich/EventBus"
"github.com/avast/retry-go/v4"
@ -1344,7 +1345,10 @@ func (lp *Loadpoint) publishChargeProgress() {
// workaround for Go-E resetting during disconnect, see
// https://github.com/evcc-io/evcc/issues/5092
if f > lp.chargedAtStartup {
lp.sessionEnergy.Update(f - lp.chargedAtStartup)
added, addedGreen := lp.sessionEnergy.Update(f - lp.chargedAtStartup)
if telemetry.Enabled() && added > 0 {
telemetry.UpdateEnergy(added, addedGreen)
}
}
} else {
lp.log.ERROR.Printf("charge rater: %v", err)

View file

@ -1,149 +0,0 @@
package core
import (
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
)
const (
DefaultGridPrice = 0.30
DefaultFeedInPrice = 0.08
)
// publisher gives access to the site's publish function
type publisher interface {
publish(key string, val interface{})
}
// Site is the main configuration container. A site can host multiple loadpoints.
type Savings struct {
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)
gridSavedCost float64 // Running total of saved cost from self consumption (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)
hasPublished bool // Has initial publish happened?
}
func NewSavings(tariffs tariff.Tariffs) *Savings {
clock := clock.New()
savings := &Savings{
clock: clock,
tariffs: tariffs,
started: clock.Now(),
updated: clock.Now(),
}
savings.load()
return savings
}
func (s *Savings) load() {
s.started, _ = settings.Time("savings.started")
s.gridCharged, _ = settings.Float("savings.gridCharged")
s.gridCost, _ = settings.Float("savings.gridCost")
s.gridSavedCost, _ = settings.Float("savings.gridSavedCost")
s.selfConsumptionCharged, _ = settings.Float("savings.selfConsumptionCharged")
s.selfConsumptionCost, _ = settings.Float("savings.selfConsumptionCost")
}
func (s *Savings) save() {
settings.SetTime("savings.started", s.started)
settings.SetFloat("savings.gridCharged", s.gridCharged)
settings.SetFloat("savings.gridCost", s.gridCost)
settings.SetFloat("savings.gridSavedCost", s.gridSavedCost)
settings.SetFloat("savings.selfConsumptionCharged", s.selfConsumptionCharged)
settings.SetFloat("savings.selfConsumptionCost", s.selfConsumptionCost)
}
func (s *Savings) Since() time.Time {
return s.started
}
func (s *Savings) SelfConsumptionPercent() float64 {
if s.TotalCharged() == 0 {
return 0
}
return s.selfConsumptionCharged / s.TotalCharged() * 100
}
func (s *Savings) TotalCharged() float64 {
return s.gridCharged + s.selfConsumptionCharged
}
func (s *Savings) CostTotal() float64 {
return s.gridCost + s.selfConsumptionCost
}
func (s *Savings) EffectivePrice() float64 {
if s.TotalCharged() == 0 {
return s.currentGridPrice()
}
return s.CostTotal() / s.TotalCharged()
}
func (s *Savings) SavingsAmount() float64 {
return s.gridSavedCost
}
func (s *Savings) currentGridPrice() float64 {
price, err := s.tariffs.CurrentGridPrice()
if err != nil {
price = DefaultGridPrice
}
return price
}
func (s *Savings) currentFeedInPrice() float64 {
price, err := s.tariffs.CurrentFeedInPrice()
if err != nil {
price = DefaultFeedInPrice
}
return price
}
// Update savings calculation and return grid/green energy added since last update
func (s *Savings) Update(p publisher, greenShare, chargePower float64) float64 {
defer func() { s.updated = s.clock.Now() }()
// no charging, no need to update
if chargePower == 0 && s.hasPublished {
return 0
}
// assume charge power as constant over the duration -> rough kWh estimate
deltaCharged := s.clock.Since(s.updated).Hours() * chargePower / 1e3
deltaSelf := deltaCharged * greenShare
deltaGrid := deltaCharged - deltaSelf
gridPrice := s.currentGridPrice()
feedInPrice := s.currentFeedInPrice()
s.gridCharged += deltaGrid
s.gridCost += deltaGrid * gridPrice
s.gridSavedCost += deltaSelf * (gridPrice - feedInPrice)
s.selfConsumptionCharged += deltaSelf
s.selfConsumptionCost += deltaSelf * feedInPrice
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())
s.hasPublished = true
s.save()
return deltaCharged
}

View file

@ -1,177 +0,0 @@
package core
import (
"math"
"testing"
"time"
"github.com/benbjohnson/clock"
)
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.selfConsumptionCharged, self) {
t.Errorf("ChargedSelfConsumption was incorrect, got: %.3f, want: %.3f.", s.selfConsumptionCharged, self)
}
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)
}
}
func compareWithTolerane(a, b float64) bool {
tolerance := 0.001
diff := math.Abs(a - b)
return diff < tolerance
}
type StubPublisher struct{}
func (p StubPublisher) publish(key string, val interface{}) {}
func TestSavingsWithDifferentTimespans(t *testing.T) {
p := StubPublisher{}
clck := clock.NewMock()
s := &Savings{
clock: clck,
started: clck.Now(),
updated: clck.Now(),
}
type tcStep = struct {
dt time.Duration
greenShare, charge float64
}
tc := []struct {
title string
steps []tcStep
total, self, percentage float64
}{
{"10 second not charging, full grid",
[]tcStep{
{10 * time.Second, 0, 0},
},
0, 0, 0, // 0Wh
},
{"10 second 11kW charging, full grid",
[]tcStep{
{10 * time.Second, 0, 11000},
},
0.030556, 0, 0, // 30,555Wh
},
{"10 second 11kW charging, full grid",
[]tcStep{
{10 * time.Second, 0, 11000},
},
0.061111, 0, 0, // 61,111Wh
},
{"5x 2 second 11kW charging, full grid",
[]tcStep{
{2 * time.Second, 0, 11000},
{2 * time.Second, 0, 11000},
{2 * time.Second, 0, 11000},
{2 * time.Second, 0, 11000},
{2 * time.Second, 0, 11000},
},
0.092, 0, 0, // 91,666Wh
},
{"30 min 11kW charging, full grid",
[]tcStep{
{30 * time.Minute, 0, 11000},
},
5.592, 0, 0, // 5561,111Wh
},
{"4 hours 11kW charging, full pv",
[]tcStep{
{4 * time.Hour, 1, 11000},
},
49.592, 44, 88,
},
}
s.Update(p, 0, 0)
for _, tc := range tc {
t.Logf("%+v", tc)
for _, tc := range tc.steps {
clck.Add(tc.dt)
s.Update(p, tc.greenShare, tc.charge)
}
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
greenShare, charge float64
}
tc := []struct {
title string
steps []tcStep
effectivePrice, savingsAmount float64
}{
{"1 hour, 10kW, full grid",
[]tcStep{
{time.Hour, 0, 10000},
},
0.3, 0,
},
{"1 hour, 10kW, full pv",
[]tcStep{
{time.Hour, 1, 10000},
},
0.08, 2.2,
},
{"1 hour, 10kW, full battery",
[]tcStep{
{time.Hour, 1, 10000},
},
0.08, 2.2,
},
{"1 hour, 10kW, half grid, half pv",
[]tcStep{
{time.Hour, 0.5, 10000},
},
0.19, 1.1,
},
}
for _, tc := range tc {
t.Logf("%+v", tc)
s := &Savings{
clock: clck,
started: clck.Now(),
updated: clck.Now(),
}
s.Update(p, 0, 0)
for _, tc := range tc.steps {
clck.Add(tc.dt)
s.Update(p, tc.greenShare, tc.charge)
}
assertPrices(t, s, tc.effectivePrice, tc.savingsAmount)
}
}

View file

@ -78,7 +78,7 @@ type Site struct {
loadpoints []*Loadpoint // Loadpoints
coordinator *coordinator.Coordinator // Vehicles
prioritizer *prioritizer.Prioritizer // Power budgets
savings *Savings // Savings
stats *Stats // Stats
// cached state
gridPower float64 // Grid power
@ -119,7 +119,7 @@ func NewSiteFromConfig(
config.Vehicles().Subscribe(site.updateVehicles)
site.prioritizer = prioritizer.New(log)
site.savings = NewSavings(tariffs)
site.stats = NewStats()
site.restoreSettings()
@ -780,14 +780,14 @@ func (site *Site) update(lp Updater) {
site.publishTariffs(greenShareHome, greenShareLoadpoints)
// TODO: use energy instead of current power for better results
deltaCharged := site.savings.Update(site, greenShareLoadpoints, totalChargePower)
if telemetry.Enabled() && totalChargePower > standbyPower {
go telemetry.UpdateChargeProgress(site.log, totalChargePower, deltaCharged, greenShareLoadpoints)
go telemetry.UpdateChargeProgress(site.log, totalChargePower, greenShareLoadpoints)
}
} else {
site.log.ERROR.Println(err)
}
site.stats.Update(site)
}
// prepare publishes initial values
@ -808,7 +808,6 @@ func (site *Site) prepare() {
site.publish("smartCostType", tariff.Type().String())
}
site.publish("currency", site.tariffs.Currency.String())
site.publish("savingsSince", site.savings.Since())
site.publish("vehicles", vehicleTitles(site.GetVehicles()))
}

95
core/stats.go Normal file
View file

@ -0,0 +1,95 @@
package core
import (
"time"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
)
// publisher gives access to the site's publish function
type publisher interface {
publish(key string, val interface{})
}
// Publishes long term charging statistics
type Stats struct {
updated time.Time // Time of last charged value update
log *util.Logger
}
func NewStats() *Stats {
return &Stats{
log: util.NewLogger("stats"),
}
}
// Update publishes stats based on charging sessions
func (s *Stats) Update(p publisher) {
if time.Since(s.updated) < time.Hour {
return
}
var stats = map[string]map[string]float64{
"30d": s.calculate(30),
"365d": s.calculate(365),
"total": s.calculate(365 * 100), // 100 years
}
p.publish("stats", stats)
s.updated = time.Now()
}
// calculate reads the stats for the last n-days
func (s *Stats) calculate(days int) map[string]float64 {
result := make(map[string]float64)
// Calculate the date from numberOfDays ago
fromDate := time.Now().AddDate(0, 0, -days)
// Struct to hold the results
var dbResult struct {
SolarPercentage float64
ChargedKWh float64
AvgPrice float64
AvgCo2 float64
}
// Calculate solar_percentage and total_kwh
if err := db.Instance.Raw(`
SELECT SUM(charged_kwh * solar_percentage) / SUM(charged_kwh) AS SolarPercentage,
SUM(charged_kwh) as ChargedKWh
FROM sessions
WHERE finished >= ?
AND charged_kwh > 0
AND solar_percentage IS NOT NULL`, fromDate).Scan(&dbResult).Error; err != nil {
s.log.ERROR.Printf("error executing solar stats query: %v", err)
}
// Calculate avg_price
if err := db.Instance.Raw(`
SELECT SUM(charged_kwh * price_per_kwh) / SUM(charged_kwh) AS AvgPrice
FROM sessions
WHERE finished >= ?
AND charged_kwh > 0
AND price_per_kwh IS NOT NULL`, fromDate).Scan(&dbResult).Error; err != nil {
s.log.ERROR.Printf("error executing price stats query: %v", err)
}
// Calculate avg_co2
if err := db.Instance.Raw(`
SELECT SUM(charged_kwh * co2_per_kwh) / SUM(charged_kwh) AS AvgCo2
FROM sessions
WHERE finished >= ?
AND charged_kwh > 0
AND co2_per_kwh IS NOT NULL`, fromDate).Scan(&dbResult).Error; err != nil {
s.log.ERROR.Printf("error executing co2 stats query: %v", err)
}
result["solarPercentage"] = dbResult.SolarPercentage
result["chargedKWh"] = dbResult.ChargedKWh
result["avgPrice"] = dbResult.AvgPrice
result["avgCo2"] = dbResult.AvgCo2
return result
}

View file

@ -69,21 +69,27 @@ powerSub2 = "laden ..."
tabTitle = "Live-Community"
[footer.savings]
co2Saved = "{value} eingespart"
co2Title = "CO₂ Emisson"
configurePriceCo2 = "Preis und CO₂-Emissionen konfigurieren"
footerLong = "{percent}% Sonnenenergie"
footerShort = "{percent}% Sonne"
modalTitle = "Auswertung Ladeenergie"
moneySaved = "{value} gespart"
percentGrid = "{grid} kWh Netz"
percentSelf = "{self} kWh Sonne"
percentTitle = "Sonnenenergie"
priceFeedIn = "{feedInPrice} Einspeisung"
priceGrid = "{gridPrice} Netz"
periodLabel = "Zeitraum:"
priceTitle = "Energiepreis"
savingsComparedToGrid = "gegenüber Netzbezug"
savingsTitle = "Ersparnis"
savingsTotalEnergy = "{total} kWh geladen"
since = "seit {since}"
referenceGrid = "Netz"
referenceLabel = "Referenzdaten:"
tabTitle = "Meine Daten"
[footer.savings.period]
30d = "letzte 30 Tage"
365d = "letzte 365 Tage"
total = "gesamt"
[footer.sponsor]
becomeSponsor = "Sponsor werden"
confetti = "Lust auf Konfetti?"

View file

@ -66,21 +66,27 @@ powerSub2 = "charging…"
tabTitle = "Live community"
[footer.savings]
co2Saved = "{value} saved"
co2Title = "CO₂ Emissons"
configurePriceCo2 = "Learn how to configure price and CO₂ data."
footerLong = "{percent}% solar energy"
footerShort = "{percent}% solar"
modalTitle = "Charge Energy Overview"
moneySaved = "{value} saved"
percentGrid = "{grid} kWh grid"
percentSelf = "{self} kWh solar"
percentTitle = "Solar Energy"
priceFeedIn = "{feedInPrice} feed-in"
priceGrid = "{gridPrice} grid"
periodLabel = "Period:"
priceTitle = "Energy Price"
savingsComparedToGrid = "compared to grid"
savingsTitle = "Savings"
savingsTotalEnergy = "{total} kWh charged"
since = "since {since}"
referenceGrid = "grid"
referenceLabel = "Reference data:"
tabTitle = "My data"
[footer.savings.period]
30d = "last 30 days"
365d = "last 365 days"
total = "all time"
[footer.sponsor]
becomeSponsor = "Become a Sponsor"
confetti = "Ready for confetti?"

57
tests/stats.evcc.yaml Executable file
View file

@ -0,0 +1,57 @@
interval: 0.1s
site:
title: Hello World
meters:
grid: grid
meters:
- name: grid
type: custom
power:
source: js
script: |
1000
- name: charger_meter
type: custom
power:
source: js
script: |
500
loadpoints:
- title: Carport
charger: charger
meter: charger_meter
mode: now
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
false
status:
source: js
script: |
"A"
maxcurrent:
source: js
script: |
16
tariffs:
currency: CHF
grid:
type: fixed
price: 0.30
feedin:
type: fixed
price: 0.10
co2:
type: fixed
price: 300

102
tests/stats.spec.js Normal file
View file

@ -0,0 +1,102 @@
const { test, expect } = require("@playwright/test");
const { start, stop } = require("./evcc");
test.beforeAll(async () => {
await start("stats.evcc.yaml", "stats.sql");
});
test.afterAll(async () => {
await stop();
});
test.describe("footer", async () => {
test("default 30d solar percentage in footer and change on period switch", async ({ page }) => {
await page.goto("/");
// last 30 days
await expect(page.getByTestId("savings-button")).toContainText("60% solar energy");
// last 365 days
await page.getByTestId("savings-button").click();
await page
.getByTestId("savings-period-select")
.getByRole("combobox")
.selectOption("last 365 days");
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByTestId("savings-button")).toContainText("30% solar energy");
// all time
await page.getByTestId("savings-button").click();
await page.getByTestId("savings-period-select").getByRole("combobox").selectOption("all time");
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByTestId("savings-button")).toContainText("50% solar energy");
});
});
test.describe("stats values", async () => {
test("last 30 days", async ({ page }) => {
await page.goto("/");
await page.getByTestId("savings-button").click();
await page
.getByTestId("savings-period-select")
.getByRole("combobox")
.selectOption("last 30 days");
const solar = await page.getByTestId("savings-tile-solar");
expect(solar).toContainText("60.0%");
expect(solar).toContainText("30 kWh solar");
expect(solar).toContainText("20 kWh grid");
const price = await page.getByTestId("savings-tile-price");
expect(price).toContainText("18.0rp/kWh");
expect(price).toContainText("6 CHF saved");
const co2 = await page.getByTestId("savings-tile-co2");
expect(co2).toContainText("8g/kWh");
expect(co2).toContainText("19 kg saved");
});
test("last 365 days", async ({ page }) => {
await page.goto("/");
await page.getByTestId("savings-button").click();
await page
.getByTestId("savings-period-select")
.getByRole("combobox")
.selectOption("last 365 days");
const solar = await page.getByTestId("savings-tile-solar");
expect(solar).toContainText("30.0%");
expect(solar).toContainText("30 kWh solar");
expect(solar).toContainText("70 kWh grid");
const price = await page.getByTestId("savings-tile-price");
expect(price).toContainText("24.0rp/kWh");
expect(price).toContainText("6 CHF saved");
const co2 = await page.getByTestId("savings-tile-co2");
expect(co2).toContainText("14g/kWh");
expect(co2).toContainText("37 kg saved");
});
test("reference data", async ({ page }) => {
await page.goto("/");
await page.getByTestId("savings-button").click();
const reference = await page.getByTestId("savings-reference");
expect(reference).toContainText("Reference data:");
expect(reference).toContainText("30.0 rp/kWh (grid)");
expect(reference).toContainText("⌀ 385 g/kWh");
expect(reference).toContainText("Germany");
expect(await page.getByTestId("savings-tile-co2")).toContainText("19 kg saved");
await page
.getByTestId("savings-region-select")
.getByRole("combobox")
.selectOption("Switzerland");
expect(reference).toContainText("⌀ 46 g/kWh");
expect(reference).toContainText("Switzerland");
expect(await page.getByTestId("savings-tile-co2")).toContainText("2 kg saved");
});
});

24
tests/stats.sql Normal file
View file

@ -0,0 +1,24 @@
DROP TABLE IF EXISTS `sessions`;
CREATE TABLE `sessions` (
`id` integer,
`created` datetime,
`finished` datetime,
`loadpoint` text,
`identifier` text,
`vehicle` text,
`meter_start_kwh` real,
`meter_end_kwh` real,
`charged_kwh` real,
`odometer` real,
`solar_percentage` real,
`price` real,
`price_per_kwh` real,
`co2_per_kwh` real,
`charge_duration` integer,
PRIMARY KEY (`id`)
);
INSERT INTO `sessions` VALUES (1, datetime('now', '-1 day'),datetime('now', '-0 day'),'Carport',NULL,'e-Golf',NULL,NULL,40,NULL, 50, 8,0.2,10,null);
INSERT INTO `sessions` VALUES (2, datetime('now', '-11 day'),datetime('now', '-10 day'),'Carport',NULL,'e-Golf',NULL,NULL,10,NULL,100, 1,0.1, 0,null);
INSERT INTO `sessions` VALUES (3, datetime('now', '-101 day'),datetime('now', '-100 day'),'Carport',NULL,'e-Golf',NULL,NULL,50,NULL, 0,15,0.3,20,null);
INSERT INTO `sessions` VALUES (4, datetime('now', '-901 day'),datetime('now', '-900 day'),'Carport',NULL,'e-Golf',NULL,NULL,40,NULL,100, 3,0.1, 0,null);

View file

@ -56,16 +56,11 @@ func Create(machineID string) {
instanceID = machineID
}
// UpdateChargeProgress accumulates the charge delta and uploads at given interval.
// This interval must be smaller that the apis expiry interval for treating power values as current.
func UpdateChargeProgress(log *util.Logger, power, deltaCharged, greenShare float64) {
// UpdateChargeProgress uploads power and energy data every 30 seconds
func UpdateChargeProgress(log *util.Logger, power, greenShare float64) {
mu.Lock()
defer mu.Unlock()
// cache
accChargeEnergy += deltaCharged
accGreenEnergy += deltaCharged * greenShare
if time.Since(updated) < 30*time.Second {
return
}
@ -75,6 +70,16 @@ func UpdateChargeProgress(log *util.Logger, power, deltaCharged, greenShare floa
}
}
// UpdateEnergy accumulates the energy delta for later upload
func UpdateEnergy(chargeEnergy, greenEnergy float64) {
mu.Lock()
defer mu.Unlock()
// cache
accChargeEnergy += chargeEnergy
accGreenEnergy += greenEnergy
}
// Persist uploads the accumulated data if necessary
func Persist(log *util.Logger) {
mu.Lock()