Add option to show current price and co2 info (#6048)

This commit is contained in:
Michael Geers 2023-02-06 21:57:00 +01:00 • committed by GitHub
parent d01985402f
commit 4ff0b9a5fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 430 additions and 159 deletions

View file

@ -27,6 +27,10 @@ import Energyflow from "./Energyflow.vue";
:batteryPower="800"
:batterySoc="77"
siteTitle="Home"
:battery="[
{ soc: 44, capacity: 13.3 },
{ soc: 82, capacity: 21 },
]"
/>
</Variant>
<Variant title="battery charging">

View file

@ -65,12 +65,17 @@
:soc="batterySoc"
:power="batteryDischarge"
:valuesInKw="valuesInKw"
:tooltip="batteryTooltip"
/>
<EnergyflowEntry
:name="$t('main.energyflow.gridImport')"
icon="powersupply"
:power="gridImport"
:valuesInKw="valuesInKw"
:price="tariffGrid"
:currency="currency"
:co2="tariffCo2"
:tooltip="detailsTooltip(tariffGrid, tariffCo2)"
/>
</div>
</div>
@ -89,6 +94,10 @@
icon="home"
:power="homePower"
:valuesInKw="valuesInKw"
:price="tariffEffectivePrice"
:currency="currency"
:co2="tariffEffectiveCo2"
:tooltip="detailsTooltip(tariffEffectivePrice, tariffEffectiveCo2)"
/>
<EnergyflowEntry
:name="
@ -100,6 +109,10 @@
:vehicleIcons="vehicleIcons"
:power="loadpointsPower"
:valuesInKw="valuesInKw"
:price="tariffEffectivePrice"
:currency="currency"
:co2="tariffEffectiveCo2"
:tooltip="detailsTooltip(tariffEffectivePrice, tariffEffectiveCo2)"
/>
<EnergyflowEntry
v-if="batteryConfigured"
@ -108,12 +121,16 @@
:soc="batterySoc"
:power="batteryCharge"
:valuesInKw="valuesInKw"
:tooltip="batteryTooltip"
/>
<EnergyflowEntry
:name="$t('main.energyflow.pvExport')"
icon="powersupply"
:power="pvExport"
:valuesInKw="valuesInKw"
:price="-tariffFeedIn"
:currency="currency"
:tooltip="detailsTooltip(-tariffFeedIn)"
/>
</div>
</div>
@ -143,9 +160,16 @@ export default {
loadpointsPower: { type: Number, default: 0 },
activeLoadpointsCount: { type: Number, default: 0 },
batteryConfigured: Boolean,
battery: { type: Array },
batteryPower: { type: Number, default: 0 },
batterySoc: { type: Number, default: 0 },
vehicleIcons: { type: Array },
tariffGrid: { type: Number },
tariffFeedIn: { type: Number },
tariffEffectivePrice: { type: Number },
tariffCo2: { type: Number },
tariffEffectiveCo2: { type: Number },
currency: { type: String },
},
data: () => {
return { detailsOpen: false, detailsCompleteHeight: null };
@ -187,6 +211,16 @@ export default {
detailsHeight: function () {
return this.detailsOpen ? this.detailsCompleteHeight + "px" : 0;
},
batteryTooltip() {
if (!Array.isArray(this.battery)) {
return;
}
return this.battery.map(({ soc, capacity }) => {
const energy = this.fmtKWh((capacity / 100) * soc * 1e3, true, false, 1);
const total = this.fmtKWh(capacity * 1e3, true, true, 1);
return this.$t("main.energyflow.batteryTooltip", { energy, total, soc });
});
},
},
mounted() {
window.addEventListener("resize", this.updateHeight);
@ -199,6 +233,16 @@ export default {
window.removeEventListener("resize", this.updateHeight);
},
methods: {
detailsTooltip(price, co2) {
const result = [];
if (co2 !== undefined) {
result.push(`${this.fmtCo2Long(co2)}`);
}
if (price !== undefined) {
result.push(`${this.fmtPricePerKWh(price, this.currency)}`);
}
return result;
},
kw: function (watt) {
return this.fmtKw(watt, this.valuesInKw);
},

View file

@ -5,10 +5,22 @@
<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
<component :is="`shopicon-regular-${icon}`" v-else></component>
</span>
<span class="text-nowrap flex-grow-1 ms-3">{{ name }}</span>
<span class="text-end text-nowrap ps-1 fw-bold"
><span v-if="hasSoc">{{ soc }}% / </span>
<AnimatedNumber :to="power" :format="kw" />
<span class="text-nowrap flex-grow-1 ms-3">
{{ name }}
</span>
<span class="text-end text-nowrap ps-1 fw-bold d-flex">
<div
ref="details"
class="evcc-gray fw-normal"
data-bs-toggle="tooltip"
title=" "
@click.stop=""
>
<span v-if="showPrice()"><AnimatedNumber :to="price" :format="fmtPrice" /></span>
<span v-if="showCo2()"><AnimatedNumber :to="co2" :format="fmtCo2Short" /></span>
<span v-if="hasSoc">{{ soc }}%</span>
</div>
<AnimatedNumber :to="power" :format="kw" class="power" />
</span>
</div>
</template>
@ -17,10 +29,12 @@
import "@h2d2/shopicons/es/regular/powersupply";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/home";
import Tooltip from "bootstrap/js/dist/tooltip";
import BatteryIcon from "./BatteryIcon.vue";
import formatter from "../../mixins/formatter";
import AnimatedNumber from "../AnimatedNumber.vue";
import VehicleIcon from "../VehicleIcon";
import { showGridPrice, showGridCo2 } from "../../gridDetails";
export default {
name: "EnergyflowEntry",
@ -31,8 +45,15 @@ export default {
icon: { type: String },
power: { type: Number },
soc: { type: Number },
price: { type: Number },
co2: { type: Number },
valuesInKw: { type: Boolean },
vehicleIcons: { type: Array },
currency: { type: String },
tooltip: { type: Array },
},
data() {
return { tooltipInstance: null };
},
computed: {
active: function () {
@ -48,10 +69,39 @@ export default {
return this.isBattery && !isNaN(this.soc);
},
},
watch: {
tooltip(newVal, oldVal) {
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
this.updateTooltip();
}
},
},
mounted: function () {
this.updateTooltip();
},
methods: {
showPrice() {
return showGridPrice() && !isNaN(this.price);
},
showCo2() {
return showGridCo2() && !isNaN(this.co2);
},
kw: function (watt) {
return this.fmtKw(watt, this.valuesInKw);
},
fmtPrice: function (price) {
return this.fmtPricePerKWh(price, this.currency, true);
},
updateTooltip: function () {
if (!Array.isArray(this.tooltip) || !this.tooltip.length) {
return;
}
if (!this.tooltipInstance) {
this.tooltipInstance = new Tooltip(this.$refs.details, { html: true });
}
const html = `<div class="text-end">${this.tooltip.join("<br/>")}</div>`;
this.tooltipInstance.setContent({ ".tooltip-inner": html });
},
},
};
</script>
@ -59,4 +109,7 @@ export default {
.entry {
transition: color var(--evcc-transition-medium) linear;
}
.power {
min-width: 75px;
}
</style>

View file

@ -63,6 +63,23 @@
"
/>
</FormRow>
<FormRow
id="settingsGridDetails"
:label="$t('settings.gridDetails.label')"
>
<SelectGroup
id="settingsGridDetails"
v-model="gridDetails"
class="w-100"
:options="
GRID_DETAILS.map((value) => ({
value,
name: $t(`settings.gridDetails.${value}`),
disabled: isDisabled(value),
}))
"
/>
</FormRow>
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
</FormRow>
@ -81,20 +98,25 @@ import SelectGroup from "./SelectGroup.vue";
import { getLocalePreference, setLocalePreference, LOCALES, removeLocalePreference } from "../i18n";
import { getThemePreference, setThemePreference, THEMES } from "../theme";
import { getUnits, setUnits, UNITS } from "../units";
import { getGridDetails, setGridDetails, GRID_DETAILS } from "../gridDetails";
export default {
name: "GlobalSettingsModal",
components: { TelemetrySettings, FormRow, SelectGroup },
props: {
sponsor: String,
hasPrice: Boolean,
hasCo2: Boolean,
},
data: function () {
return {
theme: getThemePreference(),
language: getLocalePreference() || "",
unit: getUnits(),
gridDetails: getGridDetails(),
THEMES,
UNITS,
GRID_DETAILS,
};
},
computed: {
@ -111,6 +133,9 @@ export default {
unit(value) {
setUnits(value);
},
gridDetails(value) {
setGridDetails(value);
},
theme(value) {
setThemePreference(value);
},
@ -123,6 +148,11 @@ export default {
}
},
},
methods: {
isDisabled(option) {
return (option === "co2" && !this.hasCo2) || (option === "price" && !this.hasPrice);
},
},
};
</script>
<style scoped>

View file

@ -7,6 +7,7 @@
type="button"
class="btn btn-sm flex-grow-1 flex-shrink-1"
:class="{ active: option.value === modelValue }"
:disabled="option.disabled"
@click="$emit('update:modelValue', option.value)"
>
{{ option.name }}

View file

@ -61,6 +61,7 @@ export default {
batteryConfigured: Boolean,
batteryPower: Number,
batterySoc: Number,
battery: Array,
gridCurrents: Array,
prioritySoc: Number,
siteTitle: String,
@ -68,7 +69,6 @@ export default {
auth: Object,
// footer
currency: String,
savingsAmount: Number,
savingsEffectivePrice: Number,
@ -77,8 +77,12 @@ export default {
savingsSelfConsumptionPercent: Number,
savingsSince: String,
savingsTotalCharged: Number,
greenShare: Number,
tariffFeedIn: Number,
tariffGrid: Number,
tariffEffectivePrice: Number,
tariffCo2: Number,
tariffEffectiveCo2: Number,
availableVersion: String,
releaseNotes: String,
@ -113,6 +117,12 @@ export default {
const vehicleLogins = this.auth ? this.auth.vehicles : {};
return { vehicleLogins, ...this.collectProps(TopNavigation) };
},
hasPrice: function () {
return !isNaN(this.tariffGrid);
},
hasCo2: function () {
return !isNaN(this.tariffCo2);
},
showParkingLot: function () {
// work in progess
return false;

View file

@ -94,9 +94,7 @@ export default {
if (price === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
}
return this.isCo2
? `${Math.round(price)} g/kWh`
: this.fmtPricePerKWh(price, this.unit);
return this.isCo2 ? this.fmtCo2Medium(price) : this.fmtPricePerKWh(price, this.unit);
},
activeSlot() {
return this.slots[this.activeIndex];

View file

@ -92,7 +92,7 @@
</a>
</li>
</ul>
<GlobalSettingsModal :sponsor="sponsor" />
<GlobalSettingsModal v-bind="globalSettingsModalProps" />
</div>
</template>
@ -104,12 +104,14 @@ import "@h2d2/shopicons/es/regular/moonstars";
import "@h2d2/shopicons/es/regular/menu";
import "@h2d2/shopicons/es/regular/newtab";
import GlobalSettingsModal from "./GlobalSettingsModal.vue";
import collector from "../mixins/collector";
import baseAPI from "../baseapi";
export default {
name: "TopNavigation",
components: { GlobalSettingsModal },
mixins: [collector],
props: {
vehicleLogins: {
type: Object,
@ -118,8 +120,13 @@ export default {
},
},
sponsor: String,
hasPrice: Boolean,
hasCo2: Boolean,
},
computed: {
globalSettingsModalProps: function () {
return this.collectProps(GlobalSettingsModal);
},
logoutCount() {
return this.providerLogins.filter((login) => !login.loggedIn).length;
},

22
assets/js/gridDetails.js Normal file
View file

@ -0,0 +1,22 @@
import settings from "./settings";
const NONE = "none";
const PRICE = "price";
const CO2 = "co2";
export const GRID_DETAILS = [NONE, PRICE, CO2];
export function getGridDetails() {
return GRID_DETAILS.includes(settings.gridDetails) ? settings.gridDetails : NONE;
}
export function showGridPrice() {
return settings.gridDetails === PRICE;
}
export function showGridCo2() {
return settings.gridDetails === CO2;
}
export function setGridDetails(value) {
settings.gridDetails = value;
}

View file

@ -33,7 +33,7 @@ export default {
}).format(value)}${unit}`;
},
fmtKWh: function (watt, kw, withUnit, digits) {
return this.fmtKw(watt, kw, withUnit, digits) + "h";
return this.fmtKw(watt, kw, withUnit, digits) + (withUnit ? "h" : "");
},
fmtNumber: function (number, decimals) {
return new Intl.NumberFormat(this.$i18n.locale, {
@ -42,6 +42,15 @@ export default {
maximumFractionDigits: decimals,
}).format(number);
},
fmtCo2Short: function (gramms) {
return `${this.fmtNumber(gramms, 0)} g`;
},
fmtCo2Medium: function (gramms) {
return `${this.fmtNumber(gramms, 0)} g/kWh`;
},
fmtCo2Long: function (gramms) {
return `${this.fmtNumber(gramms, 0)} gCO₂e/kWh`;
},
fmtUnit: function (val) {
return Math.abs(val) >= this.fmtLimit ? "k" : "";
},
@ -182,7 +191,7 @@ export default {
const symbols = { EUR: "€", USD: "$" };
return symbols[currency] || currency;
},
fmtPricePerKWh: function (amout = 0, currency = "EUR") {
fmtPricePerKWh: function (amout = 0, currency = "EUR", short = false) {
let unit = currency;
let value = amout;
let maximumFractionDigits = 3;
@ -194,7 +203,7 @@ export default {
return `${new Intl.NumberFormat(this.$i18n.locale, {
style: "decimal",
maximumFractionDigits,
}).format(value)} ${unit}/kWh`;
}).format(value)} ${unit}${short ? "" : "/kWh"}`;
},
fmtTimeAgo: function (elapsed) {
const units = {

View file

@ -4,6 +4,7 @@ const SETTINGS_LOCALE = "settings_locale";
const SETTINGS_THEME = "settings_theme";
const SETTINGS_UNIT = "settings_unit";
const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
const SETTINGS_GRID_DETAILS = "settings_grid_details";
function read(key) {
return window.localStorage[key];
@ -38,12 +39,14 @@ const settings = reactive({
locale: read(SETTINGS_LOCALE),
theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT),
gridDetails: read(SETTINGS_GRID_DETAILS),
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
});
watch(() => settings.locale, save(SETTINGS_LOCALE));
watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT));
watch(() => settings.gridDetails, save(SETTINGS_GRID_DETAILS));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
export default settings;

View file

@ -60,6 +60,7 @@ meters:
if (state.batterySoc < 10) state.batterySoc = 90;
if (state.batterySoc > 90) state.batterySoc = 10;
state.batterySoc;
capacity: 13.4
- name: meter_charger_1
type: custom

View file

@ -1,11 +1,9 @@
package core
import (
"math"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
)
@ -22,17 +20,16 @@ type publisher 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)
lastGridPrice, lastFeedInPrice float64 // Stores the last published grid price. Needed to detect price changes (Awattar, ..)
hasPublished bool // Has initial publish happened?
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 {
@ -97,80 +94,45 @@ func (s *Savings) SavingsAmount() float64 {
return s.gridSavedCost
}
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)
gridImport := math.Max(0, gridPower)
selfConsumption := math.Max(0, batteryDischarge+pvConsumption+batteryCharge)
share := selfConsumption / (gridImport + selfConsumption)
if math.IsNaN(share) {
return 0
}
return share
}
func currentPrice(t api.Tariff, dfltPrice float64) float64 {
if t != nil {
if rr, err := t.Rates(); err == nil {
if r, err := rr.Current(time.Now()); err == nil {
return r.Price
}
}
}
return dfltPrice
}
func (s *Savings) currentGridPrice() float64 {
return currentPrice(s.tariffs.Grid, DefaultGridPrice)
price, err := s.tariffs.CurrentGridPrice()
if err != nil {
price = DefaultGridPrice
}
return price
}
func (s *Savings) currentFeedInPrice() float64 {
return currentPrice(s.tariffs.FeedIn, DefaultFeedInPrice)
}
func (s *Savings) updatePrices(p publisher) (float64, float64) {
gridPrice := s.currentGridPrice()
if gridPrice != s.lastGridPrice {
s.lastGridPrice = gridPrice
p.publish("tariffGrid", gridPrice)
price, err := s.tariffs.CurrentFeedInPrice()
if err != nil {
price = DefaultFeedInPrice
}
feedinPrice := s.currentFeedInPrice()
if feedinPrice != s.lastFeedInPrice {
s.lastFeedInPrice = feedinPrice
p.publish("tariffFeedIn", feedinPrice)
}
return gridPrice, feedinPrice
return price
}
// Update savings calculation and return grid/green energy added since last update
func (s *Savings) Update(p publisher, gridPower, pvPower, batteryPower, chargePower float64) (float64, float64) {
gridPrice, feedinPrice := s.updatePrices(p)
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, 0
return 0
}
// assume charge power as constant over the duration -> rough kWh estimate
deltaCharged := s.clock.Since(s.updated).Hours() * chargePower / 1e3
share := s.shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower)
deltaSelf := deltaCharged * share
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.gridSavedCost += deltaSelf * (gridPrice - feedInPrice)
s.selfConsumptionCharged += deltaSelf
s.selfConsumptionCost += deltaSelf * feedinPrice
s.selfConsumptionCost += deltaSelf * feedInPrice
p.publish("savingsTotalCharged", s.TotalCharged())
p.publish("savingsGridCharged", s.gridCharged)
@ -178,9 +140,10 @@ func (s *Savings) Update(p publisher, gridPower, pvPower, batteryPower, chargePo
p.publish("savingsSelfConsumptionPercent", s.SelfConsumptionPercent())
p.publish("savingsEffectivePrice", s.EffectivePrice())
p.publish("savingsAmount", s.SavingsAmount())
s.hasPublished = true
s.save()
return deltaCharged, deltaSelf
return deltaCharged
}

View file

@ -39,55 +39,6 @@ type StubPublisher struct{}
func (p StubPublisher) publish(key string, val interface{}) {}
func TestSavingsWithChangingEnergySources(t *testing.T) {
p := StubPublisher{}
clck := clock.NewMock()
s := &Savings{
clock: clck,
started: clck.Now(),
updated: clck.Now(),
}
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},
}
s.Update(p, 0, 0, 0, 0)
for _, tc := range tc {
t.Logf("%+v", tc)
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) {
p := StubPublisher{}
@ -99,8 +50,8 @@ func TestSavingsWithDifferentTimespans(t *testing.T) {
}
type tcStep = struct {
dt time.Duration
grid, pv, battery, charge float64
dt time.Duration
greenShare, charge float64
}
tc := []struct {
@ -110,54 +61,54 @@ func TestSavingsWithDifferentTimespans(t *testing.T) {
}{
{"10 second not charging, full grid",
[]tcStep{
{10 * time.Second, 1000, 0, 0, 0},
{10 * time.Second, 0, 0},
},
0, 0, 0, // 0Wh
},
{"10 second 11kW charging, full grid",
[]tcStep{
{10 * time.Second, 0, 0, 0, 11000},
{10 * time.Second, 0, 11000},
},
0.030556, 0, 0, // 30,555Wh
},
{"10 second 11kW charging, full grid",
[]tcStep{
{10 * time.Second, 0, 0, 0, 11000},
{10 * time.Second, 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},
{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, 0, 0, 11000},
{30 * time.Minute, 0, 11000},
},
5.592, 0, 0, // 5561,111Wh
},
{"4 hours 11kW charging, full pv",
[]tcStep{
{4 * time.Hour, 0, 11000, 0, 11000},
{4 * time.Hour, 1, 11000},
},
49.592, 44, 88,
},
}
s.Update(p, 0, 0, 0, 0)
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.grid, tc.pv, tc.battery, tc.charge)
s.Update(p, tc.greenShare, tc.charge)
}
assertEnergy(t, s, tc.total, tc.self, tc.percentage)
@ -170,8 +121,8 @@ func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
clck := clock.NewMock()
type tcStep = struct {
dt time.Duration
grid, pv, battery, charge float64
dt time.Duration
greenShare, charge float64
}
tc := []struct {
@ -181,26 +132,26 @@ func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
}{
{"1 hour, 10kW, full grid",
[]tcStep{
{time.Hour, 10000, 0, 0, 10000},
{time.Hour, 0, 10000},
},
0.3, 0,
},
{"1 hour, 10kW, full pv",
[]tcStep{
{time.Hour, 0, 10000, 0, 10000},
{time.Hour, 1, 10000},
},
0.08, 2.2,
},
{"1 hour, 10kW, full battery",
[]tcStep{
{time.Hour, 0, 0, 10000, 10000},
{time.Hour, 1, 10000},
},
0.08, 2.2,
},
{"1 hour, 10kW, half grid, half pv",
[]tcStep{
{time.Hour, 5000, 0, 5000, 10000},
{time.Hour, 0.5, 10000},
},
0.19, 1.1,
},
@ -214,11 +165,11 @@ func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
started: clck.Now(),
updated: clck.Now(),
}
s.Update(p, 0, 0, 0, 0)
s.Update(p, 0, 0)
for _, tc := range tc.steps {
clck.Add(tc.dt)
s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
s.Update(p, tc.greenShare, tc.charge)
}
assertPrices(t, s, tc.effectivePrice, tc.savingsAmount)

View file

@ -75,6 +75,8 @@ type Site struct {
batteryPower float64 // Battery charge power
batterySoc float64 // Battery soc
batteryBuffered bool // Battery buffer active
publishCache map[string]any // store last published values to avoid unnecessary republishing
}
// MetersConfig contains the loadpoint's meter configuration
@ -207,8 +209,9 @@ func NewSiteFromConfig(
// NewSite creates a Site with sane defaults
func NewSite() *Site {
lp := &Site{
log: util.NewLogger("site"),
Voltage: 230, // V
log: util.NewLogger("site"),
publishCache: make(map[string]any),
Voltage: 230, // V
}
return lp
@ -331,6 +334,16 @@ func (site *Site) publish(key string, val interface{}) {
}
}
// publishDelta deduplicates messages before publishing
func (site *Site) publishDelta(key string, val interface{}) {
if v, ok := site.publishCache[key]; ok && v == val {
return
}
site.publishCache[key] = val
site.publish(key, val)
}
// updateMeter updates and publishes single meter
func (site *Site) updateMeter(meter api.Meter, power *float64) func() error {
return func() error {
@ -544,6 +557,65 @@ func (site *Site) sitePower(totalChargePower float64) (float64, error) {
return sitePower, nil
}
func (site *Site) greenShare() float64 {
batteryDischarge := math.Max(0, site.batteryPower)
batteryCharge := -math.Min(0, site.batteryPower)
pvConsumption := math.Min(site.pvPower, site.pvPower+site.gridPower-batteryCharge)
gridImport := math.Max(0, site.gridPower)
selfConsumption := math.Max(0, batteryDischarge+pvConsumption+batteryCharge)
share := selfConsumption / (gridImport + selfConsumption)
if math.IsNaN(share) {
return 0
}
return share
}
// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
func (s *Site) effectivePrice(greenShare float64) (float64, error) {
if grid, err := s.tariffs.CurrentGridPrice(); err == nil {
feedin, err := s.tariffs.CurrentFeedInPrice()
if err != nil {
feedin = 0
}
return grid*(1-greenShare) + feedin*greenShare, nil
}
return 0, api.ErrNotAvailable
}
// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
func (s *Site) effectiveCo2(greenShare float64) (float64, error) {
if co2, err := s.tariffs.CurrentCo2(); err == nil {
return co2 * (1 - greenShare), nil
}
return 0, api.ErrNotAvailable
}
func (s *Site) publishTariffs() {
greenShare := s.greenShare()
s.publish("greenShare", greenShare)
if gridPrice, err := s.tariffs.CurrentGridPrice(); err == nil {
s.publishDelta("tariffGrid", gridPrice)
}
if feedInPrice, err := s.tariffs.CurrentFeedInPrice(); err == nil {
s.publishDelta("tariffFeedIn", feedInPrice)
}
if co2, err := s.tariffs.CurrentCo2(); err == nil {
s.publishDelta("tariffCo2", co2)
}
if price, err := s.effectivePrice(greenShare); err == nil {
s.publish("tariffEffectivePrice", price)
}
if co2, err := s.effectiveCo2(greenShare); err == nil {
s.publish("tariffEffectiveCo2", co2)
}
}
func (site *Site) update(lp Updater) {
site.log.DEBUG.Println("----")
@ -565,11 +637,13 @@ func (site *Site) update(lp Updater) {
site.Health.Update()
}
// update savings and aggregate telemetry
site.publishTariffs()
greenShare := site.greenShare()
// TODO: use energy instead of current power for better results
deltaCharged, deltaSelf := site.savings.Update(site, site.gridPower, site.pvPower, site.batteryPower, totalChargePower)
deltaCharged := site.savings.Update(site, greenShare, totalChargePower)
if telemetry.Enabled() && totalChargePower > standbyPower {
go telemetry.UpdateChargeProgress(site.log, totalChargePower, deltaCharged, deltaSelf)
go telemetry.UpdateChargeProgress(site.log, totalChargePower, deltaCharged, greenShare)
}
}

View file

@ -30,3 +30,48 @@ func TestSitePower(t *testing.T) {
}
}
}
func TestGreenShare(t *testing.T) {
tc := []struct {
title string
grid, pv, battery float64
share float64
}{
{"half grid, half pv",
2500, 2500, 0,
0.5},
{"full pv",
0, 5000, 0,
1},
{"full grid",
5000, 0, 0,
0},
{"half grid, half battery",
2500, 0, 2500,
0.5},
{"full pv, pv export",
-5000, 10000, 0,
1},
{"full pv, pv export, battery charge",
-2500, 10000, -2500,
1},
{"double charge speed, full grid",
10000, 0, 0,
0},
}
for _, tc := range tc {
t.Logf("%+v", tc)
s := &Site{
gridPower: tc.grid,
pvPower: tc.pv,
batteryPower: tc.battery,
}
share := s.greenShare()
if share != tc.share {
t.Errorf("greenShare wanted %.f, got %.f", tc.share, share)
}
}
}

View file

@ -6,4 +6,16 @@ export default defineConfig({
setupFile: "./histoire.setup.js",
viteNodeInlineDeps: [/!axios/],
routerMode: "hash",
backgroundPresets: [
{
label: "default",
color: "var(--evcc-background)",
contrastColor: "var(--evcc-default-text)",
},
{
label: "box",
color: "var(--evcc-box)",
contrastColor: "var(--evcc-default-text)",
},
],
});

View file

@ -229,6 +229,12 @@ vehicle = "Fahrzeug"
[settings]
title = "Einstellungen"
[settings.gridDetails]
co2 = "CO₂"
label = "Netztarifdetails"
none = "keine"
price = "Preis"
[settings.language]
auto = "Automatisch"
label = "Sprache"

View file

@ -229,6 +229,12 @@ vehicle = "Vehicle"
[settings]
title = "Settings"
[settings.gridDetails]
co2 = "CO₂"
label = "Grid details"
none = "none"
price = "price"
[settings.language]
auto = "Automatic"
label = "Language"

View file

@ -104,7 +104,7 @@ func (t *ElectricityMaps) run(done chan error) {
// Unit implements the api.Tariff interface
func (t *ElectricityMaps) Unit() string {
return "gCO2eq"
return Co2Equivalent
}
func (t *ElectricityMaps) Rates() (api.Rates, error) {

View file

@ -126,7 +126,7 @@ func (t *GrünStromIndex) run(done chan error) {
// Unit implements the api.Tariff interface
func (t *GrünStromIndex) Unit() string {
return "gCO2eq"
return Co2Equivalent
}
// Rates implements the api.Tariff interface

View file

@ -7,6 +7,9 @@ import (
"golang.org/x/text/currency"
)
// Co2Equivalent is the unit for co2 emissions
const Co2Equivalent = "gCO2eq"
type Tariffs struct {
Currency currency.Unit
Grid, FeedIn, Planner api.Tariff
@ -25,6 +28,35 @@ func NewTariffs(currency currency.Unit, grid, feedin, planner api.Tariff) *Tarif
}
}
func currentPrice(t api.Tariff) (float64, error) {
if t != nil {
if rr, err := t.Rates(); err == nil {
if r, err := rr.Current(time.Now()); err == nil {
return r.Price, nil
}
}
}
return 0, api.ErrNotAvailable
}
// CurrentGridPrice returns the current grid price.
func (t *Tariffs) CurrentGridPrice() (float64, error) {
return currentPrice(t.Grid)
}
// CurrentFeedInPrice returns the current feed-in price.
func (t *Tariffs) CurrentFeedInPrice() (float64, error) {
return currentPrice(t.FeedIn)
}
// CurrentCo2 determines the grids co2 emission.
func (t *Tariffs) CurrentCo2() (float64, error) {
if t.Planner != nil && t.Planner.Unit() == Co2Equivalent {
return currentPrice(t.Planner)
}
return 0, api.ErrNotAvailable
}
// outdatedError returns api.ErrOutdated if t is older than 2*d
func outdatedError(t time.Time, d time.Duration) error {
if time.Since(t) > 2*d {

View file

@ -58,19 +58,19 @@ func Create(machineID string) {
// 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, deltaGreen float64) {
func UpdateChargeProgress(log *util.Logger, power, deltaCharged, greenShare float64) {
mu.Lock()
defer mu.Unlock()
// cache
accChargeEnergy += deltaCharged
accGreenEnergy += deltaGreen
accGreenEnergy += deltaCharged * greenShare
if time.Since(updated) < 30*time.Second {
return
}
if err := upload(log, power, power*deltaGreen/deltaCharged); err != nil {
if err := upload(log, power, power*greenShare); err != nil {
log.ERROR.Printf("telemetry: upload failed: %v", err)
}
}