Tariffs: rename price to value (BC) (#19182)
This commit is contained in:
parent
e945ed10fb
commit
48d84356e9
57 changed files with 271 additions and 249 deletions
|
|
@ -10,12 +10,12 @@ import (
|
|||
type Rate struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
Price float64 `json:"price"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// IsZero returns is the rate is the zero value
|
||||
func (r Rate) IsZero() bool {
|
||||
return r.Start.IsZero() && r.End.IsZero() && r.Price == 0
|
||||
return r.Start.IsZero() && r.End.IsZero() && r.Value == 0
|
||||
}
|
||||
|
||||
// Rates is a slice of (future) tariff rates
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestRates(t *testing.T) {
|
|||
return Rate{
|
||||
Start: clock.Now().Add(time.Duration(start) * time.Hour),
|
||||
End: clock.Now().Add(time.Duration(start+1) * time.Hour),
|
||||
Price: val,
|
||||
Value: val,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -26,11 +26,11 @@ func TestRates(t *testing.T) {
|
|||
for i := 1; i <= 4; i++ {
|
||||
r, err := rr.At(clock.Now().Add(time.Duration(i) * time.Hour))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, float64(i), r.Price)
|
||||
assert.Equal(t, float64(i), r.Value)
|
||||
|
||||
r, err = rr.At(clock.Now().Add(time.Duration(i)*time.Hour + 30*time.Minute))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, float64(i), r.Price)
|
||||
assert.Equal(t, float64(i), r.Value)
|
||||
}
|
||||
|
||||
_, err = rr.At(clock.Now().Add(5 * time.Hour))
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ import collector from "../../mixins/collector.js";
|
|||
import api, { allowClientError } from "../../api.js";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import deepEqual from "../../utils/deepEqual.js";
|
||||
import convertRates from "../../utils/convertRates";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { Vehicle, PartialBy, Timeout, Tariff, SelectOption, CURRENCY } from "../../types/evcc";
|
||||
|
||||
import type {
|
||||
StaticPlan,
|
||||
RepeatingPlan,
|
||||
|
|
@ -316,10 +316,10 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
const tariffRes = await api.get(`tariff/planner`, allowClientError);
|
||||
const res = await api.get(`tariff/planner`, allowClientError);
|
||||
|
||||
this.tariff = {
|
||||
rates: tariffRes.status === 404 ? [] : tariffRes.data.result.rates,
|
||||
rates: res.status === 404 ? [] : convertRates(res.data.result.rates),
|
||||
lastUpdate: new Date(),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function createDate(hoursFromNow: number) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function createRate(price: number, hoursFromNow: number, durationHours = 1): Rate {
|
||||
function createRate(value: number, hoursFromNow: number, durationHours = 1): Rate {
|
||||
const start = new Date(now.getTime());
|
||||
start.setHours(start.getHours() + hoursFromNow);
|
||||
start.setMinutes(0);
|
||||
|
|
@ -21,7 +21,7 @@ function createRate(price: number, hoursFromNow: number, durationHours = 1): Rat
|
|||
end.setMinutes(0);
|
||||
end.setSeconds(0);
|
||||
end.setMilliseconds(0);
|
||||
return { start, end, price };
|
||||
return { start, end, value };
|
||||
}
|
||||
|
||||
// Scenario data
|
||||
|
|
@ -30,7 +30,7 @@ const co2Data = {
|
|||
545, 518, 545, 518, 0, 545, 527, 527, 536, 518, 400, 336, 336, 339, 344, 336, 336, 336, 372,
|
||||
400, 555, 555, 545, 555, 564, 545, 555, 545, 536, 545, 527, 536, 518, 545, 509, 336, 336,
|
||||
336,
|
||||
].map((price, i) => createRate(price, i)),
|
||||
].map((value, i) => createRate(value, i)),
|
||||
duration: 8695,
|
||||
plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)],
|
||||
smartCostType: "co2",
|
||||
|
|
@ -72,7 +72,7 @@ const unknownData = {
|
|||
const dynamicData = {
|
||||
rates: [
|
||||
0.12, 0.15, 0, -0.05, -0.11, -0.24, -0.08, 0.12, 0.25, 0.29, 0.22, 0.31, 0.31, 0.33,
|
||||
].map((price, i) => createRate(price, i)),
|
||||
].map((value, i) => createRate(value, i)),
|
||||
duration: 8695,
|
||||
plan: [createRate(0.23, 2, 5)],
|
||||
smartCostType: "price",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { mount, config } from "@vue/test-utils";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import Preview from "./Preview.vue";
|
||||
import type { Slot } from "./types";
|
||||
import type { Slot } from "../../types/evcc.js";
|
||||
|
||||
config.global.mocks["$i18n"] = { locale: "de-DE" };
|
||||
config.global.mocks["$t"] = (a: any) => a;
|
||||
|
|
@ -13,14 +13,14 @@ describe("basics", () => {
|
|||
{
|
||||
start: new Date("2023-01-11T11:00:00+01:00"),
|
||||
end: new Date("2023-01-22T00:00:00+01:00"),
|
||||
price: 0.4,
|
||||
value: 0.4,
|
||||
},
|
||||
];
|
||||
const PLAN = [
|
||||
{
|
||||
start: new Date("2023-01-11T12:00:00+01:00"),
|
||||
end: new Date("2023-01-11T13:00:00+01:00"),
|
||||
price: 0.2,
|
||||
value: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -75,8 +75,8 @@ describe("basics", () => {
|
|||
expect(result[3].charging).eq(false);
|
||||
});
|
||||
|
||||
test("all slots have the same fixed price", () => {
|
||||
result.forEach((slot) => expect(slot.price).eq(0.4));
|
||||
test("all slots have the same fixed value", () => {
|
||||
result.forEach((slot) => expect(slot.value).eq(0.4));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -87,24 +87,24 @@ describe("zoned tariffs", () => {
|
|||
{
|
||||
start: new Date("2023-01-11T11:00:00+01:00"),
|
||||
end: new Date("2023-01-11T12:00:00+01:00"),
|
||||
price: 0.2,
|
||||
value: 0.2,
|
||||
},
|
||||
{
|
||||
start: new Date("2023-01-11T12:00:00+01:00"),
|
||||
end: new Date("2023-01-22T00:00:00+01:00"),
|
||||
price: 0.4,
|
||||
value: 0.4,
|
||||
},
|
||||
];
|
||||
const PLAN = [
|
||||
{
|
||||
start: new Date("2023-01-11T11:30:00+01:00"),
|
||||
end: new Date("2023-01-11T13:00:00+01:00"),
|
||||
price: 0.3,
|
||||
value: 0.3,
|
||||
},
|
||||
{
|
||||
start: new Date("2023-01-11T14:30:00+01:00"),
|
||||
end: new Date("2023-01-11T16:00:00+01:00"),
|
||||
price: 0.2,
|
||||
value: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ describe("zoned tariffs", () => {
|
|||
|
||||
test("first slot is cheap, others are expensive", () => {
|
||||
const [first, ...others] = result;
|
||||
expect(first.price).eq(0.2);
|
||||
others.forEach((slot) => expect(slot.price).eq(0.4));
|
||||
expect(first.value).eq(0.2);
|
||||
others.forEach((slot) => expect(slot.value).eq(0.4));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<span v-else>{{ $t("main.targetChargePlan.priceLabel") }}</span>
|
||||
</div>
|
||||
<div class="value text-primary">
|
||||
{{ fmtAvgPrice }}
|
||||
{{ fmtAvgValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -39,8 +39,7 @@ import { defineComponent, type PropType } from "vue";
|
|||
import formatter from "../../mixins/formatter.js";
|
||||
import { CO2_TYPE } from "../../units.js";
|
||||
import TariffChart from "../Tariff/TariffChart.vue";
|
||||
import type { CURRENCY, Rate } from "assets/js/types/evcc.js";
|
||||
import type { Slot } from "./types.js";
|
||||
import type { CURRENCY, Rate, Slot } from "../../types/evcc.js";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChargingPlanPreview",
|
||||
|
|
@ -87,29 +86,29 @@ export default defineComponent({
|
|||
hasTariff(): boolean {
|
||||
return (this.rates?.length || 0) > 1;
|
||||
},
|
||||
avgPrice(): number | undefined {
|
||||
avgValue(): number | undefined {
|
||||
let hourSum = 0;
|
||||
let priceSum = 0;
|
||||
let valueSum = 0;
|
||||
this.convertDates(this.plan).forEach((slot) => {
|
||||
const hours = (slot.end.getTime() - slot.start.getTime()) / 3600000;
|
||||
if (slot.price) {
|
||||
if (slot.value) {
|
||||
hourSum += hours;
|
||||
priceSum += hours * slot.price;
|
||||
valueSum += hours * slot.value;
|
||||
}
|
||||
});
|
||||
return hourSum ? priceSum / hourSum : undefined;
|
||||
return hourSum ? valueSum / hourSum : undefined;
|
||||
},
|
||||
fmtAvgPrice(): string {
|
||||
fmtAvgValue(): string {
|
||||
if (this.duration === 0) {
|
||||
return "—";
|
||||
}
|
||||
const price = this.activeSlot ? this.activeSlot.price : this.avgPrice;
|
||||
if (price === undefined) {
|
||||
const value = this.activeSlot ? this.activeSlot.value : this.avgValue;
|
||||
if (value === undefined) {
|
||||
return this.$t("main.targetChargePlan.unknownPrice");
|
||||
}
|
||||
return this.isCo2
|
||||
? this.fmtCo2Medium(price)
|
||||
: this.fmtPricePerKWh(price, this.currency);
|
||||
? this.fmtCo2Medium(value)
|
||||
: this.fmtPricePerKWh(value, this.currency);
|
||||
},
|
||||
activeSlot(): Slot | null {
|
||||
return this.activeIndex ? this.slots[this.activeIndex] : null;
|
||||
|
|
@ -122,10 +121,8 @@ export default defineComponent({
|
|||
}
|
||||
return null;
|
||||
},
|
||||
targetHourOffset(): number | null {
|
||||
if (!this.targetTime) {
|
||||
return null;
|
||||
}
|
||||
targetHourOffset(): number | undefined {
|
||||
if (!this.targetTime) return;
|
||||
const start = new Date(this.startTime);
|
||||
start.setMinutes(0);
|
||||
start.setSeconds(0);
|
||||
|
|
@ -155,7 +152,7 @@ export default defineComponent({
|
|||
const day = this.weekdayShort(start);
|
||||
const toLate = this.targetTime && this.targetTime <= start;
|
||||
// TODO: handle multiple matching time slots
|
||||
const price = this.findSlotInRange(start, end, rates)?.price;
|
||||
const value = this.findSlotInRange(start, end, rates)?.value;
|
||||
const isTarget =
|
||||
this.targetTime && start <= this.targetTime && end > this.targetTime;
|
||||
const charging = this.findSlotInRange(start, end, plan) != null;
|
||||
|
|
@ -167,7 +164,7 @@ export default defineComponent({
|
|||
this.targetTime < this.endTime;
|
||||
result.push({
|
||||
day,
|
||||
price,
|
||||
value,
|
||||
startHour,
|
||||
endHour,
|
||||
charging,
|
||||
|
|
@ -193,7 +190,7 @@ export default defineComponent({
|
|||
return {
|
||||
start: new Date(item.start),
|
||||
end: new Date(item.end),
|
||||
price: item.price,
|
||||
value: item.value,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
11
assets/js/components/ChargingPlans/types.d.ts
vendored
11
assets/js/components/ChargingPlans/types.d.ts
vendored
|
|
@ -32,14 +32,3 @@ export interface StaticEnergyPlan {
|
|||
energy: number;
|
||||
time: Date;
|
||||
}
|
||||
|
||||
export interface Slot {
|
||||
day: string;
|
||||
price?: number;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
charging: boolean;
|
||||
toLate?: boolean | null;
|
||||
warning?: boolean | null;
|
||||
isTarget?: boolean | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,32 +11,32 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import type { PropType } from "vue";
|
||||
import { isPriceSlot, type PriceSlot, type TimeseriesEntry } from "../../utils/forecast.ts";
|
||||
import formatter from "../../mixins/formatter.ts";
|
||||
import { isForecastSlot, type ForecastSlot, type TimeseriesEntry } from "../../utils/forecast";
|
||||
import formatter from "../../mixins/formatter";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ForecastActiveSlot",
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
activeSlot: { type: Object as PropType<PriceSlot | TimeseriesEntry | null> },
|
||||
activeSlot: { type: Object as PropType<ForecastSlot | TimeseriesEntry | null> },
|
||||
},
|
||||
computed: {
|
||||
isSlot() {
|
||||
return this.activeSlot !== null && isPriceSlot(this.activeSlot);
|
||||
return this.activeSlot !== null && isForecastSlot(this.activeSlot);
|
||||
},
|
||||
isTimeseries() {
|
||||
return this.activeSlot !== null && !isPriceSlot(this.activeSlot);
|
||||
return this.activeSlot !== null && !isForecastSlot(this.activeSlot);
|
||||
},
|
||||
day() {
|
||||
const startDate = new Date((this.activeSlot! as PriceSlot).start);
|
||||
const startDate = new Date((this.activeSlot! as ForecastSlot).start);
|
||||
return this.weekdayShort(startDate);
|
||||
},
|
||||
start() {
|
||||
const startDate = new Date((this.activeSlot! as PriceSlot).start);
|
||||
const startDate = new Date((this.activeSlot! as ForecastSlot).start);
|
||||
return this.hourShort(startDate);
|
||||
},
|
||||
end() {
|
||||
const endDate = new Date((this.activeSlot! as PriceSlot).end);
|
||||
const endDate = new Date((this.activeSlot! as ForecastSlot).end);
|
||||
return this.hourShort(endDate);
|
||||
},
|
||||
time() {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import colors, { lighterColor } from "../../colors.ts";
|
|||
import {
|
||||
highestSlotIndexByDay,
|
||||
ForecastType,
|
||||
type PriceSlot,
|
||||
type ForecastSlot,
|
||||
type SolarDetails,
|
||||
type TimeseriesEntry,
|
||||
} from "../../utils/forecast.ts";
|
||||
|
|
@ -63,9 +63,9 @@ export default defineComponent({
|
|||
components: { Bar },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
grid: { type: Array as PropType<PriceSlot[]> },
|
||||
grid: { type: Array as PropType<ForecastSlot[]> },
|
||||
solar: { type: Object as PropType<SolarDetails> },
|
||||
co2: { type: Array as PropType<PriceSlot[]> },
|
||||
co2: { type: Array as PropType<ForecastSlot[]> },
|
||||
currency: { type: String as PropType<CURRENCY> },
|
||||
selected: { type: String as PropType<ForecastType> },
|
||||
},
|
||||
|
|
@ -174,7 +174,7 @@ export default defineComponent({
|
|||
datasets.push({
|
||||
label: ForecastType.Price,
|
||||
data: this.gridSlots.map((slot, index) => ({
|
||||
y: slot.price,
|
||||
y: slot.value,
|
||||
x: new Date(slot.start),
|
||||
highlight:
|
||||
active &&
|
||||
|
|
@ -202,7 +202,7 @@ export default defineComponent({
|
|||
? this.selectedIndex === index
|
||||
: index === this.maxCo2Index || index === this.minCo2Index);
|
||||
return {
|
||||
y: slot.price,
|
||||
y: slot.value,
|
||||
x: new Date(slot.start),
|
||||
highlight: dataActive,
|
||||
active: dataActive,
|
||||
|
|
@ -422,7 +422,7 @@ export default defineComponent({
|
|||
now.setMilliseconds(0);
|
||||
this.startDate = now;
|
||||
},
|
||||
filterSlots(slots: PriceSlot[] = []) {
|
||||
filterSlots(slots: ForecastSlot[] = []) {
|
||||
return slots.filter(
|
||||
(slot) =>
|
||||
new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate
|
||||
|
|
@ -462,7 +462,7 @@ export default defineComponent({
|
|||
}, 100);
|
||||
}
|
||||
},
|
||||
yMax(slots: PriceSlot[] = []): number | undefined {
|
||||
yMax(slots: ForecastSlot[] = []): number | undefined {
|
||||
const value = this.maxValue(slots);
|
||||
return value ? value * 1.15 : undefined;
|
||||
},
|
||||
|
|
@ -472,18 +472,18 @@ export default defineComponent({
|
|||
// use scale and unscaled to determine max scale
|
||||
return Math.max(maxValue * scale, maxValue) * 1.15;
|
||||
},
|
||||
maxIndex(slots: PriceSlot[] = []) {
|
||||
maxIndex(slots: ForecastSlot[] = []) {
|
||||
return slots.reduce((max, slot, index) => {
|
||||
return slot.price > slots[max].price ? index : max;
|
||||
return slot.value > slots[max].value ? index : max;
|
||||
}, 0);
|
||||
},
|
||||
minIndex(slots: PriceSlot[] = []) {
|
||||
minIndex(slots: ForecastSlot[] = []) {
|
||||
return slots.reduce((min, slot, index) => {
|
||||
return slot.price < slots[min].price ? index : min;
|
||||
return slot.value < slots[min].value ? index : min;
|
||||
}, 0);
|
||||
},
|
||||
maxValue(slots: PriceSlot[] = []) {
|
||||
return slots[this.maxIndex(slots)]?.price || null;
|
||||
maxValue(slots: ForecastSlot[] = []) {
|
||||
return slots[this.maxIndex(slots)]?.value || null;
|
||||
},
|
||||
maxEntryValue(entries: TimeseriesEntry[] = []) {
|
||||
return entries[this.maxEntryIndex(entries)]?.val || null;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { ForecastType, type PriceSlot, type SolarDetails } from "../../utils/forecast.ts";
|
||||
import { ForecastType, type ForecastSlot, type SolarDetails } from "../../utils/forecast.ts";
|
||||
import formatter, { POWER_UNIT } from "../../mixins/formatter.ts";
|
||||
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
|
||||
import type { CURRENCY, Timeout } from "assets/js/types/evcc.ts";
|
||||
|
|
@ -89,8 +89,8 @@ export default defineComponent({
|
|||
mixins: [formatter],
|
||||
props: {
|
||||
type: { type: String as () => ForecastType, required: true },
|
||||
grid: { type: Array as PropType<PriceSlot[]> },
|
||||
co2: { type: Array as PropType<PriceSlot[]> },
|
||||
grid: { type: Array as PropType<ForecastSlot[]> },
|
||||
co2: { type: Array as PropType<ForecastSlot[]> },
|
||||
solar: { type: Object as PropType<SolarDetails> },
|
||||
currency: { type: String as PropType<CURRENCY> },
|
||||
},
|
||||
|
|
@ -107,7 +107,7 @@ export default defineComponent({
|
|||
isPrice() {
|
||||
return this.type === ForecastType.Price;
|
||||
},
|
||||
upcomingSlots(): PriceSlot[] {
|
||||
upcomingSlots(): ForecastSlot[] {
|
||||
const now = this.now;
|
||||
const slots = this.isPrice ? this.grid || [] : this.co2 || [];
|
||||
return slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
|
||||
|
|
@ -115,21 +115,21 @@ export default defineComponent({
|
|||
averagePrice() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const price = slots.reduce((acc, slot) => acc + slot.price, 0) / slots.length;
|
||||
const price = slots.reduce((acc, slot) => acc + slot.value, 0) / slots.length;
|
||||
return this.fmtValue(price, true);
|
||||
},
|
||||
priceRange() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const min = Math.min(...slots.map((slot) => slot.price));
|
||||
const max = Math.max(...slots.map((slot) => slot.price));
|
||||
const min = Math.min(...slots.map((slot) => slot.value));
|
||||
const max = Math.max(...slots.map((slot) => slot.value));
|
||||
return `${this.fmtValue(min, false)} – ${this.fmtValue(max, true)}`;
|
||||
},
|
||||
lowestPriceHour() {
|
||||
if (this.isSolar) return "";
|
||||
const slots = this.upcomingSlots;
|
||||
const min = Math.min(...slots.map((slot) => slot.price));
|
||||
const slot = slots.find((slot) => slot.price === min);
|
||||
const min = Math.min(...slots.map((slot) => slot.value));
|
||||
const slot = slots.find((slot) => slot.value === min);
|
||||
if (!slot) return "";
|
||||
const start = new Date(slot.start);
|
||||
const end = new Date(slot.end);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ import TypeSelect from "./TypeSelect.vue";
|
|||
import Details from "./Details.vue";
|
||||
import ActiveSlot from "./ActiveSlot.vue";
|
||||
import {
|
||||
type PriceSlot,
|
||||
type ForecastSlot,
|
||||
type TimeseriesEntry,
|
||||
type Forecast,
|
||||
ForecastType,
|
||||
|
|
@ -85,7 +85,7 @@ export default defineComponent({
|
|||
data(): {
|
||||
isModalVisible: boolean;
|
||||
selectedType: ForecastType;
|
||||
selectedSlot: PriceSlot | TimeseriesEntry | null;
|
||||
selectedSlot: ForecastSlot | TimeseriesEntry | null;
|
||||
} {
|
||||
return {
|
||||
isModalVisible: false,
|
||||
|
|
@ -134,7 +134,7 @@ export default defineComponent({
|
|||
modalInvisible() {
|
||||
this.isModalVisible = false;
|
||||
},
|
||||
updateSlot(slot: PriceSlot | TimeseriesEntry | null) {
|
||||
updateSlot(slot: ForecastSlot | TimeseriesEntry | null) {
|
||||
this.selectedSlot = slot;
|
||||
},
|
||||
updateSelectedType() {
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ export default {
|
|||
const { rates } = res.data.result;
|
||||
this.referenceGrid =
|
||||
rates.reduce((acc, slot) => {
|
||||
return acc + slot.price;
|
||||
return acc + slot.value;
|
||||
}, 0) / rates.length;
|
||||
} catch (e) {
|
||||
this.referenceGrid = undefined;
|
||||
|
|
|
|||
|
|
@ -82,47 +82,53 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import formatter from "../../mixins/formatter.js";
|
||||
import TariffChart from "./TariffChart.vue";
|
||||
import { CO2_TYPE } from "../../units.js";
|
||||
import api, { allowClientError } from "../../api.js";
|
||||
import convertRates from "../../utils/convertRates";
|
||||
import type { PropType } from "vue";
|
||||
import type { Tariff, CURRENCY, Rate, SelectOption, Slot } from "../../types/evcc";
|
||||
|
||||
export default {
|
||||
name: "SmartCostLimit",
|
||||
components: { TariffChart },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
smartCostLimit: Number,
|
||||
smartCostLimit: {
|
||||
type: [Number, null] as PropType<number | null>,
|
||||
required: true,
|
||||
},
|
||||
smartCostType: String,
|
||||
tariffGrid: Number,
|
||||
currency: String,
|
||||
currency: String as PropType<CURRENCY>,
|
||||
loadpointId: Number,
|
||||
multipleLoadpoints: Boolean,
|
||||
possible: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedSmartCostLimit: null,
|
||||
tariff: null,
|
||||
startTime: null,
|
||||
activeIndex: null,
|
||||
selectedSmartCostLimit: null as number | null,
|
||||
tariff: null as Tariff | null,
|
||||
startTime: null as Date | null,
|
||||
activeIndex: null as number | null,
|
||||
applyToAllVisible: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isCo2() {
|
||||
isCo2(): boolean {
|
||||
return this.smartCostType === CO2_TYPE;
|
||||
},
|
||||
costOptions() {
|
||||
costOptions(): SelectOption<number>[] {
|
||||
const { max } = this.costRange(this.totalSlots);
|
||||
|
||||
const values = [];
|
||||
const values = [] as number[];
|
||||
const stepSize = this.optionStepSize;
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const value = this.optionStartValue + stepSize * i;
|
||||
if (value > max + stepSize) break;
|
||||
values.push(this.roundLimit(value));
|
||||
if (max !== undefined && value > max + stepSize) break;
|
||||
values.push(this.roundLimit(value) as number);
|
||||
}
|
||||
// add special entry if currently selected value is not in the scale
|
||||
const selected = this.selectedSmartCostLimit;
|
||||
|
|
@ -140,7 +146,7 @@ export default {
|
|||
const stepSize = this.optionStepSize;
|
||||
// always show some negative values for price
|
||||
const start = this.isCo2 ? 0 : stepSize * -11;
|
||||
const minValue = Math.min(start, min);
|
||||
const minValue = min !== undefined ? Math.min(start, min) : start;
|
||||
return Math.floor(minValue / stepSize) * stepSize;
|
||||
},
|
||||
optionStepSize() {
|
||||
|
|
@ -148,6 +154,9 @@ export default {
|
|||
return 1;
|
||||
}
|
||||
const { min, max } = this.costRange(this.totalSlots);
|
||||
if (min === undefined || max === undefined) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const baseSteps = [0.001, 0.002, 0.005];
|
||||
const range = max - Math.min(0, min);
|
||||
|
|
@ -159,11 +168,11 @@ export default {
|
|||
}
|
||||
return 1;
|
||||
},
|
||||
slots() {
|
||||
const result = [];
|
||||
if (!this.tariff?.rates) return result;
|
||||
slots(): Slot[] {
|
||||
const result: Slot[] = [];
|
||||
if (!this.tariff?.rates || !this.startTime) return result;
|
||||
|
||||
const rates = this.convertDates(this.tariff.rates);
|
||||
const { rates } = this.tariff;
|
||||
const oneHour = 60 * 60 * 1000;
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
|
|
@ -177,17 +186,19 @@ export default {
|
|||
const endHour = end.getHours();
|
||||
const day = this.weekdayShort(start);
|
||||
// TODO: handle multiple matching time slots
|
||||
const price = this.findSlotInRange(start, end, rates)?.price;
|
||||
const value = this.findRateInRange(start, end, rates)?.value;
|
||||
const charging =
|
||||
price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== null;
|
||||
const selectable = price !== undefined;
|
||||
result.push({ day, price, startHour, endHour, charging, selectable });
|
||||
this.selectedSmartCostLimit !== null &&
|
||||
value !== undefined &&
|
||||
value <= this.selectedSmartCostLimit;
|
||||
const selectable = value !== undefined;
|
||||
result.push({ day, value, startHour, endHour, charging, selectable });
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
totalSlots() {
|
||||
return this.slots.filter((s) => s.price !== undefined);
|
||||
return this.slots.filter((s) => s.value !== undefined);
|
||||
},
|
||||
chargingSlots() {
|
||||
return this.totalSlots.filter((s) => s.charging);
|
||||
|
|
@ -198,8 +209,8 @@ export default {
|
|||
chargingCostRange() {
|
||||
return this.fmtCostRange(this.costRange(this.chargingSlots));
|
||||
},
|
||||
activeSlot() {
|
||||
return this.slots[this.activeIndex];
|
||||
activeSlot(): Slot | null {
|
||||
return this.activeIndex !== null ? this.slots[this.activeIndex] : null;
|
||||
},
|
||||
activeSlotName() {
|
||||
if (this.activeSlot) {
|
||||
|
|
@ -210,14 +221,14 @@ export default {
|
|||
return null;
|
||||
},
|
||||
activeSlotCost() {
|
||||
const price = this.activeSlot?.price;
|
||||
if (price === undefined) {
|
||||
const value = this.activeSlot?.value;
|
||||
if (value === undefined) {
|
||||
return this.$t("main.targetChargePlan.unknownPrice");
|
||||
}
|
||||
if (this.isCo2) {
|
||||
return this.fmtCo2Medium(price);
|
||||
return this.fmtCo2Medium(value);
|
||||
}
|
||||
return this.fmtPricePerKWh(price, this.currency);
|
||||
return this.fmtPricePerKWh(value, this.currency);
|
||||
},
|
||||
title() {
|
||||
return this.$t(`smartCost.${this.isCo2 ? "clean" : "cheap"}Title`);
|
||||
|
|
@ -245,10 +256,10 @@ export default {
|
|||
this.selectedSmartCostLimit = this.roundLimit(this.smartCostLimit);
|
||||
},
|
||||
methods: {
|
||||
roundLimit(limit) {
|
||||
roundLimit(limit: number | null): number | null {
|
||||
return limit === null ? null : Math.round(limit * 1000) / 1000;
|
||||
},
|
||||
fmtSmartCostLimit(limit) {
|
||||
fmtSmartCostLimit(limit: number | null): string {
|
||||
if (limit === null) {
|
||||
return this.$t("smartCost.none");
|
||||
}
|
||||
|
|
@ -258,45 +269,38 @@ export default {
|
|||
},
|
||||
async updateTariff() {
|
||||
try {
|
||||
const tariffRes = await api.get(`tariff/planner`, allowClientError);
|
||||
if (tariffRes.status === 200) {
|
||||
this.tariff = tariffRes.data.result;
|
||||
const res = await api.get(`tariff/planner`, allowClientError);
|
||||
if (res.status === 200) {
|
||||
this.tariff = {
|
||||
rates: convertRates(res.data.result.rates),
|
||||
lastUpdate: new Date(),
|
||||
};
|
||||
this.startTime = new Date();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
convertDates(list) {
|
||||
if (!list?.length) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => {
|
||||
return {
|
||||
start: new Date(item.start),
|
||||
end: new Date(item.end),
|
||||
price: item.price,
|
||||
};
|
||||
});
|
||||
},
|
||||
findSlotInRange(start, end, slots) {
|
||||
return slots.find((s) => {
|
||||
if (s.start.getTime() < start.getTime()) {
|
||||
return s.end.getTime() > start.getTime();
|
||||
findRateInRange(start: Date, end: Date, rates: Rate[]) {
|
||||
return rates.find((r) => {
|
||||
if (r.start.getTime() < start.getTime()) {
|
||||
return r.end.getTime() > start.getTime();
|
||||
}
|
||||
return s.start.getTime() < end.getTime();
|
||||
return r.start.getTime() < end.getTime();
|
||||
});
|
||||
},
|
||||
costRange(slots) {
|
||||
let min = undefined;
|
||||
let max = undefined;
|
||||
costRange(slots: Slot[]): { min: number | undefined; max: number | undefined } {
|
||||
let min = undefined as number | undefined;
|
||||
let max = undefined as number | undefined;
|
||||
slots.forEach((slot) => {
|
||||
min = min === undefined ? slot.price : Math.min(min, slot.price);
|
||||
max = max === undefined ? slot.price : Math.max(max, slot.price);
|
||||
if (slot.value === undefined) return;
|
||||
min = min === undefined ? slot.value : Math.min(min, slot.value);
|
||||
max = max === undefined ? slot.value : Math.max(max, slot.value);
|
||||
});
|
||||
return { min, max };
|
||||
},
|
||||
fmtCostRange({ min, max }) {
|
||||
fmtCostRange({ min, max }: { min: number | undefined; max: number | undefined }): string {
|
||||
if (min === undefined || max === undefined) return "";
|
||||
const fmtMin = this.isCo2
|
||||
? this.fmtCo2Short(min)
|
||||
: this.fmtPricePerKWh(min, this.currency, true);
|
||||
|
|
@ -305,21 +309,22 @@ export default {
|
|||
: this.fmtPricePerKWh(max, this.currency, true);
|
||||
return `${fmtMin} – ${fmtMax}`;
|
||||
},
|
||||
slotHovered(index) {
|
||||
slotHovered(index: number) {
|
||||
this.activeIndex = index;
|
||||
},
|
||||
slotSelected(index) {
|
||||
const price = this.slots[index].price;
|
||||
if (price !== undefined) {
|
||||
slotSelected(index: number) {
|
||||
const value = this.slots[index].value;
|
||||
if (value !== undefined) {
|
||||
// 3 decimal precision
|
||||
const priceRounded = Math.ceil(price * 1000) / 1000;
|
||||
this.saveSmartCostLimit(priceRounded);
|
||||
const valueRounded = Math.ceil(value * 1000) / 1000;
|
||||
this.saveSmartCostLimit(`${valueRounded}`);
|
||||
}
|
||||
},
|
||||
changeSmartCostLimit($event) {
|
||||
this.saveSmartCostLimit($event.target.value);
|
||||
changeSmartCostLimit($event: Event) {
|
||||
const value = ($event.target as HTMLSelectElement).value;
|
||||
this.saveSmartCostLimit(value);
|
||||
},
|
||||
async saveSmartCostLimit(limit) {
|
||||
async saveSmartCostLimit(limit: string) {
|
||||
const url = this.isLoadpoint
|
||||
? `loadpoints/${this.loadpointId}/smartcostlimit`
|
||||
: "batterygridchargelimit";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="chart position-relative">
|
||||
<div
|
||||
v-for="(slot, index) in slots"
|
||||
:key="slot.start"
|
||||
:key="`${slot.day}-${slot.startHour}`"
|
||||
:data-index="index"
|
||||
class="slot user-select-none"
|
||||
:class="{
|
||||
|
|
@ -22,8 +22,8 @@
|
|||
@mouseup="hoverSlot(null)"
|
||||
@click="selectSlot(index)"
|
||||
>
|
||||
<div class="slot-bar" :style="priceStyle(slot.price)">
|
||||
<span v-if="slot.price === undefined && avgPrice" class="unknown">?</span>
|
||||
<div class="slot-bar" :style="valueStyle(slot.value)">
|
||||
<span v-if="slot.value === undefined && avgValue" class="unknown">?</span>
|
||||
</div>
|
||||
<div class="slot-label">
|
||||
<span v-if="!slot.isTarget || targetNearlyOutOfRange">{{
|
||||
|
|
@ -52,11 +52,12 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import type { PropType } from "vue";
|
||||
import "@h2d2/shopicons/es/regular/arrowright";
|
||||
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
|
||||
import formatter from "../../mixins/formatter.js";
|
||||
import { CO2_TYPE } from "../../units.js";
|
||||
import type { Slot } from "../../types/evcc.ts";
|
||||
|
||||
const BAR_WIDTH = 20;
|
||||
|
||||
|
|
@ -67,29 +68,29 @@ export default {
|
|||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
slots: Array,
|
||||
slots: { type: Array as PropType<Slot[]>, default: () => [] },
|
||||
targetText: [String, null],
|
||||
targetOffset: [Number, null],
|
||||
targetOffset: { type: Number, default: 0 },
|
||||
},
|
||||
emits: ["slot-hovered", "slot-selected"],
|
||||
data() {
|
||||
return {
|
||||
activeIndex: null,
|
||||
activeIndex: null as number | null,
|
||||
startTime: new Date(),
|
||||
longPressTimer: null,
|
||||
longPressTimer: undefined as number | undefined,
|
||||
isLongPress: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
priceInfo() {
|
||||
valueInfo() {
|
||||
let max = Number.MIN_VALUE;
|
||||
let min = 0;
|
||||
this.slots
|
||||
.map((s) => s.price)
|
||||
.filter((price) => price !== undefined)
|
||||
.forEach((price) => {
|
||||
max = Math.max(max, price);
|
||||
min = Math.min(min, price);
|
||||
.map((s) => s.value)
|
||||
.filter((value) => value !== undefined)
|
||||
.forEach((value) => {
|
||||
max = Math.max(max, value);
|
||||
min = Math.min(min, value);
|
||||
});
|
||||
return { min, range: max - min };
|
||||
},
|
||||
|
|
@ -104,30 +105,27 @@ export default {
|
|||
targetOutOfRange() {
|
||||
return this.targetOffset > this.slots.length;
|
||||
},
|
||||
avgPrice() {
|
||||
avgValue() {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
this.slots.forEach((s) => {
|
||||
if (s.price !== undefined) {
|
||||
sum += s.price;
|
||||
if (s.value !== undefined) {
|
||||
sum += s.value;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return sum / count;
|
||||
},
|
||||
isCo2() {
|
||||
return this.unit === CO2_TYPE;
|
||||
},
|
||||
activeSlot() {
|
||||
return this.slots[this.activeIndex];
|
||||
return this.activeIndex !== null ? this.slots[this.activeIndex] : null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
hoverSlot(index) {
|
||||
hoverSlot(index: number | null) {
|
||||
this.activeIndex = index;
|
||||
this.$emit("slot-hovered", index);
|
||||
},
|
||||
selectSlot(index) {
|
||||
selectSlot(index: number) {
|
||||
if (this.isLongPress) {
|
||||
this.isLongPress = false;
|
||||
return;
|
||||
|
|
@ -136,7 +134,7 @@ export default {
|
|||
this.$emit("slot-selected", index);
|
||||
}
|
||||
},
|
||||
showWeekday(index) {
|
||||
showWeekday(index: number) {
|
||||
const slot = this.slots[index];
|
||||
if (!slot) {
|
||||
return false;
|
||||
|
|
@ -151,15 +149,15 @@ export default {
|
|||
}
|
||||
return false;
|
||||
},
|
||||
priceStyle(price) {
|
||||
const value = price === undefined ? this.avgPrice : price;
|
||||
valueStyle(value: number | undefined) {
|
||||
const val = value === undefined ? this.avgValue : value;
|
||||
const height =
|
||||
value !== undefined && !isNaN(value)
|
||||
? `${10 + (90 / this.priceInfo.range) * (value - this.priceInfo.min)}%`
|
||||
value !== undefined && !isNaN(val)
|
||||
? `${10 + (90 / this.valueInfo.range) * (val - this.valueInfo.min)}%`
|
||||
: "75%";
|
||||
return { height };
|
||||
},
|
||||
startLongPress(index) {
|
||||
startLongPress(index: number) {
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
this.isLongPress = true;
|
||||
this.hoverSlot(index);
|
||||
|
|
|
|||
|
|
@ -35,10 +35,29 @@ export interface Tariff {
|
|||
lastUpdate: Date;
|
||||
}
|
||||
|
||||
// data from api with string-based timestamps
|
||||
export interface RateRaw {
|
||||
start: string;
|
||||
end: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Rate {
|
||||
start: Date;
|
||||
end: Date;
|
||||
price: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Slot {
|
||||
day: string;
|
||||
value?: number;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
charging: boolean;
|
||||
toLate?: boolean | null;
|
||||
warning?: boolean | null;
|
||||
isTarget?: boolean | null;
|
||||
selectable?: boolean | null;
|
||||
}
|
||||
|
||||
export interface SelectOption<T> {
|
||||
|
|
|
|||
14
assets/js/utils/convertRates.ts
Normal file
14
assets/js/utils/convertRates.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { RateRaw, Rate } from "../types/evcc";
|
||||
|
||||
function convertRate(rate: RateRaw): Rate {
|
||||
return {
|
||||
start: new Date(rate.start),
|
||||
end: new Date(rate.end),
|
||||
value: rate.value,
|
||||
};
|
||||
}
|
||||
|
||||
export default function convertRates(rates: RateRaw[] | null): Rate[] {
|
||||
if (!rates) return [];
|
||||
return rates.map(convertRate);
|
||||
}
|
||||
|
|
@ -5,14 +5,14 @@ export interface TimeseriesEntry {
|
|||
ts: string;
|
||||
}
|
||||
|
||||
export interface PriceSlot {
|
||||
export interface ForecastSlot {
|
||||
start: string;
|
||||
end: string;
|
||||
price: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function isPriceSlot(obj?: TimeseriesEntry | PriceSlot): obj is PriceSlot {
|
||||
return (obj as PriceSlot).start !== undefined;
|
||||
export function isForecastSlot(obj?: TimeseriesEntry | ForecastSlot): obj is ForecastSlot {
|
||||
return (obj as ForecastSlot).start !== undefined;
|
||||
}
|
||||
|
||||
export interface EnergyByDay {
|
||||
|
|
@ -29,8 +29,8 @@ export interface SolarDetails {
|
|||
}
|
||||
|
||||
export interface Forecast {
|
||||
grid?: PriceSlot[];
|
||||
co2?: PriceSlot[];
|
||||
grid?: ForecastSlot[];
|
||||
co2?: ForecastSlot[];
|
||||
solar?: SolarDetails;
|
||||
}
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
|
|||
if (!solar?.scale) return solar;
|
||||
|
||||
const { scale } = solar;
|
||||
let result = deepCopy(solar);
|
||||
const result = deepCopy(solar);
|
||||
|
||||
if (result.today) result.today.energy *= scale;
|
||||
if (result.tomorrow) result.tomorrow.energy *= scale;
|
||||
|
|
|
|||
|
|
@ -47,14 +47,14 @@ import Chart from "../components/Forecast/Chart.vue";
|
|||
import IconSelectItem from "../components/Helper/IconSelectItem.vue";
|
||||
import IconSelectGroup from "../components/Helper/IconSelectGroup.vue";
|
||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||
import { ForecastType, type PriceSlot, type SolarDetails } from "../utils/forecast";
|
||||
import { ForecastType, type ForecastSlot, type SolarDetails } from "../utils/forecast";
|
||||
import formatter from "../mixins/formatter.ts";
|
||||
import store from "../store.ts";
|
||||
|
||||
interface Forecast {
|
||||
grid?: PriceSlot[];
|
||||
grid?: ForecastSlot[];
|
||||
solar?: SolarDetails;
|
||||
co2?: PriceSlot[];
|
||||
co2?: ForecastSlot[];
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func runTariff(cmd *cobra.Command, args []string) {
|
|||
const format = "2006-01-02 15:04:05"
|
||||
|
||||
for _, r := range rates {
|
||||
fmt.Fprintf(tw, "%s\t%s\t%.3f\n", r.Start.Local().Format(format), r.End.Local().Format(format), r.Price)
|
||||
fmt.Fprintf(tw, "%s\t%s\t%.3f\n", r.Start.Local().Format(format), r.End.Local().Format(format), r.Value)
|
||||
}
|
||||
tw.Flush()
|
||||
|
||||
|
|
|
|||
|
|
@ -1866,7 +1866,7 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rate
|
|||
// cheap tariff
|
||||
if smartCostActive {
|
||||
rate, _ := rates.At(time.Now())
|
||||
lp.log.DEBUG.Printf("smart cost active: %.2f", rate.Price)
|
||||
lp.log.DEBUG.Printf("smart cost active: %.2f", rate.Value)
|
||||
err = lp.fastCharging()
|
||||
lp.resetPhaseTimer()
|
||||
lp.elapsePVTimer() // let PV mode disable immediately afterwards
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
|
|||
|
||||
// log plan
|
||||
for _, slot := range plan {
|
||||
lp.log.TRACE.Printf(" slot from: %v to %v cost %.3f", slot.Start.Round(time.Second).Local(), slot.End.Round(time.Second).Local(), slot.Price)
|
||||
lp.log.TRACE.Printf(" slot from: %v to %v cost %.3f", slot.Start.Round(time.Second).Local(), slot.End.Round(time.Second).Local(), slot.Value)
|
||||
}
|
||||
|
||||
activeSlot := planner.SlotAt(lp.clock.Now(), plan)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
|
||||
rate, err := rates.At(time.Now())
|
||||
limit := lp.GetSmartCostLimit()
|
||||
return err == nil && limit != nil && rate.Price <= *limit
|
||||
return err == nil && limit != nil && rate.Value <= *limit
|
||||
}
|
||||
|
||||
// smartCostNextStart returns the next start time for a smart cost rate below the limit
|
||||
|
|
@ -21,7 +21,7 @@ func (lp *Loadpoint) smartCostNextStart(rates api.Rates) time.Time {
|
|||
|
||||
now := time.Now()
|
||||
for _, slot := range rates {
|
||||
if slot.Start.After(now) && slot.Price <= *limit {
|
||||
if slot.Start.After(now) && slot.Value <= *limit {
|
||||
return slot.Start
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func AverageCost(plan api.Rates) float64 {
|
|||
for _, slot := range plan {
|
||||
slotDuration := slot.End.Sub(slot.Start)
|
||||
duration += slotDuration
|
||||
cost += float64(slotDuration) * slot.Price
|
||||
cost += float64(slotDuration) * slot.Value
|
||||
}
|
||||
return cost / float64(duration)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra
|
|||
ar := api.Rate{
|
||||
Start: slotStart,
|
||||
End: slotStart.Add(slotDuration),
|
||||
Price: v,
|
||||
Value: v,
|
||||
}
|
||||
res = append(res, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import "github.com/evcc-io/evcc/api"
|
|||
// sortByCost is a sortFunc for slices.Sort
|
||||
func sortByCost(i, j api.Rate) int {
|
||||
switch {
|
||||
case i.Price < j.Price:
|
||||
case i.Value < j.Value:
|
||||
return -1
|
||||
case i.Price > j.Price:
|
||||
case i.Value > j.Value:
|
||||
return +1
|
||||
default:
|
||||
return j.Start.Compare(i.Start)
|
||||
|
|
|
|||
|
|
@ -13,15 +13,15 @@ import (
|
|||
func testRates(clock clock.Clock) api.Rates {
|
||||
return api.Rates{
|
||||
{
|
||||
Price: 2,
|
||||
Value: 2,
|
||||
Start: clock.Now().Add(2 * time.Hour),
|
||||
},
|
||||
{
|
||||
Price: 2,
|
||||
Value: 2,
|
||||
Start: clock.Now(),
|
||||
},
|
||||
{
|
||||
Price: 1,
|
||||
Value: 1,
|
||||
Start: clock.Now().Add(time.Hour),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,12 +93,12 @@ func (site *Site) plannerRates() (api.Rates, error) {
|
|||
|
||||
func (site *Site) smartCostActive(lp loadpoint.API, rate api.Rate) bool {
|
||||
limit := lp.GetSmartCostLimit()
|
||||
return limit != nil && !rate.IsZero() && rate.Price <= *limit
|
||||
return limit != nil && !rate.IsZero() && rate.Value <= *limit
|
||||
}
|
||||
|
||||
func (site *Site) batteryGridChargeActive(rate api.Rate) bool {
|
||||
limit := site.GetBatteryGridChargeLimit()
|
||||
return limit != nil && !rate.IsZero() && rate.Price <= *limit
|
||||
return limit != nil && !rate.IsZero() && rate.Value <= *limit
|
||||
}
|
||||
|
||||
func (site *Site) dischargeControlActive(rate api.Rate) bool {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ func timestampSeries(rr api.Rates) timeseries {
|
|||
return lo.Map(rr, func(r api.Rate, _ int) tsval {
|
||||
return tsval{
|
||||
Timestamp: r.Start,
|
||||
Value: r.Price,
|
||||
Value: r.Value,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,10 +105,10 @@ func (t *Amber) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: startTime.Local(),
|
||||
End: endTime.Local(),
|
||||
Price: r.PerKwh / 1e2,
|
||||
Value: r.PerKwh / 1e2,
|
||||
}
|
||||
if r.AdvancedPrice != nil {
|
||||
ar.Price = r.AdvancedPrice.Predicted / 1e2
|
||||
ar.Value = r.AdvancedPrice.Predicted / 1e2
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ func (t *Awattar) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: r.StartTimestamp.Local(),
|
||||
End: r.EndTimestamp.Local(),
|
||||
Price: t.totalPrice(r.Marketprice/1e3, r.StartTimestamp),
|
||||
Value: t.totalPrice(r.Marketprice/1e3, r.StartTimestamp),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ func (t *combined) Rates() (api.Rates, error) {
|
|||
return nil, errors.New("combined tariffs must have the same period length")
|
||||
}
|
||||
|
||||
rate.Price += r.Price
|
||||
rate.Value += r.Value
|
||||
}
|
||||
|
||||
res = append(res, rate)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func TestCombined(t *testing.T) {
|
|||
return api.Rate{
|
||||
Start: clock.Now().Add(time.Duration(start) * time.Hour),
|
||||
End: clock.Now().Add(time.Duration(start+1) * time.Hour),
|
||||
Price: val,
|
||||
Value: val,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ func (t *EdfTempo) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: ts,
|
||||
End: ts.Add(time.Hour),
|
||||
Price: t.totalPrice(t.prices[strings.ToLower(r.Value)], ts),
|
||||
Value: t.totalPrice(t.prices[strings.ToLower(r.Value)], ts),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ func (t *ElectricityMaps) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: r.Datetime.Local(),
|
||||
End: r.Datetime.Add(time.Hour).Local(),
|
||||
Price: r.CarbonIntensity,
|
||||
Value: r.CarbonIntensity,
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ func (t *Elering) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: ts,
|
||||
End: ts.Add(time.Hour),
|
||||
Price: t.totalPrice(r.Price/1e3, ts),
|
||||
Value: t.totalPrice(r.Price/1e3, ts),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ func (t *Energinet) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: ts.Local(),
|
||||
End: ts.Add(time.Hour).Local(),
|
||||
Price: t.totalPrice(r.SpotPriceDKK/1e3, ts),
|
||||
Value: t.totalPrice(r.SpotPriceDKK/1e3, ts),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ func (t *Entsoe) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: r.Start.Local(),
|
||||
End: r.End.Local(),
|
||||
Price: t.totalPrice(r.Value, r.Start),
|
||||
Value: t.totalPrice(r.Value, r.Start),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,9 +118,9 @@ func (t *Fixed) Rates() (api.Rates, error) {
|
|||
}
|
||||
|
||||
rate := api.Rate{
|
||||
Price: zone.Price,
|
||||
Start: ts,
|
||||
End: end,
|
||||
Value: zone.Price,
|
||||
}
|
||||
|
||||
res = append(res, rate)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func TestFixed(t *testing.T) {
|
|||
|
||||
for hour := 0; hour < 24; hour++ {
|
||||
expect = append(expect, api.Rate{
|
||||
Price: 0.3,
|
||||
Value: 0.3,
|
||||
Start: dayStart.Add(time.Hour * time.Duration(hour)),
|
||||
End: dayStart.Add(time.Hour * time.Duration(hour+1)),
|
||||
})
|
||||
|
|
@ -60,7 +60,7 @@ func TestFixedSplitZones(t *testing.T) {
|
|||
// 00:00-05:00 0.1
|
||||
for hour := 0; hour < 5; hour++ {
|
||||
expect = append(expect, api.Rate{
|
||||
Price: 0.1,
|
||||
Value: 0.1,
|
||||
Start: dayStart.Add(time.Hour * time.Duration(hour)),
|
||||
End: dayStart.Add(time.Hour * time.Duration(hour+1)),
|
||||
})
|
||||
|
|
@ -68,14 +68,14 @@ func TestFixedSplitZones(t *testing.T) {
|
|||
|
||||
// 05:00-05:30 0.1
|
||||
expect = append(expect, api.Rate{
|
||||
Price: 0.1,
|
||||
Value: 0.1,
|
||||
Start: dayStart.Add(5 * time.Hour),
|
||||
End: dayStart.Add(5*time.Hour + 30*time.Minute),
|
||||
})
|
||||
|
||||
// 05:30-06:00 0.5
|
||||
expect = append(expect, api.Rate{
|
||||
Price: 0.5,
|
||||
Value: 0.5,
|
||||
Start: dayStart.Add(5*time.Hour + 30*time.Minute),
|
||||
End: dayStart.Add(6 * time.Hour),
|
||||
})
|
||||
|
|
@ -83,7 +83,7 @@ func TestFixedSplitZones(t *testing.T) {
|
|||
// 06:00-21:00 0.5
|
||||
for hour := 6; hour < 21; hour++ {
|
||||
expect = append(expect, api.Rate{
|
||||
Price: 0.5,
|
||||
Value: 0.5,
|
||||
Start: dayStart.Add(time.Hour * time.Duration(hour)),
|
||||
End: dayStart.Add(time.Hour * time.Duration(hour+1)),
|
||||
})
|
||||
|
|
@ -92,7 +92,7 @@ func TestFixedSplitZones(t *testing.T) {
|
|||
// 21:00-00:00 0.1
|
||||
for hour := 21; hour < 24; hour++ {
|
||||
expect = append(expect, api.Rate{
|
||||
Price: 0.1,
|
||||
Value: 0.1,
|
||||
Start: dayStart.Add(time.Hour * time.Duration(hour)),
|
||||
End: dayStart.Add(time.Hour * time.Duration(hour+1)),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func (t *GroupeE) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: r.StartTimestamp.Local(),
|
||||
End: r.EndTimestamp.Local(),
|
||||
Price: r.VarioPlus / 1e2, // Rp/kWh
|
||||
Value: r.VarioPlus / 1e2, // Rp/kWh
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,9 +88,9 @@ func (t *GrünStromIndex) run(done chan error) {
|
|||
data := make(api.Rates, 0, len(res.Forecast))
|
||||
for _, r := range res.Forecast {
|
||||
data = append(data, api.Rate{
|
||||
Price: float64(r.Co2GStandard),
|
||||
Start: time.UnixMilli(r.Timeframe.Start).Local(),
|
||||
End: time.UnixMilli(r.Timeframe.End).Local(),
|
||||
Value: float64(r.Co2GStandard),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func TestMergeRatesAfter(t *testing.T) {
|
|||
return api.Rate{
|
||||
Start: clock.Now().Add(time.Duration(start) * time.Hour),
|
||||
End: clock.Now().Add(time.Duration(start+1) * time.Hour),
|
||||
Price: val,
|
||||
Value: val,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ func (t *Ngeso) run(done chan error) {
|
|||
Start: r.ValidityStart.Time,
|
||||
End: r.ValidityEnd.Time,
|
||||
// Use the forecasted rate, as the actual rate is only available for historical data
|
||||
Price: r.Intensity.Forecast,
|
||||
Value: r.Intensity.Forecast,
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ func (t *Octopus) run(done chan error) {
|
|||
Start: r.ValidityStart,
|
||||
End: rateEnd,
|
||||
// UnitRates are supplied inclusive of tax, though this could be flipped easily with a config flag.
|
||||
Price: r.PriceInclusiveTax / 1e2,
|
||||
Value: r.PriceInclusiveTax / 1e2,
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ func (t *Ostrom) runStatic(done chan error) {
|
|||
data[i] = api.Rate{
|
||||
Start: ts,
|
||||
End: ts.Add(time.Hour),
|
||||
Price: price / 100.0,
|
||||
Value: price / 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ func (t *Ostrom) run(done chan error) {
|
|||
data = append(data, api.Rate{
|
||||
Start: ts,
|
||||
End: ts.Add(time.Hour),
|
||||
Price: (val.Marketprice + val.AdditionalCost) / 100.0, // Both values include VAT
|
||||
Value: (val.Marketprice + val.AdditionalCost) / 100.0, // Both values include VAT
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) {
|
|||
ar := api.Rate{
|
||||
Start: ts,
|
||||
End: ts.Add(time.Hour),
|
||||
Price: t.totalPrice(price/1e3, ts),
|
||||
Value: t.totalPrice(price/1e3, ts),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func (t *SmartEnergy) run(done chan error) {
|
|||
ar := api.Rate{
|
||||
Start: r.Date.Local(),
|
||||
End: r.Date.Add(time.Hour).Local(),
|
||||
Price: t.totalPrice(r.Value/100, r.Date),
|
||||
Value: t.totalPrice(r.Value/100, r.Date),
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,12 +95,12 @@ func (t *Solcast) run(interval time.Duration, done chan error) {
|
|||
rr := api.Rate{
|
||||
Start: start,
|
||||
End: start.Add(time.Hour),
|
||||
Price: r.PvEstimate * 1e3,
|
||||
Value: r.PvEstimate * 1e3,
|
||||
}
|
||||
if r.Period.Duration() != time.Hour {
|
||||
for i, r := range data {
|
||||
if r.Start.Equal(rr.Start) {
|
||||
data[i].Price = (r.Price + rr.Price) / 2
|
||||
data[i].Value = (r.Value + rr.Value) / 2
|
||||
continue NEXT
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval
|
|||
}
|
||||
for i, r := range data {
|
||||
data[i] = api.Rate{
|
||||
Price: t.totalPrice(r.Price, r.Start),
|
||||
Value: t.totalPrice(r.Value, r.Start),
|
||||
Start: r.Start.Local(),
|
||||
End: r.End.Local(),
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ func (t *Tariff) priceRates() (api.Rates, error) {
|
|||
res[i] = api.Rate{
|
||||
Start: slot,
|
||||
End: slot.Add(time.Hour),
|
||||
Price: t.totalPrice(price, slot),
|
||||
Value: t.totalPrice(price, slot),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func At(t api.Tariff, ts time.Time) (api.Rate, error) {
|
|||
// Now returns the price/cost/value at the given time
|
||||
func Now(t api.Tariff) (float64, error) {
|
||||
r, err := At(t, time.Now())
|
||||
return r.Price, err
|
||||
return r.Value, err
|
||||
}
|
||||
|
||||
func Forecast(t api.Tariff) api.Rates {
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ func (t *Tibber) rates(pi []tibber.Price) api.Rates {
|
|||
ar := api.Rate{
|
||||
Start: r.StartsAt.Local(),
|
||||
End: r.StartsAt.Add(time.Hour).Local(),
|
||||
Price: price,
|
||||
Value: price,
|
||||
}
|
||||
data = append(data, ar)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,5 +18,5 @@ render: |
|
|||
[.timestamps, .prices] | transpose | map({
|
||||
"start": .[0] | strptime("%FT%T.%f%z") | strftime("%FT%TZ"),
|
||||
"end": .[0] | strptime("%FT%T.%f%z") | mktime + 3600 | strftime("%FT%TZ"),
|
||||
"price": .[1]
|
||||
"value": .[1]
|
||||
}) | tostring
|
||||
|
|
|
|||
|
|
@ -46,5 +46,5 @@ render: |
|
|||
[.unix_seconds, .price] | transpose | map({
|
||||
"start": .[0] | todate,
|
||||
"end": .[0]+3600 | todate,
|
||||
"price": .[1]/1000
|
||||
"value": .[1]/1000
|
||||
}) | tostring
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ render: |
|
|||
{
|
||||
"start": .datum | strptime("%F %T") | strflocaltime("%FT%T%:z"),
|
||||
"end": .datum | strptime("%F %T") | strflocaltime("%FT%T%:z") | strptime("%FT%T%z") | mktime + 3600 | strflocaltime("%FT%T%:z"),
|
||||
"price": .prijs{{ .provider }} | tonumber
|
||||
"value": .prijs{{ .provider }} | tonumber
|
||||
}
|
||||
] | tostring
|
||||
- name: tomorrow
|
||||
|
|
@ -69,7 +69,7 @@ render: |
|
|||
{
|
||||
"start": .datum | strptime("%F %T") | strflocaltime("%FT%T%:z"),
|
||||
"end": .datum | strptime("%F %T") | strflocaltime("%FT%T%:z") | strptime("%FT%T%z") | mktime + 3600 | strflocaltime("%FT%T%:z"),
|
||||
"price": .prijs{{ .provider }} | tonumber
|
||||
"value": .prijs{{ .provider }} | tonumber
|
||||
}
|
||||
] | tostring
|
||||
interval: {{ .interval }}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,6 @@ render: |
|
|||
[ .result.watts | to_entries.[] | {
|
||||
"start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")),
|
||||
"end": (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
|
||||
"price": .value
|
||||
"value": .value
|
||||
} ] | tostring
|
||||
interval: {{ .interval }}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ render: |
|
|||
{
|
||||
"start": .deliveryStart,
|
||||
"end": .deliveryEnd,
|
||||
"price": .entryPerArea.{{ .region }} / 1000
|
||||
"value": .entryPerArea.{{ .region }} / 1000
|
||||
}
|
||||
] | tostring
|
||||
- name: tomorrow
|
||||
|
|
@ -76,7 +76,7 @@ render: |
|
|||
{
|
||||
"start": .deliveryStart,
|
||||
"end": .deliveryEnd,
|
||||
"price": .entryPerArea.{{ .region }} / 1000
|
||||
"value": .entryPerArea.{{ .region }} / 1000
|
||||
}
|
||||
] | tostring
|
||||
{{ include "tariff-base" . }}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ render: |
|
|||
[ .[] | {
|
||||
start: .from,
|
||||
end: (.from | fromdate + 3600 | todate),
|
||||
price: (.price/100)
|
||||
value: (.price/100)
|
||||
} ] | tostring
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue