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

@ -166,33 +166,35 @@ func (c Messaging) IsConfigured() bool {
} }
type Tariffs struct { type Tariffs struct {
Currency string Currency string
Grid config.Typed Grid config.Typed
FeedIn config.Typed FeedIn config.Typed
Co2 config.Typed Co2 config.Typed
Planner config.Typed Planner config.Typed
Solar []config.Typed Solar []config.Typed
Temperature config.Typed
} }
func (c Tariffs) IsConfigured() bool { 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 { type TariffRefs struct {
Grid string `json:"grid"` Grid string `json:"grid"`
FeedIn string `json:"feedIn"` FeedIn string `json:"feedIn"`
Co2 string `json:"co2"` Co2 string `json:"co2"`
Planner string `json:"planner"` Planner string `json:"planner"`
Solar []string `json:"solar"` Solar []string `json:"solar"`
Temperature string `json:"temperature"`
} }
func (refs TariffRefs) IsConfigured() bool { 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] { func (refs TariffRefs) Used() iter.Seq[string] {
return func(yield func(string) bool) { 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 ref != "" {
if !yield(ref) { if !yield(ref) {
return return

View file

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

View file

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

View file

@ -7,11 +7,11 @@ import (
"strings" "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 { func (i TariffUsage) String() string {
i -= 1 i -= 1
@ -30,9 +30,10 @@ func _TariffUsageNoOp() {
_ = x[TariffUsageGrid-(3)] _ = x[TariffUsageGrid-(3)]
_ = x[TariffUsagePlanner-(4)] _ = x[TariffUsagePlanner-(4)]
_ = x[TariffUsageSolar-(5)] _ = 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{ var _TariffUsageNameToValueMap = map[string]TariffUsage{
_TariffUsageName[0:3]: TariffUsageCo2, _TariffUsageName[0:3]: TariffUsageCo2,
@ -45,6 +46,8 @@ var _TariffUsageNameToValueMap = map[string]TariffUsage{
_TariffUsageLowerName[13:20]: TariffUsagePlanner, _TariffUsageLowerName[13:20]: TariffUsagePlanner,
_TariffUsageName[20:25]: TariffUsageSolar, _TariffUsageName[20:25]: TariffUsageSolar,
_TariffUsageLowerName[20:25]: TariffUsageSolar, _TariffUsageLowerName[20:25]: TariffUsageSolar,
_TariffUsageName[25:36]: TariffUsageTemperature,
_TariffUsageLowerName[25:36]: TariffUsageTemperature,
} }
var _TariffUsageNames = []string{ var _TariffUsageNames = []string{
@ -53,6 +56,7 @@ var _TariffUsageNames = []string{
_TariffUsageName[9:13], _TariffUsageName[9:13],
_TariffUsageName[13:20], _TariffUsageName[13:20],
_TariffUsageName[20:25], _TariffUsageName[20:25],
_TariffUsageName[25:36],
} }
// TariffUsageString retrieves an enum value from the enum constants string name. // 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-export-contrast: var(--evcc-dark-yellow);
--evcc-price: #ff912fff; --evcc-price: #ff912fff;
--evcc-co2: #00916eff; --evcc-co2: #00916eff;
--evcc-temperature: #1b6fba;
--evcc-background: var(--bs-gray-bright); --evcc-background: var(--bs-gray-bright);
--evcc-box: var(--bs-white); --evcc-box: var(--bs-white);
--evcc-box-border: var(--bs-gray-brighter); --evcc-box-border: var(--bs-gray-brighter);
@ -198,6 +199,10 @@ h5 {
color: var(--evcc-co2) !important; color: var(--evcc-co2) !important;
} }
.text-temperature {
color: var(--evcc-temperature) !important;
}
.text-export { .text-export {
color: var(--evcc-export-contrast) !important; color: var(--evcc-export-contrast) !important;
} }

View file

@ -24,6 +24,7 @@ const colors: {
pricePerKWh: string | null; pricePerKWh: string | null;
price: string | null; price: string | null;
co2: string | null; co2: string | null;
temperature: string | null;
export: string | null; export: string | null;
background: string | null; background: string | null;
light: string | null; light: string | null;
@ -40,6 +41,7 @@ const colors: {
pricePerKWh: null, pricePerKWh: null,
price: null, price: null,
co2: null, co2: null,
temperature: null,
export: null, export: null,
background: null, background: null,
light: null, light: null,
@ -147,6 +149,7 @@ export function updateCssColors() {
colors.grid = style.getPropertyValue("--evcc-grid"); colors.grid = style.getPropertyValue("--evcc-grid");
colors.price = style.getPropertyValue("--evcc-price"); colors.price = style.getPropertyValue("--evcc-price");
colors.co2 = style.getPropertyValue("--evcc-co2"); colors.co2 = style.getPropertyValue("--evcc-co2");
colors.temperature = style.getPropertyValue("--evcc-temperature");
colors.export = style.getPropertyValue("--evcc-export-contrast"); colors.export = style.getPropertyValue("--evcc-export-contrast");
colors.background = style.getPropertyValue("--evcc-background"); colors.background = style.getPropertyValue("--evcc-background");
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); 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 PHASE_TAGS = ["phaseCurrents", "phaseVoltages", "phasePowers"];
const FORECAST_TAGS = ["priceRates", "co2Rates", "solarRates"]; const FORECAST_TAGS = ["priceRates", "co2Rates", "solarRates", "temperatureRates"];
export default { export default {
name: "DeviceTags", name: "DeviceTags",
@ -122,6 +122,7 @@ export default {
priceRates: "price", priceRates: "price",
co2Rates: "co2", co2Rates: "co2",
solarRates: "solar", solarRates: "solar",
temperatureRates: "temperature",
}; };
// Find which forecast tag is present // Find which forecast tag is present
@ -220,6 +221,7 @@ export default {
return this.fmtPercentage(value, 1); return this.fmtPercentage(value, 1);
case "temp": case "temp":
case "heaterTempLimit": case "heaterTempLimit":
case "outdoorTemp":
return this.fmtTemperature(value); return this.fmtTemperature(value);
case "odometer": case "odometer":
case "range": case "range":
@ -290,6 +292,8 @@ export default {
return short ? this.fmtCo2Short(value) : this.fmtCo2Medium(value); return short ? this.fmtCo2Short(value) : this.fmtCo2Medium(value);
case "solar": case "solar":
return this.fmtW(value); return this.fmtW(value);
case "temperature":
return this.fmtTemperature(value);
default: default:
return value; 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/eco1";
import "@h2d2/shopicons/es/regular/clock"; import "@h2d2/shopicons/es/regular/clock";
import "@h2d2/shopicons/es/regular/sun"; import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/thermometerhalf";
import { type PropType } from "vue"; import { type PropType } from "vue";
import type { TariffType, CURRENCY } from "@/types/evcc"; import type { TariffType, CURRENCY } from "@/types/evcc";
import DeviceCard from "./DeviceCard.vue"; import DeviceCard from "./DeviceCard.vue";
@ -49,7 +50,7 @@ export default {
hasError: { type: Boolean, default: false }, hasError: { type: Boolean, default: false },
title: String, title: String,
tags: { type: Object, default: () => ({}) }, tags: { type: Object, default: () => ({}) },
currency: { type: String as PropType<CURRENCY>, required: true }, currency: { type: String as PropType<CURRENCY> },
}, },
emits: ["edit"], emits: ["edit"],
computed: { computed: {
@ -69,6 +70,7 @@ export default {
co2: "shopicon-regular-eco1", co2: "shopicon-regular-eco1",
planner: "shopicon-regular-clock", planner: "shopicon-regular-clock",
solar: "shopicon-regular-sun", solar: "shopicon-regular-sun",
temperature: "shopicon-regular-thermometerhalf",
}; };
return iconMap[this.tariffType]; return iconMap[this.tariffType];
}, },

View file

@ -65,6 +65,7 @@ import { getModal } from "@/configModal";
import tariffPriceYaml from "./defaultYaml/tariffPrice.yaml?raw"; import tariffPriceYaml from "./defaultYaml/tariffPrice.yaml?raw";
import tariffCo2Yaml from "./defaultYaml/tariffCo2.yaml?raw"; import tariffCo2Yaml from "./defaultYaml/tariffCo2.yaml?raw";
import tariffSolarYaml from "./defaultYaml/tariffSolar.yaml?raw"; import tariffSolarYaml from "./defaultYaml/tariffSolar.yaml?raw";
import tariffTemperatureYaml from "./defaultYaml/tariffTemperature.yaml?raw";
const initialValues = { const initialValues = {
type: ConfigType.Template, type: ConfigType.Template,
@ -129,7 +130,9 @@ export default defineComponent({
methods: { methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] { provideTemplateOptions(products: Product[]): TemplateGroup[] {
// Use different custom option text for tariffs vs forecasts // 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 const customLabel = isForecast
? this.$t("config.tariff.customForecast") ? this.$t("config.tariff.customForecast")
: this.$t("config.tariff.customTariff"); : this.$t("config.tariff.customTariff");
@ -141,6 +144,7 @@ export default defineComponent({
"demo-co2-forecast", "demo-co2-forecast",
"demo-dynamic-grid", "demo-dynamic-grid",
"demo-solar-forecast", "demo-solar-forecast",
"demo-temperature-forecast",
"energy-charts-api", "energy-charts-api",
]; ];
@ -162,9 +166,11 @@ export default defineComponent({
const priceProducts = filterByGroup("price"); const priceProducts = filterByGroup("price");
const co2Products = filterByGroup("co2"); const co2Products = filterByGroup("co2");
const solarProducts = filterByGroup("solar"); const solarProducts = filterByGroup("solar");
const temperatureProducts = filterByGroup("temperature");
const priceGeneric = extractGeneric("price"); const priceGeneric = extractGeneric("price");
const co2Generic = extractGeneric("co2"); const co2Generic = extractGeneric("co2");
const solarGeneric = extractGeneric("solar"); const solarGeneric = extractGeneric("solar");
const temperatureGeneric = extractGeneric("temperature");
// Special handling for planner: show price + co2 services // Special handling for planner: show price + co2 services
if (this.tariffType === "planner") { if (this.tariffType === "planner") {
@ -194,6 +200,7 @@ export default defineComponent({
feedIn: { service: priceProducts, generic: priceGeneric }, feedIn: { service: priceProducts, generic: priceGeneric },
co2: { service: co2Products, generic: co2Generic }, co2: { service: co2Products, generic: co2Generic },
solar: { service: solarProducts, generic: solarGeneric }, solar: { service: solarProducts, generic: solarGeneric },
temperature: { service: temperatureProducts, generic: temperatureGeneric },
}; };
const mapped = (this.tariffType && groupMap[this.tariffType]) || { const mapped = (this.tariffType && groupMap[this.tariffType]) || {
service: [], service: [],
@ -225,6 +232,8 @@ export default defineComponent({
values.yaml = tariffCo2Yaml; values.yaml = tariffCo2Yaml;
} else if (this.tariffType === "solar") { } else if (this.tariffType === "solar") {
values.yaml = tariffSolarYaml; 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 chartMixin from "./chartMixin";
import type { ForecastSlot } from "./types"; import type { ForecastSlot } from "./types";
export type ValueChartType = "co2" | "temperature";
export default defineComponent({ export default defineComponent({
name: "Co2Chart", name: "ValueChart",
mixins: [formatter, chartMixin], mixins: [formatter, chartMixin],
props: { 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: { computed: {
color(): string {
return (this.type === "co2" ? colors.co2 : colors.temperature) || "";
},
slots(): ForecastSlot[] { 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(): { markPoints(): {
coord: [string, number]; coord: [string, number];
@ -51,20 +62,20 @@ export default defineComponent({
if (slots[minIdx]) { if (slots[minIdx]) {
points.push({ points.push({
coord: [clampStart(slots[minIdx]!.start, this.startDate), slots[minIdx]!.value], 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] }, label: { position: "bottom", offset: [0, 2] },
}); });
} }
if (maxIdx !== minIdx && slots[maxIdx]) { if (maxIdx !== minIdx && slots[maxIdx]) {
points.push({ points.push({
coord: [clampStart(slots[maxIdx]!.start, this.startDate), slots[maxIdx]!.value], coord: [clampStart(slots[maxIdx]!.start, this.startDate), slots[maxIdx]!.value],
value: this.fmtGrams(slots[maxIdx]!.value), value: this.fmtShort(slots[maxIdx]!.value),
}); });
} }
return points; return points;
}, },
chartOption(): Record<string, unknown> { chartOption(): Record<string, unknown> {
const co2Color = colors.co2 || ""; const color = this.color;
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const vThis = this; const vThis = this;
@ -75,13 +86,13 @@ export default defineComponent({
tooltip: { tooltip: {
trigger: "axis", trigger: "axis",
axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } }, axisPointer: { type: "line", snap: true, lineStyle: { color: "transparent" } },
...tooltipStyle(co2Color, () => this.chart), ...tooltipStyle(color, () => this.chart),
formatter(params: { value: [string, number] }[]) { formatter(params: { value: [string, number] }[]) {
const p = params[0]; const p = params[0];
if (!p) return ""; if (!p) return "";
const d = new Date(p.value[0]); const d = new Date(p.value[0]);
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`; 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( xAxis: forecastXAxes(
@ -91,6 +102,7 @@ export default defineComponent({
this.weekdayShort this.weekdayShort
), ),
yAxis: forecastYAxis({ yAxis: forecastYAxis({
min: this.yMin,
splitNumber: 2, splitNumber: 2,
axisLabel: { axisLabel: {
color: colors.muted, color: colors.muted,
@ -105,14 +117,14 @@ export default defineComponent({
symbol: "circle", symbol: "circle",
symbolSize: 6, symbolSize: 6,
showSymbol: false, showSymbol: false,
lineStyle: { color: co2Color, width: 3 }, lineStyle: { color, width: 3 },
emphasis: { emphasis: {
disabled: false, disabled: false,
scale: false, scale: false,
itemStyle: { color: co2Color, borderColor: co2Color, borderWidth: 2 }, itemStyle: { color, borderColor: color, borderWidth: 2 },
}, },
markPoint: markPointLabel( markPoint: markPointLabel(
co2Color, color,
this.tooltipVisible ? [] : this.markPoints, this.tooltipVisible ? [] : this.markPoints,
this.startDate, this.startDate,
this.endDate 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> </script>

View file

@ -2,16 +2,16 @@
<div v-if="average" class="row gx-2 mt-1"> <div v-if="average" class="row gx-2 mt-1">
<div class="col-6"> <div class="col-6">
<small> <small>
<span class="text-gray">{{ $t("forecast.co2.range") }}</span> <span class="text-gray">{{ $t(`forecast.${type}.range`) }}</span>
<br /> <br />
<span class="text-co2 fw-bold">{{ range }}</span> <span :class="`text-${type}`" class="fw-bold">{{ range }}</span>
</small> </small>
</div> </div>
<div class="col-6 text-end"> <div class="col-6 text-end">
<small> <small>
<span class="text-gray">{{ $t("forecast.co2.average") }}</span> <span class="text-gray">{{ $t(`forecast.${type}.average`) }}</span>
<br /> <br />
<span class="text-co2 fw-bold">{{ average }}</span> <span :class="`text-${type}`" class="fw-bold">{{ average }}</span>
</small> </small>
</div> </div>
</div> </div>
@ -21,21 +21,23 @@
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import type { ForecastSlot } from "./types"; import type { ForecastSlot } from "./types";
import type { ValueChartType } from "./ValueChart.vue";
const MAX_HOURS = 96; const MAX_HOURS = 96;
const SLOTS_PER_HOUR = 4; const SLOTS_PER_HOUR = 4;
export default defineComponent({ export default defineComponent({
name: "Co2Details", name: "ValueDetails",
mixins: [formatter], mixins: [formatter],
props: { props: {
co2: { type: Array as PropType<ForecastSlot[]> }, type: { type: String as PropType<ValueChartType>, required: true },
rates: { type: Array as PropType<ForecastSlot[]> },
}, },
computed: { computed: {
upcomingSlots(): ForecastSlot[] { upcomingSlots(): ForecastSlot[] {
if (!Array.isArray(this.co2)) return []; if (!Array.isArray(this.rates)) return [];
const now = new Date(); const now = new Date();
return this.co2 return this.rates
.filter((slot) => new Date(slot.end) > now) .filter((slot) => new Date(slot.end) > now)
.slice(0, MAX_HOURS * SLOTS_PER_HOUR); .slice(0, MAX_HOURS * SLOTS_PER_HOUR);
}, },
@ -43,14 +45,22 @@ export default defineComponent({
if (this.upcomingSlots.length === 0) return ""; if (this.upcomingSlots.length === 0) return "";
const avg = const avg =
this.upcomingSlots.reduce((a, s) => a + s.value, 0) / this.upcomingSlots.length; this.upcomingSlots.reduce((a, s) => a + s.value, 0) / this.upcomingSlots.length;
return this.fmtCo2Medium(avg); return this.fmtValue(avg);
}, },
range(): string { range(): string {
if (this.upcomingSlots.length === 0) return ""; if (this.upcomingSlots.length === 0) return "";
const values = this.upcomingSlots.map((s) => s.value); const values = this.upcomingSlots.map((s) => s.value);
const min = Math.min(...values); const min = Math.min(...values);
const max = Math.max(...values); const max = Math.max(...values);
return `${this.fmtNumber(min, 0)} – ${this.fmtCo2Medium(max)}`; 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; tariffFeedIn?: number;
tariffCo2?: number; tariffCo2?: number;
tariffSolar?: number; tariffSolar?: number;
tariffTemperature?: number;
mqtt?: MqttConfig; mqtt?: MqttConfig;
influx?: InfluxConfig; influx?: InfluxConfig;
hems?: ConfigStatus<HemsConfig, HemsStatus>; hems?: ConfigStatus<HemsConfig, HemsStatus>;
@ -744,6 +745,7 @@ export interface Forecast {
solar?: SolarDetails; solar?: SolarDetails;
planner?: ForecastSlot[]; planner?: ForecastSlot[];
feedin?: ForecastSlot[]; feedin?: ForecastSlot[];
temperature?: ForecastSlot[];
} }
export interface SelectOption<T> { export interface SelectOption<T> {
@ -763,7 +765,7 @@ export type DeviceType =
| "hems"; | "hems";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer"; export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux"; 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 // see https://stackoverflow.com/a/54178819
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

View file

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

View file

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

View file

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

View file

@ -97,19 +97,7 @@ func collectTariffRefs() error {
} }
// Collect all non-empty refs // Collect all non-empty refs
if refs.Grid != "" { references.tariff = slices.AppendSeq(references.tariff, refs.Used())
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...)
return nil 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.Co2, refs.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(conf.Planner, refs.Planner, &tariffs.Planner) }) 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 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 { if err := eg.Wait(); err != nil {
return &tariffs, &ClassError{ClassTariff, err} return &tariffs, &ClassError{ClassTariff, err}
} }

View file

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

View file

@ -9,14 +9,15 @@ import (
const ( const (
// groups // groups
Forecast = "forecast" Forecast = "forecast"
Battery = "battery" Temperature = "temperature"
Grid = "grid" Battery = "battery"
PV = "pv" Grid = "grid"
Home = "home" // meter and group (virtual measurement) PV = "pv"
Loadpoint = "loadpoint" Home = "home" // meter and group (virtual measurement)
Meter = "meter" // additional meter (ext, monitoring only) Loadpoint = "loadpoint"
Consumer = "consumer" // consumer meter (consumers list or aux) Meter = "meter" // additional meter (ext, monitoring only)
Consumer = "consumer" // consumer meter (consumers list or aux)
) )
type Collector struct { type Collector struct {
@ -121,9 +122,12 @@ func (c *Collector) persist() error {
return persist(c.entity, c.started, c.accu.Energy, c.accu.ReturnEnergy, c.accu.SocTemp) 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 { 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) 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 // GroupOrder is the canonical display order of metric groups, mirroring the
// frontend GROUP_ORDER plus home/forecast. // 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{ var aggregateFormats = map[string]string{
"15m": "%Y-%m-%d %H:%M", "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 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 // multiple batteries
for _, ref := range site.Meters.BatteryMetersRef { for _, ref := range site.Meters.BatteryMetersRef {
dev, err := config.Meters().ByName(ref) 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 { if v, err := tariff.Now(site.GetTariff(api.TariffUsageSolar)); err == nil {
site.publish(keys.TariffSolar, v) 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 { if v := site.effectivePrice(greenShareHome); v != nil {
site.publish(keys.TariffPriceHome, v) site.publish(keys.TariffPriceHome, v)
} }
@ -98,16 +101,18 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
} }
fc := struct { fc := struct {
Co2 api.Rates `json:"co2,omitempty"` Co2 api.Rates `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"` FeedIn api.Rates `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"` Grid api.Rates `json:"grid,omitempty"`
Planner api.Rates `json:"planner,omitempty"` Planner api.Rates `json:"planner,omitempty"`
Solar *solarDetails `json:"solar,omitempty"` Solar *solarDetails `json:"solar,omitempty"`
Temperature api.Rates `json:"temperature,omitempty"`
}{ }{
Co2: tariff.Rates(site.GetTariff(api.TariffUsageCo2)), Co2: tariff.Rates(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Rates(site.GetTariff(api.TariffUsageFeedIn)), FeedIn: tariff.Rates(site.GetTariff(api.TariffUsageFeedIn)),
Planner: tariff.Rates(site.GetTariff(api.TariffUsagePlanner)), Planner: tariff.Rates(site.GetTariff(api.TariffUsagePlanner)),
Grid: tariff.Rates(site.GetTariff(api.TariffUsageGrid)), Grid: tariff.Rates(site.GetTariff(api.TariffUsageGrid)),
Temperature: tariff.Rates(site.GetTariff(api.TariffUsageTemperature)),
} }
// calculate adjusted solar rates // 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() res.Scale = site.solarScale()
return res return res

View file

@ -227,6 +227,7 @@
"no": "Nein", "no": "Nein",
"odometer": "Kilometerstand", "odometer": "Kilometerstand",
"org": "Organisation", "org": "Organisation",
"outdoorTemp": "Außentemperatur",
"phaseCurrents": "Strom", "phaseCurrents": "Strom",
"phasePowers": "Leistung", "phasePowers": "Leistung",
"phaseVoltages": "Spannung", "phaseVoltages": "Spannung",
@ -927,7 +928,8 @@
"feedIn": "Einspeisevergütung hinzufügen", "feedIn": "Einspeisevergütung hinzufügen",
"grid": "Netzbezugstarif hinzufügen", "grid": "Netzbezugstarif hinzufügen",
"planner": "Planer-Vorhersage hinzufügen", "planner": "Planer-Vorhersage hinzufügen",
"solar": "Solar-Vorhersage hinzufügen" "solar": "Solar-Vorhersage hinzufügen",
"temperature": "Temperatur-Vorhersage hinzufügen"
}, },
"planner": { "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.", "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", "titleAdd": "Solar-Vorhersage hinzufügen",
"titleEdit": "Solar-Vorhersage bearbeiten" "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", "template": "Anbieter",
"title": "Tarife & Vorhersagen", "title": "Tarife & Vorhersagen",
"titleChoice": "Was möchtest du hinzufügen?", "titleChoice": "Was möchtest du hinzufügen?",
@ -948,7 +955,8 @@
"feedIn": "Einspeisevergütung", "feedIn": "Einspeisevergütung",
"grid": "Netzbezugspreis", "grid": "Netzbezugspreis",
"planner": "Planer", "planner": "Planer",
"solar": "Solar" "solar": "Solar",
"temperature": "Temperaturvorhersage"
}, },
"zones": { "zones": {
"add": "Zone hinzufügen", "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.", "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.", "price": "Dynamischen Stromtarif konfigurieren, um Ladepläne automatisch zu optimieren und Einsparungen zu berechnen.",
"setup": "Tarife und Vorhersagen einrichten", "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", "hideLine": "Linie ausblenden",
"modalTitle": "Vorhersage", "modalTitle": "Vorhersage",
@ -1123,10 +1132,15 @@
"solarAdjust": "Solarprognose anhand der realen Produktionsdaten anpassen{percent}.", "solarAdjust": "Solarprognose anhand der realen Produktionsdaten anpassen{percent}.",
"solarAdjustMedium": "an reale Produktionsdaten anpassen", "solarAdjustMedium": "an reale Produktionsdaten anpassen",
"solarAdjustShort": "anpassen", "solarAdjustShort": "anpassen",
"temperature": {
"average": "Durchschnitt",
"range": "Bereich"
},
"type": { "type": {
"co2": "CO₂-Emissionen", "co2": "CO₂-Emissionen",
"price": "Netzpreis", "price": "Netzpreis",
"solar": "Solarproduktion" "solar": "Solarproduktion",
"temperature": "Außentemperatur"
} }
}, },
"general": { "general": {

View file

@ -227,6 +227,7 @@
"no": "no", "no": "no",
"odometer": "Odometer", "odometer": "Odometer",
"org": "Organization", "org": "Organization",
"outdoorTemp": "Outdoor temperature",
"phaseCurrents": "Current", "phaseCurrents": "Current",
"phasePowers": "Power", "phasePowers": "Power",
"phaseVoltages": "Voltage", "phaseVoltages": "Voltage",
@ -927,7 +928,8 @@
"feedIn": "Add grid export tariff", "feedIn": "Add grid export tariff",
"grid": "Add grid import tariff", "grid": "Add grid import tariff",
"planner": "Add planner forecast", "planner": "Add planner forecast",
"solar": "Add solar forecast" "solar": "Add solar forecast",
"temperature": "Add temperature forecast"
}, },
"planner": { "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.", "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", "titleAdd": "Add Solar Forecast",
"titleEdit": "Edit 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", "template": "Provider",
"title": "Tariffs & Forecasts", "title": "Tariffs & Forecasts",
"titleChoice": "What Do You Want To Add?", "titleChoice": "What Do You Want To Add?",
@ -948,7 +955,8 @@
"feedIn": "Grid Export Price", "feedIn": "Grid Export Price",
"grid": "Grid Import Price", "grid": "Grid Import Price",
"planner": "Planner", "planner": "Planner",
"solar": "Solar" "solar": "Solar",
"temperature": "Temperature Forecast"
}, },
"zones": { "zones": {
"add": "Add zone", "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.", "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.", "price": "Configure your dynamic electricity tariff to automatically optimize charging plans and calculate savings.",
"setup": "Set up tariffs and forecasts", "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", "hideLine": "hide line",
"modalTitle": "Forecast", "modalTitle": "Forecast",
@ -1123,10 +1132,15 @@
"solarAdjust": "Adjust solar forecast based on real production data{percent}.", "solarAdjust": "Adjust solar forecast based on real production data{percent}.",
"solarAdjustMedium": "real data adjust", "solarAdjustMedium": "real data adjust",
"solarAdjustShort": "adjust", "solarAdjustShort": "adjust",
"temperature": {
"average": "Average",
"range": "Range"
},
"type": { "type": {
"co2": "CO₂ Emissions", "co2": "CO₂ Emissions",
"price": "Grid Price", "price": "Grid Price",
"solar": "Solar Production" "solar": "Solar Production",
"temperature": "Outdoor Temperature"
} }
}, },
"general": { "general": {

View file

@ -562,7 +562,7 @@ func cleanupTariffRef(name string) {
return 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 { if *ref == name {
*ref = "" *ref = ""
} }

View file

@ -470,6 +470,9 @@ func testInstance(ctx context.Context, instance any) map[string]testResult {
case api.TariffTypeSolar: case api.TariffTypeSolar:
valueKey = "power" valueKey = "power"
ratesKey = "solarRates" ratesKey = "solarRates"
case api.TariffTypeTemperature:
valueKey = "outdoorTemp"
ratesKey = "temperatureRates"
default: default:
valueKey = "price" 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 { if t.typ == api.TariffTypeSolar {
periodStart = beginningOfDay() 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) mergeRatesAfter(t.data, data, periodStart)
once.Do(func() { close(done) }) once.Do(func() { close(done) })

View file

@ -8,8 +8,8 @@ import (
) )
type Tariffs struct { type Tariffs struct {
Currency currency.Unit 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 // At returns the rate at the given time
@ -109,6 +109,9 @@ func (t *Tariffs) Get(u api.TariffUsage) api.Tariff {
case api.TariffUsageSolar: case api.TariffUsageSolar:
return t.Solar return t.Solar
case api.TariffUsageTemperature:
return t.Temperature
default: default:
return nil 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", "-10.0 öre",
"Grid CO₂", "Grid CO₂",
"300 g", "300 g",
"Solar forecast",
"Outdoor temperature",
"15.5°C",
].join("") ].join("")
); );
}); });
@ -117,6 +120,7 @@ grid:
const tariffFeedin = page.getByTestId("tariff-feedIn"); const tariffFeedin = page.getByTestId("tariff-feedIn");
const tariffCo2 = page.getByTestId("tariff-co2"); const tariffCo2 = page.getByTestId("tariff-co2");
const tariffSolar = page.getByTestId("tariff-solar"); const tariffSolar = page.getByTestId("tariff-solar");
const tariffTemperature = page.getByTestId("tariff-temperature");
const tariffPlanner = page.getByTestId("tariff-planner"); const tariffPlanner = page.getByTestId("tariff-planner");
const addTariff = page.getByRole("button", { name: "Add tariff" }); const addTariff = page.getByRole("button", { name: "Add tariff" });
const addForecast = page.getByRole("button", { name: "Add forecast" }); const addForecast = page.getByRole("button", { name: "Add forecast" });
@ -126,6 +130,9 @@ grid:
const addGridExport = modal.getByRole("button", { name: "Add grid export tariff" }); const addGridExport = modal.getByRole("button", { name: "Add grid export tariff" });
const addCo2Forecast = modal.getByRole("button", { name: "Add CO₂ forecast" }); const addCo2Forecast = modal.getByRole("button", { name: "Add CO₂ forecast" });
const addSolarForecast = modal.getByRole("button", { name: "Add solar 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 addPlannerForecast = modal.getByRole("button", { name: "Add planner forecast" });
const save = modal.getByRole("button", { name: "Validate & save" }); const save = modal.getByRole("button", { name: "Validate & save" });
@ -184,11 +191,22 @@ grid:
await expectModalHidden(modal); await expectModalHidden(modal);
await expect(tariffSolar.nth(1)).toContainText("Roof West"); 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 // create planner forecast
await addForecast.click(); await addForecast.click();
await expectModalVisible(modal); 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(addCo2Forecast).not.toBeVisible();
await expect(addTemperatureForecast).not.toBeVisible();
await expect(addSolarForecast).toBeVisible(); await expect(addSolarForecast).toBeVisible();
await expect(addPlannerForecast).toBeVisible(); await expect(addPlannerForecast).toBeVisible();
await addPlannerForecast.click(); await addPlannerForecast.click();
@ -214,12 +232,16 @@ grid:
await expect(tariffSolar).toHaveCount(2); await expect(tariffSolar).toHaveCount(2);
await expect(tariffSolar.nth(0)).toContainText("Roof South"); await expect(tariffSolar.nth(0)).toContainText("Roof South");
await expect(tariffSolar.nth(1)).toContainText("Roof West"); 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).toBeVisible();
await expect(tariffPlanner).toContainText("Price"); await expect(tariffPlanner).toContainText("Price");
// delete all in reverse order // delete all in reverse order
await deleteTariff(modal, tariffPlanner); await deleteTariff(modal, tariffPlanner);
await expect(tariffPlanner).toHaveCount(0); await expect(tariffPlanner).toHaveCount(0);
await deleteTariff(modal, tariffTemperature);
await expect(tariffTemperature).toHaveCount(0);
await deleteTariff(modal, tariffSolar, 1); await deleteTariff(modal, tariffSolar, 1);
await expect(tariffSolar).toHaveCount(1); await expect(tariffSolar).toHaveCount(1);
await deleteTariff(modal, tariffSolar, 0); await deleteTariff(modal, tariffSolar, 0);
@ -235,6 +257,12 @@ grid:
// final state: both add buttons visible again // final state: both add buttons visible again
await expect(addTariff).toBeVisible(); await expect(addTariff).toBeVisible();
await expect(addForecast).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 }) => { test("currency change", async ({ page }) => {

View file

@ -51,3 +51,19 @@ tariffs:
co2: co2:
type: fixed type: fixed
price: 300 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: solar:
de: PV Vorhersage de: PV Vorhersage
en: PV forecast en: PV forecast
temperature:
de: Temperaturvorhersage
en: Temperature forecast