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 {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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" . }}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue