Tariffs: add outdoor temperature type and OpenMeteo (#27780)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Daniel Martin 2026-07-17 14:54:56 +02:00 • committed by GitHub
parent 68db0b7727
commit d68e5e5809
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 449 additions and 135 deletions

View file

@ -172,10 +172,11 @@ type Tariffs struct {
Co2 config.Typed
Planner config.Typed
Solar []config.Typed
Temperature config.Typed
}
func (c Tariffs) IsConfigured() bool {
return c.Currency != "" || c.Grid.Type != "" || c.FeedIn.Type != "" || c.Co2.Type != "" || c.Planner.Type != "" || len(c.Solar) > 0
return c.Currency != "" || c.Grid.Type != "" || c.FeedIn.Type != "" || c.Co2.Type != "" || c.Planner.Type != "" || len(c.Solar) > 0 || c.Temperature.Type != ""
}
type TariffRefs struct {
@ -184,15 +185,16 @@ type TariffRefs struct {
Co2 string `json:"co2"`
Planner string `json:"planner"`
Solar []string `json:"solar"`
Temperature string `json:"temperature"`
}
func (refs TariffRefs) IsConfigured() bool {
return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0
return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0 || refs.Temperature != ""
}
func (refs TariffRefs) Used() iter.Seq[string] {
return func(yield func(string) bool) {
for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner}, refs.Solar...) {
for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner, refs.Temperature}, refs.Solar...) {
if ref != "" {
if !yield(ref) {
return

View file

@ -12,6 +12,7 @@ const (
TariffTypePriceForecast
TariffTypeCo2
TariffTypeSolar
TariffTypeTemperature // outdoor temperature forecast in °C
)
type TariffUsage int
@ -23,4 +24,5 @@ const (
TariffUsageGrid
TariffUsagePlanner
TariffUsageSolar
TariffUsageTemperature // outdoor temperature forecast
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solar"
const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solartemperature"
var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44}
var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44, 55}
const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solar"
const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solartemperature"
func (i TariffType) String() string {
i -= 1
@ -30,9 +30,10 @@ func _TariffTypeNoOp() {
_ = x[TariffTypePriceForecast-(3)]
_ = x[TariffTypeCo2-(4)]
_ = x[TariffTypeSolar-(5)]
_ = x[TariffTypeTemperature-(6)]
}
var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar}
var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar, TariffTypeTemperature}
var _TariffTypeNameToValueMap = map[string]TariffType{
_TariffTypeName[0:11]: TariffTypePriceStatic,
@ -45,6 +46,8 @@ var _TariffTypeNameToValueMap = map[string]TariffType{
_TariffTypeLowerName[36:39]: TariffTypeCo2,
_TariffTypeName[39:44]: TariffTypeSolar,
_TariffTypeLowerName[39:44]: TariffTypeSolar,
_TariffTypeName[44:55]: TariffTypeTemperature,
_TariffTypeLowerName[44:55]: TariffTypeTemperature,
}
var _TariffTypeNames = []string{
@ -53,6 +56,7 @@ var _TariffTypeNames = []string{
_TariffTypeName[23:36],
_TariffTypeName[36:39],
_TariffTypeName[39:44],
_TariffTypeName[44:55],
}
// TariffTypeString retrieves an enum value from the enum constants string name.

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _TariffUsageName = "co2feedingridplannersolar"
const _TariffUsageName = "co2feedingridplannersolartemperature"
var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25}
var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25, 36}
const _TariffUsageLowerName = "co2feedingridplannersolar"
const _TariffUsageLowerName = "co2feedingridplannersolartemperature"
func (i TariffUsage) String() string {
i -= 1
@ -30,9 +30,10 @@ func _TariffUsageNoOp() {
_ = x[TariffUsageGrid-(3)]
_ = x[TariffUsagePlanner-(4)]
_ = x[TariffUsageSolar-(5)]
_ = x[TariffUsageTemperature-(6)]
}
var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedIn, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar}
var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedIn, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar, TariffUsageTemperature}
var _TariffUsageNameToValueMap = map[string]TariffUsage{
_TariffUsageName[0:3]: TariffUsageCo2,
@ -45,6 +46,8 @@ var _TariffUsageNameToValueMap = map[string]TariffUsage{
_TariffUsageLowerName[13:20]: TariffUsagePlanner,
_TariffUsageName[20:25]: TariffUsageSolar,
_TariffUsageLowerName[20:25]: TariffUsageSolar,
_TariffUsageName[25:36]: TariffUsageTemperature,
_TariffUsageLowerName[25:36]: TariffUsageTemperature,
}
var _TariffUsageNames = []string{
@ -53,6 +56,7 @@ var _TariffUsageNames = []string{
_TariffUsageName[9:13],
_TariffUsageName[13:20],
_TariffUsageName[20:25],
_TariffUsageName[25:36],
}
// TariffUsageString retrieves an enum value from the enum constants string name.

View file

@ -42,6 +42,7 @@
--evcc-export-contrast: var(--evcc-dark-yellow);
--evcc-price: #ff912fff;
--evcc-co2: #00916eff;
--evcc-temperature: #1b6fba;
--evcc-background: var(--bs-gray-bright);
--evcc-box: var(--bs-white);
--evcc-box-border: var(--bs-gray-brighter);
@ -198,6 +199,10 @@ h5 {
color: var(--evcc-co2) !important;
}
.text-temperature {
color: var(--evcc-temperature) !important;
}
.text-export {
color: var(--evcc-export-contrast) !important;
}

View file

@ -24,6 +24,7 @@ const colors: {
pricePerKWh: string | null;
price: string | null;
co2: string | null;
temperature: string | null;
export: string | null;
background: string | null;
light: string | null;
@ -40,6 +41,7 @@ const colors: {
pricePerKWh: null,
price: null,
co2: null,
temperature: null,
export: null,
background: null,
light: null,
@ -147,6 +149,7 @@ export function updateCssColors() {
colors.grid = style.getPropertyValue("--evcc-grid");
colors.price = style.getPropertyValue("--evcc-price");
colors.co2 = style.getPropertyValue("--evcc-co2");
colors.temperature = style.getPropertyValue("--evcc-temperature");
colors.export = style.getPropertyValue("--evcc-export-contrast");
colors.background = style.getPropertyValue("--evcc-background");
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");

View file

@ -76,7 +76,7 @@ const HIDDEN_TAGS = ["icon", "heating", "integratedDevice"];
const PHASE_TAGS = ["phaseCurrents", "phaseVoltages", "phasePowers"];
const FORECAST_TAGS = ["priceRates", "co2Rates", "solarRates"];
const FORECAST_TAGS = ["priceRates", "co2Rates", "solarRates", "temperatureRates"];
export default {
name: "DeviceTags",
@ -122,6 +122,7 @@ export default {
priceRates: "price",
co2Rates: "co2",
solarRates: "solar",
temperatureRates: "temperature",
};
// Find which forecast tag is present
@ -220,6 +221,7 @@ export default {
return this.fmtPercentage(value, 1);
case "temp":
case "heaterTempLimit":
case "outdoorTemp":
return this.fmtTemperature(value);
case "odometer":
case "range":
@ -290,6 +292,8 @@ export default {
return short ? this.fmtCo2Short(value) : this.fmtCo2Medium(value);
case "solar":
return this.fmtW(value);
case "temperature":
return this.fmtTemperature(value);
default:
return value;
}

View file

@ -23,6 +23,7 @@ import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/clock";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/thermometerhalf";
import { type PropType } from "vue";
import type { TariffType, CURRENCY } from "@/types/evcc";
import DeviceCard from "./DeviceCard.vue";
@ -49,7 +50,7 @@ export default {
hasError: { type: Boolean, default: false },
title: String,
tags: { type: Object, default: () => ({}) },
currency: { type: String as PropType<CURRENCY>, required: true },
currency: { type: String as PropType<CURRENCY> },
},
emits: ["edit"],
computed: {
@ -69,6 +70,7 @@ export default {
co2: "shopicon-regular-eco1",
planner: "shopicon-regular-clock",
solar: "shopicon-regular-sun",
temperature: "shopicon-regular-thermometerhalf",
};
return iconMap[this.tariffType];
},

View file

@ -65,6 +65,7 @@ import { getModal } from "@/configModal";
import tariffPriceYaml from "./defaultYaml/tariffPrice.yaml?raw";
import tariffCo2Yaml from "./defaultYaml/tariffCo2.yaml?raw";
import tariffSolarYaml from "./defaultYaml/tariffSolar.yaml?raw";
import tariffTemperatureYaml from "./defaultYaml/tariffTemperature.yaml?raw";
const initialValues = {
type: ConfigType.Template,
@ -129,7 +130,9 @@ export default defineComponent({
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
// Use different custom option text for tariffs vs forecasts
const isForecast = ["co2", "planner", "solar"].includes(this.tariffType || "");
const isForecast = ["co2", "planner", "solar", "temperature"].includes(
this.tariffType || ""
);
const customLabel = isForecast
? this.$t("config.tariff.customForecast")
: this.$t("config.tariff.customTariff");
@ -141,6 +144,7 @@ export default defineComponent({
"demo-co2-forecast",
"demo-dynamic-grid",
"demo-solar-forecast",
"demo-temperature-forecast",
"energy-charts-api",
];
@ -162,9 +166,11 @@ export default defineComponent({
const priceProducts = filterByGroup("price");
const co2Products = filterByGroup("co2");
const solarProducts = filterByGroup("solar");
const temperatureProducts = filterByGroup("temperature");
const priceGeneric = extractGeneric("price");
const co2Generic = extractGeneric("co2");
const solarGeneric = extractGeneric("solar");
const temperatureGeneric = extractGeneric("temperature");
// Special handling for planner: show price + co2 services
if (this.tariffType === "planner") {
@ -194,6 +200,7 @@ export default defineComponent({
feedIn: { service: priceProducts, generic: priceGeneric },
co2: { service: co2Products, generic: co2Generic },
solar: { service: solarProducts, generic: solarGeneric },
temperature: { service: temperatureProducts, generic: temperatureGeneric },
};
const mapped = (this.tariffType && groupMap[this.tariffType]) || {
service: [],
@ -225,6 +232,8 @@ export default defineComponent({
values.yaml = tariffCo2Yaml;
} else if (this.tariffType === "solar") {
values.yaml = tariffSolarYaml;
} else if (this.tariffType === "temperature") {
values.yaml = tariffTemperatureYaml;
}
}
},

View file

@ -0,0 +1,28 @@
## Outdoor temperature forecast
tariff: temperature
forecast: # hourly outdoor temperature forecast (°C)
source: js
script: |
var rates = [];
var now = new Date();
for (var i = 0; i < 48; i++) {
var start = new Date(now.getTime() + i * 3600000);
rates.push({
start: start.toISOString(),
end: new Date(start.getTime() + 3600000).toISOString(),
value: 15 // °C
});
}
JSON.stringify(rates);
## HTTP example (uncomment to use)
#forecast: # hourly outdoor temperature forecast (°C)
# source: http
# uri: https://example.com/api/temperature
# jq: |
# map({
# "start": .start,
# "end": .end,
# "value": .temperature
# }) | tostring

View file

@ -24,15 +24,26 @@ import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin";
import type { ForecastSlot } from "./types";
export type ValueChartType = "co2" | "temperature";
export default defineComponent({
name: "Co2Chart",
name: "ValueChart",
mixins: [formatter, chartMixin],
props: {
co2: { type: Array as PropType<ForecastSlot[]>, required: true },
type: { type: String as PropType<ValueChartType>, required: true },
rates: { type: Array as PropType<ForecastSlot[]>, required: true },
},
computed: {
color(): string {
return (this.type === "co2" ? colors.co2 : colors.temperature) || "";
},
slots(): ForecastSlot[] {
return filterForecastSlots(this.co2, this.startDate, this.endDate);
return filterForecastSlots(this.rates, this.startDate, this.endDate);
},
yMin(): number {
// co2 is never negative, temperature may be
if (this.type === "co2" || !this.slots.length) return 0;
return Math.min(0, Math.floor(Math.min(...this.slots.map((s) => s.value))));
},
markPoints(): {
coord: [string, number];
@ -51,20 +62,20 @@ export default defineComponent({
if (slots[minIdx]) {
points.push({
coord: [clampStart(slots[minIdx]!.start, this.startDate), slots[minIdx]!.value],
value: this.fmtGrams(slots[minIdx]!.value),
value: this.fmtShort(slots[minIdx]!.value),
label: { position: "bottom", offset: [0, 2] },
});
}
if (maxIdx !== minIdx && slots[maxIdx]) {
points.push({
coord: [clampStart(slots[maxIdx]!.start, this.startDate), slots[maxIdx]!.value],
value: this.fmtGrams(slots[maxIdx]!.value),
value: this.fmtShort(slots[maxIdx]!.value),
});
}
return points;
},
chartOption(): Record<string, unknown> {
const co2Color = colors.co2 || "";
const color = this.color;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const vThis = this;
@ -75,13 +86,13 @@ export default defineComponent({
tooltip: {
trigger: "axis",
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
...tooltipStyle(co2Color, () => this.chart),
...tooltipStyle(color, () => this.chart),
formatter(params: { value: [string, number] }[]) {
const p = params[0];
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
return tooltipTable(time, [{ values: [vThis.fmtCo2Medium(p.value[1])] }]);
return tooltipTable(time, [{ values: [vThis.fmtLong(p.value[1])] }]);
},
},
xAxis: forecastXAxes(
@ -91,6 +102,7 @@ export default defineComponent({
this.weekdayShort
),
yAxis: forecastYAxis({
min: this.yMin,
splitNumber: 2,
axisLabel: {
color: colors.muted,
@ -105,14 +117,14 @@ export default defineComponent({
symbol: "circle",
symbolSize: 6,
showSymbol: false,
lineStyle: { color: co2Color, width: 3 },
lineStyle: { color, width: 3 },
emphasis: {
disabled: false,
scale: false,
itemStyle: { color: co2Color, borderColor: co2Color, borderWidth: 2 },
itemStyle: { color, borderColor: color, borderWidth: 2 },
},
markPoint: markPointLabel(
co2Color,
color,
this.tooltipVisible ? [] : this.markPoints,
this.startDate,
this.endDate
@ -122,5 +134,13 @@ export default defineComponent({
};
},
},
methods: {
fmtShort(value: number): string {
return this.type === "co2" ? this.fmtGrams(value) : this.fmtTemperature(value);
},
fmtLong(value: number): string {
return this.type === "co2" ? this.fmtCo2Medium(value) : this.fmtTemperature(value);
},
},
});
</script>

View file

@ -2,16 +2,16 @@
<div v-if="average" class="row gx-2 mt-1">
<div class="col-6">
<small>
<span class="text-gray">{{ $t("forecast.co2.range") }}</span>
<span class="text-gray">{{ $t(`forecast.${type}.range`) }}</span>
<br />
<span class="text-co2 fw-bold">{{ range }}</span>
<span :class="`text-${type}`" class="fw-bold">{{ range }}</span>
</small>
</div>
<div class="col-6 text-end">
<small>
<span class="text-gray">{{ $t("forecast.co2.average") }}</span>
<span class="text-gray">{{ $t(`forecast.${type}.average`) }}</span>
<br />
<span class="text-co2 fw-bold">{{ average }}</span>
<span :class="`text-${type}`" class="fw-bold">{{ average }}</span>
</small>
</div>
</div>
@ -21,21 +21,23 @@
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { ForecastSlot } from "./types";
import type { ValueChartType } from "./ValueChart.vue";
const MAX_HOURS = 96;
const SLOTS_PER_HOUR = 4;
export default defineComponent({
name: "Co2Details",
name: "ValueDetails",
mixins: [formatter],
props: {
co2: { type: Array as PropType<ForecastSlot[]> },
type: { type: String as PropType<ValueChartType>, required: true },
rates: { type: Array as PropType<ForecastSlot[]> },
},
computed: {
upcomingSlots(): ForecastSlot[] {
if (!Array.isArray(this.co2)) return [];
if (!Array.isArray(this.rates)) return [];
const now = new Date();
return this.co2
return this.rates
.filter((slot) => new Date(slot.end) > now)
.slice(0, MAX_HOURS * SLOTS_PER_HOUR);
},
@ -43,14 +45,22 @@ export default defineComponent({
if (this.upcomingSlots.length === 0) return "";
const avg =
this.upcomingSlots.reduce((a, s) => a + s.value, 0) / this.upcomingSlots.length;
return this.fmtCo2Medium(avg);
return this.fmtValue(avg);
},
range(): string {
if (this.upcomingSlots.length === 0) return "";
const values = this.upcomingSlots.map((s) => s.value);
const min = Math.min(...values);
const max = Math.max(...values);
if (this.type === "co2") {
return `${this.fmtNumber(min, 0)} – ${this.fmtCo2Medium(max)}`;
}
return `${this.fmtValue(min)} – ${this.fmtValue(max)}`;
},
},
methods: {
fmtValue(value: number): string {
return this.type === "co2" ? this.fmtCo2Medium(value) : this.fmtTemperature(value);
},
},
});

View file

@ -100,6 +100,7 @@ export interface State {
tariffFeedIn?: number;
tariffCo2?: number;
tariffSolar?: number;
tariffTemperature?: number;
mqtt?: MqttConfig;
influx?: InfluxConfig;
hems?: ConfigStatus<HemsConfig, HemsStatus>;
@ -744,6 +745,7 @@ export interface Forecast {
solar?: SolarDetails;
planner?: ForecastSlot[];
feedin?: ForecastSlot[];
temperature?: ForecastSlot[];
}
export interface SelectOption<T> {
@ -763,7 +765,7 @@ export type DeviceType =
| "hems";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar";
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
// see https://stackoverflow.com/a/54178819
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

View file

@ -222,7 +222,6 @@
tariff-type="co2"
:has-error="hasDeviceError('tariff', co2Tariff.name)"
:tags="deviceTags('tariff', co2Tariff.name)"
:currency="currency"
@edit="openModal('tariff', { type: 'co2', id: co2Tariff.id })"
/>
<TariffCard
@ -235,6 +234,16 @@
:currency="currency"
@edit="openModal('tariff', { type: 'solar', id: tariff.id })"
/>
<TariffCard
v-if="temperatureTariff"
:tariff="temperatureTariff"
tariff-type="temperature"
:has-error="hasDeviceError('tariff', temperatureTariff.name)"
:tags="deviceTags('tariff', temperatureTariff.name)"
@edit="
openModal('tariff', { type: 'temperature', id: temperatureTariff.id })
"
/>
<TariffCard
v-if="plannerTariff"
:tariff="plannerTariff"
@ -671,6 +680,7 @@ export default defineComponent({
co2: "",
planner: "",
solar: [] as string[],
temperature: "",
},
site: {
grid: "",
@ -755,6 +765,10 @@ export default defineComponent({
const name = this.tariffRefs?.planner;
return name ? this.tariffs.find((t) => t.name === name) : null;
},
temperatureTariff() {
const name = this.tariffRefs?.temperature;
return name ? this.tariffs.find((t) => t.name === name) : null;
},
solarTariffs() {
const names = this.tariffRefs?.solar || [];
return names.map((name) => this.tariffs.find((t) => t.name === name)).filter(Boolean);
@ -769,16 +783,19 @@ export default defineComponent({
const types: TariffType[] = [];
if (!this.co2Tariff) types.push("co2");
types.push("solar"); // Solar can have multiple
if (!this.temperatureTariff) types.push("temperature");
if (!this.plannerTariff) types.push("planner");
return types;
},
tariffTags(): DeviceTags {
const { tariffGrid, tariffFeedIn, tariffCo2, tariffSolar } = store.state;
const { tariffGrid, tariffFeedIn, tariffCo2, tariffSolar, tariffTemperature } =
store.state;
if (
tariffGrid === undefined &&
tariffFeedIn === undefined &&
tariffCo2 === undefined &&
tariffSolar === undefined
tariffSolar === undefined &&
tariffTemperature === undefined
) {
return { configured: { value: false } };
}
@ -787,6 +804,7 @@ export default defineComponent({
feedinPrice: {},
co2: {},
solarForecast: {},
outdoorTemp: {},
};
if (tariffGrid) {
tags.gridPrice = { value: tariffGrid };
@ -800,6 +818,9 @@ export default defineComponent({
if (tariffSolar) {
tags.solarForecast = { value: tariffSolar };
}
if (tariffTemperature !== undefined) {
tags.outdoorTemp = { value: tariffTemperature };
}
return tags;
},
mqttTags(): DeviceTags {

View file

@ -7,31 +7,15 @@
<div v-if="!forecastAvailable" class="flex-grow-1 d-flex">
<div class="empty-box d-flex flex-column p-5">
<ul class="list-unstyled mb-4">
<li class="d-flex align-items-start gap-2 mb-3">
<shopicon-regular-sun
size="s"
class="text-muted flex-shrink-0"
></shopicon-regular-sun>
<li
v-for="{ type, icon } in emptyItems"
:key="type"
class="d-flex align-items-start gap-2 mb-3"
>
<component :is="icon" size="s" class="text-muted flex-shrink-0" />
<div>
<strong>{{ $t("forecast.type.solar") }}</strong>
<div class="text-muted">{{ $t("forecast.empty.solar") }}</div>
</div>
</li>
<li class="d-flex align-items-start gap-2 mb-3">
<DynamicPriceIcon class="text-muted flex-shrink-0" />
<div>
<strong>{{ $t("forecast.type.price") }}</strong>
<div class="text-muted">{{ $t("forecast.empty.price") }}</div>
</div>
</li>
<li class="d-flex align-items-start gap-2 mb-3">
<shopicon-regular-eco1
size="s"
class="text-muted flex-shrink-0"
></shopicon-regular-eco1>
<div>
<strong>{{ $t("forecast.type.co2") }}</strong>
<div class="text-muted">{{ $t("forecast.empty.co2") }}</div>
<strong>{{ $t(`forecast.type.${type}`) }}</strong>
<div class="text-muted">{{ $t(`forecast.empty.${type}`) }}</div>
</div>
</li>
</ul>
@ -121,21 +105,23 @@
</Card>
<Card
v-if="forecast.co2"
:title="$t('forecast.type.co2')"
v-for="t in valueForecastTypes"
:key="t"
:title="$t(`forecast.type.${t}`)"
edge-to-edge
class="box-pull-out mb-4"
>
<div class="chart-edge">
<Co2Chart
:co2="forecast.co2"
<ValueChart
:type="t"
:rates="forecast[t]!"
:chart-width="chartWidth"
:end-date="chartEndDate"
:scroll-left="scrollLeft"
@scroll="onChartScroll"
/>
</div>
<Co2Details :co2="forecast.co2" />
<ValueDetails :type="t" :rates="forecast[t]" />
</Card>
</main>
</div>
@ -145,7 +131,8 @@
<script lang="ts">
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/eco1";
import { defineComponent } from "vue";
import "@h2d2/shopicons/es/regular/thermometerhalf";
import { defineComponent, markRaw } from "vue";
import Header from "../components/Top/Header.vue";
import Card from "../components/Helper/Card.vue";
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
@ -153,8 +140,8 @@ import SolarChart from "../components/Forecast/SolarChart.vue";
import SolarDetails from "../components/Forecast/SolarDetails.vue";
import PriceChart from "../components/Forecast/PriceChart.vue";
import GridDetails from "../components/Forecast/GridDetails.vue";
import Co2Chart from "../components/Forecast/Co2Chart.vue";
import Co2Details from "../components/Forecast/Co2Details.vue";
import ValueChart, { type ValueChartType } from "../components/Forecast/ValueChart.vue";
import ValueDetails from "../components/Forecast/ValueDetails.vue";
import formatter from "@/mixins/formatter";
import api from "@/api";
import settings from "@/settings";
@ -174,8 +161,8 @@ export default defineComponent({
SolarDetails,
PriceChart,
GridDetails,
Co2Chart,
Co2Details,
ValueChart,
ValueDetails,
},
mixins: [formatter],
data() {
@ -189,7 +176,23 @@ export default defineComponent({
return store.state?.forecast || {};
},
forecastAvailable() {
return !!(this.forecast.grid || this.forecast.solar || this.forecast.co2);
return !!(
this.forecast.grid ||
this.forecast.solar ||
this.forecast.co2 ||
this.forecast.temperature
);
},
valueForecastTypes(): ValueChartType[] {
return (["co2", "temperature"] as ValueChartType[]).filter((t) => this.forecast[t]);
},
emptyItems() {
return [
{ type: "solar", icon: "shopicon-regular-sun" },
{ type: "price", icon: markRaw(DynamicPriceIcon) },
{ type: "co2", icon: "shopicon-regular-eco1" },
{ type: "temperature", icon: "shopicon-regular-thermometerhalf" },
];
},
startDate(): Date {
const now = new Date();
@ -203,11 +206,11 @@ export default defineComponent({
},
dataEndDate(): Date {
let latest = this.startDate.getTime();
for (const s of this.forecast.grid || []) {
const t = new Date(s.end).getTime();
if (t > latest) latest = t;
}
for (const s of this.forecast.co2 || []) {
for (const s of [
...(this.forecast.grid || []),
...(this.forecast.co2 || []),
...(this.forecast.temperature || []),
]) {
const t = new Date(s.end).getTime();
if (t > latest) latest = t;
}

View file

@ -272,3 +272,6 @@ tariffs:
type: template
template: demo-co2-forecast
base: 350
temperature:
type: template
template: demo-temperature-forecast

View file

@ -97,19 +97,7 @@ func collectTariffRefs() error {
}
// Collect all non-empty refs
if refs.Grid != "" {
references.tariff = append(references.tariff, refs.Grid)
}
if refs.FeedIn != "" {
references.tariff = append(references.tariff, refs.FeedIn)
}
if refs.Co2 != "" {
references.tariff = append(references.tariff, refs.Co2)
}
if refs.Planner != "" {
references.tariff = append(references.tariff, refs.Planner)
}
references.tariff = append(references.tariff, refs.Solar...)
references.tariff = slices.AppendSeq(references.tariff, refs.Used())
return nil
}

View file

@ -1214,6 +1214,7 @@ func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tari
eg.Go(func() error { return configureTariff(conf.Co2, refs.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(conf.Planner, refs.Planner, &tariffs.Planner) })
eg.Go(func() error { return configureSolarTariffs(conf.Solar, refs.Solar, &tariffs.Solar) })
eg.Go(func() error { return configureTariff(conf.Temperature, refs.Temperature, &tariffs.Temperature) })
if err := eg.Wait(); err != nil {
return &tariffs, &ClassError{ClassTariff, err}
}

View file

@ -30,6 +30,7 @@ const (
TariffPriceHome = "tariffPriceHome"
TariffPriceLoadpoints = "tariffPriceLoadpoints"
TariffSolar = "tariffSolar"
TariffTemperature = "tariffTemperature"
Vehicles = "vehicles"
// meters

View file

@ -10,6 +10,7 @@ import (
const (
// groups
Forecast = "forecast"
Temperature = "temperature"
Battery = "battery"
Grid = "grid"
PV = "pv"
@ -121,9 +122,12 @@ func (c *Collector) persist() error {
return persist(c.entity, c.started, c.accu.Energy, c.accu.ReturnEnergy, c.accu.SocTemp)
}
// SetSocTemp records the slot-start soc (temperature when isTemp). Call after AddEnergy.
// SetSocTemp records the slot-start soc (temperature when isTemp).
// Advances the slot via process() so it can be used without a prior AddEnergy call.
func (c *Collector) SetSocTemp(value float64, isTemp bool) error {
c.accu.setSocTemp(value)
if err := c.process(func() { c.accu.setSocTemp(value) }); err != nil {
return err
}
return c.entity.updateIsTemp(isTemp)
}

View file

@ -39,7 +39,7 @@ type SeriesExport []Series
// GroupOrder is the canonical display order of metric groups, mirroring the
// frontend GROUP_ORDER plus home/forecast.
var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Consumer, Meter, Home, Forecast}
var GroupOrder = []string{PV, Battery, Grid, Loadpoint, Consumer, Meter, Home, Forecast, Temperature}
var aggregateFormats = map[string]string{
"15m": "%Y-%m-%d %H:%M",

View file

@ -231,6 +231,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
site.collectors[metrics.Forecast] = fc
// temperature forecast collector (populated when TariffUsageTemperature is configured)
tc, err := metrics.NewCollector(metrics.Temperature, metrics.Temperature, metrics.Temperature)
if err != nil {
return err
}
site.collectors[metrics.Temperature] = tc
// multiple batteries
for _, ref := range site.Meters.BatteryMetersRef {
dev, err := config.Meters().ByName(ref)

View file

@ -84,6 +84,9 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil {
site.publish(keys.TariffSolar, v)
}
if v, err := tariff.Now(site.GetTariff(api.TariffUsageTemperature)); err == nil {
site.publish(keys.TariffTemperature, v)
}
if v := site.effectivePrice(greenShareHome); v != nil {
site.publish(keys.TariffPriceHome, v)
}
@ -103,11 +106,13 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
Grid api.Rates `json:"grid,omitempty"`
Planner api.Rates `json:"planner,omitempty"`
Solar *solarDetails `json:"solar,omitempty"`
Temperature api.Rates `json:"temperature,omitempty"`
}{
Co2: tariff.Rates(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Rates(site.GetTariff(api.TariffUsageFeedIn)),
Planner: tariff.Rates(site.GetTariff(api.TariffUsagePlanner)),
Grid: tariff.Rates(site.GetTariff(api.TariffUsageGrid)),
Temperature: tariff.Rates(site.GetTariff(api.TariffUsageTemperature)),
}
// calculate adjusted solar rates
@ -152,6 +157,12 @@ func (site *Site) solarDetails(solar api.Rates) solarDetails {
}
}
if r, err := tariff.At(site.GetTariff(api.TariffUsageTemperature), time.Now()); err == nil {
if err := site.collectors[metrics.Temperature].SetSocTemp(r.Value, true); err != nil {
site.log.ERROR.Printf("temperature collector soc_temp: %v", err)
}
}
res.Scale = site.solarScale()
return res

View file

@ -227,6 +227,7 @@
"no": "Nein",
"odometer": "Kilometerstand",
"org": "Organisation",
"outdoorTemp": "Außentemperatur",
"phaseCurrents": "Strom",
"phasePowers": "Leistung",
"phaseVoltages": "Spannung",
@ -927,7 +928,8 @@
"feedIn": "Einspeisevergütung hinzufügen",
"grid": "Netzbezugstarif hinzufügen",
"planner": "Planer-Vorhersage hinzufügen",
"solar": "Solar-Vorhersage hinzufügen"
"solar": "Solar-Vorhersage hinzufügen",
"temperature": "Temperatur-Vorhersage hinzufügen"
},
"planner": {
"description": "Erweiterte Einstellung. Normalerweise nicht nötig, da dynamische Stromtarife oder CO₂-Vorhersagen automatisch verwendet werden. Ermöglicht eine zusätzliche Datenquelle, die nur für die Ladeplanung verwendet wird und nicht für Statistiken und Preisberechnungen.",
@ -940,6 +942,11 @@
"titleAdd": "Solar-Vorhersage hinzufügen",
"titleEdit": "Solar-Vorhersage bearbeiten"
},
"temperature": {
"description": "Vorhersage der Außentemperatur am Standort. Wird in der Oberfläche angezeigt und für die Prognose des Heizbedarfs verwendet.",
"titleAdd": "Temperatur-Vorhersage hinzufügen",
"titleEdit": "Temperatur-Vorhersage bearbeiten"
},
"template": "Anbieter",
"title": "Tarife & Vorhersagen",
"titleChoice": "Was möchtest du hinzufügen?",
@ -948,7 +955,8 @@
"feedIn": "Einspeisevergütung",
"grid": "Netzbezugspreis",
"planner": "Planer",
"solar": "Solar"
"solar": "Solar",
"temperature": "Temperaturvorhersage"
},
"zones": {
"add": "Zone hinzufügen",
@ -1101,7 +1109,8 @@
"co2": "Erfahre, wann Netzstrom in deiner Region sauber ist. Ladepläne werden auf niedrige Emissionen optimiert und CO₂-Einsparungen berechnet.",
"price": "Dynamischen Stromtarif konfigurieren, um Ladepläne automatisch zu optimieren und Einsparungen zu berechnen.",
"setup": "Tarife und Vorhersagen einrichten",
"solar": "Erwartete Solarproduktion für heute und die nächsten Tage. Wird in Zukunft auch für die automatische Ladeoptimierung verwendet."
"solar": "Erwartete Solarproduktion für heute und die nächsten Tage. Wird in Zukunft auch für die automatische Ladeoptimierung verwendet.",
"temperature": "Zeigt die erwartete Außentemperatur am Standort. Wird für die Prognose des Heizbedarfs verwendet."
},
"hideLine": "Linie ausblenden",
"modalTitle": "Vorhersage",
@ -1123,10 +1132,15 @@
"solarAdjust": "Solarprognose anhand der realen Produktionsdaten anpassen{percent}.",
"solarAdjustMedium": "an reale Produktionsdaten anpassen",
"solarAdjustShort": "anpassen",
"temperature": {
"average": "Durchschnitt",
"range": "Bereich"
},
"type": {
"co2": "CO₂-Emissionen",
"price": "Netzpreis",
"solar": "Solarproduktion"
"solar": "Solarproduktion",
"temperature": "Außentemperatur"
}
},
"general": {

View file

@ -227,6 +227,7 @@
"no": "no",
"odometer": "Odometer",
"org": "Organization",
"outdoorTemp": "Outdoor temperature",
"phaseCurrents": "Current",
"phasePowers": "Power",
"phaseVoltages": "Voltage",
@ -927,7 +928,8 @@
"feedIn": "Add grid export tariff",
"grid": "Add grid import tariff",
"planner": "Add planner forecast",
"solar": "Add solar forecast"
"solar": "Add solar forecast",
"temperature": "Add temperature forecast"
},
"planner": {
"description": "Advanced setting. Usually not needed as dynamic electricity tariffs or CO₂ forecasts are used automatically. Enables an additional data source that's only used for charge planning, not for statistics and price calculations.",
@ -940,6 +942,11 @@
"titleAdd": "Add Solar Forecast",
"titleEdit": "Edit Solar Forecast"
},
"temperature": {
"description": "Outdoor temperature forecast for your location. Displayed in the interface and used to predict heating demand.",
"titleAdd": "Add Temperature Forecast",
"titleEdit": "Edit Temperature Forecast"
},
"template": "Provider",
"title": "Tariffs & Forecasts",
"titleChoice": "What Do You Want To Add?",
@ -948,7 +955,8 @@
"feedIn": "Grid Export Price",
"grid": "Grid Import Price",
"planner": "Planner",
"solar": "Solar"
"solar": "Solar",
"temperature": "Temperature Forecast"
},
"zones": {
"add": "Add zone",
@ -1101,7 +1109,8 @@
"co2": "See when grid energy in your region is clean. Charging plans will optimize for low emissions and calculate CO₂ savings.",
"price": "Configure your dynamic electricity tariff to automatically optimize charging plans and calculate savings.",
"setup": "Set up tariffs and forecasts",
"solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future."
"solar": "See expected solar production for today and the coming days. Will also be used for automatic charging optimization in the future.",
"temperature": "See the expected outdoor temperature for your location. Used to predict heating demand."
},
"hideLine": "hide line",
"modalTitle": "Forecast",
@ -1123,10 +1132,15 @@
"solarAdjust": "Adjust solar forecast based on real production data{percent}.",
"solarAdjustMedium": "real data adjust",
"solarAdjustShort": "adjust",
"temperature": {
"average": "Average",
"range": "Range"
},
"type": {
"co2": "CO₂ Emissions",
"price": "Grid Price",
"solar": "Solar Production"
"solar": "Solar Production",
"temperature": "Outdoor Temperature"
}
},
"general": {

View file

@ -562,7 +562,7 @@ func cleanupTariffRef(name string) {
return
}
for _, ref := range []*string{&refs.Grid, &refs.FeedIn, &refs.Co2, &refs.Planner} {
for _, ref := range []*string{&refs.Grid, &refs.FeedIn, &refs.Co2, &refs.Planner, &refs.Temperature} {
if *ref == name {
*ref = ""
}

View file

@ -470,6 +470,9 @@ func testInstance(ctx context.Context, instance any) map[string]testResult {
case api.TariffTypeSolar:
valueKey = "power"
ratesKey = "solarRates"
case api.TariffTypeTemperature:
valueKey = "outdoorTemp"
ratesKey = "temperatureRates"
default:
valueKey = "price"
}

View file

@ -121,6 +121,10 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval
if t.typ == api.TariffTypeSolar {
periodStart = beginningOfDay()
}
if t.typ == api.TariffTypeTemperature {
// Keep 7 days of historical data (in 15-minute intervals) for temperature correction algorithm
periodStart = time.Now().AddDate(0, 0, -7)
}
mergeRatesAfter(t.data, data, periodStart)
once.Do(func() { close(done) })

View file

@ -9,7 +9,7 @@ import (
type Tariffs struct {
Currency currency.Unit
Grid, FeedIn, Co2, Planner, Solar api.Tariff
Grid, FeedIn, Co2, Planner, Solar, Temperature api.Tariff
}
// At returns the rate at the given time
@ -109,6 +109,9 @@ func (t *Tariffs) Get(u api.TariffUsage) api.Tariff {
case api.TariffUsageSolar:
return t.Solar
case api.TariffUsageTemperature:
return t.Temperature
default:
return nil
}

View file

@ -0,0 +1,61 @@
template: demo-temperature-forecast
products:
- description:
de: Demo Temperatur-Vorhersage
en: Demo Temperature Forecast
requirements:
description:
de: Zu Demonstrationszwecken. Liefert Außentemperaturdaten basierend auf einem typischen Tagesverlauf.
en: For demonstration purposes. Provides outdoor temperature data based on a typical daily cycle.
evcc: ["skiptest"]
group: temperature
params:
- name: min
type: float
unit: °C
default: 10
description:
en: Minimum temperature
de: Minimale Temperatur
- name: max
type: float
unit: °C
default: 20
description:
en: Maximum temperature
de: Maximale Temperatur
render: |
type: custom
tariff: temperature
forecast:
source: js
script: |
// Generate temperature forecast for next 72 hours in 15-minute slots
// Sinusoidal daily cycle between min and max: coldest around 05:00, warmest around 17:00
var now = new Date();
var start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
var rates = [];
var min = parseFloat({{ .min }});
var max = parseFloat({{ .max }});
var base = (min + max) / 2;
var amplitude = (max - min) / 2;
var slotMinutes = 15;
var slotDuration = slotMinutes * 60 * 1000;
var slotsPerDay = (24 * 60) / slotMinutes;
for (var day = 0; day < 3; day++) {
for (var slot = 0; slot < slotsPerDay; slot++) {
var time = new Date(start.getTime() + (day * slotsPerDay + slot) * slotDuration);
var hour = (slot * slotMinutes) / 60;
var temp = base - amplitude * Math.cos(((hour - 5) / 24) * 2 * Math.PI);
rates.push({
start: time.toISOString(),
end: new Date(time.getTime() + slotDuration).toISOString(),
value: Math.round(temp * 10) / 10
});
}
}
JSON.stringify(rates);

View file

@ -0,0 +1,34 @@
template: open-meteo-temperature
products:
- brand: Open-Meteo Temperature
requirements:
description:
en: Free Weather API [open-meteo.com](https://open-meteo.com) for outdoor temperature data in 15-minute intervals. Open-Meteo is an open-source weather API and offers free access for non-commercial use. No API key required.
de: Freie Wetter-API [open-meteo.com](https://open-meteo.com) für Außentemperaturdaten in 15-Minuten-Intervallen. Open-Meteo ist eine Open-Source-Wetter-API und bietet kostenlosen Zugriff für nicht-kommerzielle Nutzung. Kein API-Schlüssel erforderlich.
evcc: ["skiptest"]
group: temperature
params:
- name: lat
required: true
- name: lon
required: true
- name: interval
default: 1h
advanced: true
render: |
type: custom
tariff: temperature
forecast:
source: http
uri: https://api.open-meteo.com/v1/forecast?latitude={{ .lat }}&longitude={{ .lon }}&minutely_15=temperature_2m&past_days=7&forecast_days=3&timezone=UTC
jq: |
[ .minutely_15.time as $times
| .minutely_15.temperature_2m as $temps
| range(0; ($times | length))
| {
start: ($times[.] + ":00Z"),
end: (($times[.] + ":00Z" | fromdateiso8601) + 900 | todateiso8601),
value: $temps[.]
}
] | tostring
interval: {{ .interval }}

View file

@ -104,6 +104,9 @@ grid:
"-10.0 öre",
"Grid CO₂",
"300 g",
"Solar forecast",
"Outdoor temperature",
"15.5°C",
].join("")
);
});
@ -117,6 +120,7 @@ grid:
const tariffFeedin = page.getByTestId("tariff-feedIn");
const tariffCo2 = page.getByTestId("tariff-co2");
const tariffSolar = page.getByTestId("tariff-solar");
const tariffTemperature = page.getByTestId("tariff-temperature");
const tariffPlanner = page.getByTestId("tariff-planner");
const addTariff = page.getByRole("button", { name: "Add tariff" });
const addForecast = page.getByRole("button", { name: "Add forecast" });
@ -126,6 +130,9 @@ grid:
const addGridExport = modal.getByRole("button", { name: "Add grid export tariff" });
const addCo2Forecast = modal.getByRole("button", { name: "Add CO₂ forecast" });
const addSolarForecast = modal.getByRole("button", { name: "Add solar forecast" });
const addTemperatureForecast = modal.getByRole("button", {
name: "Add temperature forecast",
});
const addPlannerForecast = modal.getByRole("button", { name: "Add planner forecast" });
const save = modal.getByRole("button", { name: "Validate & save" });
@ -184,11 +191,22 @@ grid:
await expectModalHidden(modal);
await expect(tariffSolar.nth(1)).toContainText("Roof West");
// create temperature forecast
await addForecast.click();
await expectModalVisible(modal);
await addTemperatureForecast.click();
await modal.getByLabel("Provider").selectOption("Demo Temperature Forecast");
await save.click();
await expectModalHidden(modal);
await expect(tariffTemperature).toContainText("Outdoor temperature");
await expect(tariffTemperature).toContainText(["Forecast", "10.0°C – 20.0°C"].join(""));
// create planner forecast
await addForecast.click();
await expectModalVisible(modal);
// co2 already exists, should not be offered
// co2 and temperature already exist, should not be offered
await expect(addCo2Forecast).not.toBeVisible();
await expect(addTemperatureForecast).not.toBeVisible();
await expect(addSolarForecast).toBeVisible();
await expect(addPlannerForecast).toBeVisible();
await addPlannerForecast.click();
@ -214,12 +232,16 @@ grid:
await expect(tariffSolar).toHaveCount(2);
await expect(tariffSolar.nth(0)).toContainText("Roof South");
await expect(tariffSolar.nth(1)).toContainText("Roof West");
await expect(tariffTemperature).toBeVisible();
await expect(tariffTemperature).toContainText("Outdoor temperature");
await expect(tariffPlanner).toBeVisible();
await expect(tariffPlanner).toContainText("Price");
// delete all in reverse order
await deleteTariff(modal, tariffPlanner);
await expect(tariffPlanner).toHaveCount(0);
await deleteTariff(modal, tariffTemperature);
await expect(tariffTemperature).toHaveCount(0);
await deleteTariff(modal, tariffSolar, 1);
await expect(tariffSolar).toHaveCount(1);
await deleteTariff(modal, tariffSolar, 0);
@ -235,6 +257,12 @@ grid:
// final state: both add buttons visible again
await expect(addTariff).toBeVisible();
await expect(addForecast).toBeVisible();
// deletes must not leave stale refs behind that break the next start
await restart();
await page.reload();
await expect(addTariff).toBeVisible();
await expect(addForecast).toBeVisible();
});
test("currency change", async ({ page }) => {

View file

@ -51,3 +51,19 @@ tariffs:
co2:
type: fixed
price: 300
temperature:
type: custom
tariff: temperature
forecast:
source: js
script: |
var now = Date.now();
var rates = [];
for (var i = 0; i < 48; i++) {
rates.push({
start: new Date(now + i * 3600000).toISOString(),
end: new Date(now + (i + 1) * 3600000).toISOString(),
value: 15.5
});
}
JSON.stringify(rates);

View file

@ -924,3 +924,6 @@ devicegroups:
solar:
de: PV Vorhersage
en: PV forecast
temperature:
de: Temperaturvorhersage
en: Temperature forecast