mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[energyforecast] Initial contribution (#20325)
* initial version Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
@@ -112,6 +112,7 @@
|
||||
/bundles/org.openhab.binding.emotiva/ @espenaf
|
||||
/bundles/org.openhab.binding.energenie/ @hmerk
|
||||
/bundles/org.openhab.binding.energidataservice/ @jlaur
|
||||
/bundles/org.openhab.binding.energyforecast/ @weymann
|
||||
/bundles/org.openhab.binding.enigma2/ @gdolfen
|
||||
/bundles/org.openhab.binding.enocean/ @fruggy83
|
||||
/bundles/org.openhab.binding.enphase/ @Hilbrand
|
||||
|
||||
@@ -546,6 +546,11 @@
|
||||
<artifactId>org.openhab.binding.energidataservice</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.energyforecast</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.binding.enigma2</artifactId>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
||||
@@ -0,0 +1,97 @@
|
||||
# Energy Forecast Binding
|
||||
|
||||
Binding provides AI price forecast 48h / 96h beyond day-ahead pricing with [Energy Forecast Service](https://www.energyforecast.de/).
|
||||
Check in beforehand if your [price zone](https://www.energyforecast.de/api-docs/index.html) is supported.
|
||||
[Registration](https://www.energyforecast.de/users/sign_up) is mandatory!
|
||||
|
||||
## Binding Configuration
|
||||
|
||||
### `price-forecast` Thing Configuration
|
||||
|
||||
| Name | Type | Description | Default | Required |
|
||||
|-------------------|-----------|-----------------------------------------------------------------------------------|-----------|----------|
|
||||
| token | text | Token for energy forecast service to provide forecast data | N/A | yes |
|
||||
| zone | text | Bidding zone for price queries | N/A | yes |
|
||||
| fixCost | decimal | Net fix costs in ct/kWh added on top of the forecast price, e.g. 15,3 | 0 | no |
|
||||
| resolution | text | Resolution in ISO 8601 Duration format | PT15M | no |
|
||||
| refreshInterval | integer | Refresh interval in minutes. Check with service throttling | 180 | no |
|
||||
| errorLimit | integer | Limit error percentage values for better visualization | 0 | no |
|
||||
|
||||
`token` needs to be generated after registration at [Energy Forecast Service](https://www.energyforecast.de/api_keys).
|
||||
`zone` from [API](https://www.energyforecast.de/api-docs/index.html) are given as options.
|
||||
`resolution` time resolution given as options. `PT15M` an `PT60M` are supported.
|
||||
`refreshInterval` given in minutes. Align this value with your [booked plan](https://www.energyforecast.de/pricing).
|
||||
`errorLimit` to avoid extraordinary error percentage values in `metric` group. Value `0` is no limit.
|
||||
|
||||
#### Calculate Gross Price
|
||||
|
||||
`fixCost` shall be net costs which will be added to the net energy price.
|
||||
If you've already one or more items holding fix cost values consider to [calculate the future prices in a rule](https://www.openhab.org/addons/bindings/energidataservice/#time-series).
|
||||
Use [VAT Transformation Service](https://www.openhab.org/addons/transformations/vat/) to calculate the gross price.
|
||||
|
||||
## Channels
|
||||
|
||||
All channels delivering `timeseries` information.
|
||||
Attaching items which are bound only to `rrd4j` persistence will not work.
|
||||
If you don't have a database installed [InMemory persistence](https://www.openhab.org/addons/persistence/inmemory/) can be used.
|
||||
|
||||
### Group `price`
|
||||
|
||||
| Channel | Type | Description |
|
||||
|---------------|-----------------------|---------------------------------------------------|
|
||||
| series | Number:EnergyPrice | Actual and future price series |
|
||||
| origin | Number | Originator of the price (market or forecast) |
|
||||
|
||||
`series` delivers price information from 48h up to 96h into the future depending on your [booked plan](https://www.energyforecast.de/pricing).
|
||||
`origin` shows for every price the originator.
|
||||
|
||||
- 0: Market
|
||||
- 1: AI Forecast
|
||||
|
||||
### Group `metric`
|
||||
|
||||
Metrics for AI price forecasts.
|
||||
Calculation is done on net prices without configured `fixCost` and any VAT applied via transformations/rules.
|
||||
See [Future Forecasting](https://www.future-forecasting.de/en/wiki/fehlermass/) for further description.
|
||||
|
||||
**Note: After first installation these values will stay empty up to 1,5 days!**
|
||||
To compare market and forecast prices this time is needed until a market price is available.
|
||||
|
||||
| Channel | Type | Description |
|
||||
|-------------------|-----------------------|-----------------------------------------------------------------------------------|
|
||||
| forecast | Number:EnergyPrice | AI forecast price series without any market prices |
|
||||
| forecast-error | Number:EnergyPrice | Difference between market and forecast price |
|
||||
| percent-error | Number:Dimensionless | Percentage error between market and forecast price |
|
||||
| mean-abs | Number:EnergyPrice | Mean absolute error showing the average of absolute forecast errors |
|
||||
| mean-abs-percent | Number:Dimensionless | Mean absolute percentage error showing the average of absolute percentage errors |
|
||||
|
||||
`forecast` timeseries contains only forecast prices, market prices are excluded.
|
||||
`forecast-error` price difference between market and forecast price.
|
||||
`percent-error` is the `forecast-error` in percent.
|
||||
It can show extraordinary high values if market prices are around zero cost.
|
||||
If market price is 0.001 and forecast was 0.006 the percentage error is _high_ while `forecast-error` is quite low.
|
||||
For visualization you can limit these values with configuration `errorLimit`.
|
||||
|
||||
`mean-abs` shows the average of all absolute `forecast-error` calculations as one value.
|
||||
`mean-abs-percent` shows the average of all absolute `percent-error` calculations as one value.
|
||||
|
||||
## Full Example
|
||||
|
||||
### `demo.things`
|
||||
|
||||
```java
|
||||
Thing energyforecast:price-forecast:UID "Energy Forecast" [zone="YOUR_BIDDING_ZONE", token="YOUR_TOKEN", fixCost=12.3, resolution="PT15M", refreshInterval=180, errorLimit=0]
|
||||
```
|
||||
|
||||
### `demo.items`
|
||||
|
||||
```java
|
||||
Number:EnergyPrice Energy_Forecast_Price_Series "Price Series" {channel="energyforecast:price-forecast:UID:price#series"}
|
||||
Number Energy_Forecast_Price_Origins "Price Origin" {channel="energyforecast:price-forecast:UID:price#origin"}
|
||||
|
||||
Number:EnergyPrice Energy_Forecast_Forecast "Forecast" {channel="energyforecast:price-forecast:UID:metric#forecast"}
|
||||
Number:EnergyPrice Energy_Forecast_Forecast_Error "Forecast Error" {channel="energyforecast:price-forecast:UID:metric#forecast-error"}
|
||||
Number:Dimensionless Energy_Forecast_Percent_Error "Percent Error" {channel="energyforecast:price-forecast:UID:metric#percent-error"}
|
||||
Number:EnergyPrice Energy_Forecast_Mean_Absolute "Mean Absolute" {channel="energyforecast:price-forecast:UID:metric#mean-abs"}
|
||||
Number:Dimensionless Energy_Forecast_Mean_Absolute_Percent "Mean Absolute Percent" {channel="energyforecast:price-forecast:UID:metric#mean-abs-percent"}
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.binding.energyforecast</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: EnergyForecast Binding</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>${json.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.binding.energyforecast-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.6.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-energyforecast" description="EnergyForecast Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.energyforecast/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast.internal;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link EnergyForecastBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EnergyForecastBindingConstants {
|
||||
|
||||
public static final String BINDING_ID = "energyforecast";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_PRICE_FORECAST = new ThingTypeUID(BINDING_ID, "price-forecast");
|
||||
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_PRICE_FORECAST);
|
||||
|
||||
public static final String ENERGY_FORECAST_URL = "https://www.energyforecast.de/api/v1/predictions/next_96_hours";
|
||||
|
||||
public static final String CHANNEL_GROUP_PRICE = "price";
|
||||
public static final String CHANNEL_GROUP_METRIC = "metric";
|
||||
|
||||
public static final String CHANNEL_PRICE_SERIES = "series";
|
||||
public static final String CHANNEL_PRICE_ORIGIN = "origin";
|
||||
|
||||
public static final String CHANNEL_METRIC_FORECAST = "forecast";
|
||||
public static final String CHANNEL_METRIC_FORECAST_ERROR = "forecast-error";
|
||||
public static final String CHANNEL_METRIC_PERCENT_ERROR = "percent-error";
|
||||
public static final String CHANNEL_METRIC_MAE = "mean-abs";
|
||||
public static final String CHANNEL_METRIC_MAPE = "mean-abs-percent";
|
||||
|
||||
public static Clock clock = Clock.systemDefaultZone();
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast.internal;
|
||||
|
||||
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.energyforecast.internal.handler.EnergyForecastHandler;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.storage.StorageService;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link EnergyForecastHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(configurationPid = "binding.energyforecast", service = ThingHandlerFactory.class)
|
||||
public class EnergyForecastHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private final StorageService storageService;
|
||||
private final HttpClientFactory hcf;
|
||||
private final TimeZoneProvider tzp;
|
||||
|
||||
@Activate
|
||||
public EnergyForecastHandlerFactory(final @Reference HttpClientFactory hcf,
|
||||
final @Reference StorageService storageService, final @Reference TimeZoneProvider tzp) {
|
||||
this.hcf = hcf;
|
||||
this.storageService = storageService;
|
||||
this.tzp = tzp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (THING_TYPE_PRICE_FORECAST.equals(thingTypeUID)) {
|
||||
return new EnergyForecastHandler(thing, hcf.getCommonHttpClient(), storageService, tzp);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* The {@link EnergyForecastConfiguration} class contains fields mapping thing configuration parameters.
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EnergyForecastConfiguration {
|
||||
|
||||
public String zone = "";
|
||||
public String token = "";
|
||||
public double fixCost = 0.0;
|
||||
public String resolution = "PT15M";
|
||||
public int refreshInterval = 180;
|
||||
public int errorLimit = 0;
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast.internal.dto;
|
||||
|
||||
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.openhab.binding.energyforecast.internal.config.EnergyForecastConfiguration;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.storage.Storage;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link PriceInfo} class is responsible for storing the price information, calculating the metrics and providing
|
||||
* timeseries
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class PriceInfo {
|
||||
|
||||
private static final String JSON_KEY_FORECAST = "forecast";
|
||||
private static final String JSON_KEY_MARKET = "market";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(PriceInfo.class);
|
||||
private final Storage<String> store;
|
||||
private final TimeZoneProvider tzp;
|
||||
|
||||
private EnergyForecastConfiguration config;
|
||||
|
||||
// map with market and forecast prices
|
||||
private SortedMap<Instant, Double> priceSeries = new TreeMap<>();
|
||||
private SortedMap<Instant, String> originSeries = new TreeMap<>();
|
||||
|
||||
// for metrics
|
||||
// map keeping all forecast data
|
||||
private SortedMap<Instant, Double> forecastSeries = new TreeMap<>();
|
||||
private SortedMap<Instant, Double> forecastErrorSeries = new TreeMap<>();
|
||||
private SortedMap<Instant, Double> percentErrorSeries = new TreeMap<>();
|
||||
private Double maeValue = Double.NaN;
|
||||
private Double mapeValue = Double.NaN;
|
||||
|
||||
public PriceInfo(EnergyForecastConfiguration config, Storage<String> store, TimeZoneProvider tzp) {
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
this.tzp = tzp;
|
||||
restore();
|
||||
}
|
||||
|
||||
private void restore() {
|
||||
String forecastString = store.get(JSON_KEY_FORECAST);
|
||||
if (forecastString != null) {
|
||||
JSONObject forecastJson = new JSONObject(forecastString);
|
||||
forecastJson.keySet().forEach(key -> {
|
||||
forecastSeries.put(Instant.parse(key), forecastJson.getDouble(key));
|
||||
});
|
||||
logger.debug("Restored {} forecast entries from storage", forecastJson.length());
|
||||
} else {
|
||||
logger.debug("No forecast data found in storage, starting with empty series.");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void newPriceSeries(String prices) {
|
||||
JSONArray priceArray = new JSONArray(prices);
|
||||
priceArray.forEach(item -> {
|
||||
JSONObject jsonObject = (JSONObject) item;
|
||||
Instant start = Instant.parse(jsonObject.getString("start"));
|
||||
Double price = jsonObject.getDouble("price");
|
||||
String origin = jsonObject.getString("price_origin");
|
||||
priceSeries.put(start, price);
|
||||
originSeries.put(start, origin);
|
||||
if (JSON_KEY_FORECAST.equals(origin)) {
|
||||
forecastSeries.put(start, price);
|
||||
}
|
||||
});
|
||||
store.put(JSON_KEY_FORECAST, new JSONObject(forecastSeries).toString());
|
||||
calculateMetrics();
|
||||
}
|
||||
|
||||
private void calculateMetrics() {
|
||||
originSeries.forEach((time, orgin) -> {
|
||||
if (JSON_KEY_MARKET.equals(orgin)) {
|
||||
Double marketPrice = priceSeries.get(time);
|
||||
Double forecastPrice = forecastSeries.get(time);
|
||||
if (marketPrice != null && forecastPrice != null) {
|
||||
// forecast error
|
||||
double forecastError = marketPrice - forecastPrice;
|
||||
forecastErrorSeries.put(time, forecastError);
|
||||
|
||||
// percentage error
|
||||
double percentageError = 0;
|
||||
if (marketPrice != 0) {
|
||||
percentageError = (forecastError / marketPrice) * 100;
|
||||
percentErrorSeries.put(time, percentageError);
|
||||
} // omit undefined percentage error when market price is zero
|
||||
}
|
||||
}
|
||||
});
|
||||
maeValue = forecastErrorSeries.values().stream().mapToDouble(value -> Math.abs(value)).sum()
|
||||
/ forecastErrorSeries.size();
|
||||
// remove all undefined values for mape calculation
|
||||
SortedMap<Instant, Double> cleanPercentErrorSeries = percentErrorSeries.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() != Double.MIN_VALUE)
|
||||
.collect(TreeMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), TreeMap::putAll);
|
||||
mapeValue = cleanPercentErrorSeries.values().stream().filter(value -> value != Double.MIN_VALUE)
|
||||
.mapToDouble(value -> Math.abs(value)).sum() / cleanPercentErrorSeries.size();
|
||||
}
|
||||
|
||||
public synchronized void consolidate() {
|
||||
Instant startOfDay = LocalDate.now(clock).atStartOfDay(tzp.getTimeZone()).toInstant();
|
||||
priceSeries = priceSeries.tailMap(startOfDay);
|
||||
originSeries = originSeries.tailMap(startOfDay);
|
||||
forecastSeries = forecastSeries.tailMap(startOfDay);
|
||||
forecastErrorSeries = forecastErrorSeries.tailMap(startOfDay);
|
||||
percentErrorSeries = percentErrorSeries.tailMap(startOfDay);
|
||||
}
|
||||
|
||||
public synchronized Map<String, TimeSeries> getTimeSeries() {
|
||||
Map<String, TimeSeries> timeSeriesMap = new HashMap<>();
|
||||
timeSeriesMap.put(CHANNEL_PRICE_SERIES, getPriceTimeSeries(priceSeries, true));
|
||||
timeSeriesMap.put(CHANNEL_PRICE_ORIGIN, getOriginsTimeSeries(originSeries));
|
||||
timeSeriesMap.put(CHANNEL_METRIC_FORECAST, getPriceTimeSeries(forecastSeries, true));
|
||||
timeSeriesMap.put(CHANNEL_METRIC_FORECAST_ERROR, getPriceTimeSeries(forecastErrorSeries, false));
|
||||
timeSeriesMap.put(CHANNEL_METRIC_PERCENT_ERROR, getPercentTimeSeries(percentErrorSeries));
|
||||
timeSeriesMap.put(CHANNEL_METRIC_MAE, getSingleValueSeries(maeValue, " EUR/kWh"));
|
||||
timeSeriesMap.put(CHANNEL_METRIC_MAPE, getSingleValueSeries(mapeValue, " %"));
|
||||
return timeSeriesMap;
|
||||
}
|
||||
|
||||
private TimeSeries getPercentTimeSeries(SortedMap<Instant, Double> source) {
|
||||
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
|
||||
source.forEach((time, value) -> {
|
||||
series.add(time, QuantityType.valueOf(value + " %"));
|
||||
});
|
||||
return series;
|
||||
}
|
||||
|
||||
private TimeSeries getPriceTimeSeries(SortedMap<Instant, Double> source, boolean priceCorrection) {
|
||||
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
|
||||
source.forEach((time, value) -> {
|
||||
double price = priceCorrection ? (value + (config.fixCost / 100)) : value;
|
||||
series.add(time, QuantityType.valueOf(price + " EUR/kWh"));
|
||||
});
|
||||
return series;
|
||||
}
|
||||
|
||||
private TimeSeries getOriginsTimeSeries(SortedMap<Instant, String> source) {
|
||||
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
|
||||
source.forEach((time, value) -> {
|
||||
int origin = JSON_KEY_MARKET.equals(value) ? 0 : 1;
|
||||
series.add(time, new DecimalType(origin));
|
||||
});
|
||||
return series;
|
||||
}
|
||||
|
||||
private TimeSeries getSingleValueSeries(Double value, String unit) {
|
||||
TimeSeries series = new TimeSeries(TimeSeries.Policy.REPLACE);
|
||||
if (!value.isNaN()) {
|
||||
series.add(Instant.now(), QuantityType.valueOf(value + unit));
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
public void handleRemoval() {
|
||||
store.remove(JSON_KEY_FORECAST);
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast.internal.handler;
|
||||
|
||||
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.energyforecast.internal.config.EnergyForecastConfiguration;
|
||||
import org.openhab.binding.energyforecast.internal.dto.PriceInfo;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.storage.StorageService;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* {@link EnergyForecastHandler} handles config, thing status and channel updates
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EnergyForecastHandler extends BaseThingHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(EnergyForecastHandler.class);
|
||||
private final HttpClient httpClient;
|
||||
private final TimeZoneProvider tzp;
|
||||
private final StorageService storageService;
|
||||
|
||||
private @Nullable ScheduledFuture<?> refreshJob;
|
||||
private @Nullable PriceInfo prices;
|
||||
|
||||
private EnergyForecastConfiguration config = new EnergyForecastConfiguration();
|
||||
|
||||
public EnergyForecastHandler(Thing thing, HttpClient client, StorageService storageService, TimeZoneProvider tzp) {
|
||||
super(thing);
|
||||
this.httpClient = client;
|
||||
this.storageService = storageService;
|
||||
this.tzp = tzp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
if (command instanceof RefreshType) {
|
||||
if (prices == null) {
|
||||
logger.debug("Ignoring refresh command for channel {} because prices are not initialized", channelUID);
|
||||
return;
|
||||
}
|
||||
updatePriceChannels();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
config = getConfigAs(EnergyForecastConfiguration.class);
|
||||
if (checkConfig()) {
|
||||
prices = new PriceInfo(config, storageService.getStorage(thing.getUID().getAsString()), tzp);
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
refreshJob = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.refreshInterval,
|
||||
TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkConfig() {
|
||||
if (config.token.isBlank()) {
|
||||
configError("@text/thing-status.energyforecast.token-empty");
|
||||
return false;
|
||||
}
|
||||
if (config.zone.isBlank()) {
|
||||
configError("@text/thing-status.energyforecast.zone-empty");
|
||||
return false;
|
||||
}
|
||||
if (config.refreshInterval < 15) {
|
||||
configError("@text/thing-status.energyforecast.refresh-interval-too-low");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void configError(String reason) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
ScheduledFuture<?> localRefreshJob = refreshJob;
|
||||
if (localRefreshJob != null) {
|
||||
localRefreshJob.cancel(true);
|
||||
refreshJob = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleRemoval() {
|
||||
PriceInfo localPrices = prices;
|
||||
if (localPrices != null) {
|
||||
localPrices.handleRemoval();
|
||||
}
|
||||
super.handleRemoval();
|
||||
}
|
||||
|
||||
private void refreshData() {
|
||||
if (!config.token.isBlank()) {
|
||||
if (fetchEnergyForecastPrices()) {
|
||||
getPriceInfo().consolidate();
|
||||
updatePriceChannels();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePriceChannels() {
|
||||
Map<String, TimeSeries> seriesMap = getPriceInfo().getTimeSeries();
|
||||
|
||||
// price group
|
||||
sendSeriesToChannel(CHANNEL_GROUP_PRICE, CHANNEL_PRICE_SERIES, seriesMap.get(CHANNEL_PRICE_SERIES));
|
||||
sendSeriesToChannel(CHANNEL_GROUP_PRICE, CHANNEL_PRICE_ORIGIN, seriesMap.get(CHANNEL_PRICE_ORIGIN));
|
||||
|
||||
// metric group
|
||||
sendSeriesToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_FORECAST, seriesMap.get(CHANNEL_METRIC_FORECAST));
|
||||
sendSeriesToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_FORECAST_ERROR,
|
||||
seriesMap.get(CHANNEL_METRIC_FORECAST_ERROR));
|
||||
sendSeriesToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_PERCENT_ERROR,
|
||||
seriesMap.get(CHANNEL_METRIC_PERCENT_ERROR));
|
||||
|
||||
sendValueToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_MAE, seriesMap.get(CHANNEL_METRIC_MAE));
|
||||
sendValueToChannel(CHANNEL_GROUP_METRIC, CHANNEL_METRIC_MAPE, seriesMap.get(CHANNEL_METRIC_MAPE));
|
||||
}
|
||||
|
||||
private void sendSeriesToChannel(String group, String channel, @Nullable TimeSeries series) {
|
||||
if (series != null) {
|
||||
if (CHANNEL_METRIC_PERCENT_ERROR.equals(channel)) {
|
||||
TimeSeries limitSeries = new TimeSeries(TimeSeries.Policy.REPLACE);
|
||||
series.getStates().forEach(entry -> limitSeries.add(entry.timestamp(), applyLimit(entry.state())));
|
||||
sendTimeSeries(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + channel, limitSeries);
|
||||
} else {
|
||||
sendTimeSeries(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + channel, series);
|
||||
}
|
||||
} else {
|
||||
logger.warn("No data available for channel {} in group {}", channel, group);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendValueToChannel(String group, String channel, @Nullable TimeSeries series) {
|
||||
if (series != null) {
|
||||
Optional<TimeSeries.Entry> value = series.getStates().findFirst();
|
||||
value.ifPresent(entry -> {
|
||||
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + channel, applyLimit(entry.state()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private State applyLimit(State state) {
|
||||
if (config.errorLimit != 0) {
|
||||
if (state instanceof QuantityType quantity) {
|
||||
if (Units.PERCENT.equals(quantity.getUnit())) {
|
||||
double originValue = quantity.doubleValue();
|
||||
if (originValue > 0) {
|
||||
return QuantityType.valueOf(Math.min(config.errorLimit, quantity.doubleValue()) + " %");
|
||||
} else {
|
||||
return QuantityType.valueOf(Math.max(-config.errorLimit, quantity.doubleValue()) + " %");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private boolean fetchEnergyForecastPrices() {
|
||||
try {
|
||||
Request forecastRequest = httpClient.newRequest(ENERGY_FORECAST_URL).timeout(10, TimeUnit.SECONDS);
|
||||
forecastRequest.param("fixed_cost_cent", "0");
|
||||
forecastRequest.param("vat", "0");
|
||||
forecastRequest.param("resolution", "PT15M".equals(config.resolution) ? "QUARTER_HOURLY" : "HOURLY");
|
||||
forecastRequest.param("market_zone", config.zone);
|
||||
forecastRequest.param("token", config.token);
|
||||
|
||||
ContentResponse response = forecastRequest.send();
|
||||
int status = response.getStatus();
|
||||
logger.debug("Energy price forecast response: {}", status);
|
||||
if (status == HttpStatus.OK_200) {
|
||||
getPriceInfo().newPriceSeries(response.getContentAsString());
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
return true;
|
||||
} else {
|
||||
String reason = response.getReason();
|
||||
String statusMessage = "HTTP " + status + (!reason.isEmpty() ? " " + reason : "");
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, statusMessage);
|
||||
return false;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt(); // restore interrupt status
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
return false;
|
||||
} catch (ExecutionException | TimeoutException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private PriceInfo getPriceInfo() {
|
||||
PriceInfo localPrices = prices;
|
||||
if (localPrices == null) {
|
||||
throw new IllegalStateException("PriceInfo is not initialized");
|
||||
}
|
||||
return localPrices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<addon:addon id="energyforecast" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||
|
||||
<type>binding</type>
|
||||
<name>Energy Forecast Binding</name>
|
||||
<description>Binding provides AI price forecast 48h / 96h beyond day-ahead pricing</description>
|
||||
<connection>cloud</connection>
|
||||
<countries>at,be,ch,cz,de,lu,dk,fr,hu,it,nl,no,pl,se,si</countries>
|
||||
</addon:addon>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# add-on
|
||||
|
||||
addon.energyforecast.name = Energy Forecast Binding
|
||||
addon.energyforecast.description = Binding provides AI price forecast 48h / 96h beyond day-ahead pricing
|
||||
|
||||
# thing types
|
||||
|
||||
thing-type.energyforecast.price-forecast.label = Price Forecast
|
||||
thing-type.energyforecast.price-forecast.description = AI based price forecast beyond day-ahead prices
|
||||
|
||||
# thing types config
|
||||
|
||||
thing-type.config.energyforecast.price-forecast.errorLimit.label = Error Limit
|
||||
thing-type.config.energyforecast.price-forecast.errorLimit.description = Limit error percentage values for better visualization
|
||||
thing-type.config.energyforecast.price-forecast.fixCost.label = Energy Forecast Fix Cost
|
||||
thing-type.config.energyforecast.price-forecast.fixCost.description = Net fix costs in ct/kWh which will be added on top of the forecast price, e.g. 15,3
|
||||
thing-type.config.energyforecast.price-forecast.refreshInterval.label = Refresh Interval
|
||||
thing-type.config.energyforecast.price-forecast.refreshInterval.description = Refresh interval in minutes. Check with service throttling
|
||||
thing-type.config.energyforecast.price-forecast.resolution.label = Time Resolution
|
||||
thing-type.config.energyforecast.price-forecast.resolution.description = Resolution in ISO 8601 Duration format
|
||||
thing-type.config.energyforecast.price-forecast.resolution.option.PT15M = 15 minutes
|
||||
thing-type.config.energyforecast.price-forecast.resolution.option.PT60M = 60 minutes
|
||||
thing-type.config.energyforecast.price-forecast.token.label = Energy Forecast Token
|
||||
thing-type.config.energyforecast.price-forecast.token.description = Token for energy forecast service to provide forecast data
|
||||
thing-type.config.energyforecast.price-forecast.zone.label = Bidding Zone
|
||||
thing-type.config.energyforecast.price-forecast.zone.description = Bidding zone for price queries
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.AT = Austria
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.BE = Belgium
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.CH = Switzerland
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.CZ = Czech Republic
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.DE-LU = Germany, Luxembourg
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.DK1 = Denmark 1
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.DK2 = Denmark 2
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.FR = France
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.HU = Hungary
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.IT-North = Italy North
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.NL = Netherlands
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.NO2 = Norway 2
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.PL = Poland
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.SE4 = Sweden 4
|
||||
thing-type.config.energyforecast.price-forecast.zone.option.SI = Slovenia
|
||||
|
||||
# channel group types
|
||||
|
||||
channel-group-type.energyforecast.metric-group.label = Metrics
|
||||
channel-group-type.energyforecast.metric-group.description = Metric calculations for AI price forecasts
|
||||
channel-group-type.energyforecast.metric-group.channel.forecast.label = AI Forecast Price
|
||||
channel-group-type.energyforecast.metric-group.channel.forecast.description = AI forecast price series without any market prices
|
||||
channel-group-type.energyforecast.metric-group.channel.forecast-error.label = Forecast Error
|
||||
channel-group-type.energyforecast.metric-group.channel.forecast-error.description = Difference between market and forecast price
|
||||
channel-group-type.energyforecast.metric-group.channel.mean-abs.label = Mean Absolute
|
||||
channel-group-type.energyforecast.metric-group.channel.mean-abs.description = Mean absolute error showing the average of absolute forecast errors
|
||||
channel-group-type.energyforecast.metric-group.channel.mean-abs-percent.label = Mean Absolute Percent
|
||||
channel-group-type.energyforecast.metric-group.channel.mean-abs-percent.description = Mean absolute percentage error showing the average of absolute percentage errors
|
||||
channel-group-type.energyforecast.metric-group.channel.percent-error.label = Percentage Error
|
||||
channel-group-type.energyforecast.metric-group.channel.percent-error.description = Percentage error between market and forecast price
|
||||
channel-group-type.energyforecast.price-group.label = Pricing
|
||||
channel-group-type.energyforecast.price-group.description = Price information including price and price origin
|
||||
channel-group-type.energyforecast.price-group.channel.origin.label = Price Origin
|
||||
channel-group-type.energyforecast.price-group.channel.origin.description = Originator of the price (market or forecast)
|
||||
channel-group-type.energyforecast.price-group.channel.series.label = Price Series
|
||||
channel-group-type.energyforecast.price-group.channel.series.description = Actual and future price series
|
||||
|
||||
# channel types
|
||||
|
||||
channel-type.energyforecast.origin.label = Price Origin
|
||||
channel-type.energyforecast.origin.state.option.0 = Market
|
||||
channel-type.energyforecast.origin.state.option.1 = AI Forecast
|
||||
channel-type.energyforecast.percent.label = Delta
|
||||
channel-type.energyforecast.price.label = Energy Price
|
||||
|
||||
# thing status
|
||||
|
||||
thing-status.energyforecast.token-empty = Token configuration is mandatory
|
||||
thing-status.energyforecast.zone-empty = Zone configuration is mandatory
|
||||
thing-status.energyforecast.refresh-interval-too-low = Refresh interval minimum is 15 minutes
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="energyforecast"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<thing-type id="price-forecast">
|
||||
<label>Price Forecast</label>
|
||||
<description>AI based price forecast beyond day-ahead prices</description>
|
||||
<category>WebService</category>
|
||||
<semantic-equipment-tag>WebService</semantic-equipment-tag>
|
||||
|
||||
<channel-groups>
|
||||
<channel-group id="price" typeId="price-group"/>
|
||||
<channel-group id="metric" typeId="metric-group"/>
|
||||
</channel-groups>
|
||||
|
||||
<config-description>
|
||||
<parameter name="zone" type="text" required="true">
|
||||
<label>Bidding Zone</label>
|
||||
<description>Bidding zone for price queries</description>
|
||||
<options>
|
||||
<option value="AT">Austria</option>
|
||||
<option value="BE">Belgium</option>
|
||||
<option value="CH">Switzerland</option>
|
||||
<option value="CZ">Czech Republic</option>
|
||||
<option value="DE-LU">Germany, Luxembourg</option>
|
||||
<option value="DK1">Denmark 1</option>
|
||||
<option value="DK2">Denmark 2</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="HU">Hungary</option>
|
||||
<option value="IT-North">Italy North</option>
|
||||
<option value="NL">Netherlands</option>
|
||||
<option value="NO2">Norway 2</option>
|
||||
<option value="PL">Poland</option>
|
||||
<option value="SE4">Sweden 4</option>
|
||||
<option value="SI">Slovenia</option>
|
||||
</options>
|
||||
<limitToOptions>false</limitToOptions>
|
||||
</parameter>
|
||||
<parameter name="token" type="text" required="true">
|
||||
<label>Energy Forecast Token</label>
|
||||
<description>Token for energy forecast service to provide forecast data</description>
|
||||
</parameter>
|
||||
<parameter name="fixCost" type="decimal" required="false">
|
||||
<label>Energy Forecast Fix Cost</label>
|
||||
<description>Net fix costs in ct/kWh which will be added on top of the forecast price, e.g. 15,3</description>
|
||||
<default>0</default>
|
||||
</parameter>
|
||||
<parameter name="resolution" type="text" required="false">
|
||||
<label>Time Resolution</label>
|
||||
<description>Resolution in ISO 8601 Duration format</description>
|
||||
<options>
|
||||
<option value="PT15M">15 minutes</option>
|
||||
<option value="PT60M">60 minutes</option>
|
||||
</options>
|
||||
<default>PT15M</default>
|
||||
</parameter>
|
||||
<parameter name="refreshInterval" type="integer" min="15">
|
||||
<label>Refresh Interval</label>
|
||||
<description>Refresh interval in minutes. Check with service throttling</description>
|
||||
<default>180</default>
|
||||
</parameter>
|
||||
<parameter name="errorLimit" type="integer">
|
||||
<label>Error Limit</label>
|
||||
<description>Limit error percentage values for better visualization</description>
|
||||
<default>0</default>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</thing-type>
|
||||
|
||||
<channel-group-type id="price-group">
|
||||
<label>Pricing</label>
|
||||
<description>Price information including price and price origin</description>
|
||||
<channels>
|
||||
<channel id="series" typeId="price">
|
||||
<label>Price Series</label>
|
||||
<description>Actual and future price series</description>
|
||||
</channel>
|
||||
<channel id="origin" typeId="origin">
|
||||
<label>Price Origin</label>
|
||||
<description>Originator of the price (market or forecast)</description>
|
||||
</channel>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-group-type id="metric-group">
|
||||
<label>Metrics</label>
|
||||
<description>Metric calculations for AI price forecasts</description>
|
||||
<channels>
|
||||
<channel id="forecast" typeId="price">
|
||||
<label>AI Forecast Price</label>
|
||||
<description>AI forecast price series without any market prices </description>
|
||||
</channel>
|
||||
<channel id="forecast-error" typeId="price">
|
||||
<label>Forecast Error</label>
|
||||
<description>Difference between market and forecast price</description>
|
||||
</channel>
|
||||
<channel id="percent-error" typeId="percent">
|
||||
<label>Percentage Error</label>
|
||||
<description>Percentage error between market and forecast price</description>
|
||||
</channel>
|
||||
<channel id="mean-abs" typeId="price">
|
||||
<label>Mean Absolute</label>
|
||||
<description>Mean absolute error showing the average of absolute forecast errors</description>
|
||||
</channel>
|
||||
<channel id="mean-abs-percent" typeId="percent">
|
||||
<label>Mean Absolute Percent</label>
|
||||
<description>Mean absolute percentage error showing the average of absolute percentage errors</description>
|
||||
</channel>
|
||||
</channels>
|
||||
</channel-group-type>
|
||||
|
||||
<channel-type id="price">
|
||||
<item-type>Number:EnergyPrice</item-type>
|
||||
<label>Energy Price</label>
|
||||
<category>Price</category>
|
||||
<tags>
|
||||
<tag>Status</tag>
|
||||
<tag>Price</tag>
|
||||
</tags>
|
||||
<state pattern="%.3f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="percent">
|
||||
<item-type unitHint="%">Number:Dimensionless</item-type>
|
||||
<label>Delta</label>
|
||||
<state pattern="%.1f %unit%"/>
|
||||
</channel-type>
|
||||
<channel-type id="origin">
|
||||
<item-type>Number</item-type>
|
||||
<label>Price Origin</label>
|
||||
<state>
|
||||
<options>
|
||||
<option value="0">Market</option>
|
||||
<option value="1">AI Forecast</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
</thing:thing-descriptions>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory;
|
||||
import org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* {@link APICaller} performs a real query towards energyforecast.de to check if the API is still working as expected.
|
||||
* This test should be executed before each release.
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class APICaller {
|
||||
private final Logger logger = LoggerFactory.getLogger(APICaller.class);
|
||||
|
||||
private static HttpClient httpClient = new HttpClient(new SslContextFactory.Client());
|
||||
private String token = "YOUR_TOKEN";
|
||||
|
||||
public static void main(String[] args) {
|
||||
APICaller api = new APICaller();
|
||||
api.call();
|
||||
}
|
||||
|
||||
private void call() {
|
||||
try {
|
||||
httpClient.start();
|
||||
ContentResponse response = httpClient
|
||||
.GET(EnergyForecastBindingConstants.ENERGY_FORECAST_URL + "?token=" + token);
|
||||
logger.warn("Response status: {}", response.getStatus());
|
||||
logger.warn("{}", response.getContentAsString());
|
||||
httpClient.stop();
|
||||
} catch (Exception e) {
|
||||
logger.warn("Call failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.config.core.ConfigDescription;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Channel;
|
||||
import org.openhab.core.thing.ChannelGroupUID;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||
import org.openhab.core.thing.binding.builder.ChannelBuilder;
|
||||
import org.openhab.core.thing.binding.builder.ThingStatusInfoBuilder;
|
||||
import org.openhab.core.thing.type.ChannelGroupTypeUID;
|
||||
import org.openhab.core.thing.type.ChannelTypeUID;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
|
||||
/**
|
||||
* Test implementation of {@link ThingHandlerCallback} used by the energyforecast binding
|
||||
* to verify interactions in unit and integration tests. It records state updates, triggers
|
||||
* and time series sent by handlers so that tests can assert on them.
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class CallbackMock implements ThingHandlerCallback {
|
||||
private @Nullable Bridge bridge;
|
||||
private ThingStatusInfo status = ThingStatusInfoBuilder.create(ThingStatus.OFFLINE).build();
|
||||
public Map<String, State> stateMap = new HashMap<>();
|
||||
public Map<String, String> triggerMap = new HashMap<>();
|
||||
public Map<String, TimeSeries> timeSeriesMap = new HashMap<>();
|
||||
|
||||
public void clear() {
|
||||
stateMap.clear();
|
||||
}
|
||||
|
||||
public @Nullable State getState(String channel) {
|
||||
return stateMap.get(channel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stateUpdated(ChannelUID channelUID, State state) {
|
||||
stateMap.put(channelUID.getAsString(), state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postCommand(ChannelUID channelUID, Command command) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
|
||||
synchronized (timeSeriesMap) {
|
||||
timeSeriesMap.put(channelUID.getAsString(), timeSeries);
|
||||
timeSeriesMap.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable TimeSeries getTimeSeries(String channel) {
|
||||
synchronized (timeSeriesMap) {
|
||||
Instant start = Instant.now();
|
||||
Instant check = Instant.now();
|
||||
while (timeSeriesMap.get(channel) == null && Duration.between(start, check).getSeconds() < 10) {
|
||||
try {
|
||||
timeSeriesMap.wait(100);
|
||||
} catch (InterruptedException e) {
|
||||
fail("Interrupted getting timeseries " + channel);
|
||||
}
|
||||
check = Instant.now();
|
||||
}
|
||||
TimeSeries timeSeries = timeSeriesMap.get(channel);
|
||||
if (timeSeries == null) {
|
||||
fail("No timeseries available for " + channel);
|
||||
}
|
||||
return timeSeries;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void statusUpdated(Thing thing, ThingStatusInfo thingStatus) {
|
||||
synchronized (this) {
|
||||
status = thingStatus;
|
||||
this.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
public ThingStatusInfo getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void waitForOnline() {
|
||||
synchronized (this) {
|
||||
Instant start = Instant.now();
|
||||
Instant check = Instant.now();
|
||||
while (!ThingStatus.ONLINE.equals(status.getStatus()) && Duration.between(start, check).getSeconds() < 10) {
|
||||
try {
|
||||
this.wait(1000);
|
||||
} catch (InterruptedException e) {
|
||||
fail("Interrupted waiting for ONLINE");
|
||||
}
|
||||
check = Instant.now();
|
||||
}
|
||||
}
|
||||
// if method is exited without reaching ONLINE e.g. through timeout fail
|
||||
if (!ThingStatus.ONLINE.equals(status.getStatus())) {
|
||||
fail("waitForOnline just reached status " + status);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void thingUpdated(Thing thing) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateConfigurationParameters(Thing thing, Map<String, Object> configurationParameters) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateConfigurationParameters(Channel channel, Map<String, Object> configurationParameters) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ConfigDescription getConfigDescription(ChannelTypeUID channelTypeUID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ConfigDescription getConfigDescription(ThingTypeUID thingTypeUID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configurationUpdated(Thing thing) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void migrateThingType(Thing thing, ThingTypeUID thingTypeUID, Configuration configuration) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelTriggered(Thing thing, ChannelUID channelUID, String event) {
|
||||
triggerMap.put(channelUID.getAsString(), event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelBuilder createChannelBuilder(ChannelUID channelUID, ChannelTypeUID channelTypeUID) {
|
||||
return ChannelBuilder.create(new ChannelUID("handler:test"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelBuilder editChannel(Thing thing, ChannelUID channelUID) {
|
||||
return ChannelBuilder.create(new ChannelUID("handler:test"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChannelBuilder> createChannelBuilders(ChannelGroupUID channelGroupUID,
|
||||
ChannelGroupTypeUID channelGroupTypeUID) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isChannelLinked(ChannelUID channelUID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Bridge getBridge(ThingUID bridgeUID) {
|
||||
return bridge;
|
||||
}
|
||||
|
||||
public void setBridge(Bridge bridge) {
|
||||
this.bridge = bridge;
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.THING_TYPE_PRICE_FORECAST;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.client.api.ContentResponse;
|
||||
import org.eclipse.jetty.client.api.Request;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants;
|
||||
import org.openhab.binding.energyforecast.internal.handler.EnergyForecastHandler;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.unit.CurrencyUnits;
|
||||
import org.openhab.core.storage.Storage;
|
||||
import org.openhab.core.test.storage.VolatileStorageService;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.internal.ThingImpl;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
|
||||
/**
|
||||
* {@link HandlerTest} tests handler initialization, data update cycle and channel updates
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class HandlerTest {
|
||||
private TimeZoneProvider tzp = new TimeZoneProvider() {
|
||||
@Override
|
||||
public ZoneId getTimeZone() {
|
||||
return ZoneId.of("Europe/Berlin");
|
||||
}
|
||||
};
|
||||
|
||||
@BeforeAll
|
||||
static void setup() {
|
||||
String fixedInstant = "2026-02-26T15:00:00Z";
|
||||
EnergyForecastBindingConstants.clock = Clock.fixed(Instant.parse(fixedInstant), ZoneId.of("Europe/Berlin"));
|
||||
CurrencyUnits.addUnit(CurrencyUnits.createCurrency("EUR", "Euro"));
|
||||
}
|
||||
|
||||
String readFile(String path) {
|
||||
try {
|
||||
return Files.readString(Paths.get(path));
|
||||
} catch (IOException e) {
|
||||
fail(e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
HttpClient prepareResponse(String fileName) {
|
||||
HttpClient client = mock(HttpClient.class);
|
||||
Request request = mock(Request.class);
|
||||
ContentResponse response = mock(ContentResponse.class);
|
||||
when(client.newRequest(anyString())).thenReturn(request);
|
||||
when(request.timeout(10, TimeUnit.SECONDS)).thenReturn(request);
|
||||
try {
|
||||
when(request.send()).thenReturn(response);
|
||||
} catch (InterruptedException | TimeoutException | ExecutionException e) {
|
||||
fail(e.getMessage());
|
||||
}
|
||||
when(response.getStatus()).thenReturn(HttpStatus.OK_200);
|
||||
when(response.getContentAsString()).thenReturn(readFile(fileName));
|
||||
return client;
|
||||
}
|
||||
|
||||
List<?> createHandler(@Nullable Configuration config) {
|
||||
ThingImpl thing = new ThingImpl(THING_TYPE_PRICE_FORECAST, "forecast_test");
|
||||
if (config != null) {
|
||||
thing.setConfiguration(config);
|
||||
}
|
||||
HttpClient client = prepareResponse("src/test/resources/2026-02-26-response.json");
|
||||
VolatileStorageService storageService = new VolatileStorageService();
|
||||
EnergyForecastHandler tester = new EnergyForecastHandler(thing, client, storageService, tzp);
|
||||
CallbackMock callback = new CallbackMock();
|
||||
tester.setCallback(callback);
|
||||
return List.of(tester, callback, thing, storageService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConfigError() {
|
||||
List<?> testObjects = createHandler(null);
|
||||
EnergyForecastHandler tester = (EnergyForecastHandler) testObjects.get(0);
|
||||
assertNotNull(tester);
|
||||
CallbackMock callback = (CallbackMock) testObjects.get(1);
|
||||
assertNotNull(callback);
|
||||
ThingImpl thing = (ThingImpl) testObjects.get(2);
|
||||
assertNotNull(thing);
|
||||
tester.initialize();
|
||||
|
||||
ThingStatusInfo statusInfo = callback.getStatus();
|
||||
assertEquals(ThingStatus.OFFLINE, statusInfo.getStatus());
|
||||
assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusInfo.getStatusDetail());
|
||||
assertEquals("@text/thing-status.energyforecast.token-empty", statusInfo.getDescription());
|
||||
|
||||
Configuration config = new Configuration();
|
||||
config.put("token", "abc");
|
||||
thing.setConfiguration(config);
|
||||
tester.initialize();
|
||||
|
||||
statusInfo = callback.getStatus();
|
||||
assertEquals(ThingStatus.OFFLINE, statusInfo.getStatus());
|
||||
assertEquals(ThingStatusDetail.CONFIGURATION_ERROR, statusInfo.getStatusDetail());
|
||||
assertEquals("@text/thing-status.energyforecast.zone-empty", statusInfo.getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFullCycle() {
|
||||
Configuration config = new Configuration();
|
||||
config.put("token", "abc");
|
||||
config.put("zone", "DE-LU");
|
||||
List<?> testObjects = createHandler(config);
|
||||
EnergyForecastHandler tester = (EnergyForecastHandler) testObjects.get(0);
|
||||
assertNotNull(tester);
|
||||
CallbackMock callback = (CallbackMock) testObjects.get(1);
|
||||
assertNotNull(callback);
|
||||
ThingImpl thing = (ThingImpl) testObjects.get(2);
|
||||
assertNotNull(thing);
|
||||
VolatileStorageService storageService = (VolatileStorageService) testObjects.get(3);
|
||||
assertNotNull(storageService);
|
||||
Storage<String> store = storageService.getStorage(thing.getUID().getAsString());
|
||||
tester.initialize();
|
||||
callback.waitForOnline();
|
||||
TimeSeries priceSeries = callback.getTimeSeries("energyforecast:price-forecast:forecast_test:price#series");
|
||||
assertNotNull(priceSeries);
|
||||
assertEquals(23 + 48, priceSeries.size());
|
||||
|
||||
String storeData = store.get("forecast");
|
||||
assertNotNull(storeData);
|
||||
JSONObject storedJson = new JSONObject(storeData);
|
||||
assertEquals(23, storedJson.length());
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.energyforecast;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.openhab.binding.energyforecast.internal.EnergyForecastBindingConstants.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.energyforecast.internal.config.EnergyForecastConfiguration;
|
||||
import org.openhab.binding.energyforecast.internal.dto.PriceInfo;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.CurrencyUnits;
|
||||
import org.openhab.core.test.storage.VolatileStorage;
|
||||
import org.openhab.core.types.TimeSeries;
|
||||
|
||||
/**
|
||||
* {@link PriceInfoTest} to check test calculations based on predefined responses.
|
||||
*
|
||||
* @author Bernd Weymann - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
class PriceInfoTest {
|
||||
private TimeZoneProvider tzp = new TimeZoneProvider() {
|
||||
@Override
|
||||
public ZoneId getTimeZone() {
|
||||
return ZoneId.of("Europe/Berlin");
|
||||
}
|
||||
};
|
||||
|
||||
String readFile(String path) {
|
||||
try {
|
||||
return Files.readString(Paths.get(path));
|
||||
} catch (IOException e) {
|
||||
fail(e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
PriceInfo createPriceInfo(EnergyForecastConfiguration config, VolatileStorage<String> store) {
|
||||
CurrencyUnits.addUnit(CurrencyUnits.createCurrency("EUR", "Euro"));
|
||||
PriceInfo priceInfo = new PriceInfo(config, store, tzp);
|
||||
|
||||
priceInfo.newPriceSeries(readFile("src/test/resources/2026-02-26-response.json"));
|
||||
Map<String, TimeSeries> timeSeriesMap = priceInfo.getTimeSeries();
|
||||
TimeSeries maeSeries = timeSeriesMap.get(CHANNEL_METRIC_MAE);
|
||||
assertNotNull(maeSeries);
|
||||
assertEquals(0, maeSeries.size());
|
||||
return priceInfo;
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMapeValue() {
|
||||
PriceInfo priceInfo = createPriceInfo(new EnergyForecastConfiguration(), new VolatileStorage<>());
|
||||
priceInfo.newPriceSeries(readFile("src/test/resources/2026-02-27-response.json"));
|
||||
Map<String, TimeSeries> timeSeriesMap = priceInfo.getTimeSeries();
|
||||
|
||||
TimeSeries mapeSeries = timeSeriesMap.get(CHANNEL_METRIC_MAPE);
|
||||
assertNotNull(mapeSeries);
|
||||
assertEquals(1, mapeSeries.size());
|
||||
double expectedMape = 12.94562;
|
||||
mapeSeries.getStates().forEach(entry -> {
|
||||
assertEquals(expectedMape, ((QuantityType<?>) entry.state()).doubleValue(), 0.0001,
|
||||
"MAPE value should match expected value");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStorage() {
|
||||
VolatileStorage<String> store = new VolatileStorage<>();
|
||||
createPriceInfo(new EnergyForecastConfiguration(), store);
|
||||
String storeString = store.get(CHANNEL_METRIC_FORECAST);
|
||||
JSONObject storedForecast = new JSONObject(storeString);
|
||||
assertEquals(23, storedForecast.length(), "Stored forecast should have 23 entries");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFixCost() {
|
||||
double fixCost = 12.3; // ct/kWh
|
||||
EnergyForecastConfiguration configNoFixCosts = new EnergyForecastConfiguration();
|
||||
PriceInfo priceInfoNoFixCosts = createPriceInfo(configNoFixCosts, new VolatileStorage<>());
|
||||
EnergyForecastConfiguration configWithFixCost = new EnergyForecastConfiguration();
|
||||
configWithFixCost.fixCost = fixCost;
|
||||
PriceInfo priceInfoWithFixCosts = createPriceInfo(configWithFixCost, new VolatileStorage<>());
|
||||
|
||||
Map<String, TimeSeries> timeSeriesMapNoFixCost = priceInfoNoFixCosts.getTimeSeries();
|
||||
TimeSeries priceTimeSeriesNoFixCost = timeSeriesMapNoFixCost.get(CHANNEL_PRICE_SERIES);
|
||||
assertNotNull(priceTimeSeriesNoFixCost);
|
||||
|
||||
Map<String, TimeSeries> timeSeriesMapWithFixCost = priceInfoWithFixCosts.getTimeSeries();
|
||||
TimeSeries priceTimeSeriesWithFixCost = timeSeriesMapWithFixCost.get(CHANNEL_PRICE_SERIES);
|
||||
assertNotNull(priceTimeSeriesWithFixCost);
|
||||
|
||||
Iterator<TimeSeries.Entry> noFixCostIterator = priceTimeSeriesNoFixCost.getStates().iterator();
|
||||
Iterator<TimeSeries.Entry> fixCostIterator = priceTimeSeriesWithFixCost.getStates().iterator();
|
||||
|
||||
double euroPerKWhFixCost = fixCost / 100;
|
||||
while (noFixCostIterator.hasNext() && fixCostIterator.hasNext()) {
|
||||
TimeSeries.Entry noFixCostEntry = noFixCostIterator.next();
|
||||
TimeSeries.Entry fixCostEntry = fixCostIterator.next();
|
||||
|
||||
double noFixCostPrice = ((QuantityType<?>) noFixCostEntry.state()).doubleValue();
|
||||
double fixCostPrice = ((QuantityType<?>) fixCostEntry.state()).doubleValue();
|
||||
assertEquals(euroPerKWhFixCost, fixCostPrice - noFixCostPrice, 0.0001,
|
||||
"Price difference should be equal to fix cost");
|
||||
}
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
[
|
||||
{
|
||||
"start": "2026-02-26T00:00:00.000+01:00",
|
||||
"end": "2026-02-26T01:00:00.000+01:00",
|
||||
"price": 0.26945,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T01:00:00.000+01:00",
|
||||
"end": "2026-02-26T02:00:00.000+01:00",
|
||||
"price": 0.26463,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T02:00:00.000+01:00",
|
||||
"end": "2026-02-26T03:00:00.000+01:00",
|
||||
"price": 0.26025,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T03:00:00.000+01:00",
|
||||
"end": "2026-02-26T04:00:00.000+01:00",
|
||||
"price": 0.25936,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T04:00:00.000+01:00",
|
||||
"end": "2026-02-26T05:00:00.000+01:00",
|
||||
"price": 0.25868,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T05:00:00.000+01:00",
|
||||
"end": "2026-02-26T06:00:00.000+01:00",
|
||||
"price": 0.26876,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T06:00:00.000+01:00",
|
||||
"end": "2026-02-26T07:00:00.000+01:00",
|
||||
"price": 0.29848,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T07:00:00.000+01:00",
|
||||
"end": "2026-02-26T08:00:00.000+01:00",
|
||||
"price": 0.30952,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T08:00:00.000+01:00",
|
||||
"end": "2026-02-26T09:00:00.000+01:00",
|
||||
"price": 0.29444,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T09:00:00.000+01:00",
|
||||
"end": "2026-02-26T10:00:00.000+01:00",
|
||||
"price": 0.27421,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T10:00:00.000+01:00",
|
||||
"end": "2026-02-26T11:00:00.000+01:00",
|
||||
"price": 0.25924,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T11:00:00.000+01:00",
|
||||
"end": "2026-02-26T12:00:00.000+01:00",
|
||||
"price": 0.24199,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T12:00:00.000+01:00",
|
||||
"end": "2026-02-26T13:00:00.000+01:00",
|
||||
"price": 0.20233,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T13:00:00.000+01:00",
|
||||
"end": "2026-02-26T14:00:00.000+01:00",
|
||||
"price": 0.19525,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T14:00:00.000+01:00",
|
||||
"end": "2026-02-26T15:00:00.000+01:00",
|
||||
"price": 0.23163,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T15:00:00.000+01:00",
|
||||
"end": "2026-02-26T16:00:00.000+01:00",
|
||||
"price": 0.26335,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T16:00:00.000+01:00",
|
||||
"end": "2026-02-26T17:00:00.000+01:00",
|
||||
"price": 0.29122,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T17:00:00.000+01:00",
|
||||
"end": "2026-02-26T18:00:00.000+01:00",
|
||||
"price": 0.31074,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T18:00:00.000+01:00",
|
||||
"end": "2026-02-26T19:00:00.000+01:00",
|
||||
"price": 0.31309,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T19:00:00.000+01:00",
|
||||
"end": "2026-02-26T20:00:00.000+01:00",
|
||||
"price": 0.30699,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T20:00:00.000+01:00",
|
||||
"end": "2026-02-26T21:00:00.000+01:00",
|
||||
"price": 0.28971,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T21:00:00.000+01:00",
|
||||
"end": "2026-02-26T22:00:00.000+01:00",
|
||||
"price": 0.27852,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T22:00:00.000+01:00",
|
||||
"end": "2026-02-26T23:00:00.000+01:00",
|
||||
"price": 0.27424,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-26T23:00:00.000+01:00",
|
||||
"end": "2026-02-27T00:00:00.000+01:00",
|
||||
"price": 0.26367,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T00:00:00.000+01:00",
|
||||
"end": "2026-02-27T01:00:00.000+01:00",
|
||||
"price": 0.24416,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T01:00:00.000+01:00",
|
||||
"end": "2026-02-27T02:00:00.000+01:00",
|
||||
"price": 0.24514,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T02:00:00.000+01:00",
|
||||
"end": "2026-02-27T03:00:00.000+01:00",
|
||||
"price": 0.24672,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T03:00:00.000+01:00",
|
||||
"end": "2026-02-27T04:00:00.000+01:00",
|
||||
"price": 0.24987,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T04:00:00.000+01:00",
|
||||
"end": "2026-02-27T05:00:00.000+01:00",
|
||||
"price": 0.25201,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T05:00:00.000+01:00",
|
||||
"end": "2026-02-27T06:00:00.000+01:00",
|
||||
"price": 0.25704,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T06:00:00.000+01:00",
|
||||
"end": "2026-02-27T07:00:00.000+01:00",
|
||||
"price": 0.26959,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T07:00:00.000+01:00",
|
||||
"end": "2026-02-27T08:00:00.000+01:00",
|
||||
"price": 0.28384,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T08:00:00.000+01:00",
|
||||
"end": "2026-02-27T09:00:00.000+01:00",
|
||||
"price": 0.2729,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T09:00:00.000+01:00",
|
||||
"end": "2026-02-27T10:00:00.000+01:00",
|
||||
"price": 0.25514,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T10:00:00.000+01:00",
|
||||
"end": "2026-02-27T11:00:00.000+01:00",
|
||||
"price": 0.21604,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T11:00:00.000+01:00",
|
||||
"end": "2026-02-27T12:00:00.000+01:00",
|
||||
"price": 0.17966,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T12:00:00.000+01:00",
|
||||
"end": "2026-02-27T13:00:00.000+01:00",
|
||||
"price": 0.1785,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T13:00:00.000+01:00",
|
||||
"end": "2026-02-27T14:00:00.000+01:00",
|
||||
"price": 0.1785,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T14:00:00.000+01:00",
|
||||
"end": "2026-02-27T15:00:00.000+01:00",
|
||||
"price": 0.18769,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T15:00:00.000+01:00",
|
||||
"end": "2026-02-27T16:00:00.000+01:00",
|
||||
"price": 0.2496,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T16:00:00.000+01:00",
|
||||
"end": "2026-02-27T17:00:00.000+01:00",
|
||||
"price": 0.28798,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T17:00:00.000+01:00",
|
||||
"end": "2026-02-27T18:00:00.000+01:00",
|
||||
"price": 0.31559,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T18:00:00.000+01:00",
|
||||
"end": "2026-02-27T19:00:00.000+01:00",
|
||||
"price": 0.31904,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T19:00:00.000+01:00",
|
||||
"end": "2026-02-27T20:00:00.000+01:00",
|
||||
"price": 0.30854,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T20:00:00.000+01:00",
|
||||
"end": "2026-02-27T21:00:00.000+01:00",
|
||||
"price": 0.29753,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T21:00:00.000+01:00",
|
||||
"end": "2026-02-27T22:00:00.000+01:00",
|
||||
"price": 0.28673,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T22:00:00.000+01:00",
|
||||
"end": "2026-02-27T23:00:00.000+01:00",
|
||||
"price": 0.28263,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T23:00:00.000+01:00",
|
||||
"end": "2026-02-28T00:00:00.000+01:00",
|
||||
"price": 0.2737,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T00:00:00.000+01:00",
|
||||
"end": "2026-02-28T01:00:00.000+01:00",
|
||||
"price": 0.25978,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T01:00:00.000+01:00",
|
||||
"end": "2026-02-28T02:00:00.000+01:00",
|
||||
"price": 0.2586,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T02:00:00.000+01:00",
|
||||
"end": "2026-02-28T03:00:00.000+01:00",
|
||||
"price": 0.25241,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T03:00:00.000+01:00",
|
||||
"end": "2026-02-28T04:00:00.000+01:00",
|
||||
"price": 0.25047,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T04:00:00.000+01:00",
|
||||
"end": "2026-02-28T05:00:00.000+01:00",
|
||||
"price": 0.24941,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T05:00:00.000+01:00",
|
||||
"end": "2026-02-28T06:00:00.000+01:00",
|
||||
"price": 0.25106,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T06:00:00.000+01:00",
|
||||
"end": "2026-02-28T07:00:00.000+01:00",
|
||||
"price": 0.25192,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T07:00:00.000+01:00",
|
||||
"end": "2026-02-28T08:00:00.000+01:00",
|
||||
"price": 0.25476,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T08:00:00.000+01:00",
|
||||
"end": "2026-02-28T09:00:00.000+01:00",
|
||||
"price": 0.25404,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T09:00:00.000+01:00",
|
||||
"end": "2026-02-28T10:00:00.000+01:00",
|
||||
"price": 0.24679,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T10:00:00.000+01:00",
|
||||
"end": "2026-02-28T11:00:00.000+01:00",
|
||||
"price": 0.2425,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T11:00:00.000+01:00",
|
||||
"end": "2026-02-28T12:00:00.000+01:00",
|
||||
"price": 0.23763,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T12:00:00.000+01:00",
|
||||
"end": "2026-02-28T13:00:00.000+01:00",
|
||||
"price": 0.23554,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T13:00:00.000+01:00",
|
||||
"end": "2026-02-28T14:00:00.000+01:00",
|
||||
"price": 0.2354,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T14:00:00.000+01:00",
|
||||
"end": "2026-02-28T15:00:00.000+01:00",
|
||||
"price": 0.23785,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T15:00:00.000+01:00",
|
||||
"end": "2026-02-28T16:00:00.000+01:00",
|
||||
"price": 0.24236,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T16:00:00.000+01:00",
|
||||
"end": "2026-02-28T17:00:00.000+01:00",
|
||||
"price": 0.2468,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T17:00:00.000+01:00",
|
||||
"end": "2026-02-28T18:00:00.000+01:00",
|
||||
"price": 0.25263,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T18:00:00.000+01:00",
|
||||
"end": "2026-02-28T19:00:00.000+01:00",
|
||||
"price": 0.26072,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T19:00:00.000+01:00",
|
||||
"end": "2026-02-28T20:00:00.000+01:00",
|
||||
"price": 0.26165,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T20:00:00.000+01:00",
|
||||
"end": "2026-02-28T21:00:00.000+01:00",
|
||||
"price": 0.25457,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T21:00:00.000+01:00",
|
||||
"end": "2026-02-28T22:00:00.000+01:00",
|
||||
"price": 0.24939,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T22:00:00.000+01:00",
|
||||
"end": "2026-02-28T23:00:00.000+01:00",
|
||||
"price": 0.24787,
|
||||
"price_origin": "forecast"
|
||||
}
|
||||
]
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
[
|
||||
{
|
||||
"start": "2026-02-27T00:00:00.000+01:00",
|
||||
"end": "2026-02-27T01:00:00.000+01:00",
|
||||
"price": 0.24416,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T01:00:00.000+01:00",
|
||||
"end": "2026-02-27T02:00:00.000+01:00",
|
||||
"price": 0.24514,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T02:00:00.000+01:00",
|
||||
"end": "2026-02-27T03:00:00.000+01:00",
|
||||
"price": 0.24672,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T03:00:00.000+01:00",
|
||||
"end": "2026-02-27T04:00:00.000+01:00",
|
||||
"price": 0.24987,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T04:00:00.000+01:00",
|
||||
"end": "2026-02-27T05:00:00.000+01:00",
|
||||
"price": 0.25201,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T05:00:00.000+01:00",
|
||||
"end": "2026-02-27T06:00:00.000+01:00",
|
||||
"price": 0.25704,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T06:00:00.000+01:00",
|
||||
"end": "2026-02-27T07:00:00.000+01:00",
|
||||
"price": 0.26959,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T07:00:00.000+01:00",
|
||||
"end": "2026-02-27T08:00:00.000+01:00",
|
||||
"price": 0.28384,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T08:00:00.000+01:00",
|
||||
"end": "2026-02-27T09:00:00.000+01:00",
|
||||
"price": 0.2729,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T09:00:00.000+01:00",
|
||||
"end": "2026-02-27T10:00:00.000+01:00",
|
||||
"price": 0.25514,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T10:00:00.000+01:00",
|
||||
"end": "2026-02-27T11:00:00.000+01:00",
|
||||
"price": 0.21604,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T11:00:00.000+01:00",
|
||||
"end": "2026-02-27T12:00:00.000+01:00",
|
||||
"price": 0.17966,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T12:00:00.000+01:00",
|
||||
"end": "2026-02-27T13:00:00.000+01:00",
|
||||
"price": 0.1785,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T13:00:00.000+01:00",
|
||||
"end": "2026-02-27T14:00:00.000+01:00",
|
||||
"price": 0.1785,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T14:00:00.000+01:00",
|
||||
"end": "2026-02-27T15:00:00.000+01:00",
|
||||
"price": 0.18769,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T15:00:00.000+01:00",
|
||||
"end": "2026-02-27T16:00:00.000+01:00",
|
||||
"price": 0.2496,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T16:00:00.000+01:00",
|
||||
"end": "2026-02-27T17:00:00.000+01:00",
|
||||
"price": 0.28798,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T17:00:00.000+01:00",
|
||||
"end": "2026-02-27T18:00:00.000+01:00",
|
||||
"price": 0.31559,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T18:00:00.000+01:00",
|
||||
"end": "2026-02-27T19:00:00.000+01:00",
|
||||
"price": 0.31904,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T19:00:00.000+01:00",
|
||||
"end": "2026-02-27T20:00:00.000+01:00",
|
||||
"price": 0.30854,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T20:00:00.000+01:00",
|
||||
"end": "2026-02-27T21:00:00.000+01:00",
|
||||
"price": 0.29753,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T21:00:00.000+01:00",
|
||||
"end": "2026-02-27T22:00:00.000+01:00",
|
||||
"price": 0.28673,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T22:00:00.000+01:00",
|
||||
"end": "2026-02-27T23:00:00.000+01:00",
|
||||
"price": 0.28263,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-27T23:00:00.000+01:00",
|
||||
"end": "2026-02-28T00:00:00.000+01:00",
|
||||
"price": 0.2737,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T00:00:00.000+01:00",
|
||||
"end": "2026-02-28T01:00:00.000+01:00",
|
||||
"price": 0.27373,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T01:00:00.000+01:00",
|
||||
"end": "2026-02-28T02:00:00.000+01:00",
|
||||
"price": 0.2693,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T02:00:00.000+01:00",
|
||||
"end": "2026-02-28T03:00:00.000+01:00",
|
||||
"price": 0.26745,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T03:00:00.000+01:00",
|
||||
"end": "2026-02-28T04:00:00.000+01:00",
|
||||
"price": 0.26391,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T04:00:00.000+01:00",
|
||||
"end": "2026-02-28T05:00:00.000+01:00",
|
||||
"price": 0.26403,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T05:00:00.000+01:00",
|
||||
"end": "2026-02-28T06:00:00.000+01:00",
|
||||
"price": 0.26448,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T06:00:00.000+01:00",
|
||||
"end": "2026-02-28T07:00:00.000+01:00",
|
||||
"price": 0.26525,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T07:00:00.000+01:00",
|
||||
"end": "2026-02-28T08:00:00.000+01:00",
|
||||
"price": 0.2693,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T08:00:00.000+01:00",
|
||||
"end": "2026-02-28T09:00:00.000+01:00",
|
||||
"price": 0.24648,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T09:00:00.000+01:00",
|
||||
"end": "2026-02-28T10:00:00.000+01:00",
|
||||
"price": 0.19965,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T10:00:00.000+01:00",
|
||||
"end": "2026-02-28T11:00:00.000+01:00",
|
||||
"price": 0.1785,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T11:00:00.000+01:00",
|
||||
"end": "2026-02-28T12:00:00.000+01:00",
|
||||
"price": 0.17847,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T12:00:00.000+01:00",
|
||||
"end": "2026-02-28T13:00:00.000+01:00",
|
||||
"price": 0.1782,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T13:00:00.000+01:00",
|
||||
"end": "2026-02-28T14:00:00.000+01:00",
|
||||
"price": 0.17823,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T14:00:00.000+01:00",
|
||||
"end": "2026-02-28T15:00:00.000+01:00",
|
||||
"price": 0.1785,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T15:00:00.000+01:00",
|
||||
"end": "2026-02-28T16:00:00.000+01:00",
|
||||
"price": 0.19617,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T16:00:00.000+01:00",
|
||||
"end": "2026-02-28T17:00:00.000+01:00",
|
||||
"price": 0.25662,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T17:00:00.000+01:00",
|
||||
"end": "2026-02-28T18:00:00.000+01:00",
|
||||
"price": 0.28551,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T18:00:00.000+01:00",
|
||||
"end": "2026-02-28T19:00:00.000+01:00",
|
||||
"price": 0.29179,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T19:00:00.000+01:00",
|
||||
"end": "2026-02-28T20:00:00.000+01:00",
|
||||
"price": 0.27983,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T20:00:00.000+01:00",
|
||||
"end": "2026-02-28T21:00:00.000+01:00",
|
||||
"price": 0.26076,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T21:00:00.000+01:00",
|
||||
"end": "2026-02-28T22:00:00.000+01:00",
|
||||
"price": 0.25543,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T22:00:00.000+01:00",
|
||||
"end": "2026-02-28T23:00:00.000+01:00",
|
||||
"price": 0.25546,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-02-28T23:00:00.000+01:00",
|
||||
"end": "2026-03-01T00:00:00.000+01:00",
|
||||
"price": 0.2507,
|
||||
"price_origin": "market"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T00:00:00.000+01:00",
|
||||
"end": "2026-03-01T01:00:00.000+01:00",
|
||||
"price": 0.24583,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T01:00:00.000+01:00",
|
||||
"end": "2026-03-01T02:00:00.000+01:00",
|
||||
"price": 0.24497,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T02:00:00.000+01:00",
|
||||
"end": "2026-03-01T03:00:00.000+01:00",
|
||||
"price": 0.24195,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T03:00:00.000+01:00",
|
||||
"end": "2026-03-01T04:00:00.000+01:00",
|
||||
"price": 0.24051,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T04:00:00.000+01:00",
|
||||
"end": "2026-03-01T05:00:00.000+01:00",
|
||||
"price": 0.24283,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T05:00:00.000+01:00",
|
||||
"end": "2026-03-01T06:00:00.000+01:00",
|
||||
"price": 0.2439,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T06:00:00.000+01:00",
|
||||
"end": "2026-03-01T07:00:00.000+01:00",
|
||||
"price": 0.24325,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T07:00:00.000+01:00",
|
||||
"end": "2026-03-01T08:00:00.000+01:00",
|
||||
"price": 0.2455,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T08:00:00.000+01:00",
|
||||
"end": "2026-03-01T09:00:00.000+01:00",
|
||||
"price": 0.24623,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T09:00:00.000+01:00",
|
||||
"end": "2026-03-01T10:00:00.000+01:00",
|
||||
"price": 0.24104,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T10:00:00.000+01:00",
|
||||
"end": "2026-03-01T11:00:00.000+01:00",
|
||||
"price": 0.23912,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T11:00:00.000+01:00",
|
||||
"end": "2026-03-01T12:00:00.000+01:00",
|
||||
"price": 0.23648,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T12:00:00.000+01:00",
|
||||
"end": "2026-03-01T13:00:00.000+01:00",
|
||||
"price": 0.23746,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T13:00:00.000+01:00",
|
||||
"end": "2026-03-01T14:00:00.000+01:00",
|
||||
"price": 0.23803,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T14:00:00.000+01:00",
|
||||
"end": "2026-03-01T15:00:00.000+01:00",
|
||||
"price": 0.24172,
|
||||
"price_origin": "forecast"
|
||||
},
|
||||
{
|
||||
"start": "2026-03-01T15:00:00.000+01:00",
|
||||
"end": "2026-03-01T16:00:00.000+01:00",
|
||||
"price": 0.24531,
|
||||
"price_origin": "forecast"
|
||||
}
|
||||
]
|
||||
@@ -147,6 +147,7 @@
|
||||
<module>org.openhab.binding.emotiva</module>
|
||||
<module>org.openhab.binding.energenie</module>
|
||||
<module>org.openhab.binding.energidataservice</module>
|
||||
<module>org.openhab.binding.energyforecast</module>
|
||||
<module>org.openhab.binding.enigma2</module>
|
||||
<module>org.openhab.binding.enocean</module>
|
||||
<module>org.openhab.binding.enphase</module>
|
||||
|
||||
Reference in New Issue
Block a user