Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bundles/org.openhab.binding.energidataservice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public class EnergiDataServiceConfiguration {
*/
public String gridCompanyGLN = "";

/**
* Recalculate spot prices to hourly average based on quarter-hourly.
*/
public boolean hourlySpotPrices = false;
Comment thread
jlaur marked this conversation as resolved.

/**
* Global Location Number of Energinet.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -328,7 +329,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)) {
Expand Down Expand Up @@ -467,14 +468,27 @@ public int updateSpotPriceTimeSeries(LocalDate startDate, LocalDate endDate)
DayAheadPriceRecord[] spotPriceRecords = apiController.getDayAheadPrices(config.priceArea, currency,
DateQueryParameter.of(startDate.isBefore(dayAheadFirstDate) ? dayAheadFirstDate : startDate),
DateQueryParameter.of(endDate.plusDays(1)), properties);

Map<Instant, BigDecimal> dayAheadPrices = new TreeMap<>();
for (DayAheadPriceRecord record : Arrays.stream(spotPriceRecords)
.sorted(Comparator.comparing(DayAheadPriceRecord::time)).toList()) {
BigDecimal spotPrice = isDKK ? record.dayAheadPriceDKK() : record.dayAheadPriceEUR();
if (spotPrice == null) {
continue;
}
spotPriceTimeSeries.add(record.time(),
getEnergyPrice(spotPrice.divide(BigDecimal.valueOf(1000)), currency));
dayAheadPrices.put(record.time(), spotPrice.divide(BigDecimal.valueOf(1000)));
}

if (config.hourlySpotPrices && !dayAheadPrices.isEmpty()) {
Map<Instant, BigDecimal> hourlyAverages = ElectricityPriceProvider
.calculateHourlyAverages(dayAheadPrices);
for (Entry<Instant, BigDecimal> entry : hourlyAverages.entrySet()) {
spotPriceTimeSeries.add(entry.getKey(), getEnergyPrice(entry.getValue(), currency));
}
} else {
for (Entry<Instant, BigDecimal> entry : dayAheadPrices.entrySet()) {
spotPriceTimeSeries.add(entry.getKey(), getEnergyPrice(entry.getValue(), currency));
}
}
}
if (spotPriceTimeSeries.size() > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -151,19 +178,40 @@ private void stopJobs() {
private void refreshElectricityPrices() {
RetryStrategy retryPolicy;
try {
// Track initial cache state for all spot price subscriptions to detect transitions
Map<SpotPriceSubscription, Boolean> initialCacheFullState = new HashMap<>();
for (Entry<Subscription, Set<ElectricityPriceListener>> subscriptionListener : subscriptionToListeners
.entrySet()) {
if (subscriptionListener.getKey() instanceof SpotPriceSubscription spotPriceSubscription) {
initialCacheFullState.put(spotPriceSubscription,
getSpotPriceSubscriptionDataCache(spotPriceSubscription).arePricesFullyCached());
}
}

for (Entry<Subscription, Set<ElectricityPriceListener>> subscriptionListener : subscriptionToListeners
.entrySet()) {
Subscription subscription = subscriptionListener.getKey();
Set<ElectricityPriceListener> listeners = subscriptionListener.getValue();

boolean spotPricesUpdated = downloadPricesIfNotCached(subscription)
&& subscription instanceof SpotPriceSubscription;
downloadPricesIfNotCached(subscription);

updateCurrentPrices(subscription);
publishPricesFromCache(subscription, listeners);
}

if (spotPricesUpdated && getSpotPriceSubscriptionDataCache(subscription).arePricesFullyCached()) {
listeners.forEach(listener -> listener.onDayAheadAvailable());
// Notify listeners for subscriptions whose cache transitioned to fully-cached state
for (Entry<SpotPriceSubscription, Boolean> entry : initialCacheFullState.entrySet()) {
SpotPriceSubscription spotPriceSubscription = entry.getKey();
boolean wasFullyCached = entry.getValue();
boolean isNowFullyCached = getSpotPriceSubscriptionDataCache(spotPriceSubscription)
.arePricesFullyCached();

if (!wasFullyCached && isNowFullyCached) {
Set<ElectricityPriceListener> listenersForSubscription = subscriptionToListeners
.get(spotPriceSubscription);
if (listenersForSubscription != null) {
listenersForSubscription.forEach(ElectricityPriceListener::onDayAheadAvailable);
}
}
}

Expand Down Expand Up @@ -282,10 +330,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);
Expand All @@ -309,9 +368,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()));
}
Comment thread
jlaur marked this conversation as resolved.
}
} finally {
listenerToSubscriptions.keySet().forEach(listener -> listener.onPropertiesUpdated(properties));
Expand Down Expand Up @@ -486,4 +552,35 @@ private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
refreshJob.cancel(true);
}
}

public static Map<Instant, BigDecimal> calculateHourlyAverages(Map<Instant, BigDecimal> quarterHourlyPrices) {
Map<Instant, List<BigDecimal>> groupedByHour = quarterHourlyPrices.entrySet().stream()
.collect(Collectors.groupingBy(e -> e.getKey().truncatedTo(ChronoUnit.HOURS), TreeMap::new,
Collectors.mapping(Map.Entry::getValue, Collectors.toList())));

Map<Instant, BigDecimal> hourlyAverages = new TreeMap<>();

for (Map.Entry<Instant, List<BigDecimal>> entry : groupedByHour.entrySet()) {
List<BigDecimal> 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<BigDecimal> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ public void flush() {
priceMap.entrySet().removeIf(entry -> entry.getKey().isBefore(firstHourStart));
}

@Override
public boolean put(Map<Instant, BigDecimal> records) {
if (priceMap.equals(records)) {
return false;
}

priceMap.clear();
priceMap.putAll(records);
Comment thread
jlaur marked this conversation as resolved.
flush();
return true;
}

/**
* Get map of all cached prices.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ public interface SubscriptionDataCache<R> {
*/
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<Instant, BigDecimal> records);

/**
* Get cached prices.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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() {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
<option value="EUR">Euro</option>
</options>
</parameter>
<parameter name="hourlySpotPrices" type="boolean">
<label>Hourly Spot Prices</label>
<description>Recalculate spot prices to hourly average based on quarter-hourly.</description>
<default>false</default>
</parameter>
<parameter name="gridCompanyGLN" type="text">
<label>Grid Company GLN</label>
<description>Global Location Number of the grid company.</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading