diff --git a/bundles/org.openhab.binding.energidataservice/README.md b/bundles/org.openhab.binding.energidataservice/README.md index 987a8afcd2b2c..7637769ab7643 100644 --- a/bundles/org.openhab.binding.energidataservice/README.md +++ b/bundles/org.openhab.binding.energidataservice/README.md @@ -16,6 +16,7 @@ All channels are available for thing type `service`. | --------------------- | ------- | -------------------------------------------------------------------- | ------------- | -------- | | priceArea | text | Price area for spot prices (same as bidding zone) | | yes | | currencyCode | text | Currency code in which to obtain spot prices | DKK | no | +| hourlySpotPrices | boolean | Recalculate spot prices to hourly average based on quarter hourly | false | no | | gridCompanyGLN | integer | Global Location Number of the Grid Company | | no | | energinetGLN | integer | Global Location Number of Energinet | 5790000432752 | no | | reducedElectricityTax | boolean | Reduced electricity tax applies. For electric heating customers only | false | no | diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/config/EnergiDataServiceConfiguration.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/config/EnergiDataServiceConfiguration.java index 5db8219c0f6c4..756e324980543 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/config/EnergiDataServiceConfiguration.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/config/EnergiDataServiceConfiguration.java @@ -41,6 +41,11 @@ public class EnergiDataServiceConfiguration { */ public String gridCompanyGLN = ""; + /** + * Recalculate spot prices to hourly average based on quarter hourly + */ + public boolean hourlySpotPrices = false; + /** * Global Location Number of Energinet. */ diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java index 0bd80785197eb..d0ac27960e8ff 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java @@ -328,7 +328,7 @@ private void unsubscribe(Subscription subscription) { private Subscription getChannelSubscription(String channelId) { if (CHANNEL_SPOT_PRICE.equals(channelId)) { - return SpotPriceSubscription.of(config.priceArea, config.getCurrency()); + return SpotPriceSubscription.of(config.priceArea, config.getCurrency(), config.hourlySpotPrices); } else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) { return Co2EmissionSubscription.of(config.priceArea, Co2EmissionSubscription.Type.Prognosis); } else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) { diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java index 0d8e95e75a132..c98281066a303 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java @@ -15,6 +15,7 @@ import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; @@ -23,11 +24,15 @@ import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -125,7 +130,29 @@ public void subscribe(ElectricityPriceListener listener, Subscription subscripti public void unsubscribe(ElectricityPriceListener listener, Subscription subscription) { boolean isLastDistinctSubscription = unsubscribeInternal(listener, subscription); if (isLastDistinctSubscription) { - subscriptionDataCaches.remove(subscription); + boolean hasOtherSubscription = false; + if (subscription instanceof SpotPriceSubscription spotPriceSubscription + && !spotPriceSubscription.isHourlyAverage() + && subscriptionToListeners.containsKey(SpotPriceSubscription + .of(spotPriceSubscription.getPriceArea(), spotPriceSubscription.getCurrency(), true))) { + hasOtherSubscription = true; + logger.trace("Not removing cache for {}, another subscription depends on it.", subscription); + } + + if (!hasOtherSubscription) { + logger.trace("Removing cache for {}, no remaining subscriptions depend on it.", subscription); + subscriptionDataCaches.remove(subscription); + } + + if (subscription instanceof SpotPriceSubscription spotPriceSubscription + && spotPriceSubscription.isHourlyAverage()) { + Subscription rawSubscription = SpotPriceSubscription.of(spotPriceSubscription.getPriceArea(), + spotPriceSubscription.getCurrency(), false); + if (!subscriptionToListeners.containsKey(rawSubscription)) { + logger.trace("Removing cache for {}, no remaining subscriptions depend on it.", rawSubscription); + subscriptionDataCaches.remove(rawSubscription); + } + } } if (subscriptionToListeners.isEmpty()) { @@ -282,10 +309,21 @@ private boolean downloadSpotPricesIfNotCached(SpotPriceSubscription subscription SpotPriceSubscriptionCache cache = getSpotPriceSubscriptionDataCache(subscription); if (cache.arePricesFullyCached()) { - logger.debug("Cached spot prices still valid, skipping download."); + logger.debug("Cached spot prices still valid for {}, skipping download.", subscription); return false; } + if (subscription.isHourlyAverage()) { + SpotPriceSubscription rawSubscription = SpotPriceSubscription.of(subscription.getPriceArea(), + subscription.getCurrency(), false); + SpotPriceSubscriptionCache rawCache = getSpotPriceSubscriptionDataCache(rawSubscription); + if (rawCache.arePricesFullyCached()) { + logger.debug("Recalculating cached spot prices for {}, skipping download.", subscription); + cache.put(calculateHourlyAverages(rawCache.get())); + return false; + } + } + DateQueryParameter start; if (cache.areHistoricPricesCached()) { start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW); @@ -309,9 +347,16 @@ private boolean downloadSpotPrices(SpotPriceSubscription subscription, DateQuery subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties); isUpdated = cache.put(spotPriceRecords); } else { + SpotPriceSubscription rawSubscription = SpotPriceSubscription.of(subscription.getPriceArea(), + subscription.getCurrency(), false); + SpotPriceSubscriptionCache rawCache = getSpotPriceSubscriptionDataCache(rawSubscription); DayAheadPriceRecord[] dayAheadRecords = apiController.getDayAheadPrices(subscription.getPriceArea(), subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties); - isUpdated = cache.put(dayAheadRecords); + isUpdated = rawCache.put(dayAheadRecords); + if (subscription.isHourlyAverage()) { + logger.debug("Recalculating spot prices for {}, after downloading day-ahead prices.", subscription); + isUpdated = cache.put(calculateHourlyAverages(rawCache.get())); + } } } finally { listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties)); @@ -319,6 +364,37 @@ private boolean downloadSpotPrices(SpotPriceSubscription subscription, DateQuery return isUpdated; } + private static Map calculateHourlyAverages(Map quarterHourlyPrices) { + Map> groupedByHour = quarterHourlyPrices.entrySet().stream() + .collect(Collectors.groupingBy(e -> e.getKey().truncatedTo(ChronoUnit.HOURS), TreeMap::new, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); + + Map hourlyAverages = new TreeMap<>(); + + for (Map.Entry> entry : groupedByHour.entrySet()) { + List prices = entry.getValue(); + + // Expect exactly 4 quarter-hour values + if (prices.size() == 4) { + BigDecimal avg = average(prices); + if (avg != null) { + hourlyAverages.put(entry.getKey(), avg); + } + } + } + + return hourlyAverages; + } + + private static @Nullable BigDecimal average(List<@Nullable BigDecimal> values) { + List nonNulls = values.stream().filter(Objects::nonNull).toList(); + if (nonNulls.size() != 4) { + return null; + } + BigDecimal sum = nonNulls.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + return sum.divide(BigDecimal.valueOf(nonNulls.size()), RoundingMode.HALF_UP); + } + private boolean downloadTariffsIfNotCached(DatahubPriceSubscription subscription) throws InterruptedException, DataServiceException { GlobalLocationNumber globalLocationNumber = subscription.getGlobalLocationNumber(); diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java index c7f402f26bae8..953179475c1fc 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java @@ -53,6 +53,17 @@ public void flush() { priceMap.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart)); } + @Override + public boolean put(Map records) { + if (priceMap.equals(records)) { + return false; + } + + priceMap.clear(); + priceMap.putAll(records); + return true; + } + /** * Get map of all cached prices. * diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java index a6c34e51fe7ad..83a718c6ddb63 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java @@ -35,6 +35,14 @@ public interface SubscriptionDataCache { */ boolean put(R records); + /** + * Add key/value pairs to cache. + * + * @param records Records to add to cache + * @return true if the provided records resulted in any cache changes + */ + boolean put(Map records); + /** * Get cached prices. * diff --git a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java index 94034cf28f3dd..1fd6796976b3a 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java +++ b/bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java @@ -27,10 +27,12 @@ public class SpotPriceSubscription implements ElectricityPriceSubscription { private final String priceArea; private final Currency currency; + private final boolean hourlyAverage; - private SpotPriceSubscription(String priceArea, Currency currency) { + private SpotPriceSubscription(String priceArea, Currency currency, boolean hourlyAverage) { this.priceArea = priceArea; this.currency = currency; + this.hourlyAverage = hourlyAverage; } @Override @@ -42,7 +44,8 @@ public boolean equals(@Nullable Object o) { return false; } - return this.priceArea.equals(other.priceArea) && this.currency.equals(other.currency); + return this.priceArea.equals(other.priceArea) && this.currency.equals(other.currency) + && this.hourlyAverage == other.hourlyAverage; } @Override @@ -52,7 +55,8 @@ public int hashCode() { @Override public String toString() { - return "SpotPriceSubscription: PriceArea=" + priceArea + ", Currency=" + currency; + return "SpotPriceSubscription: PriceArea=" + priceArea + ", Currency=" + currency + ", HourlyAverage=" + + hourlyAverage; } public String getPriceArea() { @@ -63,7 +67,15 @@ public Currency getCurrency() { return currency; } + public boolean isHourlyAverage() { + return hourlyAverage; + } + public static SpotPriceSubscription of(String priceArea, Currency currency) { - return new SpotPriceSubscription(priceArea, currency); + return new SpotPriceSubscription(priceArea, currency, false); + } + + public static SpotPriceSubscription of(String priceArea, Currency currency, boolean hourlySpotPrices) { + return new SpotPriceSubscription(priceArea, currency, hourlySpotPrices); } } diff --git a/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/config/service.xml b/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/config/service.xml index 472b3a85a7617..75652fbcfa7eb 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/config/service.xml +++ b/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/config/service.xml @@ -23,6 +23,11 @@ + + + Recalculate spot prices to hourly average based on quarter hourly. + false + Global Location Number of the grid company. diff --git a/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/i18n/energidataservice.properties b/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/i18n/energidataservice.properties index 3567007e41c41..2e6aa6961974e 100644 --- a/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/i18n/energidataservice.properties +++ b/bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/i18n/energidataservice.properties @@ -48,6 +48,8 @@ thing-type.config.energidataservice.service.gridCompanyGLN.option.5790000706686 thing-type.config.energidataservice.service.gridCompanyGLN.option.5790001088217 = Veksel thing-type.config.energidataservice.service.gridCompanyGLN.option.5790000610976 = Vores Elnet thing-type.config.energidataservice.service.gridCompanyGLN.option.5790001089375 = Zeanet +thing-type.config.energidataservice.service.hourlySpotPrices.label = Hourly Spot Prices +thing-type.config.energidataservice.service.hourlySpotPrices.description = Recalculate spot prices to hourly average based on quarter hourly. thing-type.config.energidataservice.service.priceArea.label = Price Area thing-type.config.energidataservice.service.priceArea.description = Price area for spot prices (same as bidding zone). thing-type.config.energidataservice.service.priceArea.option.DK1 = West of the Great Belt