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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -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>>;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
14
cmd/refs.go
14
cmd/refs.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
22
i18n/de.json
22
i18n/de.json
|
|
@ -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": {
|
||||||
|
|
|
||||||
22
i18n/en.json
22
i18n/en.json
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
"-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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue