Replace savings with session based stats (#10341)
This commit is contained in:
parent
eaea20ae7a
commit
58e1238e6e
22 changed files with 574 additions and 441 deletions
26
assets/js/co2Reference.js
Normal file
26
assets/js/co2Reference.js
Normal 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 };
|
||||
|
|
@ -48,3 +48,9 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
<SavingsTile
|
||||
class="text-accent3"
|
||||
icon="eco"
|
||||
icon="lightning"
|
||||
:title="$t('footer.community.greenEnergy')"
|
||||
:value="greenEnergy.value"
|
||||
:valueFmt="fmtAnimation"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ export default {
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
const $el = document.getElementById("topNavigatonDropdown");
|
||||
if (!$el) {
|
||||
return;
|
||||
}
|
||||
this.dropdown = new Dropdown(document.getElementById("topNavigatonDropdown"));
|
||||
},
|
||||
unmounted() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
149
core/savings.go
149
core/savings.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
11
core/site.go
11
core/site.go
|
|
@ -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
95
core/stats.go
Normal 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
|
||||
}
|
||||
18
i18n/de.toml
18
i18n/de.toml
|
|
@ -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?"
|
||||
|
|
|
|||
18
i18n/en.toml
18
i18n/en.toml
|
|
@ -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
57
tests/stats.evcc.yaml
Executable 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
102
tests/stats.spec.js
Normal 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
24
tests/stats.sql
Normal 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);
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue