Tariffs: add outdoor temperature type and OpenMeteo (#27780)
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
68db0b7727
commit
d68e5e5809
34 changed files with 449 additions and 135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,3 +272,6 @@ tariffs:
|
|||
type: template
|
||||
template: demo-co2-forecast
|
||||
base: 350
|
||||
temperature:
|
||||
type: template
|
||||
template: demo-temperature-forecast
|
||||
|
|
|
|||
14
cmd/refs.go
14
cmd/refs.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const (
|
|||
TariffPriceHome = "tariffPriceHome"
|
||||
TariffPriceLoadpoints = "tariffPriceLoadpoints"
|
||||
TariffSolar = "tariffSolar"
|
||||
TariffTemperature = "tariffTemperature"
|
||||
Vehicles = "vehicles"
|
||||
|
||||
// meters
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
i18n/de.json
22
i18n/de.json
|
|
@ -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": {
|
||||
|
|
|
|||
22
i18n/en.json
22
i18n/en.json
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
61
templates/definition/tariff/demo-temperature-forecast.yaml
Normal file
61
templates/definition/tariff/demo-temperature-forecast.yaml
Normal 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);
|
||||
34
templates/definition/tariff/open-meteo-temperature.yaml
Normal file
34
templates/definition/tariff/open-meteo-temperature.yaml
Normal 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 }}
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -924,3 +924,6 @@ devicegroups:
|
|||
solar:
|
||||
de: PV Vorhersage
|
||||
en: PV forecast
|
||||
temperature:
|
||||
de: Temperaturvorhersage
|
||||
en: Temperature forecast
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue