Skip to content

Commit df2b070

Browse files
committed
Add support for hourly spot prices as calculated average
Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
1 parent 2ce6596 commit df2b070

9 files changed

Lines changed: 128 additions & 8 deletions

File tree

bundles/org.openhab.binding.energidataservice/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ All channels are available for thing type `service`.
1616
| --------------------- | ------- | -------------------------------------------------------------------- | ------------- | -------- |
1717
| priceArea | text | Price area for spot prices (same as bidding zone) | | yes |
1818
| currencyCode | text | Currency code in which to obtain spot prices | DKK | no |
19+
| hourlySpotPrices | boolean | Recalculate spot prices to hourly average based on quarter hourly | false | no |
1920
| gridCompanyGLN | integer | Global Location Number of the Grid Company | | no |
2021
| energinetGLN | integer | Global Location Number of Energinet | 5790000432752 | no |
2122
| reducedElectricityTax | boolean | Reduced electricity tax applies. For electric heating customers only | false | no |

bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/config/EnergiDataServiceConfiguration.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ public class EnergiDataServiceConfiguration {
4141
*/
4242
public String gridCompanyGLN = "";
4343

44+
/**
45+
* Recalculate spot prices to hourly average based on quarter hourly
46+
*/
47+
public boolean hourlySpotPrices = false;
48+
4449
/**
4550
* Global Location Number of Energinet.
4651
*/

bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/handler/EnergiDataServiceHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ private void unsubscribe(Subscription subscription) {
328328

329329
private Subscription getChannelSubscription(String channelId) {
330330
if (CHANNEL_SPOT_PRICE.equals(channelId)) {
331-
return SpotPriceSubscription.of(config.priceArea, config.getCurrency());
331+
return SpotPriceSubscription.of(config.priceArea, config.getCurrency(), config.hourlySpotPrices);
332332
} else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
333333
return Co2EmissionSubscription.of(config.priceArea, Co2EmissionSubscription.Type.Prognosis);
334334
} else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {

bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/ElectricityPriceProvider.java

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
1616

1717
import java.math.BigDecimal;
18+
import java.math.RoundingMode;
1819
import java.time.Duration;
1920
import java.time.Instant;
2021
import java.time.LocalDateTime;
@@ -23,11 +24,15 @@
2324
import java.time.temporal.ChronoUnit;
2425
import java.util.Collection;
2526
import java.util.HashMap;
27+
import java.util.List;
2628
import java.util.Map;
2729
import java.util.Map.Entry;
30+
import java.util.Objects;
2831
import java.util.Set;
32+
import java.util.TreeMap;
2933
import java.util.concurrent.ConcurrentHashMap;
3034
import java.util.concurrent.ScheduledFuture;
35+
import java.util.stream.Collectors;
3136
import java.util.stream.Stream;
3237

3338
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -125,7 +130,29 @@ public void subscribe(ElectricityPriceListener listener, Subscription subscripti
125130
public void unsubscribe(ElectricityPriceListener listener, Subscription subscription) {
126131
boolean isLastDistinctSubscription = unsubscribeInternal(listener, subscription);
127132
if (isLastDistinctSubscription) {
128-
subscriptionDataCaches.remove(subscription);
133+
boolean hasOtherSubscription = false;
134+
if (subscription instanceof SpotPriceSubscription spotPriceSubscription
135+
&& !spotPriceSubscription.isHourlyAverage()
136+
&& subscriptionToListeners.containsKey(SpotPriceSubscription
137+
.of(spotPriceSubscription.getPriceArea(), spotPriceSubscription.getCurrency(), true))) {
138+
hasOtherSubscription = true;
139+
logger.trace("Not removing cache for {}, another subscription depends on it.", subscription);
140+
}
141+
142+
if (!hasOtherSubscription) {
143+
logger.trace("Removing cache for {}, no remaining subscriptions depend on it.", subscription);
144+
subscriptionDataCaches.remove(subscription);
145+
}
146+
147+
if (subscription instanceof SpotPriceSubscription spotPriceSubscription
148+
&& spotPriceSubscription.isHourlyAverage()) {
149+
Subscription rawSubscription = SpotPriceSubscription.of(spotPriceSubscription.getPriceArea(),
150+
spotPriceSubscription.getCurrency(), false);
151+
if (!subscriptionToListeners.containsKey(rawSubscription)) {
152+
logger.trace("Removing cache for {}, no remaining subscriptions depend on it.", rawSubscription);
153+
subscriptionDataCaches.remove(rawSubscription);
154+
}
155+
}
129156
}
130157

131158
if (subscriptionToListeners.isEmpty()) {
@@ -282,10 +309,21 @@ private boolean downloadSpotPricesIfNotCached(SpotPriceSubscription subscription
282309
SpotPriceSubscriptionCache cache = getSpotPriceSubscriptionDataCache(subscription);
283310

284311
if (cache.arePricesFullyCached()) {
285-
logger.debug("Cached spot prices still valid, skipping download.");
312+
logger.debug("Cached spot prices still valid for {}, skipping download.", subscription);
286313
return false;
287314
}
288315

316+
if (subscription.isHourlyAverage()) {
317+
SpotPriceSubscription rawSubscription = SpotPriceSubscription.of(subscription.getPriceArea(),
318+
subscription.getCurrency(), false);
319+
SpotPriceSubscriptionCache rawCache = getSpotPriceSubscriptionDataCache(rawSubscription);
320+
if (rawCache.arePricesFullyCached()) {
321+
logger.debug("Recalculating cached spot prices for {}, skipping download.", subscription);
322+
cache.put(calculateHourlyAverages(rawCache.get()));
323+
return false;
324+
}
325+
}
326+
289327
DateQueryParameter start;
290328
if (cache.areHistoricPricesCached()) {
291329
start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
@@ -309,16 +347,54 @@ private boolean downloadSpotPrices(SpotPriceSubscription subscription, DateQuery
309347
subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties);
310348
isUpdated = cache.put(spotPriceRecords);
311349
} else {
350+
SpotPriceSubscription rawSubscription = SpotPriceSubscription.of(subscription.getPriceArea(),
351+
subscription.getCurrency(), false);
352+
SpotPriceSubscriptionCache rawCache = getSpotPriceSubscriptionDataCache(rawSubscription);
312353
DayAheadPriceRecord[] dayAheadRecords = apiController.getDayAheadPrices(subscription.getPriceArea(),
313354
subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties);
314-
isUpdated = cache.put(dayAheadRecords);
355+
isUpdated = rawCache.put(dayAheadRecords);
356+
if (subscription.isHourlyAverage()) {
357+
logger.debug("Recalculating spot prices for {}, after downloading day-ahead prices.", subscription);
358+
isUpdated = cache.put(calculateHourlyAverages(rawCache.get()));
359+
}
315360
}
316361
} finally {
317362
listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties));
318363
}
319364
return isUpdated;
320365
}
321366

367+
private static Map<Instant, BigDecimal> calculateHourlyAverages(Map<Instant, BigDecimal> quarterHourlyPrices) {
368+
Map<Instant, List<BigDecimal>> groupedByHour = quarterHourlyPrices.entrySet().stream()
369+
.collect(Collectors.groupingBy(e -> e.getKey().truncatedTo(ChronoUnit.HOURS), TreeMap::new,
370+
Collectors.mapping(Map.Entry::getValue, Collectors.toList())));
371+
372+
Map<Instant, BigDecimal> hourlyAverages = new TreeMap<>();
373+
374+
for (Map.Entry<Instant, List<BigDecimal>> entry : groupedByHour.entrySet()) {
375+
List<BigDecimal> prices = entry.getValue();
376+
377+
// Expect exactly 4 quarter-hour values
378+
if (prices.size() == 4) {
379+
BigDecimal avg = average(prices);
380+
if (avg != null) {
381+
hourlyAverages.put(entry.getKey(), avg);
382+
}
383+
}
384+
}
385+
386+
return hourlyAverages;
387+
}
388+
389+
private static @Nullable BigDecimal average(List<@Nullable BigDecimal> values) {
390+
List<BigDecimal> nonNulls = values.stream().filter(Objects::nonNull).toList();
391+
if (nonNulls.size() != 4) {
392+
return null;
393+
}
394+
BigDecimal sum = nonNulls.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
395+
return sum.divide(BigDecimal.valueOf(nonNulls.size()), RoundingMode.HALF_UP);
396+
}
397+
322398
private boolean downloadTariffsIfNotCached(DatahubPriceSubscription subscription)
323399
throws InterruptedException, DataServiceException {
324400
GlobalLocationNumber globalLocationNumber = subscription.getGlobalLocationNumber();

bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/ElectricityPriceSubscriptionCache.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ public void flush() {
5353
priceMap.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart));
5454
}
5555

56+
@Override
57+
public boolean put(Map<Instant, BigDecimal> records) {
58+
if (priceMap.equals(records)) {
59+
return false;
60+
}
61+
62+
priceMap.clear();
63+
priceMap.putAll(records);
64+
return true;
65+
}
66+
5667
/**
5768
* Get map of all cached prices.
5869
*

bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/cache/SubscriptionDataCache.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ public interface SubscriptionDataCache<R> {
3535
*/
3636
boolean put(R records);
3737

38+
/**
39+
* Add key/value pairs to cache.
40+
*
41+
* @param records Records to add to cache
42+
* @return true if the provided records resulted in any cache changes
43+
*/
44+
boolean put(Map<Instant, BigDecimal> records);
45+
3846
/**
3947
* Get cached prices.
4048
*

bundles/org.openhab.binding.energidataservice/src/main/java/org/openhab/binding/energidataservice/internal/provider/subscription/SpotPriceSubscription.java

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
public class SpotPriceSubscription implements ElectricityPriceSubscription {
2828
private final String priceArea;
2929
private final Currency currency;
30+
private final boolean hourlyAverage;
3031

31-
private SpotPriceSubscription(String priceArea, Currency currency) {
32+
private SpotPriceSubscription(String priceArea, Currency currency, boolean hourlyAverage) {
3233
this.priceArea = priceArea;
3334
this.currency = currency;
35+
this.hourlyAverage = hourlyAverage;
3436
}
3537

3638
@Override
@@ -42,7 +44,8 @@ public boolean equals(@Nullable Object o) {
4244
return false;
4345
}
4446

45-
return this.priceArea.equals(other.priceArea) && this.currency.equals(other.currency);
47+
return this.priceArea.equals(other.priceArea) && this.currency.equals(other.currency)
48+
&& this.hourlyAverage == other.hourlyAverage;
4649
}
4750

4851
@Override
@@ -52,7 +55,8 @@ public int hashCode() {
5255

5356
@Override
5457
public String toString() {
55-
return "SpotPriceSubscription: PriceArea=" + priceArea + ", Currency=" + currency;
58+
return "SpotPriceSubscription: PriceArea=" + priceArea + ", Currency=" + currency + ", HourlyAverage="
59+
+ hourlyAverage;
5660
}
5761

5862
public String getPriceArea() {
@@ -63,7 +67,15 @@ public Currency getCurrency() {
6367
return currency;
6468
}
6569

70+
public boolean isHourlyAverage() {
71+
return hourlyAverage;
72+
}
73+
6674
public static SpotPriceSubscription of(String priceArea, Currency currency) {
67-
return new SpotPriceSubscription(priceArea, currency);
75+
return new SpotPriceSubscription(priceArea, currency, false);
76+
}
77+
78+
public static SpotPriceSubscription of(String priceArea, Currency currency, boolean hourlySpotPrices) {
79+
return new SpotPriceSubscription(priceArea, currency, hourlySpotPrices);
6880
}
6981
}

bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/config/service.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
<option value="EUR">Euro</option>
2424
</options>
2525
</parameter>
26+
<parameter name="hourlySpotPrices" type="boolean">
27+
<label>Hourly Spot Prices</label>
28+
<description>Recalculate spot prices to hourly average based on quarter hourly.</description>
29+
<default>false</default>
30+
</parameter>
2631
<parameter name="gridCompanyGLN" type="text">
2732
<label>Grid Company GLN</label>
2833
<description>Global Location Number of the grid company.</description>

bundles/org.openhab.binding.energidataservice/src/main/resources/OH-INF/i18n/energidataservice.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ thing-type.config.energidataservice.service.gridCompanyGLN.option.5790000706686
4848
thing-type.config.energidataservice.service.gridCompanyGLN.option.5790001088217 = Veksel
4949
thing-type.config.energidataservice.service.gridCompanyGLN.option.5790000610976 = Vores Elnet
5050
thing-type.config.energidataservice.service.gridCompanyGLN.option.5790001089375 = Zeanet
51+
thing-type.config.energidataservice.service.hourlySpotPrices.label = Hourly Spot Prices
52+
thing-type.config.energidataservice.service.hourlySpotPrices.description = Recalculate spot prices to hourly average based on quarter hourly.
5153
thing-type.config.energidataservice.service.priceArea.label = Price Area
5254
thing-type.config.energidataservice.service.priceArea.description = Price area for spot prices (same as bidding zone).
5355
thing-type.config.energidataservice.service.priceArea.option.DK1 = West of the Great Belt

0 commit comments

Comments
 (0)