Tariffs: rename price to value (BC) (#19182)

This commit is contained in:
andig 2025-04-06 19:05:35 +02:00 • committed by GitHub
parent e945ed10fb
commit 48d84356e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 271 additions and 249 deletions

View file

@ -10,12 +10,12 @@ import (
type Rate struct { type Rate struct {
Start time.Time `json:"start"` Start time.Time `json:"start"`
End time.Time `json:"end"` End time.Time `json:"end"`
Price float64 `json:"price"` Value float64 `json:"value"`
} }
// IsZero returns is the rate is the zero value // IsZero returns is the rate is the zero value
func (r Rate) IsZero() bool { 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 // Rates is a slice of (future) tariff rates

View file

@ -14,7 +14,7 @@ func TestRates(t *testing.T) {
return Rate{ return Rate{
Start: clock.Now().Add(time.Duration(start) * time.Hour), Start: clock.Now().Add(time.Duration(start) * time.Hour),
End: clock.Now().Add(time.Duration(start+1) * 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++ { for i := 1; i <= 4; i++ {
r, err := rr.At(clock.Now().Add(time.Duration(i) * time.Hour)) r, err := rr.At(clock.Now().Add(time.Duration(i) * time.Hour))
assert.NoError(t, err) 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)) r, err = rr.At(clock.Now().Add(time.Duration(i)*time.Hour + 30*time.Minute))
assert.NoError(t, err) 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)) _, err = rr.At(clock.Now().Add(5 * time.Hour))

View file

@ -75,9 +75,9 @@ import collector from "../../mixins/collector.js";
import api, { allowClientError } from "../../api.js"; import api, { allowClientError } from "../../api.js";
import CustomSelect from "../Helper/CustomSelect.vue"; import CustomSelect from "../Helper/CustomSelect.vue";
import deepEqual from "../../utils/deepEqual.js"; import deepEqual from "../../utils/deepEqual.js";
import convertRates from "../../utils/convertRates";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { Vehicle, PartialBy, Timeout, Tariff, SelectOption, CURRENCY } from "../../types/evcc"; import type { Vehicle, PartialBy, Timeout, Tariff, SelectOption, CURRENCY } from "../../types/evcc";
import type { import type {
StaticPlan, StaticPlan,
RepeatingPlan, RepeatingPlan,
@ -316,10 +316,10 @@ export default defineComponent({
return; return;
} }
const tariffRes = await api.get(`tariff/planner`, allowClientError); const res = await api.get(`tariff/planner`, allowClientError);
this.tariff = { this.tariff = {
rates: tariffRes.status === 404 ? [] : tariffRes.data.result.rates, rates: res.status === 404 ? [] : convertRates(res.data.result.rates),
lastUpdate: new Date(), lastUpdate: new Date(),
}; };
}, },

View file

@ -10,7 +10,7 @@ function createDate(hoursFromNow: number) {
return result; 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()); const start = new Date(now.getTime());
start.setHours(start.getHours() + hoursFromNow); start.setHours(start.getHours() + hoursFromNow);
start.setMinutes(0); start.setMinutes(0);
@ -21,7 +21,7 @@ function createRate(price: number, hoursFromNow: number, durationHours = 1): Rat
end.setMinutes(0); end.setMinutes(0);
end.setSeconds(0); end.setSeconds(0);
end.setMilliseconds(0); end.setMilliseconds(0);
return { start, end, price }; return { start, end, value };
} }
// Scenario data // 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, 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, 400, 555, 555, 545, 555, 564, 545, 555, 545, 536, 545, 527, 536, 518, 545, 509, 336, 336,
336, 336,
].map((price, i) => createRate(price, i)), ].map((value, i) => createRate(value, i)),
duration: 8695, duration: 8695,
plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)], plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)],
smartCostType: "co2", smartCostType: "co2",
@ -72,7 +72,7 @@ const unknownData = {
const dynamicData = { const dynamicData = {
rates: [ 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, 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, duration: 8695,
plan: [createRate(0.23, 2, 5)], plan: [createRate(0.23, 2, 5)],
smartCostType: "price", smartCostType: "price",

View file

@ -1,7 +1,7 @@
import { mount, config } from "@vue/test-utils"; import { mount, config } from "@vue/test-utils";
import { beforeAll, describe, expect, test } from "vitest"; import { beforeAll, describe, expect, test } from "vitest";
import Preview from "./Preview.vue"; 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["$i18n"] = { locale: "de-DE" };
config.global.mocks["$t"] = (a: any) => a; config.global.mocks["$t"] = (a: any) => a;
@ -13,14 +13,14 @@ describe("basics", () => {
{ {
start: new Date("2023-01-11T11:00:00+01:00"), start: new Date("2023-01-11T11:00:00+01:00"),
end: new Date("2023-01-22T00:00:00+01:00"), end: new Date("2023-01-22T00:00:00+01:00"),
price: 0.4, value: 0.4,
}, },
]; ];
const PLAN = [ const PLAN = [
{ {
start: new Date("2023-01-11T12:00:00+01:00"), start: new Date("2023-01-11T12:00:00+01:00"),
end: new Date("2023-01-11T13: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); expect(result[3].charging).eq(false);
}); });
test("all slots have the same fixed price", () => { test("all slots have the same fixed value", () => {
result.forEach((slot) => expect(slot.price).eq(0.4)); 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"), start: new Date("2023-01-11T11:00:00+01:00"),
end: new Date("2023-01-11T12: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"), start: new Date("2023-01-11T12:00:00+01:00"),
end: new Date("2023-01-22T00:00:00+01:00"), end: new Date("2023-01-22T00:00:00+01:00"),
price: 0.4, value: 0.4,
}, },
]; ];
const PLAN = [ const PLAN = [
{ {
start: new Date("2023-01-11T11:30:00+01:00"), start: new Date("2023-01-11T11:30:00+01:00"),
end: new Date("2023-01-11T13:00: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"), start: new Date("2023-01-11T14:30:00+01:00"),
end: new Date("2023-01-11T16:00: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", () => { test("first slot is cheap, others are expensive", () => {
const [first, ...others] = result; const [first, ...others] = result;
expect(first.price).eq(0.2); expect(first.value).eq(0.2);
others.forEach((slot) => expect(slot.price).eq(0.4)); others.forEach((slot) => expect(slot.value).eq(0.4));
}); });
}); });

View file

@ -21,7 +21,7 @@
<span v-else>{{ $t("main.targetChargePlan.priceLabel") }}</span> <span v-else>{{ $t("main.targetChargePlan.priceLabel") }}</span>
</div> </div>
<div class="value text-primary"> <div class="value text-primary">
{{ fmtAvgPrice }} {{ fmtAvgValue }}
</div> </div>
</div> </div>
</div> </div>
@ -39,8 +39,7 @@ import { defineComponent, type PropType } from "vue";
import formatter from "../../mixins/formatter.js"; import formatter from "../../mixins/formatter.js";
import { CO2_TYPE } from "../../units.js"; import { CO2_TYPE } from "../../units.js";
import TariffChart from "../Tariff/TariffChart.vue"; import TariffChart from "../Tariff/TariffChart.vue";
import type { CURRENCY, Rate } from "assets/js/types/evcc.js"; import type { CURRENCY, Rate, Slot } from "../../types/evcc.js";
import type { Slot } from "./types.js";
export default defineComponent({ export default defineComponent({
name: "ChargingPlanPreview", name: "ChargingPlanPreview",
@ -87,29 +86,29 @@ export default defineComponent({
hasTariff(): boolean { hasTariff(): boolean {
return (this.rates?.length || 0) > 1; return (this.rates?.length || 0) > 1;
}, },
avgPrice(): number | undefined { avgValue(): number | undefined {
let hourSum = 0; let hourSum = 0;
let priceSum = 0; let valueSum = 0;
this.convertDates(this.plan).forEach((slot) => { this.convertDates(this.plan).forEach((slot) => {
const hours = (slot.end.getTime() - slot.start.getTime()) / 3600000; const hours = (slot.end.getTime() - slot.start.getTime()) / 3600000;
if (slot.price) { if (slot.value) {
hourSum += hours; 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) { if (this.duration === 0) {
return "—"; return "—";
} }
const price = this.activeSlot ? this.activeSlot.price : this.avgPrice; const value = this.activeSlot ? this.activeSlot.value : this.avgValue;
if (price === undefined) { if (value === undefined) {
return this.$t("main.targetChargePlan.unknownPrice"); return this.$t("main.targetChargePlan.unknownPrice");
} }
return this.isCo2 return this.isCo2
? this.fmtCo2Medium(price) ? this.fmtCo2Medium(value)
: this.fmtPricePerKWh(price, this.currency); : this.fmtPricePerKWh(value, this.currency);
}, },
activeSlot(): Slot | null { activeSlot(): Slot | null {
return this.activeIndex ? this.slots[this.activeIndex] : null; return this.activeIndex ? this.slots[this.activeIndex] : null;
@ -122,10 +121,8 @@ export default defineComponent({
} }
return null; return null;
}, },
targetHourOffset(): number | null { targetHourOffset(): number | undefined {
if (!this.targetTime) { if (!this.targetTime) return;
return null;
}
const start = new Date(this.startTime); const start = new Date(this.startTime);
start.setMinutes(0); start.setMinutes(0);
start.setSeconds(0); start.setSeconds(0);
@ -155,7 +152,7 @@ export default defineComponent({
const day = this.weekdayShort(start); const day = this.weekdayShort(start);
const toLate = this.targetTime && this.targetTime <= start; const toLate = this.targetTime && this.targetTime <= start;
// TODO: handle multiple matching time slots // TODO: handle multiple matching time slots
const price = this.findSlotInRange(start, end, rates)?.price; const value = this.findSlotInRange(start, end, rates)?.value;
const isTarget = const isTarget =
this.targetTime && start <= this.targetTime && end > this.targetTime; this.targetTime && start <= this.targetTime && end > this.targetTime;
const charging = this.findSlotInRange(start, end, plan) != null; const charging = this.findSlotInRange(start, end, plan) != null;
@ -167,7 +164,7 @@ export default defineComponent({
this.targetTime < this.endTime; this.targetTime < this.endTime;
result.push({ result.push({
day, day,
price, value,
startHour, startHour,
endHour, endHour,
charging, charging,
@ -193,7 +190,7 @@ export default defineComponent({
return { return {
start: new Date(item.start), start: new Date(item.start),
end: new Date(item.end), end: new Date(item.end),
price: item.price, value: item.value,
}; };
}); });
}, },

View file

@ -32,14 +32,3 @@ export interface StaticEnergyPlan {
energy: number; energy: number;
time: Date; time: Date;
} }
export interface Slot {
day: string;
price?: number;
startHour: number;
endHour: number;
charging: boolean;
toLate?: boolean | null;
warning?: boolean | null;
isTarget?: boolean | null;
}

View file

@ -11,32 +11,32 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import type { PropType } from "vue"; import type { PropType } from "vue";
import { isPriceSlot, type PriceSlot, type TimeseriesEntry } from "../../utils/forecast.ts"; import { isForecastSlot, type ForecastSlot, type TimeseriesEntry } from "../../utils/forecast";
import formatter from "../../mixins/formatter.ts"; import formatter from "../../mixins/formatter";
export default defineComponent({ export default defineComponent({
name: "ForecastActiveSlot", name: "ForecastActiveSlot",
mixins: [formatter], mixins: [formatter],
props: { props: {
activeSlot: { type: Object as PropType<PriceSlot | TimeseriesEntry | null> }, activeSlot: { type: Object as PropType<ForecastSlot | TimeseriesEntry | null> },
}, },
computed: { computed: {
isSlot() { isSlot() {
return this.activeSlot !== null && isPriceSlot(this.activeSlot); return this.activeSlot !== null && isForecastSlot(this.activeSlot);
}, },
isTimeseries() { isTimeseries() {
return this.activeSlot !== null && !isPriceSlot(this.activeSlot); return this.activeSlot !== null && !isForecastSlot(this.activeSlot);
}, },
day() { day() {
const startDate = new Date((this.activeSlot! as PriceSlot).start); const startDate = new Date((this.activeSlot! as ForecastSlot).start);
return this.weekdayShort(startDate); return this.weekdayShort(startDate);
}, },
start() { start() {
const startDate = new Date((this.activeSlot! as PriceSlot).start); const startDate = new Date((this.activeSlot! as ForecastSlot).start);
return this.hourShort(startDate); return this.hourShort(startDate);
}, },
end() { end() {
const endDate = new Date((this.activeSlot! as PriceSlot).end); const endDate = new Date((this.activeSlot! as ForecastSlot).end);
return this.hourShort(endDate); return this.hourShort(endDate);
}, },
time() { time() {

View file

@ -38,7 +38,7 @@ import colors, { lighterColor } from "../../colors.ts";
import { import {
highestSlotIndexByDay, highestSlotIndexByDay,
ForecastType, ForecastType,
type PriceSlot, type ForecastSlot,
type SolarDetails, type SolarDetails,
type TimeseriesEntry, type TimeseriesEntry,
} from "../../utils/forecast.ts"; } from "../../utils/forecast.ts";
@ -63,9 +63,9 @@ export default defineComponent({
components: { Bar }, components: { Bar },
mixins: [formatter], mixins: [formatter],
props: { props: {
grid: { type: Array as PropType<PriceSlot[]> }, grid: { type: Array as PropType<ForecastSlot[]> },
solar: { type: Object as PropType<SolarDetails> }, solar: { type: Object as PropType<SolarDetails> },
co2: { type: Array as PropType<PriceSlot[]> }, co2: { type: Array as PropType<ForecastSlot[]> },
currency: { type: String as PropType<CURRENCY> }, currency: { type: String as PropType<CURRENCY> },
selected: { type: String as PropType<ForecastType> }, selected: { type: String as PropType<ForecastType> },
}, },
@ -174,7 +174,7 @@ export default defineComponent({
datasets.push({ datasets.push({
label: ForecastType.Price, label: ForecastType.Price,
data: this.gridSlots.map((slot, index) => ({ data: this.gridSlots.map((slot, index) => ({
y: slot.price, y: slot.value,
x: new Date(slot.start), x: new Date(slot.start),
highlight: highlight:
active && active &&
@ -202,7 +202,7 @@ export default defineComponent({
? this.selectedIndex === index ? this.selectedIndex === index
: index === this.maxCo2Index || index === this.minCo2Index); : index === this.maxCo2Index || index === this.minCo2Index);
return { return {
y: slot.price, y: slot.value,
x: new Date(slot.start), x: new Date(slot.start),
highlight: dataActive, highlight: dataActive,
active: dataActive, active: dataActive,
@ -422,7 +422,7 @@ export default defineComponent({
now.setMilliseconds(0); now.setMilliseconds(0);
this.startDate = now; this.startDate = now;
}, },
filterSlots(slots: PriceSlot[] = []) { filterSlots(slots: ForecastSlot[] = []) {
return slots.filter( return slots.filter(
(slot) => (slot) =>
new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate new Date(slot.end) >= this.startDate && new Date(slot.start) <= this.endDate
@ -462,7 +462,7 @@ export default defineComponent({
}, 100); }, 100);
} }
}, },
yMax(slots: PriceSlot[] = []): number | undefined { yMax(slots: ForecastSlot[] = []): number | undefined {
const value = this.maxValue(slots); const value = this.maxValue(slots);
return value ? value * 1.15 : undefined; return value ? value * 1.15 : undefined;
}, },
@ -472,18 +472,18 @@ export default defineComponent({
// use scale and unscaled to determine max scale // use scale and unscaled to determine max scale
return Math.max(maxValue * scale, maxValue) * 1.15; return Math.max(maxValue * scale, maxValue) * 1.15;
}, },
maxIndex(slots: PriceSlot[] = []) { maxIndex(slots: ForecastSlot[] = []) {
return slots.reduce((max, slot, index) => { return slots.reduce((max, slot, index) => {
return slot.price > slots[max].price ? index : max; return slot.value > slots[max].value ? index : max;
}, 0); }, 0);
}, },
minIndex(slots: PriceSlot[] = []) { minIndex(slots: ForecastSlot[] = []) {
return slots.reduce((min, slot, index) => { return slots.reduce((min, slot, index) => {
return slot.price < slots[min].price ? index : min; return slot.value < slots[min].value ? index : min;
}, 0); }, 0);
}, },
maxValue(slots: PriceSlot[] = []) { maxValue(slots: ForecastSlot[] = []) {
return slots[this.maxIndex(slots)]?.price || null; return slots[this.maxIndex(slots)]?.value || null;
}, },
maxEntryValue(entries: TimeseriesEntry[] = []) { maxEntryValue(entries: TimeseriesEntry[] = []) {
return entries[this.maxEntryIndex(entries)]?.val || null; return entries[this.maxEntryIndex(entries)]?.val || null;

View file

@ -64,7 +64,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; 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 formatter, { POWER_UNIT } from "../../mixins/formatter.ts";
import AnimatedNumber from "../Helper/AnimatedNumber.vue"; import AnimatedNumber from "../Helper/AnimatedNumber.vue";
import type { CURRENCY, Timeout } from "assets/js/types/evcc.ts"; import type { CURRENCY, Timeout } from "assets/js/types/evcc.ts";
@ -89,8 +89,8 @@ export default defineComponent({
mixins: [formatter], mixins: [formatter],
props: { props: {
type: { type: String as () => ForecastType, required: true }, type: { type: String as () => ForecastType, required: true },
grid: { type: Array as PropType<PriceSlot[]> }, grid: { type: Array as PropType<ForecastSlot[]> },
co2: { type: Array as PropType<PriceSlot[]> }, co2: { type: Array as PropType<ForecastSlot[]> },
solar: { type: Object as PropType<SolarDetails> }, solar: { type: Object as PropType<SolarDetails> },
currency: { type: String as PropType<CURRENCY> }, currency: { type: String as PropType<CURRENCY> },
}, },
@ -107,7 +107,7 @@ export default defineComponent({
isPrice() { isPrice() {
return this.type === ForecastType.Price; return this.type === ForecastType.Price;
}, },
upcomingSlots(): PriceSlot[] { upcomingSlots(): ForecastSlot[] {
const now = this.now; const now = this.now;
const slots = this.isPrice ? this.grid || [] : this.co2 || []; const slots = this.isPrice ? this.grid || [] : this.co2 || [];
return slots.filter((slot) => new Date(slot.end) > now).slice(0, 48); return slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
@ -115,21 +115,21 @@ export default defineComponent({
averagePrice() { averagePrice() {
if (this.isSolar) return ""; if (this.isSolar) return "";
const slots = this.upcomingSlots; 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); return this.fmtValue(price, true);
}, },
priceRange() { priceRange() {
if (this.isSolar) return ""; if (this.isSolar) return "";
const slots = this.upcomingSlots; const slots = this.upcomingSlots;
const min = Math.min(...slots.map((slot) => slot.price)); const min = Math.min(...slots.map((slot) => slot.value));
const max = Math.max(...slots.map((slot) => slot.price)); const max = Math.max(...slots.map((slot) => slot.value));
return `${this.fmtValue(min, false)} – ${this.fmtValue(max, true)}`; return `${this.fmtValue(min, false)} – ${this.fmtValue(max, true)}`;
}, },
lowestPriceHour() { lowestPriceHour() {
if (this.isSolar) return ""; if (this.isSolar) return "";
const slots = this.upcomingSlots; const slots = this.upcomingSlots;
const min = Math.min(...slots.map((slot) => slot.price)); const min = Math.min(...slots.map((slot) => slot.value));
const slot = slots.find((slot) => slot.price === min); const slot = slots.find((slot) => slot.value === min);
if (!slot) return ""; if (!slot) return "";
const start = new Date(slot.start); const start = new Date(slot.start);
const end = new Date(slot.end); const end = new Date(slot.end);

View file

@ -59,7 +59,7 @@ import TypeSelect from "./TypeSelect.vue";
import Details from "./Details.vue"; import Details from "./Details.vue";
import ActiveSlot from "./ActiveSlot.vue"; import ActiveSlot from "./ActiveSlot.vue";
import { import {
type PriceSlot, type ForecastSlot,
type TimeseriesEntry, type TimeseriesEntry,
type Forecast, type Forecast,
ForecastType, ForecastType,
@ -85,7 +85,7 @@ export default defineComponent({
data(): { data(): {
isModalVisible: boolean; isModalVisible: boolean;
selectedType: ForecastType; selectedType: ForecastType;
selectedSlot: PriceSlot | TimeseriesEntry | null; selectedSlot: ForecastSlot | TimeseriesEntry | null;
} { } {
return { return {
isModalVisible: false, isModalVisible: false,
@ -134,7 +134,7 @@ export default defineComponent({
modalInvisible() { modalInvisible() {
this.isModalVisible = false; this.isModalVisible = false;
}, },
updateSlot(slot: PriceSlot | TimeseriesEntry | null) { updateSlot(slot: ForecastSlot | TimeseriesEntry | null) {
this.selectedSlot = slot; this.selectedSlot = slot;
}, },
updateSelectedType() { updateSelectedType() {

View file

@ -325,7 +325,7 @@ export default {
const { rates } = res.data.result; const { rates } = res.data.result;
this.referenceGrid = this.referenceGrid =
rates.reduce((acc, slot) => { rates.reduce((acc, slot) => {
return acc + slot.price; return acc + slot.value;
}, 0) / rates.length; }, 0) / rates.length;
} catch (e) { } catch (e) {
this.referenceGrid = undefined; this.referenceGrid = undefined;

View file

@ -82,47 +82,53 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import formatter from "../../mixins/formatter.js"; import formatter from "../../mixins/formatter.js";
import TariffChart from "./TariffChart.vue"; import TariffChart from "./TariffChart.vue";
import { CO2_TYPE } from "../../units.js"; import { CO2_TYPE } from "../../units.js";
import api, { allowClientError } from "../../api.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 { export default {
name: "SmartCostLimit", name: "SmartCostLimit",
components: { TariffChart }, components: { TariffChart },
mixins: [formatter], mixins: [formatter],
props: { props: {
smartCostLimit: Number, smartCostLimit: {
type: [Number, null] as PropType<number | null>,
required: true,
},
smartCostType: String, smartCostType: String,
tariffGrid: Number, tariffGrid: Number,
currency: String, currency: String as PropType<CURRENCY>,
loadpointId: Number, loadpointId: Number,
multipleLoadpoints: Boolean, multipleLoadpoints: Boolean,
possible: Boolean, possible: Boolean,
}, },
data() { data() {
return { return {
selectedSmartCostLimit: null, selectedSmartCostLimit: null as number | null,
tariff: null, tariff: null as Tariff | null,
startTime: null, startTime: null as Date | null,
activeIndex: null, activeIndex: null as number | null,
applyToAllVisible: false, applyToAllVisible: false,
}; };
}, },
computed: { computed: {
isCo2() { isCo2(): boolean {
return this.smartCostType === CO2_TYPE; return this.smartCostType === CO2_TYPE;
}, },
costOptions() { costOptions(): SelectOption<number>[] {
const { max } = this.costRange(this.totalSlots); const { max } = this.costRange(this.totalSlots);
const values = []; const values = [] as number[];
const stepSize = this.optionStepSize; const stepSize = this.optionStepSize;
for (let i = 1; i <= 100; i++) { for (let i = 1; i <= 100; i++) {
const value = this.optionStartValue + stepSize * i; const value = this.optionStartValue + stepSize * i;
if (value > max + stepSize) break; if (max !== undefined && value > max + stepSize) break;
values.push(this.roundLimit(value)); values.push(this.roundLimit(value) as number);
} }
// add special entry if currently selected value is not in the scale // add special entry if currently selected value is not in the scale
const selected = this.selectedSmartCostLimit; const selected = this.selectedSmartCostLimit;
@ -140,7 +146,7 @@ export default {
const stepSize = this.optionStepSize; const stepSize = this.optionStepSize;
// always show some negative values for price // always show some negative values for price
const start = this.isCo2 ? 0 : stepSize * -11; 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; return Math.floor(minValue / stepSize) * stepSize;
}, },
optionStepSize() { optionStepSize() {
@ -148,6 +154,9 @@ export default {
return 1; return 1;
} }
const { min, max } = this.costRange(this.totalSlots); const { min, max } = this.costRange(this.totalSlots);
if (min === undefined || max === undefined) {
return 1;
}
const baseSteps = [0.001, 0.002, 0.005]; const baseSteps = [0.001, 0.002, 0.005];
const range = max - Math.min(0, min); const range = max - Math.min(0, min);
@ -159,11 +168,11 @@ export default {
} }
return 1; return 1;
}, },
slots() { slots(): Slot[] {
const result = []; const result: Slot[] = [];
if (!this.tariff?.rates) return result; if (!this.tariff?.rates || !this.startTime) return result;
const rates = this.convertDates(this.tariff.rates); const { rates } = this.tariff;
const oneHour = 60 * 60 * 1000; const oneHour = 60 * 60 * 1000;
for (let i = 0; i < 42; i++) { for (let i = 0; i < 42; i++) {
@ -177,17 +186,19 @@ export default {
const endHour = end.getHours(); const endHour = end.getHours();
const day = this.weekdayShort(start); const day = this.weekdayShort(start);
// TODO: handle multiple matching time slots // TODO: handle multiple matching time slots
const price = this.findSlotInRange(start, end, rates)?.price; const value = this.findRateInRange(start, end, rates)?.value;
const charging = const charging =
price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== null; this.selectedSmartCostLimit !== null &&
const selectable = price !== undefined; value !== undefined &&
result.push({ day, price, startHour, endHour, charging, selectable }); value <= this.selectedSmartCostLimit;
const selectable = value !== undefined;
result.push({ day, value, startHour, endHour, charging, selectable });
} }
return result; return result;
}, },
totalSlots() { totalSlots() {
return this.slots.filter((s) => s.price !== undefined); return this.slots.filter((s) => s.value !== undefined);
}, },
chargingSlots() { chargingSlots() {
return this.totalSlots.filter((s) => s.charging); return this.totalSlots.filter((s) => s.charging);
@ -198,8 +209,8 @@ export default {
chargingCostRange() { chargingCostRange() {
return this.fmtCostRange(this.costRange(this.chargingSlots)); return this.fmtCostRange(this.costRange(this.chargingSlots));
}, },
activeSlot() { activeSlot(): Slot | null {
return this.slots[this.activeIndex]; return this.activeIndex !== null ? this.slots[this.activeIndex] : null;
}, },
activeSlotName() { activeSlotName() {
if (this.activeSlot) { if (this.activeSlot) {
@ -210,14 +221,14 @@ export default {
return null; return null;
}, },
activeSlotCost() { activeSlotCost() {
const price = this.activeSlot?.price; const value = this.activeSlot?.value;
if (price === undefined) { if (value === undefined) {
return this.$t("main.targetChargePlan.unknownPrice"); return this.$t("main.targetChargePlan.unknownPrice");
} }
if (this.isCo2) { if (this.isCo2) {
return this.fmtCo2Medium(price); return this.fmtCo2Medium(value);
} }
return this.fmtPricePerKWh(price, this.currency); return this.fmtPricePerKWh(value, this.currency);
}, },
title() { title() {
return this.$t(`smartCost.${this.isCo2 ? "clean" : "cheap"}Title`); return this.$t(`smartCost.${this.isCo2 ? "clean" : "cheap"}Title`);
@ -245,10 +256,10 @@ export default {
this.selectedSmartCostLimit = this.roundLimit(this.smartCostLimit); this.selectedSmartCostLimit = this.roundLimit(this.smartCostLimit);
}, },
methods: { methods: {
roundLimit(limit) { roundLimit(limit: number | null): number | null {
return limit === null ? null : Math.round(limit * 1000) / 1000; return limit === null ? null : Math.round(limit * 1000) / 1000;
}, },
fmtSmartCostLimit(limit) { fmtSmartCostLimit(limit: number | null): string {
if (limit === null) { if (limit === null) {
return this.$t("smartCost.none"); return this.$t("smartCost.none");
} }
@ -258,45 +269,38 @@ export default {
}, },
async updateTariff() { async updateTariff() {
try { try {
const tariffRes = await api.get(`tariff/planner`, allowClientError); const res = await api.get(`tariff/planner`, allowClientError);
if (tariffRes.status === 200) { if (res.status === 200) {
this.tariff = tariffRes.data.result; this.tariff = {
rates: convertRates(res.data.result.rates),
lastUpdate: new Date(),
};
this.startTime = new Date(); this.startTime = new Date();
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}, },
convertDates(list) { findRateInRange(start: Date, end: Date, rates: Rate[]) {
if (!list?.length) { return rates.find((r) => {
return []; if (r.start.getTime() < start.getTime()) {
} return r.end.getTime() > start.getTime();
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();
} }
return s.start.getTime() < end.getTime(); return r.start.getTime() < end.getTime();
}); });
}, },
costRange(slots) { costRange(slots: Slot[]): { min: number | undefined; max: number | undefined } {
let min = undefined; let min = undefined as number | undefined;
let max = undefined; let max = undefined as number | undefined;
slots.forEach((slot) => { slots.forEach((slot) => {
min = min === undefined ? slot.price : Math.min(min, slot.price); if (slot.value === undefined) return;
max = max === undefined ? slot.price : Math.max(max, slot.price); min = min === undefined ? slot.value : Math.min(min, slot.value);
max = max === undefined ? slot.value : Math.max(max, slot.value);
}); });
return { min, max }; 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 const fmtMin = this.isCo2
? this.fmtCo2Short(min) ? this.fmtCo2Short(min)
: this.fmtPricePerKWh(min, this.currency, true); : this.fmtPricePerKWh(min, this.currency, true);
@ -305,21 +309,22 @@ export default {
: this.fmtPricePerKWh(max, this.currency, true); : this.fmtPricePerKWh(max, this.currency, true);
return `${fmtMin} – ${fmtMax}`; return `${fmtMin} – ${fmtMax}`;
}, },
slotHovered(index) { slotHovered(index: number) {
this.activeIndex = index; this.activeIndex = index;
}, },
slotSelected(index) { slotSelected(index: number) {
const price = this.slots[index].price; const value = this.slots[index].value;
if (price !== undefined) { if (value !== undefined) {
// 3 decimal precision // 3 decimal precision
const priceRounded = Math.ceil(price * 1000) / 1000; const valueRounded = Math.ceil(value * 1000) / 1000;
this.saveSmartCostLimit(priceRounded); this.saveSmartCostLimit(`${valueRounded}`);
} }
}, },
changeSmartCostLimit($event) { changeSmartCostLimit($event: Event) {
this.saveSmartCostLimit($event.target.value); const value = ($event.target as HTMLSelectElement).value;
this.saveSmartCostLimit(value);
}, },
async saveSmartCostLimit(limit) { async saveSmartCostLimit(limit: string) {
const url = this.isLoadpoint const url = this.isLoadpoint
? `loadpoints/${this.loadpointId}/smartcostlimit` ? `loadpoints/${this.loadpointId}/smartcostlimit`
: "batterygridchargelimit"; : "batterygridchargelimit";

View file

@ -3,7 +3,7 @@
<div class="chart position-relative"> <div class="chart position-relative">
<div <div
v-for="(slot, index) in slots" v-for="(slot, index) in slots"
:key="slot.start" :key="`${slot.day}-${slot.startHour}`"
:data-index="index" :data-index="index"
class="slot user-select-none" class="slot user-select-none"
:class="{ :class="{
@ -22,8 +22,8 @@
@mouseup="hoverSlot(null)" @mouseup="hoverSlot(null)"
@click="selectSlot(index)" @click="selectSlot(index)"
> >
<div class="slot-bar" :style="priceStyle(slot.price)"> <div class="slot-bar" :style="valueStyle(slot.value)">
<span v-if="slot.price === undefined && avgPrice" class="unknown">?</span> <span v-if="slot.value === undefined && avgValue" class="unknown">?</span>
</div> </div>
<div class="slot-label"> <div class="slot-label">
<span v-if="!slot.isTarget || targetNearlyOutOfRange">{{ <span v-if="!slot.isTarget || targetNearlyOutOfRange">{{
@ -52,11 +52,12 @@
</div> </div>
</template> </template>
<script> <script lang="ts">
import type { PropType } from "vue";
import "@h2d2/shopicons/es/regular/arrowright"; import "@h2d2/shopicons/es/regular/arrowright";
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue"; import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
import formatter from "../../mixins/formatter.js"; import formatter from "../../mixins/formatter.js";
import { CO2_TYPE } from "../../units.js"; import type { Slot } from "../../types/evcc.ts";
const BAR_WIDTH = 20; const BAR_WIDTH = 20;
@ -67,29 +68,29 @@ export default {
}, },
mixins: [formatter], mixins: [formatter],
props: { props: {
slots: Array, slots: { type: Array as PropType<Slot[]>, default: () => [] },
targetText: [String, null], targetText: [String, null],
targetOffset: [Number, null], targetOffset: { type: Number, default: 0 },
}, },
emits: ["slot-hovered", "slot-selected"], emits: ["slot-hovered", "slot-selected"],
data() { data() {
return { return {
activeIndex: null, activeIndex: null as number | null,
startTime: new Date(), startTime: new Date(),
longPressTimer: null, longPressTimer: undefined as number | undefined,
isLongPress: false, isLongPress: false,
}; };
}, },
computed: { computed: {
priceInfo() { valueInfo() {
let max = Number.MIN_VALUE; let max = Number.MIN_VALUE;
let min = 0; let min = 0;
this.slots this.slots
.map((s) => s.price) .map((s) => s.value)
.filter((price) => price !== undefined) .filter((value) => value !== undefined)
.forEach((price) => { .forEach((value) => {
max = Math.max(max, price); max = Math.max(max, value);
min = Math.min(min, price); min = Math.min(min, value);
}); });
return { min, range: max - min }; return { min, range: max - min };
}, },
@ -104,30 +105,27 @@ export default {
targetOutOfRange() { targetOutOfRange() {
return this.targetOffset > this.slots.length; return this.targetOffset > this.slots.length;
}, },
avgPrice() { avgValue() {
let sum = 0; let sum = 0;
let count = 0; let count = 0;
this.slots.forEach((s) => { this.slots.forEach((s) => {
if (s.price !== undefined) { if (s.value !== undefined) {
sum += s.price; sum += s.value;
count++; count++;
} }
}); });
return sum / count; return sum / count;
}, },
isCo2() {
return this.unit === CO2_TYPE;
},
activeSlot() { activeSlot() {
return this.slots[this.activeIndex]; return this.activeIndex !== null ? this.slots[this.activeIndex] : null;
}, },
}, },
methods: { methods: {
hoverSlot(index) { hoverSlot(index: number | null) {
this.activeIndex = index; this.activeIndex = index;
this.$emit("slot-hovered", index); this.$emit("slot-hovered", index);
}, },
selectSlot(index) { selectSlot(index: number) {
if (this.isLongPress) { if (this.isLongPress) {
this.isLongPress = false; this.isLongPress = false;
return; return;
@ -136,7 +134,7 @@ export default {
this.$emit("slot-selected", index); this.$emit("slot-selected", index);
} }
}, },
showWeekday(index) { showWeekday(index: number) {
const slot = this.slots[index]; const slot = this.slots[index];
if (!slot) { if (!slot) {
return false; return false;
@ -151,15 +149,15 @@ export default {
} }
return false; return false;
}, },
priceStyle(price) { valueStyle(value: number | undefined) {
const value = price === undefined ? this.avgPrice : price; const val = value === undefined ? this.avgValue : value;
const height = const height =
value !== undefined && !isNaN(value) value !== undefined && !isNaN(val)
? `${10 + (90 / this.priceInfo.range) * (value - this.priceInfo.min)}%` ? `${10 + (90 / this.valueInfo.range) * (val - this.valueInfo.min)}%`
: "75%"; : "75%";
return { height }; return { height };
}, },
startLongPress(index) { startLongPress(index: number) {
this.longPressTimer = setTimeout(() => { this.longPressTimer = setTimeout(() => {
this.isLongPress = true; this.isLongPress = true;
this.hoverSlot(index); this.hoverSlot(index);

View file

@ -35,10 +35,29 @@ export interface Tariff {
lastUpdate: Date; lastUpdate: Date;
} }
// data from api with string-based timestamps
export interface RateRaw {
start: string;
end: string;
value: number;
}
export interface Rate { export interface Rate {
start: Date; start: Date;
end: 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> { export interface SelectOption<T> {

View 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);
}

View file

@ -5,14 +5,14 @@ export interface TimeseriesEntry {
ts: string; ts: string;
} }
export interface PriceSlot { export interface ForecastSlot {
start: string; start: string;
end: string; end: string;
price: number; value: number;
} }
export function isPriceSlot(obj?: TimeseriesEntry | PriceSlot): obj is PriceSlot { export function isForecastSlot(obj?: TimeseriesEntry | ForecastSlot): obj is ForecastSlot {
return (obj as PriceSlot).start !== undefined; return (obj as ForecastSlot).start !== undefined;
} }
export interface EnergyByDay { export interface EnergyByDay {
@ -29,8 +29,8 @@ export interface SolarDetails {
} }
export interface Forecast { export interface Forecast {
grid?: PriceSlot[]; grid?: ForecastSlot[];
co2?: PriceSlot[]; co2?: ForecastSlot[];
solar?: SolarDetails; solar?: SolarDetails;
} }
@ -81,7 +81,7 @@ export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
if (!solar?.scale) return solar; if (!solar?.scale) return solar;
const { scale } = solar; const { scale } = solar;
let result = deepCopy(solar); const result = deepCopy(solar);
if (result.today) result.today.energy *= scale; if (result.today) result.today.energy *= scale;
if (result.tomorrow) result.tomorrow.energy *= scale; if (result.tomorrow) result.tomorrow.energy *= scale;

View file

@ -47,14 +47,14 @@ import Chart from "../components/Forecast/Chart.vue";
import IconSelectItem from "../components/Helper/IconSelectItem.vue"; import IconSelectItem from "../components/Helper/IconSelectItem.vue";
import IconSelectGroup from "../components/Helper/IconSelectGroup.vue"; import IconSelectGroup from "../components/Helper/IconSelectGroup.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.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 formatter from "../mixins/formatter.ts";
import store from "../store.ts"; import store from "../store.ts";
interface Forecast { interface Forecast {
grid?: PriceSlot[]; grid?: ForecastSlot[];
solar?: SolarDetails; solar?: SolarDetails;
co2?: PriceSlot[]; co2?: ForecastSlot[];
} }
export default defineComponent({ export default defineComponent({

View file

@ -84,7 +84,7 @@ func runTariff(cmd *cobra.Command, args []string) {
const format = "2006-01-02 15:04:05" const format = "2006-01-02 15:04:05"
for _, r := range rates { 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() tw.Flush()

View file

@ -1866,7 +1866,7 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rate
// cheap tariff // cheap tariff
if smartCostActive { if smartCostActive {
rate, _ := rates.At(time.Now()) 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() err = lp.fastCharging()
lp.resetPhaseTimer() lp.resetPhaseTimer()
lp.elapsePVTimer() // let PV mode disable immediately afterwards lp.elapsePVTimer() // let PV mode disable immediately afterwards

View file

@ -151,7 +151,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
// log plan // log plan
for _, slot := range 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) activeSlot := planner.SlotAt(lp.clock.Now(), plan)

View file

@ -9,7 +9,7 @@ import (
func (lp *Loadpoint) smartCostActive(rates api.Rates) bool { func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
rate, err := rates.At(time.Now()) rate, err := rates.At(time.Now())
limit := lp.GetSmartCostLimit() 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 // 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() now := time.Now()
for _, slot := range rates { for _, slot := range rates {
if slot.Start.After(now) && slot.Price <= *limit { if slot.Start.After(now) && slot.Value <= *limit {
return slot.Start return slot.Start
} }
} }

View file

@ -44,7 +44,7 @@ func AverageCost(plan api.Rates) float64 {
for _, slot := range plan { for _, slot := range plan {
slotDuration := slot.End.Sub(slot.Start) slotDuration := slot.End.Sub(slot.Start)
duration += slotDuration duration += slotDuration
cost += float64(slotDuration) * slot.Price cost += float64(slotDuration) * slot.Value
} }
return cost / float64(duration) return cost / float64(duration)
} }

View file

@ -21,7 +21,7 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra
ar := api.Rate{ ar := api.Rate{
Start: slotStart, Start: slotStart,
End: slotStart.Add(slotDuration), End: slotStart.Add(slotDuration),
Price: v, Value: v,
} }
res = append(res, ar) res = append(res, ar)
} }

View file

@ -5,9 +5,9 @@ import "github.com/evcc-io/evcc/api"
// sortByCost is a sortFunc for slices.Sort // sortByCost is a sortFunc for slices.Sort
func sortByCost(i, j api.Rate) int { func sortByCost(i, j api.Rate) int {
switch { switch {
case i.Price < j.Price: case i.Value < j.Value:
return -1 return -1
case i.Price > j.Price: case i.Value > j.Value:
return +1 return +1
default: default:
return j.Start.Compare(i.Start) return j.Start.Compare(i.Start)

View file

@ -13,15 +13,15 @@ import (
func testRates(clock clock.Clock) api.Rates { func testRates(clock clock.Clock) api.Rates {
return api.Rates{ return api.Rates{
{ {
Price: 2, Value: 2,
Start: clock.Now().Add(2 * time.Hour), Start: clock.Now().Add(2 * time.Hour),
}, },
{ {
Price: 2, Value: 2,
Start: clock.Now(), Start: clock.Now(),
}, },
{ {
Price: 1, Value: 1,
Start: clock.Now().Add(time.Hour), Start: clock.Now().Add(time.Hour),
}, },
} }

View file

@ -93,12 +93,12 @@ func (site *Site) plannerRates() (api.Rates, error) {
func (site *Site) smartCostActive(lp loadpoint.API, rate api.Rate) bool { func (site *Site) smartCostActive(lp loadpoint.API, rate api.Rate) bool {
limit := lp.GetSmartCostLimit() 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 { func (site *Site) batteryGridChargeActive(rate api.Rate) bool {
limit := site.GetBatteryGridChargeLimit() 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 { func (site *Site) dischargeControlActive(rate api.Rate) bool {

View file

@ -89,7 +89,7 @@ func timestampSeries(rr api.Rates) timeseries {
return lo.Map(rr, func(r api.Rate, _ int) tsval { return lo.Map(rr, func(r api.Rate, _ int) tsval {
return tsval{ return tsval{
Timestamp: r.Start, Timestamp: r.Start,
Value: r.Price, Value: r.Value,
} }
}) })
} }

View file

@ -105,10 +105,10 @@ func (t *Amber) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: startTime.Local(), Start: startTime.Local(),
End: endTime.Local(), End: endTime.Local(),
Price: r.PerKwh / 1e2, Value: r.PerKwh / 1e2,
} }
if r.AdvancedPrice != nil { if r.AdvancedPrice != nil {
ar.Price = r.AdvancedPrice.Predicted / 1e2 ar.Value = r.AdvancedPrice.Predicted / 1e2
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -85,7 +85,7 @@ func (t *Awattar) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: r.StartTimestamp.Local(), Start: r.StartTimestamp.Local(),
End: r.EndTimestamp.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) data = append(data, ar)
} }

View file

@ -54,7 +54,7 @@ func (t *combined) Rates() (api.Rates, error) {
return nil, errors.New("combined tariffs must have the same period length") return nil, errors.New("combined tariffs must have the same period length")
} }
rate.Price += r.Price rate.Value += r.Value
} }
res = append(res, rate) res = append(res, rate)

View file

@ -28,7 +28,7 @@ func TestCombined(t *testing.T) {
return api.Rate{ return api.Rate{
Start: clock.Now().Add(time.Duration(start) * time.Hour), Start: clock.Now().Add(time.Duration(start) * time.Hour),
End: clock.Now().Add(time.Duration(start+1) * time.Hour), End: clock.Now().Add(time.Duration(start+1) * time.Hour),
Price: val, Value: val,
} }
} }

View file

@ -140,7 +140,7 @@ func (t *EdfTempo) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: ts, Start: ts,
End: ts.Add(time.Hour), 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) data = append(data, ar)
} }

View file

@ -103,7 +103,7 @@ func (t *ElectricityMaps) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: r.Datetime.Local(), Start: r.Datetime.Local(),
End: r.Datetime.Add(time.Hour).Local(), End: r.Datetime.Add(time.Hour).Local(),
Price: r.CarbonIntensity, Value: r.CarbonIntensity,
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -89,7 +89,7 @@ func (t *Elering) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: ts, Start: ts,
End: ts.Add(time.Hour), End: ts.Add(time.Hour),
Price: t.totalPrice(r.Price/1e3, ts), Value: t.totalPrice(r.Price/1e3, ts),
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -88,7 +88,7 @@ func (t *Energinet) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: ts.Local(), Start: ts.Local(),
End: ts.Add(time.Hour).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) data = append(data, ar)
} }

View file

@ -154,7 +154,7 @@ func (t *Entsoe) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: r.Start.Local(), Start: r.Start.Local(),
End: r.End.Local(), End: r.End.Local(),
Price: t.totalPrice(r.Value, r.Start), Value: t.totalPrice(r.Value, r.Start),
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -118,9 +118,9 @@ func (t *Fixed) Rates() (api.Rates, error) {
} }
rate := api.Rate{ rate := api.Rate{
Price: zone.Price,
Start: ts, Start: ts,
End: end, End: end,
Value: zone.Price,
} }
res = append(res, rate) res = append(res, rate)

View file

@ -26,7 +26,7 @@ func TestFixed(t *testing.T) {
for hour := 0; hour < 24; hour++ { for hour := 0; hour < 24; hour++ {
expect = append(expect, api.Rate{ expect = append(expect, api.Rate{
Price: 0.3, Value: 0.3,
Start: dayStart.Add(time.Hour * time.Duration(hour)), Start: dayStart.Add(time.Hour * time.Duration(hour)),
End: dayStart.Add(time.Hour * time.Duration(hour+1)), End: dayStart.Add(time.Hour * time.Duration(hour+1)),
}) })
@ -60,7 +60,7 @@ func TestFixedSplitZones(t *testing.T) {
// 00:00-05:00 0.1 // 00:00-05:00 0.1
for hour := 0; hour < 5; hour++ { for hour := 0; hour < 5; hour++ {
expect = append(expect, api.Rate{ expect = append(expect, api.Rate{
Price: 0.1, Value: 0.1,
Start: dayStart.Add(time.Hour * time.Duration(hour)), Start: dayStart.Add(time.Hour * time.Duration(hour)),
End: dayStart.Add(time.Hour * time.Duration(hour+1)), End: dayStart.Add(time.Hour * time.Duration(hour+1)),
}) })
@ -68,14 +68,14 @@ func TestFixedSplitZones(t *testing.T) {
// 05:00-05:30 0.1 // 05:00-05:30 0.1
expect = append(expect, api.Rate{ expect = append(expect, api.Rate{
Price: 0.1, Value: 0.1,
Start: dayStart.Add(5 * time.Hour), Start: dayStart.Add(5 * time.Hour),
End: dayStart.Add(5*time.Hour + 30*time.Minute), End: dayStart.Add(5*time.Hour + 30*time.Minute),
}) })
// 05:30-06:00 0.5 // 05:30-06:00 0.5
expect = append(expect, api.Rate{ expect = append(expect, api.Rate{
Price: 0.5, Value: 0.5,
Start: dayStart.Add(5*time.Hour + 30*time.Minute), Start: dayStart.Add(5*time.Hour + 30*time.Minute),
End: dayStart.Add(6 * time.Hour), End: dayStart.Add(6 * time.Hour),
}) })
@ -83,7 +83,7 @@ func TestFixedSplitZones(t *testing.T) {
// 06:00-21:00 0.5 // 06:00-21:00 0.5
for hour := 6; hour < 21; hour++ { for hour := 6; hour < 21; hour++ {
expect = append(expect, api.Rate{ expect = append(expect, api.Rate{
Price: 0.5, Value: 0.5,
Start: dayStart.Add(time.Hour * time.Duration(hour)), Start: dayStart.Add(time.Hour * time.Duration(hour)),
End: dayStart.Add(time.Hour * time.Duration(hour+1)), End: dayStart.Add(time.Hour * time.Duration(hour+1)),
}) })
@ -92,7 +92,7 @@ func TestFixedSplitZones(t *testing.T) {
// 21:00-00:00 0.1 // 21:00-00:00 0.1
for hour := 21; hour < 24; hour++ { for hour := 21; hour < 24; hour++ {
expect = append(expect, api.Rate{ expect = append(expect, api.Rate{
Price: 0.1, Value: 0.1,
Start: dayStart.Add(time.Hour * time.Duration(hour)), Start: dayStart.Add(time.Hour * time.Duration(hour)),
End: dayStart.Add(time.Hour * time.Duration(hour+1)), End: dayStart.Add(time.Hour * time.Duration(hour+1)),
}) })

View file

@ -66,7 +66,7 @@ func (t *GroupeE) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: r.StartTimestamp.Local(), Start: r.StartTimestamp.Local(),
End: r.EndTimestamp.Local(), End: r.EndTimestamp.Local(),
Price: r.VarioPlus / 1e2, // Rp/kWh Value: r.VarioPlus / 1e2, // Rp/kWh
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -88,9 +88,9 @@ func (t *GrünStromIndex) run(done chan error) {
data := make(api.Rates, 0, len(res.Forecast)) data := make(api.Rates, 0, len(res.Forecast))
for _, r := range res.Forecast { for _, r := range res.Forecast {
data = append(data, api.Rate{ data = append(data, api.Rate{
Price: float64(r.Co2GStandard),
Start: time.UnixMilli(r.Timeframe.Start).Local(), Start: time.UnixMilli(r.Timeframe.Start).Local(),
End: time.UnixMilli(r.Timeframe.End).Local(), End: time.UnixMilli(r.Timeframe.End).Local(),
Value: float64(r.Co2GStandard),
}) })
} }

View file

@ -20,7 +20,7 @@ func TestMergeRatesAfter(t *testing.T) {
return api.Rate{ return api.Rate{
Start: clock.Now().Add(time.Duration(start) * time.Hour), Start: clock.Now().Add(time.Duration(start) * time.Hour),
End: clock.Now().Add(time.Duration(start+1) * time.Hour), End: clock.Now().Add(time.Duration(start+1) * time.Hour),
Price: val, Value: val,
} }
} }

View file

@ -91,7 +91,7 @@ func (t *Ngeso) run(done chan error) {
Start: r.ValidityStart.Time, Start: r.ValidityStart.Time,
End: r.ValidityEnd.Time, End: r.ValidityEnd.Time,
// Use the forecasted rate, as the actual rate is only available for historical data // 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) data = append(data, ar)
} }

View file

@ -145,7 +145,7 @@ func (t *Octopus) run(done chan error) {
Start: r.ValidityStart, Start: r.ValidityStart,
End: rateEnd, End: rateEnd,
// UnitRates are supplied inclusive of tax, though this could be flipped easily with a config flag. // 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) data = append(data, ar)
} }

View file

@ -190,7 +190,7 @@ func (t *Ostrom) runStatic(done chan error) {
data[i] = api.Rate{ data[i] = api.Rate{
Start: ts, Start: ts,
End: ts.Add(time.Hour), 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{ data = append(data, api.Rate{
Start: ts, Start: ts,
End: ts.Add(time.Hour), 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
}) })
} }

View file

@ -205,7 +205,7 @@ func (t *Pun) getData(day time.Time) (api.Rates, error) {
ar := api.Rate{ ar := api.Rate{
Start: ts, Start: ts,
End: ts.Add(time.Hour), End: ts.Add(time.Hour),
Price: t.totalPrice(price/1e3, ts), Value: t.totalPrice(price/1e3, ts),
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -75,7 +75,7 @@ func (t *SmartEnergy) run(done chan error) {
ar := api.Rate{ ar := api.Rate{
Start: r.Date.Local(), Start: r.Date.Local(),
End: r.Date.Add(time.Hour).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) data = append(data, ar)
} }

View file

@ -95,12 +95,12 @@ func (t *Solcast) run(interval time.Duration, done chan error) {
rr := api.Rate{ rr := api.Rate{
Start: start, Start: start,
End: start.Add(time.Hour), End: start.Add(time.Hour),
Price: r.PvEstimate * 1e3, Value: r.PvEstimate * 1e3,
} }
if r.Period.Duration() != time.Hour { if r.Period.Duration() != time.Hour {
for i, r := range data { for i, r := range data {
if r.Start.Equal(rr.Start) { if r.Start.Equal(rr.Start) {
data[i].Price = (r.Price + rr.Price) / 2 data[i].Value = (r.Value + rr.Value) / 2
continue NEXT continue NEXT
} }
} }

View file

@ -99,7 +99,7 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval
} }
for i, r := range data { for i, r := range data {
data[i] = api.Rate{ data[i] = api.Rate{
Price: t.totalPrice(r.Price, r.Start), Value: t.totalPrice(r.Value, r.Start),
Start: r.Start.Local(), Start: r.Start.Local(),
End: r.End.Local(), End: r.End.Local(),
} }
@ -145,7 +145,7 @@ func (t *Tariff) priceRates() (api.Rates, error) {
res[i] = api.Rate{ res[i] = api.Rate{
Start: slot, Start: slot,
End: slot.Add(time.Hour), End: slot.Add(time.Hour),
Price: t.totalPrice(price, slot), Value: t.totalPrice(price, slot),
} }
} }

View file

@ -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 // Now returns the price/cost/value at the given time
func Now(t api.Tariff) (float64, error) { func Now(t api.Tariff) (float64, error) {
r, err := At(t, time.Now()) r, err := At(t, time.Now())
return r.Price, err return r.Value, err
} }
func Forecast(t api.Tariff) api.Rates { func Forecast(t api.Tariff) api.Rates {

View file

@ -120,7 +120,7 @@ func (t *Tibber) rates(pi []tibber.Price) api.Rates {
ar := api.Rate{ ar := api.Rate{
Start: r.StartsAt.Local(), Start: r.StartsAt.Local(),
End: r.StartsAt.Add(time.Hour).Local(), End: r.StartsAt.Add(time.Hour).Local(),
Price: price, Value: price,
} }
data = append(data, ar) data = append(data, ar)
} }

View file

@ -18,5 +18,5 @@ render: |
[.timestamps, .prices] | transpose | map({ [.timestamps, .prices] | transpose | map({
"start": .[0] | strptime("%FT%T.%f%z") | strftime("%FT%TZ"), "start": .[0] | strptime("%FT%T.%f%z") | strftime("%FT%TZ"),
"end": .[0] | strptime("%FT%T.%f%z") | mktime + 3600 | strftime("%FT%TZ"), "end": .[0] | strptime("%FT%T.%f%z") | mktime + 3600 | strftime("%FT%TZ"),
"price": .[1] "value": .[1]
}) | tostring }) | tostring

View file

@ -46,5 +46,5 @@ render: |
[.unix_seconds, .price] | transpose | map({ [.unix_seconds, .price] | transpose | map({
"start": .[0] | todate, "start": .[0] | todate,
"end": .[0]+3600 | todate, "end": .[0]+3600 | todate,
"price": .[1]/1000 "value": .[1]/1000
}) | tostring }) | tostring

View file

@ -56,7 +56,7 @@ render: |
{ {
"start": .datum | strptime("%F %T") | strflocaltime("%FT%T%:z"), "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"), "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 ] | tostring
- name: tomorrow - name: tomorrow
@ -69,7 +69,7 @@ render: |
{ {
"start": .datum | strptime("%F %T") | strflocaltime("%FT%T%:z"), "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"), "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 ] | tostring
interval: {{ .interval }} interval: {{ .interval }}

View file

@ -45,6 +45,6 @@ render: |
[ .result.watts | to_entries.[] | { [ .result.watts | to_entries.[] | {
"start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")), "start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")),
"end": (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")), "end": (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
"price": .value "value": .value
} ] | tostring } ] | tostring
interval: {{ .interval }} interval: {{ .interval }}

View file

@ -62,7 +62,7 @@ render: |
{ {
"start": .deliveryStart, "start": .deliveryStart,
"end": .deliveryEnd, "end": .deliveryEnd,
"price": .entryPerArea.{{ .region }} / 1000 "value": .entryPerArea.{{ .region }} / 1000
} }
] | tostring ] | tostring
- name: tomorrow - name: tomorrow
@ -76,7 +76,7 @@ render: |
{ {
"start": .deliveryStart, "start": .deliveryStart,
"end": .deliveryEnd, "end": .deliveryEnd,
"price": .entryPerArea.{{ .region }} / 1000 "value": .entryPerArea.{{ .region }} / 1000
} }
] | tostring ] | tostring
{{ include "tariff-base" . }} {{ include "tariff-base" . }}

View file

@ -31,5 +31,5 @@ render: |
[ .[] | { [ .[] | {
start: .from, start: .from,
end: (.from | fromdate + 3600 | todate), end: (.from | fromdate + 3600 | todate),
price: (.price/100) value: (.price/100)
} ] | tostring } ] | tostring