Add session infos (co2, price, solar %) (#7380)
This commit is contained in:
parent
6773536dcc
commit
9d14e3d446
25 changed files with 706 additions and 218 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
:smartCostUnit="smartCostUnit"
|
||||
:tariffGrid="tariffGrid"
|
||||
:tariffCo2="tariffCo2"
|
||||
:currency="currency"
|
||||
/>
|
||||
<VehcileSettingsModal />
|
||||
<Footer v-bind="footer"></Footer>
|
||||
|
|
|
|||
|
|
@ -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
25
assets/js/sessionInfo.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
110
core/energy_metrics.go
Normal 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
146
core/energy_metrics_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
core/site.go
23
core/site.go
|
|
@ -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
|
||||
|
|
|
|||
14
i18n/de.toml
14
i18n/de.toml
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
14
i18n/en.toml
14
i18n/en.toml
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue