Skip to content

Commit 136b79a

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

9 files changed

Lines changed: 170 additions & 14 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: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.util.Map;
3030
import java.util.Map.Entry;
3131
import java.util.Set;
32+
import java.util.TreeMap;
3233
import java.util.function.Function;
3334
import java.util.stream.Collectors;
3435

@@ -328,7 +329,7 @@ private void unsubscribe(Subscription subscription) {
328329

329330
private Subscription getChannelSubscription(String channelId) {
330331
if (CHANNEL_SPOT_PRICE.equals(channelId)) {
331-
return SpotPriceSubscription.of(config.priceArea, config.getCurrency());
332+
return SpotPriceSubscription.of(config.priceArea, config.getCurrency(), config.hourlySpotPrices);
332333
} else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
333334
return Co2EmissionSubscription.of(config.priceArea, Co2EmissionSubscription.Type.Prognosis);
334335
} else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {
@@ -467,14 +468,27 @@ public int updateSpotPriceTimeSeries(LocalDate startDate, LocalDate endDate)
467468
DayAheadPriceRecord[] spotPriceRecords = apiController.getDayAheadPrices(config.priceArea, currency,
468469
DateQueryParameter.of(startDate.isBefore(dayAheadFirstDate) ? dayAheadFirstDate : startDate),
469470
DateQueryParameter.of(endDate.plusDays(1)), properties);
471+
472+
Map<Instant, BigDecimal> dayAheadPrices = new TreeMap<>();
470473
for (DayAheadPriceRecord record : Arrays.stream(spotPriceRecords)
471474
.sorted(Comparator.comparing(DayAheadPriceRecord::time)).toList()) {
472475
BigDecimal spotPrice = isDKK ? record.dayAheadPriceDKK() : record.dayAheadPriceEUR();
473476
if (spotPrice == null) {
474477
continue;
475478
}
476-
spotPriceTimeSeries.add(record.time(),
477-
getEnergyPrice(spotPrice.divide(BigDecimal.valueOf(1000)), currency));
479+
dayAheadPrices.put(record.time(), spotPrice.divide(BigDecimal.valueOf(1000)));
480+
}
481+
482+
if (config.hourlySpotPrices && !dayAheadPrices.isEmpty()) {
483+
Map<Instant, BigDecimal> hourlyAverages = ElectricityPriceProvider
484+
.calculateHourlyAverages(dayAheadPrices);
485+
for (Entry<Instant, BigDecimal> entry : hourlyAverages.entrySet()) {
486+
spotPriceTimeSeries.add(entry.getKey(), getEnergyPrice(entry.getValue(), currency));
487+
}
488+
} else {
489+
for (Entry<Instant, BigDecimal> entry : dayAheadPrices.entrySet()) {
490+
spotPriceTimeSeries.add(entry.getKey(), getEnergyPrice(entry.getValue(), currency));
491+
}
478492
}
479493
}
480494
if (spotPriceTimeSeries.size() > 0) {

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

Lines changed: 104 additions & 7 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()) {
@@ -151,19 +178,40 @@ private void stopJobs() {
151178
private void refreshElectricityPrices() {
152179
RetryStrategy retryPolicy;
153180
try {
181+
// Track initial cache state for all spot price subscriptions to detect transitions
182+
Map<SpotPriceSubscription, Boolean> initialCacheFullState = new HashMap<>();
183+
for (Entry<Subscription, Set<ElectricityPriceListener>> subscriptionListener : subscriptionToListeners
184+
.entrySet()) {
185+
if (subscriptionListener.getKey() instanceof SpotPriceSubscription spotPriceSubscription) {
186+
initialCacheFullState.put(spotPriceSubscription,
187+
getSpotPriceSubscriptionDataCache(spotPriceSubscription).arePricesFullyCached());
188+
}
189+
}
190+
154191
for (Entry<Subscription, Set<ElectricityPriceListener>> subscriptionListener : subscriptionToListeners
155192
.entrySet()) {
156193
Subscription subscription = subscriptionListener.getKey();
157194
Set<ElectricityPriceListener> listeners = subscriptionListener.getValue();
158195

159-
boolean spotPricesUpdated = downloadPricesIfNotCached(subscription)
160-
&& subscription instanceof SpotPriceSubscription;
196+
downloadPricesIfNotCached(subscription);
161197

162198
updateCurrentPrices(subscription);
163199
publishPricesFromCache(subscription, listeners);
200+
}
164201

165-
if (spotPricesUpdated && getSpotPriceSubscriptionDataCache(subscription).arePricesFullyCached()) {
166-
listeners.forEach(listener -> listener.onDayAheadAvailable());
202+
// Notify listeners for subscriptions whose cache transitioned to fully-cached state
203+
for (Entry<SpotPriceSubscription, Boolean> entry : initialCacheFullState.entrySet()) {
204+
SpotPriceSubscription spotPriceSubscription = entry.getKey();
205+
boolean wasFullyCached = entry.getValue();
206+
boolean isNowFullyCached = getSpotPriceSubscriptionDataCache(spotPriceSubscription)
207+
.arePricesFullyCached();
208+
209+
if (!wasFullyCached && isNowFullyCached) {
210+
Set<ElectricityPriceListener> listenersForSubscription = subscriptionToListeners
211+
.get(spotPriceSubscription);
212+
if (listenersForSubscription != null) {
213+
listenersForSubscription.forEach(ElectricityPriceListener::onDayAheadAvailable);
214+
}
167215
}
168216
}
169217

@@ -282,10 +330,21 @@ private boolean downloadSpotPricesIfNotCached(SpotPriceSubscription subscription
282330
SpotPriceSubscriptionCache cache = getSpotPriceSubscriptionDataCache(subscription);
283331

284332
if (cache.arePricesFullyCached()) {
285-
logger.debug("Cached spot prices still valid, skipping download.");
333+
logger.debug("Cached spot prices still valid for {}, skipping download.", subscription);
286334
return false;
287335
}
288336

337+
if (subscription.isHourlyAverage()) {
338+
SpotPriceSubscription rawSubscription = SpotPriceSubscription.of(subscription.getPriceArea(),
339+
subscription.getCurrency(), false);
340+
SpotPriceSubscriptionCache rawCache = getSpotPriceSubscriptionDataCache(rawSubscription);
341+
if (rawCache.arePricesFullyCached()) {
342+
logger.debug("Recalculating cached spot prices for {}, skipping download.", subscription);
343+
cache.put(calculateHourlyAverages(rawCache.get()));
344+
return false;
345+
}
346+
}
347+
289348
DateQueryParameter start;
290349
if (cache.areHistoricPricesCached()) {
291350
start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
@@ -309,9 +368,16 @@ private boolean downloadSpotPrices(SpotPriceSubscription subscription, DateQuery
309368
subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties);
310369
isUpdated = cache.put(spotPriceRecords);
311370
} else {
371+
SpotPriceSubscription rawSubscription = SpotPriceSubscription.of(subscription.getPriceArea(),
372+
subscription.getCurrency(), false);
373+
SpotPriceSubscriptionCache rawCache = getSpotPriceSubscriptionDataCache(rawSubscription);
312374
DayAheadPriceRecord[] dayAheadRecords = apiController.getDayAheadPrices(subscription.getPriceArea(),
313375
subscription.getCurrency(), start, DateQueryParameter.EMPTY, properties);
314-
isUpdated = cache.put(dayAheadRecords);
376+
isUpdated = rawCache.put(dayAheadRecords);
377+
if (subscription.isHourlyAverage()) {
378+
logger.debug("Recalculating spot prices for {}, after downloading day-ahead prices.", subscription);
379+
isUpdated = cache.put(calculateHourlyAverages(rawCache.get()));
380+
}
315381
}
316382
} finally {
317383
listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties));
@@ -486,4 +552,35 @@ private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
486552
refreshJob.cancel(true);
487553
}
488554
}
555+
556+
public static Map<Instant, BigDecimal> calculateHourlyAverages(Map<Instant, BigDecimal> quarterHourlyPrices) {
557+
Map<Instant, List<BigDecimal>> groupedByHour = quarterHourlyPrices.entrySet().stream()
558+
.collect(Collectors.groupingBy(e -> e.getKey().truncatedTo(ChronoUnit.HOURS), TreeMap::new,
559+
Collectors.mapping(Map.Entry::getValue, Collectors.toList())));
560+
561+
Map<Instant, BigDecimal> hourlyAverages = new TreeMap<>();
562+
563+
for (Map.Entry<Instant, List<BigDecimal>> entry : groupedByHour.entrySet()) {
564+
List<BigDecimal> prices = entry.getValue();
565+
566+
// Expect exactly 4 quarter-hour values
567+
if (prices.size() == 4) {
568+
BigDecimal avg = average(prices);
569+
if (avg != null) {
570+
hourlyAverages.put(entry.getKey(), avg);
571+
}
572+
}
573+
}
574+
575+
return hourlyAverages;
576+
}
577+
578+
private static @Nullable BigDecimal average(List<@Nullable BigDecimal> values) {
579+
List<BigDecimal> nonNulls = values.stream().filter(Objects::nonNull).toList();
580+
if (nonNulls.size() != 4) {
581+
return null;
582+
}
583+
BigDecimal sum = nonNulls.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
584+
return sum.divide(BigDecimal.valueOf(nonNulls.size()), RoundingMode.HALF_UP);
585+
}
489586
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ 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+
flush();
65+
return true;
66+
}
67+
5668
/**
5769
* Get map of all cached prices.
5870
*

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)