Add session infos (co2, price, solar %) (#7380)

This commit is contained in:
Michael Geers 2023-05-02 08:59:11 +02:00 • committed by GitHub
parent 6773536dcc
commit 9d14e3d446
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 706 additions and 218 deletions

View file

@ -31,7 +31,7 @@ export default {
startVal: this.to,
formattingFn: this.format,
duration: this.duration,
decimalPlaces: 2,
decimalPlaces: 3,
});
if (this.instance.error) {
console.error(this.instance.error);

View file

@ -53,11 +53,13 @@
</td>
</tr>
<tr>
<th>
{{ $t("session.odometer") }}
<th class="align-baseline">
{{ $t("session.date") }}
</th>
<td>
{{ formatKm(session.odometer) }}
{{ fmtFullDateTime(new Date(session.created), false) }}
<br />
{{ fmtFullDateTime(new Date(session.finished), false) }}
</td>
</tr>
<tr>
@ -68,38 +70,53 @@
{{ fmtKWh(session.chargedEnergy * 1e3) }}
</td>
</tr>
<tr>
<th>
{{ $t("session.meterstart") }}
<tr v-if="session.solarPercentage != null">
<th class="align-baseline">
{{ $t("sessions.solar") }}
</th>
<td>
{{ fmtKWh(session.meterStart * 1e3) }}
{{ fmtNumber(session.solarPercentage, 1) }} % ({{
fmtKWh(
session.chargedEnergy * 10 * session.solarPercentage
)
}})
</td>
</tr>
<tr>
<th>
{{ $t("session.meterstop") }}
<tr v-if="session.price != null">
<th class="align-baseline">
{{ $t("session.price") }}
</th>
<td>
{{ fmtMoney(session.price, currency) }}
{{ fmtCurrencySymbol(currency) }}<br />
{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
</td>
</tr>
<tr v-if="session.co2PerKWh != null">
<th>
{{ $t("session.co2") }}
</th>
<td>
{{ fmtCo2Medium(session.co2PerKWh) }}
</td>
</tr>
<tr v-if="session.odometer">
<th>
{{ $t("session.odometer") }}
</th>
<td>
{{ formatKm(session.odometer) }}
</td>
</tr>
<tr v-if="session.meterStart">
<th class="align-baseline">
{{ $t("session.meter") }}
</th>
<td>
{{ fmtKWh(session.meterStart * 1e3) }}<br />
{{ fmtKWh(session.meterStop * 1e3) }}
</td>
</tr>
<tr>
<th>
{{ $t("session.started") }}
</th>
<td>
{{ fmtFullDateTime(new Date(session.created), false) }}
</td>
</tr>
<tr>
<th>
{{ $t("session.finished") }}
</th>
<td>
{{ fmtFullDateTime(new Date(session.finished), false) }}
</td>
</tr>
</tbody>
</table>
</div>

View file

@ -211,7 +211,7 @@ export default {
return null;
},
activeSlotCost() {
const price = this.activeSlot.price;
const price = this.activeSlot?.price;
if (price === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
}
@ -232,6 +232,7 @@ export default {
},
},
mounted() {
this.selectedSmartCostLimit = this.smartCostLimit;
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
},

View file

@ -57,7 +57,7 @@
:label="$t('main.loadpoint.power')"
:value="chargePower"
:valueFmt="fmtPower"
class="mb-2"
class="mb-2 text-nowrap"
align="start"
/>
<shopicon-regular-lightning
@ -78,22 +78,72 @@
:value="fmtEnergy(chargedEnergy)"
align="center"
/>
<LabelAndValue
v-if="chargeRemainingDurationInterpolated"
:label="$t('main.loadpoint.remaining')"
:value="`
${fmtShortDuration(chargeRemainingDurationInterpolated)}
${fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)}`"
align="end"
/>
<LabelAndValue
v-else
:label="$t('main.loadpoint.duration')"
:value="`
${fmtShortDuration(chargeDurationInterpolated)}
${fmtShortDurationUnit(chargeDurationInterpolated)}`"
align="end"
/>
<div class="sessionInfo" @click="nextSessionInfo">
<LabelAndValue
v-if="sessionInfo === SESSION.SOLAR"
:label="$t('main.loadpoint.solar')"
:value="sessionSolarPercentage"
:valueFmt="fmtSolar"
align="end"
/>
<div v-else-if="sessionInfo === SESSION.AVG_PRICE">
<LabelAndValue
class="d-block d-sm-none"
:label="$t('main.loadpoint.avgPrice')"
:value="sessionPricePerKWh"
:valueFmt="fmtAvgPriceShort"
align="end"
/>
<LabelAndValue
class="d-none d-sm-block"
:label="$t('main.loadpoint.avgPrice')"
:value="sessionPricePerKWh"
:valueFmt="fmtAvgPrice"
align="end"
/>
</div>
<LabelAndValue
v-else-if="sessionInfo === SESSION.PRICE"
:label="$t('main.loadpoint.price')"
:value="sessionPrice"
:valueFmt="fmtPrice"
align="end"
/>
<div v-else-if="sessionInfo === SESSION.CO2">
<LabelAndValue
class="d-block d-sm-none"
:label="$t('main.loadpoint.co2')"
:value="sessionCo2PerKWh"
:valueFmt="fmtCo2Short"
align="end"
/>
<LabelAndValue
class="d-none d-sm-block"
:label="$t('main.loadpoint.co2')"
:value="sessionCo2PerKWh"
:valueFmt="fmtCo2Medium"
align="end"
/>
</div>
<LabelAndValue
v-else-if="chargeRemainingDurationInterpolated"
:label="$t('main.loadpoint.remaining')"
:value="`
${fmtShortDuration(chargeRemainingDurationInterpolated)}
${fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)}
`"
align="end"
/>
<LabelAndValue
v-else
:label="$t('main.loadpoint.duration')"
:value="`
${fmtShortDuration(chargeDurationInterpolated)}
${fmtShortDurationUnit(chargeDurationInterpolated)}
`"
align="end"
/>
</div>
</div>
<hr class="divider" />
<Vehicle
@ -123,6 +173,7 @@ import collector from "../mixins/collector";
import LoadpointSettingsButton from "./LoadpointSettingsButton.vue";
import LoadpointSettingsModal from "./LoadpointSettingsModal.vue";
import VehicleIcon from "./VehicleIcon";
import { getSessionInfo, setSessionInfo, SESSION } from "../sessionInfo";
export default {
name: "Loadpoint",
@ -150,6 +201,13 @@ export default {
chargeDuration: Number,
charging: Boolean,
// session
sessionEnergy: Number,
sessionCo2PerKWh: Number,
sessionPricePerKWh: Number,
sessionPrice: Number,
sessionSolarPercentage: Number,
// charger
chargerFeatureIntegratedDevice: Boolean,
chargerIcon: String,
@ -202,6 +260,7 @@ export default {
smartCostUnit: String,
tariffGrid: Number,
tariffCo2: Number,
currency: String,
},
data() {
return {
@ -211,6 +270,8 @@ export default {
guardRemainingInterpolated: this.guardRemaining,
chargeDurationInterpolated: this.chargeDuration,
chargeRemainingDurationInterpolated: this.chargeRemainingDuration,
sessionInfo: getSessionInfo(this.id),
SESSION,
};
},
computed: {
@ -235,6 +296,17 @@ export default {
socBasedCharging: function () {
return !this.vehicleFeatureOffline && this.vehiclePresent;
},
sessionOptions: function () {
const result = [SESSION.TIME, SESSION.SOLAR];
if (this.currency) {
result.push(SESSION.PRICE);
result.push(SESSION.AVG_PRICE);
}
if (this.tariffCo2 > 0 || this.sessionCo2PerKWh > 0) {
result.push(SESSION.CO2);
}
return result;
},
},
watch: {
phaseRemaining() {
@ -321,6 +393,25 @@ export default {
const inKw = value == 0 || value >= 1000;
return this.fmtKWh(value, inKw);
},
fmtPrice(value) {
return `${this.fmtMoney(value, this.currency)} ${this.fmtCurrencySymbol(
this.currency
)}`;
},
fmtAvgPrice(value) {
return this.fmtPricePerKWh(value, this.currency, false);
},
fmtAvgPriceShort(value) {
return this.fmtPricePerKWh(value, this.currency, true);
},
fmtSolar(value) {
return `${this.fmtNumber(value, 1)}%`;
},
nextSessionInfo() {
const index = this.sessionOptions.indexOf(this.sessionInfo);
this.sessionInfo = this.sessionOptions[index + 1] || this.sessionOptions[0];
setSessionInfo(this.id, this.sessionInfo);
},
},
};
</script>
@ -360,4 +451,8 @@ export default {
margin: 0 -1.5rem;
}
}
.sessionInfo {
cursor: pointer;
user-select: none;
}
</style>

View file

@ -16,6 +16,7 @@
:smartCostUnit="smartCostUnit"
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
:currency="currency"
class="h-100"
:class="{ 'loadpoint-unselected': !selected(index) }"
@click="scrollTo(index)"
@ -51,6 +52,7 @@ export default {
smartCostUnit: String,
tariffGrid: Number,
tariffCo2: Number,
currency: String,
},
data() {
return { selectedIndex: 0, snapTimeout: null };

View file

@ -21,6 +21,7 @@
:smartCostUnit="smartCostUnit"
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
:currency="currency"
/>
<VehcileSettingsModal />
<Footer v-bind="footer"></Footer>

View file

@ -194,14 +194,17 @@ export default {
fmtPricePerKWh: function (amout = 0, currency = "EUR", short = false) {
let unit = currency;
let value = amout;
let minimumFractionDigits = "auto";
let maximumFractionDigits = 3;
if (["EUR", "USD"].includes(currency)) {
value *= 100;
unit = "ct";
minimumFractionDigits = 1;
maximumFractionDigits = 1;
}
return `${new Intl.NumberFormat(this.$i18n.locale, {
style: "decimal",
minimumFractionDigits,
maximumFractionDigits,
}).format(value)} ${unit}${short ? "" : "/kWh"}`;
},

25
assets/js/sessionInfo.js Normal file
View file

@ -0,0 +1,25 @@
import settings from "./settings";
const TIME = "time";
const CO2 = "co2";
const PRICE = "price";
const AVG_PRICE = "avgPrice";
const SOLAR = "solar";
export const SESSION = {
TIME,
AVG_PRICE,
PRICE,
SOLAR,
CO2,
};
export function getSessionInfo(index) {
return settings.sessionInfo[index - 1] || TIME;
}
export function setSessionInfo(index, value) {
const clone = [...settings.sessionInfo];
clone[index - 1] = value;
clone.map((v) => v || "");
settings.sessionInfo = clone;
}

View file

@ -5,6 +5,7 @@ const SETTINGS_THEME = "settings_theme";
const SETTINGS_UNIT = "settings_unit";
const SETTINGS_HIDDEN_FEATURES = "settings_hidden_features";
const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
const SESSION_INFO = "session_info";
function read(key) {
return window.localStorage[key];
@ -34,6 +35,16 @@ function saveBool(key) {
};
}
function readArray(key) {
return (read(key) || "").split(",");
}
function saveArray(key) {
return (value) => {
save(key)(value.join(","));
};
}
const settings = reactive({
telemetry: null,
locale: read(SETTINGS_LOCALE),
@ -41,6 +52,7 @@ const settings = reactive({
unit: read(SETTINGS_UNIT),
hiddenFeatures: readBool(SETTINGS_HIDDEN_FEATURES),
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
sessionInfo: readArray(SESSION_INFO),
});
watch(() => settings.locale, save(SETTINGS_LOCALE));
@ -48,5 +60,6 @@ watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT));
watch(() => settings.hiddenFeatures, saveBool(SETTINGS_HIDDEN_FEATURES));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.sessionInfo, saveArray(SESSION_INFO));
export default settings;

View file

@ -54,15 +54,25 @@
</li>
</ul>
<div class="table-responsive my-3">
<table class="table">
<table class="table text-nowrap">
<thead>
<tr>
<th scope="col" class="ps-0">{{ $t("sessions.date") }}</th>
<th scope="col">{{ $t("sessions.vehicle") }}</th>
<th scope="col" class="text-end ps-sm-4 pe-md-5">
<th scope="col" class="text-end">
{{ $t("sessions.energy") }}
</th>
<th scope="col" class="ps-3 ps-md-4 ps-md-5">
{{ $t("sessions.date") }}
<th scope="col" class="text-end">
{{ $t("sessions.solar") }}
</th>
<th scope="col" class="text-end">
{{ $t("sessions.price") }}
</th>
<th scope="col" class="text-end">
{{ $t("sessions.avgPrice") }}
</th>
<th scope="col" class="text-end pe-0">
{{ $t("sessions.co2") }}
</th>
</tr>
</thead>
@ -73,42 +83,39 @@
role="button"
@click="showDetails(session.id)"
>
<td class="align-middle">
<td class="ps-0">
{{ fmtFullDateTime(new Date(session.finished), true) }}
</td>
<td>
{{ session.vehicle }}
</td>
<td
class="text-nowrap text-end ps-sm-4 pe-md-5 align-middle"
>
<td class="text-end">
{{ fmtKWh(session.chargedEnergy * 1e3) }}
</td>
<td class="text-nowrap ps-3 ps-md-4 ps-md-5">
<span class="d-block d-sm-none">
{{
fmtFullDateTime(new Date(session.created), true)
}}
<br />
{{
fmtFullDateTime(
new Date(session.finished),
true
)
}}
<td class="text-end">
<span v-if="session.solarPercentage != null">
{{ fmtNumber(session.solarPercentage, 1) }}%
</span>
<span class="d-none d-sm-block">
{{
fmtFullDateTime(
new Date(session.created),
false
)
}}
<br />
{{
fmtFullDateTime(
new Date(session.finished),
false
)
}}
<span v-else class="text-muted">-</span>
</td>
<td class="text-end">
<span v-if="session.price != null">
{{ fmtMoney(session.price, currency) }}
{{ fmtCurrencySymbol(currency) }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td class="text-end">
<span v-if="session.pricePerKWh != null">
{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td class="text-end pe-0">
<span v-if="session.co2PerKWh != null">
{{ fmtCo2Medium(session.co2PerKWh) }}
</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
</tbody>
@ -166,13 +173,18 @@ export default {
});
},
vehicles() {
return store.state.vehicles.map((v, index) => {
return { id: index, title: v };
});
return (
store.state.vehicles?.map((v, index) => {
return { id: index, title: v };
}) || []
);
},
selectedSession() {
return this.sessions.find((s) => s.id == this.selectedSessionId);
},
currency() {
return store.state.currency;
},
},
mounted() {
this.loadSessions();

View file

@ -43,8 +43,11 @@ func New(name string) (*DB, error) {
// Session creates a charging session
func (s *DB) Session(meter float64) *Session {
t := Session{
Loadpoint: s.name,
MeterStart: meter,
Loadpoint: s.name,
}
if meter > 0 {
t.MeterStart = &meter
}
return &t

View file

@ -5,6 +5,7 @@ import (
"encoding/csv"
"fmt"
"io"
"reflect"
"strings"
"time"
@ -19,16 +20,20 @@ import (
// Session is a single charging session
type Session struct {
ID uint `json:"id" csv:"-" gorm:"primarykey"`
Created time.Time `json:"created"`
Finished time.Time `json:"finished"`
Loadpoint string `json:"loadpoint"`
Identifier string `json:"identifier"`
Vehicle string `json:"vehicle"`
Odometer float64 `json:"odometer" format:"int"`
MeterStart float64 `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"`
MeterStop float64 `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"`
ChargedEnergy float64 `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"`
ID uint `json:"id" csv:"-" gorm:"primarykey"`
Created time.Time `json:"created"`
Finished time.Time `json:"finished"`
Loadpoint string `json:"loadpoint"`
Identifier string `json:"identifier"`
Vehicle string `json:"vehicle"`
Odometer *float64 `json:"odometer" format:"int"`
MeterStart *float64 `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"`
MeterStop *float64 `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"`
ChargedEnergy float64 `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"`
SolarPercentage *float64 `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"`
Price *float64 `json:"price" csv:"Price" gorm:"column:price"`
PricePerKWh *float64 `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"`
Co2PerKWh *float64 `json:"co2PerKWh" csv:"CO2/kWh (gCO2eq)" gorm:"column:co2_per_kwh"`
}
// Sessions is a list of sessions
@ -75,21 +80,26 @@ func (t *Sessions) writeRow(ww *csv.Writer, mp *message.Printer, r Session) erro
}
var val string
format := f.Tag("format")
digits := 3
if format := f.Tag("format"); format == "int" {
digits = 0
}
switch v := f.Value().(type) {
case float64:
switch format {
case "int":
val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(0)))
default:
val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(3)))
}
val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(digits)))
case time.Time:
if !v.IsZero() {
val = v.Local().Format("2006-01-02 15:04:05")
}
default:
if rv := reflect.ValueOf(v); rv.Kind() == reflect.Ptr {
if pv := reflect.Indirect(rv); pv.CanFloat() && !rv.IsNil() {
val = mp.Sprint(number.Decimal(pv.Float(), number.NoSeparator(), number.MaxFractionDigits(digits)))
}
break
}
val = fmt.Sprintf("%v", f.Value())
}

110
core/energy_metrics.go Normal file
View file

@ -0,0 +1,110 @@
package core
// EnergyMetrics calculates stats about the charged energy and gives you details about price or co2s
type EnergyMetrics struct {
totalKWh float64 // Total amount of energy used (kWh)
solarKWh float64 // Self-produced energy energy (kWh)
price *float64 // Total cost (Currency)
co2 *float64 // Amount of emitted CO2 (gCO2eq)
currentGreenShare float64 // Current share of solar energy of site (0-1)
currentPrice *float64 // Current price per kWh
currentCo2 *float64 // Current co2 emissions
}
func NewEnergyMetrics() *EnergyMetrics {
em := &EnergyMetrics{}
em.Reset()
return em
}
// SetEnvironment updates site information like solar share, price, co2 for use in later calculations
func (em *EnergyMetrics) SetEnvironment(greenShare float64, effPrice, effCo2 *float64) {
em.currentGreenShare = greenShare
em.currentPrice = effPrice
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) {
added := chargedKWh - em.totalKWh
// nothing changed or invalid lower value
if added <= 0 {
return
}
em.totalKWh = chargedKWh
em.solarKWh += added * em.currentGreenShare
// optional values
if em.currentPrice != nil {
addedPrice := *em.currentPrice * added
newPrice := addedPrice
if em.price != nil {
newPrice = *em.price + newPrice
}
em.price = &newPrice
}
if em.currentCo2 != nil {
addedCo2 := *em.currentCo2 * added
newCo2 := addedCo2
if em.co2 != nil {
newCo2 = *em.co2 + newCo2
}
em.co2 = &newCo2
}
}
// Reset sets all calculations to initial values
func (em *EnergyMetrics) Reset() {
em.totalKWh = 0
em.solarKWh = 0
em.price = nil
em.co2 = nil
}
// TotalWh returns the total energy in Wh
func (em *EnergyMetrics) TotalWh() float64 {
return em.totalKWh * 1e3
}
// SolarPercentage returns the share of self-produced energy in percent
func (em *EnergyMetrics) SolarPercentage() float64 {
if em.totalKWh == 0 {
return 0
}
return 100 / em.totalKWh * em.solarKWh
}
// Price returns the total energy price in Currency
func (em *EnergyMetrics) Price() *float64 {
if em.totalKWh == 0 || em.price == nil {
return nil
}
return em.price
}
// PricePerKWh returns the average energy price in Currency
func (em *EnergyMetrics) PricePerKWh() *float64 {
if em.totalKWh == 0 || em.price == nil {
return nil
}
price := *em.price / em.totalKWh
return &price
}
// Co2PerKWh returns the average co2 emissions per kWh
func (em *EnergyMetrics) Co2PerKWh() *float64 {
if em.totalKWh == 0 || em.co2 == nil {
return nil
}
co2 := *em.co2 / em.totalKWh
return &co2
}
// Publish publishes metrics with a given prefix
func (em *EnergyMetrics) Publish(prefix string, p publisher) {
p.publish(prefix+"Energy", em.TotalWh())
p.publish(prefix+"SolarPercentage", em.SolarPercentage())
p.publish(prefix+"PricePerKWh", em.PricePerKWh())
p.publish(prefix+"Price", em.Price())
p.publish(prefix+"Co2PerKWh", em.Co2PerKWh())
}

146
core/energy_metrics_test.go Normal file
View file

@ -0,0 +1,146 @@
package core
import (
"testing"
)
func isEqualFloat64(a, b *float64) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func TestEnergyMetrics(t *testing.T) {
f := func(f float64) *float64 { return &f }
type tcStep = struct {
kWh, greenShare float64
effPrice, effCo2 *float64
}
tc := []struct {
title string
steps []tcStep
totalWh, solarPercentage float64
price, pricePerKWh, co2PerKWh *float64
}{
{"initial state",
[]tcStep{},
0, 0, nil, nil, nil,
},
{"energy value",
[]tcStep{
{0.1, 0, nil, nil},
{0.2, 0, nil, nil},
},
200, 0, nil, nil, nil,
},
{"ignore lower energy value",
[]tcStep{
{0.2, 0, nil, nil},
{0.1, 0, nil, nil},
},
200, 0, nil, nil, nil,
},
{"half solar",
[]tcStep{
{0.1, 1, nil, nil},
{0.2, 0, nil, nil},
},
200, 50, nil, nil, nil,
},
{"only solar",
[]tcStep{
{0.1, 1, nil, nil},
{0.2, 1, nil, nil},
},
200, 100, nil, nil, nil,
},
{"static price",
[]tcStep{
{1, 0, f(0.5), nil},
},
1000, 0, f(0.5), f(0.5), nil,
},
{"dynamic price",
[]tcStep{
{1, 0, f(1), nil},
{2, 0, f(0), nil},
},
2000, 0, f(1), f(0.5), nil,
},
{"dynamic price",
[]tcStep{
{2, 0, f(1), nil},
{4, 0, f(0), nil},
},
4000, 0, f(2), f(0.5), nil,
},
{"static co2",
[]tcStep{
{1, 0, nil, f(500)},
},
1000, 0, nil, nil, f(500),
},
{"dynamic co2",
[]tcStep{
{1, 0, nil, f(1000)},
{2, 0, nil, f(0)},
},
2000, 0, nil, nil, f(500),
},
{"grid only, half, full solar, half, grid only",
[]tcStep{
{1, 0, f(2), f(200)},
{2, 0.5, f(1), f(50)},
{3, 1, f(0), f(0)},
{4, 0.5, f(1), f(50)},
{5, 0, f(2), f(200)},
},
5000, 40, f(6), f(1.2), f(100),
},
}
for _, tc := range tc {
//t.Logf("%+v", tc)
s := NewEnergyMetrics()
for _, tc := range tc.steps {
s.SetEnvironment(tc.greenShare, tc.effPrice, tc.effCo2)
s.Update(tc.kWh)
}
if s.TotalWh() != tc.totalWh {
t.Errorf("%s: TotalWh was incorrect, got: %.3f, want: %.3f.", tc.title, s.TotalWh(), tc.totalWh)
}
if s.SolarPercentage() != tc.solarPercentage {
t.Errorf("%s: SolarPercentage was incorrect, got: %.3f, want: %.3f.", tc.title, s.SolarPercentage(), tc.solarPercentage)
}
price := s.Price()
if !isEqualFloat64(price, tc.price) {
t.Errorf("%s: Price was incorrect, got: %v, want: %v.", tc.title, *price, *tc.price)
}
pricePerKWh := s.PricePerKWh()
if !isEqualFloat64(pricePerKWh, tc.pricePerKWh) {
t.Errorf("%s: PricePerKWh was incorrect, got: %v, want: %v.", tc.title, *pricePerKWh, *tc.pricePerKWh)
}
co2PerKWh := s.Co2PerKWh()
if !isEqualFloat64(co2PerKWh, tc.co2PerKWh) {
t.Errorf("%s: Co2PerKWh was incorrect, got: %v, want: %v.", tc.title, *co2PerKWh, *tc.co2PerKWh)
}
}
// reset
s := NewEnergyMetrics()
s.SetEnvironment(1, f(1), f(1))
s.Update(1)
s.Reset()
if s.TotalWh() != 0 || s.SolarPercentage() != 0 || s.Co2PerKWh() != nil || s.Price() != nil || s.PricePerKWh() != nil {
t.Errorf("Metrics not properly reset %+v", s)
}
}

View file

@ -161,12 +161,12 @@ type Loadpoint struct {
wakeUpTimer *Timer // Vehicle wake-up timeout
// charge progress
vehicleSoc float64 // Vehicle Soc
chargeDuration time.Duration // Charge duration
chargedEnergy float64 // Charged energy while connected in Wh
chargeRemainingDuration time.Duration // Remaining charge duration
chargeRemainingEnergy float64 // Remaining charge energy in Wh
progress *Progress // Step-wise progress indicator
vehicleSoc float64 // Vehicle Soc
chargeDuration time.Duration // Charge duration
sessionEnergy *EnergyMetrics // Stats for charged energy by session
chargeRemainingDuration time.Duration // Remaining charge duration
chargeRemainingEnergy float64 // Remaining charge energy in Wh
progress *Progress // Step-wise progress indicator
// session log
db db.Database
@ -289,6 +289,7 @@ func NewLoadpoint(log *util.Logger) *Loadpoint {
Enable: ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
Disable: ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
GuardDuration: 5 * time.Minute,
sessionEnergy: NewEnergyMetrics(),
progress: NewProgress(0, 10), // soc progress indicator
coordinator: coordinator.NewDummy(), // dummy vehicle coordinator
tasks: util.NewQueue[Task](), // task queue
@ -432,7 +433,8 @@ func (lp *Loadpoint) evVehicleConnectHandler() {
lp.log.INFO.Printf("car connected")
// energy
lp.setChargedEnergy(0)
lp.sessionEnergy.Reset()
lp.sessionEnergy.Publish("session", lp)
lp.publish("chargedEnergy", lp.getChargedEnergy())
// duration
@ -470,6 +472,7 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
lp.resetMeasuredPhases()
// energy and duration
lp.sessionEnergy.Publish("session", lp)
lp.publish("chargedEnergy", lp.getChargedEnergy())
lp.publish("connectedDuration", lp.clock.Since(lp.connectedTime))
@ -1279,7 +1282,7 @@ 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.setChargedEnergy(1e3 * (f - lp.chargedAtStartup)) // convert to Wh
lp.sessionEnergy.Update(f - lp.chargedAtStartup)
}
} else {
lp.log.ERROR.Printf("charge rater: %v", err)
@ -1291,6 +1294,8 @@ func (lp *Loadpoint) publishChargeProgress() {
lp.log.ERROR.Printf("charge timer: %v", err)
}
lp.sessionEnergy.Publish("session", lp)
// deprecated: use sessionEnergy instead
lp.publish("chargedEnergy", lp.getChargedEnergy())
lp.publish("chargeDuration", lp.chargeDuration)
if _, ok := lp.chargeMeter.(api.MeterEnergy); ok {
@ -1422,7 +1427,7 @@ func (lp *Loadpoint) guardGracePeriodElapsed() bool {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool) {
func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool, greenShare float64, effPrice, effCo2 *float64) {
lp.processTasks()
mode := lp.GetMode()
@ -1432,6 +1437,8 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool)
lp.updateChargeVoltages()
lp.updateChargeCurrents()
lp.sessionEnergy.SetEnvironment(greenShare, effPrice, effCo2)
// update ChargeRater here to make sure initial meter update is caught
lp.bus.Publish(evChargeCurrent, lp.chargeCurrent)
lp.bus.Publish(evChargePower, lp.chargePower)

View file

@ -68,18 +68,11 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
}
}
// getChargedEnergy returns loadpoint charge target energy
// getChargedEnergy returns loadpoint charge target energy in Wh
func (lp *Loadpoint) getChargedEnergy() float64 {
lp.Lock()
defer lp.Unlock()
return lp.chargedEnergy
}
// setChargedEnergy returns loadpoint charge target energy
func (lp *Loadpoint) setChargedEnergy(energy float64) {
lp.Lock()
defer lp.Unlock()
lp.chargedEnergy = energy
return lp.sessionEnergy.TotalWh()
}
// GetTargetEnergy returns loadpoint charge target energy

View file

@ -45,24 +45,36 @@ func (lp *Loadpoint) createSession() {
// stopSession ends a charging session segment and persists the session.
func (lp *Loadpoint) stopSession() {
s := lp.session
// test guard
if lp.db == nil || lp.session == nil {
if lp.db == nil || s == nil {
return
}
// abort the session if charging has never started
if lp.session.Created.IsZero() {
if s.Created.IsZero() {
return
}
lp.session.Finished = lp.clock.Now()
lp.session.MeterStop = lp.chargeMeterTotal()
if chargedEnergy := lp.getChargedEnergy() / 1e3; chargedEnergy > lp.session.ChargedEnergy {
lp.session.ChargedEnergy = chargedEnergy
s.Finished = lp.clock.Now()
meterStop := lp.chargeMeterTotal()
if meterStop > 0 {
s.MeterStop = &meterStop
}
lp.db.Persist(lp.session)
if chargedEnergy := lp.getChargedEnergy() / 1e3; chargedEnergy > s.ChargedEnergy {
lp.sessionEnergy.Update(chargedEnergy)
}
solarPerc := lp.sessionEnergy.SolarPercentage()
s.SolarPercentage = &solarPerc
s.Price = lp.sessionEnergy.Price()
s.PricePerKWh = lp.sessionEnergy.PricePerKWh()
s.Co2PerKWh = lp.sessionEnergy.Co2PerKWh()
s.ChargedEnergy = lp.sessionEnergy.TotalWh() / 1e3
lp.db.Persist(s)
}
type sessionOption func(*db.Session)

View file

@ -38,10 +38,11 @@ func TestSession(t *testing.T) {
cm := &EnergyDecorator{Meter: mm, MeterEnergy: me}
lp := &Loadpoint{
log: util.NewLogger("foo"),
clock: clock,
db: db,
chargeMeter: cm,
log: util.NewLogger("foo"),
clock: clock,
db: db,
chargeMeter: cm,
sessionEnergy: NewEnergyMetrics(),
}
// create session
@ -59,12 +60,12 @@ func TestSession(t *testing.T) {
// stop charging
clock.Add(time.Hour)
lp.chargedEnergy = 1.23 * 1e3 // Wh
me.EXPECT().TotalEnergy().Return(1.0+lp.chargedEnergy/1e3, nil) // match chargedEnergy
lp.sessionEnergy.Update(1.23)
me.EXPECT().TotalEnergy().Return(1.0+lp.getChargedEnergy()/1e3, nil) // match chargedEnergy
lp.stopSession()
assert.NotNil(t, lp.session)
assert.Equal(t, lp.chargedEnergy/1e3, lp.session.ChargedEnergy)
assert.Equal(t, lp.getChargedEnergy()/1e3, lp.session.ChargedEnergy)
assert.Equal(t, clock.Now(), lp.session.Finished)
s, err := db.Sessions()
@ -74,7 +75,7 @@ func TestSession(t *testing.T) {
// stop charging - 2nd leg
clock.Add(time.Hour)
lp.chargedEnergy *= 2
lp.sessionEnergy.Update(lp.getChargedEnergy() * 2)
me.EXPECT().TotalEnergy().Return(3.0, nil) // doesn't match chargedEnergy
lp.stopSession()

View file

@ -156,18 +156,19 @@ func TestUpdatePowerZero(t *testing.T) {
charger := mock.NewMockCharger(ctrl)
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clck,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
status: tc.status, // no status change
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clck,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
sessionEnergy: NewEnergyMetrics(),
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
status: tc.status, // no status change
}
attachListeners(t, lp)
@ -181,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.Mode = tc.mode
lp.Update(0, false, false) // sitePower false,0
lp.Update(0, false, false, 0, nil, nil) // sitePower false,0
ctrl.Finish()
}
@ -401,11 +402,12 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
progress: NewProgress(0, 10), // silence nil panics
wakeUpTimer: NewTimer(), // silence nil panics
// coordinator: coordinator.NewDummy(), // silence nil panics
MinCurrent: minA,
MaxCurrent: maxA,
vehicle: vehicle, // needed for targetSoc check
socEstimator: socEstimator, // instead of vehicle: vehicle,
Mode: api.ModeNow,
MinCurrent: minA,
MaxCurrent: maxA,
vehicle: vehicle, // needed for targetSoc check
socEstimator: socEstimator, // instead of vehicle: vehicle,
Mode: api.ModeNow,
sessionEnergy: NewEnergyMetrics(),
Soc: SocConfig{
target: 90,
Poll: PollConfig{
@ -426,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, false, false)
lp.Update(500, false, false, 0, nil, nil)
t.Log("charging above target - soc deactivates charger")
clock.Add(5 * time.Minute)
@ -434,20 +436,20 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(500, false, false)
lp.Update(500, false, false, 0, nil, nil)
t.Log("deactivated charger changes status to B")
clock.Add(5 * time.Minute)
vehicle.EXPECT().Soc().Return(95.0, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-5000, false, false)
lp.Update(-5000, false, false, 0, nil, nil)
t.Log("soc has fallen below target - soc update prevented by timer")
clock.Add(5 * time.Minute)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-5000, false, false)
lp.Update(-5000, false, false, 0, nil, nil)
t.Log("soc has fallen below target - soc update timer expired")
clock.Add(pollInterval)
@ -455,7 +457,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(true).Return(nil)
lp.Update(-5000, false, false)
lp.Update(-5000, false, false, 0, nil, nil)
ctrl.Finish()
}
@ -466,18 +468,19 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger := mock.NewMockCharger(ctrl)
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
MinCurrent: minA,
MaxCurrent: maxA,
status: api.StatusC,
Mode: api.ModeOff,
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
sessionEnergy: NewEnergyMetrics(),
MinCurrent: minA,
MaxCurrent: maxA,
status: api.StatusC,
Mode: api.ModeOff,
Soc: SocConfig{
target: 70,
},
@ -495,14 +498,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, false, false)
lp.Update(500, false, false, 0, nil, nil)
t.Log("switch off when disconnected")
clock.Add(5 * time.Minute)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusA, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(-3000, false, false)
lp.Update(-3000, false, false, 0, nil, nil)
if lp.Mode != api.ModeOff {
t.Error("unexpected mode", lp.Mode)
@ -538,17 +541,18 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater := mock.NewMockChargeRater(ctrl)
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: rater,
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
MinCurrent: minA,
MaxCurrent: maxA,
status: api.StatusC,
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: rater,
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
sessionEnergy: NewEnergyMetrics(),
MinCurrent: minA,
MaxCurrent: maxA,
status: api.StatusC,
}
attachListeners(t, lp)
@ -564,14 +568,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(0.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, false, false)
lp.Update(-1, false, false, 0, nil, nil)
t.Log("at 1:00h charging at 5 kWh")
clock.Add(time.Hour)
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, false, false)
lp.Update(-1, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -579,7 +583,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, false, false)
lp.Update(-1, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -587,7 +591,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, false, false)
lp.Update(-1, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -595,7 +599,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(7.5, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, false, false)
lp.Update(-1, false, false, 0, nil, nil)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -603,7 +607,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(10.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, false, false)
lp.Update(-1, false, false, 0, nil, nil)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()
@ -768,8 +772,9 @@ func TestMinSoc(t *testing.T) {
min: tc.min,
},
vehicleSoc: tc.soc,
chargedEnergy: tc.energy,
sessionEnergy: NewEnergyMetrics(),
}
lp.sessionEnergy.Update(tc.energy / 1e3)
if v := tc.vehicle; v != nil {
lp.vehicle = tc.vehicle // avoid assigning nil to interface

View file

@ -306,7 +306,7 @@ func (lp *Loadpoint) vehicleOdometer() {
// update session once odometer is read
lp.updateSession(func(session *db.Session) {
session.Odometer = odo
session.Odometer = &odo
})
} else if !errors.Is(err, api.ErrNotAvailable) {
lp.log.ERROR.Printf("vehicle odometer: %v", err)

View file

@ -41,6 +41,7 @@ func TestPublishSocAndRange(t *testing.T) {
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
socEstimator: soc.NewEstimator(log, charger, vehicle, false),
sessionEnergy: NewEnergyMetrics(),
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
@ -333,18 +334,19 @@ func TestReconnectVehicle(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusB, nil).AnyTimes()
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clck,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
Mode: api.ModeNow,
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clck,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
chargeTimer: &Null{}, // silence nil panics
wakeUpTimer: NewTimer(),
sessionEnergy: NewEnergyMetrics(),
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
Mode: api.ModeNow,
}
lp.coordinator = coordinator.NewAdapter(lp, coordinator.New(util.NewLogger("foo"), []api.Vehicle{vehicle}))
@ -359,7 +361,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
lp.Update(0, false, false)
lp.Update(0, false, false, 0, nil, nil)
ctrl.Finish()
// detection started
@ -373,7 +375,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(0, false, false)
lp.Update(0, false, false, 0, nil, nil)
ctrl.Finish()
// vehicle detected

View file

@ -27,7 +27,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
// Updater abstracts the Loadpoint implementation for testing
type Updater interface {
loadpoint.API
Update(availablePower float64, autoCharge, batteryBuffered bool)
Update(availablePower float64, autoCharge, batteryBuffered bool, greenShare float64, effectivePrice *float64, effectiveCo2 *float64)
}
// meterMeasurement is used as slice element for publishing structured data
@ -585,23 +585,25 @@ func (site *Site) greenShare() float64 {
}
// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
func (s *Site) effectivePrice(greenShare float64) (float64, error) {
func (s *Site) effectivePrice(greenShare float64) *float64 {
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
effPrice := grid*(1-greenShare) + feedin*greenShare
return &effPrice
}
return 0, api.ErrNotAvailable
return nil
}
// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
func (s *Site) effectiveCo2(greenShare float64) (float64, error) {
func (s *Site) effectiveCo2(greenShare float64) *float64 {
if co2, err := s.tariffs.CurrentCo2(); err == nil {
return co2 * (1 - greenShare), nil
effCo2 := co2 * (1 - greenShare)
return &effCo2
}
return 0, api.ErrNotAvailable
return nil
}
func (s *Site) publishTariffs() {
@ -618,10 +620,10 @@ func (s *Site) publishTariffs() {
if co2, err := s.tariffs.CurrentCo2(); err == nil {
s.publishDelta("tariffCo2", co2)
}
if price, err := s.effectivePrice(greenShare); err == nil {
if price := s.effectivePrice(greenShare); price != nil {
s.publish("tariffEffectivePrice", price)
}
if co2, err := s.effectiveCo2(greenShare); err == nil {
if co2 := s.effectiveCo2(greenShare); co2 != nil {
s.publish("tariffEffectiveCo2", co2)
}
}
@ -662,7 +664,8 @@ func (site *Site) update(lp Updater) {
}
if sitePower, batteryBuffered, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
lp.Update(sitePower, autoCharge, batteryBuffered)
greenShare := site.greenShare()
lp.Update(sitePower, autoCharge, batteryBuffered, greenShare, site.effectivePrice(greenShare), site.effectiveCo2(greenShare))
// ignore negative pvPower values as that means it is not an energy source but consumption
homePower := site.gridPower + math.Max(0, site.pvPower) + site.batteryPower - totalChargePower

View file

@ -122,13 +122,17 @@ pvProduction = "Erzeugung"
selfConsumption = "Eigenverbrauch"
[main.loadpoint]
avgPrice = "Ø Preis"
charged = "Geladen"
co2 = "Ø CO₂"
duration = "Dauer"
fallbackName = "Ladepunkt"
power = "Leistung"
price = "Σ Preis"
remaining = "Restzeit"
remoteDisabledHard = "{source}: Deaktiviert"
remoteDisabledSoft = "{source}: Adaptives PV-Laden deaktiviert"
solar = "Sonne"
[main.loadpointSettings]
currents = "Ladestrom"
@ -246,20 +250,28 @@ reload = "Erneut laden?"
[session]
cancel = "Abbrechen"
co2 = "CO₂"
date = "Zeitraum"
delete = "Löschen"
finished = "Endzeit"
meter = "Zählerstand"
meterstart = "Anfangszählerstand"
meterstop = "Endzählerstand"
odometer = "Kilometerstand"
price = "Preis"
started = "Startzeit"
title = "Ladevorgang"
[sessions]
date = "Zeitraum"
avgPrice = "Ø Preis"
co2 = "Ø CO₂"
date = "Anfang"
downloadCsv = "Als CSV herunterladen"
energy = "Geladen"
loadpoint = "Ladepunkt"
price = "Σ Preis"
reallyDelete = "Wollen Sie diesen Ladevorgang wirklich löschen?"
solar = "Sonne"
title = "Ladevorgänge"
vehicle = "Fahrzeug"

View file

@ -122,13 +122,17 @@ pvProduction = "Production"
selfConsumption = "Self-consumption"
[main.loadpoint]
avgPrice = "Ø Price"
charged = "Charged"
co2 = "Ø CO₂"
duration = "Duration"
fallbackName = "Charging point"
power = "Power"
price = "Σ Price"
remaining = "Remaining"
remoteDisabledHard = "{source}: turned off"
remoteDisabledSoft = "{source}: turned off adaptive solar-charging"
solar = "Solar"
[main.loadpointSettings]
currents = "Charging current"
@ -246,20 +250,28 @@ reload = "Reload?"
[session]
cancel = "Cancel"
co2 = "CO₂"
date = "Period"
delete = "Delete"
finished = "Finished"
meter = "Meter"
meterstart = "Meter start"
meterstop = "Meter stop"
odometer = "Mileage"
price = "Price"
started = "Started"
title = "Charging Session"
[sessions]
date = "Period"
avgPrice = "Ø Price"
co2 = "Ø CO₂"
date = "Finished"
downloadCsv = "Download as CSV"
energy = "Charged"
loadpoint = "Charging point"
price = "Σ Price"
reallyDelete = "Do you really want to delete this session?"
solar = "Solar"
title = "Charging Sessions"
vehicle = "Vehicle"

View file

@ -69,7 +69,10 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) {
// prepare data
for i, s := range res {
res[i].Odometer = math.Round(s.Odometer*10) / 10
if s.Odometer != nil {
odo := math.Round(*s.Odometer*10) / 10
res[i].Odometer = &odo
}
}
if r.URL.Query().Get("format") == "csv" {