2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.energidataservice.internal.handler;
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
16 import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
18 import java.math.BigDecimal;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.time.LocalDate;
22 import java.time.LocalDateTime;
23 import java.time.LocalTime;
24 import java.time.ZoneId;
25 import java.time.format.DateTimeFormatter;
26 import java.time.temporal.ChronoUnit;
27 import java.util.Arrays;
28 import java.util.Collection;
29 import java.util.Comparator;
30 import java.util.Currency;
31 import java.util.List;
33 import java.util.Map.Entry;
35 import java.util.concurrent.ScheduledFuture;
36 import java.util.concurrent.TimeUnit;
38 import javax.measure.Unit;
40 import org.eclipse.jdt.annotation.NonNullByDefault;
41 import org.eclipse.jdt.annotation.Nullable;
42 import org.eclipse.jetty.client.HttpClient;
43 import org.eclipse.jetty.http.HttpStatus;
44 import org.openhab.binding.energidataservice.internal.ApiController;
45 import org.openhab.binding.energidataservice.internal.CacheManager;
46 import org.openhab.binding.energidataservice.internal.DatahubTariff;
47 import org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants;
48 import org.openhab.binding.energidataservice.internal.PriceListParser;
49 import org.openhab.binding.energidataservice.internal.action.EnergiDataServiceActions;
50 import org.openhab.binding.energidataservice.internal.api.ChargeType;
51 import org.openhab.binding.energidataservice.internal.api.ChargeTypeCode;
52 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilter;
53 import org.openhab.binding.energidataservice.internal.api.DatahubTariffFilterFactory;
54 import org.openhab.binding.energidataservice.internal.api.Dataset;
55 import org.openhab.binding.energidataservice.internal.api.DateQueryParameter;
56 import org.openhab.binding.energidataservice.internal.api.DateQueryParameterType;
57 import org.openhab.binding.energidataservice.internal.api.GlobalLocationNumber;
58 import org.openhab.binding.energidataservice.internal.api.dto.CO2EmissionRecord;
59 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecord;
60 import org.openhab.binding.energidataservice.internal.api.dto.ElspotpriceRecord;
61 import org.openhab.binding.energidataservice.internal.config.DatahubPriceConfiguration;
62 import org.openhab.binding.energidataservice.internal.config.EnergiDataServiceConfiguration;
63 import org.openhab.binding.energidataservice.internal.exception.DataServiceException;
64 import org.openhab.binding.energidataservice.internal.retry.RetryPolicyFactory;
65 import org.openhab.binding.energidataservice.internal.retry.RetryStrategy;
66 import org.openhab.core.i18n.TimeZoneProvider;
67 import org.openhab.core.library.types.DecimalType;
68 import org.openhab.core.library.types.QuantityType;
69 import org.openhab.core.library.unit.CurrencyUnits;
70 import org.openhab.core.library.unit.Units;
71 import org.openhab.core.thing.Channel;
72 import org.openhab.core.thing.ChannelUID;
73 import org.openhab.core.thing.Thing;
74 import org.openhab.core.thing.ThingStatus;
75 import org.openhab.core.thing.ThingStatusDetail;
76 import org.openhab.core.thing.binding.BaseThingHandler;
77 import org.openhab.core.thing.binding.ThingHandlerService;
78 import org.openhab.core.types.Command;
79 import org.openhab.core.types.RefreshType;
80 import org.openhab.core.types.State;
81 import org.openhab.core.types.TimeSeries;
82 import org.openhab.core.types.UnDefType;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
87 * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
88 * sent to one of the channels.
90 * @author Jacob Laursen - Initial contribution
93 public class EnergiDataServiceHandler extends BaseThingHandler {
95 private static final Duration emissionPrognosisJobInterval = Duration.ofMinutes(15);
96 private static final Duration emissionRealtimeJobInterval = Duration.ofMinutes(5);
98 private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
99 private final TimeZoneProvider timeZoneProvider;
100 private final ApiController apiController;
101 private final CacheManager cacheManager;
103 private EnergiDataServiceConfiguration config;
104 private RetryStrategy retryPolicy = RetryPolicyFactory.initial();
105 private boolean realtimeEmissionsFetchedFirstTime = false;
106 private @Nullable ScheduledFuture<?> refreshPriceFuture;
107 private @Nullable ScheduledFuture<?> refreshEmissionPrognosisFuture;
108 private @Nullable ScheduledFuture<?> refreshEmissionRealtimeFuture;
109 private @Nullable ScheduledFuture<?> priceUpdateFuture;
111 public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
113 this.timeZoneProvider = timeZoneProvider;
114 this.apiController = new ApiController(httpClient, timeZoneProvider);
115 this.cacheManager = new CacheManager();
117 // Default configuration
118 this.config = new EnergiDataServiceConfiguration();
122 public void handleCommand(ChannelUID channelUID, Command command) {
123 if (!(command instanceof RefreshType)) {
127 String channelId = channelUID.getId();
128 if (ELECTRICITY_CHANNELS.contains(channelId)) {
129 refreshElectricityPrices();
130 } else if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelId)) {
131 rescheduleEmissionPrognosisJob();
132 } else if (CHANNEL_CO2_EMISSION_REALTIME.equals(channelId)) {
133 realtimeEmissionsFetchedFirstTime = false;
134 rescheduleEmissionRealtimeJob();
139 public void initialize() {
140 config = getConfigAs(EnergiDataServiceConfiguration.class);
142 if (config.priceArea.isBlank()) {
143 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
144 "@text/offline.conf-error.no-price-area");
147 GlobalLocationNumber gln = config.getGridCompanyGLN();
148 if (!gln.isEmpty() && !gln.isValid()) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
150 "@text/offline.conf-error.invalid-grid-company-gln");
153 gln = config.getEnerginetGLN();
154 if (!gln.isEmpty() && !gln.isValid()) {
155 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
156 "@text/offline.conf-error.invalid-energinet-gln");
160 updateStatus(ThingStatus.UNKNOWN);
162 refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
164 if (isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
165 rescheduleEmissionPrognosisJob();
167 if (isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
168 rescheduleEmissionRealtimeJob();
173 public void dispose() {
174 ScheduledFuture<?> refreshPriceFuture = this.refreshPriceFuture;
175 if (refreshPriceFuture != null) {
176 refreshPriceFuture.cancel(true);
177 this.refreshPriceFuture = null;
179 ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
180 if (refreshEmissionPrognosisFuture != null) {
181 refreshEmissionPrognosisFuture.cancel(true);
182 this.refreshEmissionPrognosisFuture = null;
184 ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
185 if (refreshEmissionRealtimeFuture != null) {
186 refreshEmissionRealtimeFuture.cancel(true);
187 this.refreshEmissionRealtimeFuture = null;
189 ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
190 if (priceUpdateFuture != null) {
191 priceUpdateFuture.cancel(true);
192 this.priceUpdateFuture = null;
195 cacheManager.clear();
199 public Collection<Class<? extends ThingHandlerService>> getServices() {
200 return Set.of(EnergiDataServiceActions.class);
204 public void channelLinked(ChannelUID channelUID) {
205 super.channelLinked(channelUID);
207 if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)
208 && (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId())
209 || CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId()))) {
210 logger.warn("Item linked to channel '{}', but price area {} is not supported for this channel",
211 channelUID.getId(), config.priceArea);
216 public void channelUnlinked(ChannelUID channelUID) {
217 super.channelUnlinked(channelUID);
219 if (CHANNEL_CO2_EMISSION_PROGNOSIS.equals(channelUID.getId()) && !isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
220 logger.debug("No more items linked to channel '{}', stopping emission prognosis refresh job",
222 ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
223 if (refreshEmissionPrognosisFuture != null) {
224 refreshEmissionPrognosisFuture.cancel(true);
225 this.refreshEmissionPrognosisFuture = null;
227 } else if (CHANNEL_CO2_EMISSION_REALTIME.contains(channelUID.getId())
228 && !isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
229 logger.debug("No more items linked to channel '{}', stopping realtime emission refresh job",
231 ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
232 if (refreshEmissionRealtimeFuture != null) {
233 refreshEmissionRealtimeFuture.cancel(true);
234 this.refreshEmissionRealtimeFuture = null;
239 private void refreshElectricityPrices() {
240 RetryStrategy retryPolicy;
242 boolean spotPricesDownloaded = false;
243 if (isLinked(CHANNEL_SPOT_PRICE)) {
244 spotPricesDownloaded = downloadSpotPrices();
247 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
248 if (isLinked(datahubTariff.getChannelId())) {
249 downloadTariffs(datahubTariff);
253 updateStatus(ThingStatus.ONLINE);
255 updateElectricityTimeSeriesFromCache();
257 if (isLinked(CHANNEL_SPOT_PRICE)) {
258 long numberOfFutureSpotPrices = cacheManager.getNumberOfFutureSpotPrices();
259 LocalTime now = LocalTime.now(NORD_POOL_TIMEZONE);
261 if (numberOfFutureSpotPrices >= 13 || (numberOfFutureSpotPrices == 12
262 && now.isAfter(DAILY_REFRESH_TIME_CET.minusHours(1)) && now.isBefore(DAILY_REFRESH_TIME_CET))) {
263 if (spotPricesDownloaded) {
264 triggerChannel(CHANNEL_EVENT, EVENT_DAY_AHEAD_AVAILABLE);
266 retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
268 logger.warn("Spot prices are not available, retry scheduled (see details in Thing properties)");
269 retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing();
272 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
274 } catch (DataServiceException e) {
275 if (e.getHttpStatus() != 0) {
276 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
277 HttpStatus.getCode(e.getHttpStatus()).getMessage());
279 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
281 if (e.getCause() != null) {
282 logger.debug("Error retrieving prices", e);
284 retryPolicy = RetryPolicyFactory.fromThrowable(e);
285 } catch (InterruptedException e) {
286 logger.debug("Refresh job interrupted");
287 Thread.currentThread().interrupt();
291 reschedulePriceRefreshJob(retryPolicy);
294 private boolean downloadSpotPrices() throws InterruptedException, DataServiceException {
295 if (cacheManager.areSpotPricesFullyCached()) {
296 logger.debug("Cached spot prices still valid, skipping download.");
299 DateQueryParameter start;
300 if (cacheManager.areHistoricSpotPricesCached()) {
301 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
303 start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
304 Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
306 Map<String, String> properties = editProperties();
308 ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
309 start, DateQueryParameter.EMPTY, properties);
310 cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
312 updateProperties(properties);
317 private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
318 GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
319 if (globalLocationNumber.isEmpty()) {
322 if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
323 logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
324 cacheManager.updateTariffs(datahubTariff);
326 DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
327 cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
331 private DatahubTariffFilter getDatahubTariffFilter(DatahubTariff datahubTariff) {
332 return switch (datahubTariff) {
333 case GRID_TARIFF -> getGridTariffFilter();
334 case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
335 case TRANSMISSION_GRID_TARIFF -> DatahubTariffFilterFactory.getTransmissionGridTariff();
336 case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
337 case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
341 private GlobalLocationNumber getGlobalLocationNumber(DatahubTariff datahubTariff) {
342 return switch (datahubTariff) {
343 case GRID_TARIFF -> config.getGridCompanyGLN();
344 default -> config.getEnerginetGLN();
348 private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
349 DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
350 Map<String, String> properties = editProperties();
352 return apiController.getDatahubPriceLists(globalLocationNumber, ChargeType.Tariff, filter, properties);
354 updateProperties(properties);
358 private DatahubTariffFilter getGridTariffFilter() {
359 Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
360 if (channel == null) {
361 return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
364 DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
365 .as(DatahubPriceConfiguration.class);
367 if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
368 return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
371 DateQueryParameter start = datahubPriceConfiguration.getStart();
373 logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
374 datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
375 return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
378 Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
379 Set<String> notes = datahubPriceConfiguration.getNotes();
380 DatahubTariffFilter filter;
381 if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
382 // Completely override filter.
383 filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
385 // Only override start date in pre-configured filter.
386 filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN),
390 return new DatahubTariffFilter(filter,
391 DateQueryParameter.of(filter.getStart(), Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
394 private void refreshCo2EmissionPrognosis() {
396 updateCo2Emissions(Dataset.CO2EmissionPrognosis, CHANNEL_CO2_EMISSION_PROGNOSIS,
397 DateQueryParameter.of(DateQueryParameterType.UTC_NOW, Duration.ofMinutes(-5)));
398 updateStatus(ThingStatus.ONLINE);
399 } catch (DataServiceException e) {
400 if (e.getHttpStatus() != 0) {
401 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
402 HttpStatus.getCode(e.getHttpStatus()).getMessage());
404 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
406 if (e.getCause() != null) {
407 logger.debug("Error retrieving CO2 emission prognosis", e);
409 } catch (InterruptedException e) {
410 logger.debug("Emission prognosis refresh job interrupted");
411 Thread.currentThread().interrupt();
416 private void refreshCo2EmissionRealtime() {
418 updateCo2Emissions(Dataset.CO2Emission, CHANNEL_CO2_EMISSION_REALTIME,
419 DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
420 realtimeEmissionsFetchedFirstTime ? Duration.ofMinutes(-5) : Duration.ofHours(-24)));
421 realtimeEmissionsFetchedFirstTime = true;
422 updateStatus(ThingStatus.ONLINE);
423 } catch (DataServiceException e) {
424 if (e.getHttpStatus() != 0) {
425 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
426 HttpStatus.getCode(e.getHttpStatus()).getMessage());
428 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
430 if (e.getCause() != null) {
431 logger.debug("Error retrieving CO2 realtime emissions", e);
433 } catch (InterruptedException e) {
434 logger.debug("Emission realtime refresh job interrupted");
435 Thread.currentThread().interrupt();
440 private void updateCo2Emissions(Dataset dataset, String channelId, DateQueryParameter dateQueryParameter)
441 throws InterruptedException, DataServiceException {
442 if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)) {
443 // Dataset is only for Denmark.
446 Map<String, String> properties = editProperties();
447 CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, config.priceArea,
448 dateQueryParameter, properties);
449 updateProperties(properties);
451 TimeSeries timeSeries = new TimeSeries(REPLACE);
452 Instant now = Instant.now();
454 if (dataset == Dataset.CO2Emission && emissionRecords.length > 0) {
455 // Records are sorted descending, first record is current.
456 updateState(channelId, new QuantityType<>(emissionRecords[0].emission(), Units.GRAM_PER_KILOWATT_HOUR));
459 for (CO2EmissionRecord emissionRecord : emissionRecords) {
460 State state = new QuantityType<>(emissionRecord.emission(), Units.GRAM_PER_KILOWATT_HOUR);
461 timeSeries.add(emissionRecord.start(), state);
463 if (dataset == Dataset.CO2EmissionPrognosis && now.compareTo(emissionRecord.start()) >= 0
464 && now.compareTo(emissionRecord.end()) < 0) {
465 updateState(channelId, state);
468 sendTimeSeries(channelId, timeSeries);
471 private void updatePrices() {
472 cacheManager.cleanup();
474 updateCurrentSpotPrice();
475 Arrays.stream(DatahubTariff.values())
476 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
478 reschedulePriceUpdateJob();
481 private void updateCurrentSpotPrice() {
482 if (!isLinked(CHANNEL_SPOT_PRICE)) {
485 BigDecimal spotPrice = cacheManager.getSpotPrice();
486 updatePriceState(CHANNEL_SPOT_PRICE, spotPrice, config.getCurrency());
489 private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
490 if (!isLinked(channelId)) {
493 updatePriceState(channelId, tariff, CURRENCY_DKK);
496 private void updatePriceState(String channelID, @Nullable BigDecimal price, Currency currency) {
497 updateState(channelID, price != null ? getEnergyPrice(price, currency) : UnDefType.UNDEF);
500 private State getEnergyPrice(BigDecimal price, Currency currency) {
501 String currencyCode = currency.getCurrencyCode();
502 Unit<?> unit = CurrencyUnits.getInstance().getUnit(currencyCode);
504 logger.trace("Currency {} is unknown, falling back to DecimalType", currency.getCurrencyCode());
505 return new DecimalType(price);
508 return new QuantityType<>(price + " " + currencyCode + "/kWh");
509 } catch (IllegalArgumentException e) {
510 logger.debug("Unable to create QuantityType, falling back to DecimalType", e);
511 return new DecimalType(price);
516 * Download spot prices in requested period and update corresponding channel with time series.
518 * @param startDate Start date of period
519 * @param endDate End date of period
520 * @return number of published states
522 public int updateSpotPriceTimeSeries(LocalDate startDate, LocalDate endDate)
523 throws InterruptedException, DataServiceException {
524 if (!isLinked(CHANNEL_SPOT_PRICE)) {
527 Map<String, String> properties = editProperties();
529 Currency currency = config.getCurrency();
530 ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, currency,
531 DateQueryParameter.of(startDate), DateQueryParameter.of(endDate.plusDays(1)), properties);
532 boolean isDKK = EnergiDataServiceBindingConstants.CURRENCY_DKK.equals(currency);
533 TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
534 if (spotPriceRecords.length == 0) {
537 for (ElspotpriceRecord record : Arrays.stream(spotPriceRecords)
538 .sorted(Comparator.comparing(ElspotpriceRecord::hour)).toList()) {
539 spotPriceTimeSeries.add(record.hour(), getEnergyPrice(
540 (isDKK ? record.spotPriceDKK() : record.spotPriceEUR()).divide(BigDecimal.valueOf(1000)),
543 sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
544 return spotPriceRecords.length;
546 updateProperties(properties);
551 * Download tariffs in requested period and update corresponding channel with time series.
553 * @param datahubTariff Tariff to update
554 * @param startDate Start date of period
555 * @param endDate End date of period
556 * @return number of published states
558 public int updateTariffTimeSeries(DatahubTariff datahubTariff, LocalDate startDate, LocalDate endDate)
559 throws InterruptedException, DataServiceException {
560 if (!isLinked(datahubTariff.getChannelId())) {
563 GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
564 if (globalLocationNumber.isEmpty()) {
567 DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
568 DateQueryParameter start = filter.getStart();
569 DateQueryParameterType filterStartDateType = start.getDateType();
570 LocalDate filterStartDate = start.getDate();
571 if (filterStartDateType != null) {
572 // For filters with date relative to current date, override with provided parameters.
573 filter = new DatahubTariffFilter(filter, DateQueryParameter.of(startDate), DateQueryParameter.of(endDate));
574 } else if (filterStartDate != null && startDate.isBefore(filterStartDate)) {
575 throw new IllegalArgumentException("Start date before " + start.getDate() + " is not supported");
577 Collection<DatahubPricelistRecord> datahubRecords = downloadPriceLists(globalLocationNumber, filter);
578 ZoneId zoneId = timeZoneProvider.getTimeZone();
579 Instant firstHourStart = startDate.atStartOfDay(zoneId).toInstant();
580 Instant lastHourStart = endDate.plusDays(1).atStartOfDay(zoneId).toInstant();
581 Map<Instant, BigDecimal> tariffMap = new PriceListParser().toHourly(datahubRecords, firstHourStart,
584 return updatePriceTimeSeries(datahubTariff.getChannelId(), tariffMap, CURRENCY_DKK, true);
587 private void updateElectricityTimeSeriesFromCache() {
588 updatePriceTimeSeries(CHANNEL_SPOT_PRICE, cacheManager.getSpotPrices(), config.getCurrency(), false);
590 for (DatahubTariff datahubTariff : DatahubTariff.values()) {
591 String channelId = datahubTariff.getChannelId();
592 updatePriceTimeSeries(channelId, cacheManager.getTariffs(datahubTariff), CURRENCY_DKK, true);
596 private int updatePriceTimeSeries(String channelId, Map<Instant, BigDecimal> priceMap, Currency currency,
597 boolean deduplicate) {
598 if (!isLinked(channelId)) {
601 List<Entry<Instant, BigDecimal>> prices = priceMap.entrySet().stream().sorted(Map.Entry.comparingByKey())
603 TimeSeries timeSeries = new TimeSeries(REPLACE);
604 BigDecimal previousTariff = null;
605 for (Entry<Instant, BigDecimal> price : prices) {
606 Instant hourStart = price.getKey();
607 BigDecimal priceValue = price.getValue();
608 if (deduplicate && priceValue.equals(previousTariff)) {
609 // Skip redundant states.
612 timeSeries.add(hourStart, getEnergyPrice(priceValue, currency));
613 previousTariff = priceValue;
615 if (timeSeries.size() > 0) {
616 sendTimeSeries(channelId, timeSeries);
618 return timeSeries.size();
622 * Get the configured {@link Currency} for spot prices.
624 * @return Spot price currency
626 public Currency getCurrency() {
627 return config.getCurrency();
631 * Get cached spot prices or try once to download them if not cached
632 * (usually if no items are linked).
634 * @return Map of future spot prices
636 public Map<Instant, BigDecimal> getSpotPrices() {
638 downloadSpotPrices();
639 } catch (DataServiceException e) {
640 if (logger.isDebugEnabled()) {
641 logger.warn("Error retrieving spot prices", e);
643 logger.warn("Error retrieving spot prices: {}", e.getMessage());
645 } catch (InterruptedException e) {
646 Thread.currentThread().interrupt();
649 return cacheManager.getSpotPrices();
653 * Return cached tariffs or try once to download them if not cached
654 * (usually if no items are linked).
656 * @return Map of future tariffs
658 public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
660 downloadTariffs(datahubTariff);
661 } catch (DataServiceException e) {
662 if (logger.isDebugEnabled()) {
663 logger.warn("Error retrieving tariffs", e);
665 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
667 } catch (InterruptedException e) {
668 Thread.currentThread().interrupt();
671 return cacheManager.getTariffs(datahubTariff);
675 * Return whether reduced electricity tax is set in configuration.
677 * @return true if reduced electricity tax applies
679 public boolean isReducedElectricityTax() {
680 return config.reducedElectricityTax;
683 private void reschedulePriceUpdateJob() {
684 ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
685 if (priceUpdateJob != null) {
686 // Do not interrupt ourselves.
687 priceUpdateJob.cancel(false);
688 this.priceUpdateFuture = null;
691 Instant now = Instant.now();
692 long millisUntilNextClockHour = Duration
693 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
694 this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
695 TimeUnit.MILLISECONDS);
696 logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
699 private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
700 // Preserve state of previous retry policy when configuration is the same.
701 if (!retryPolicy.equals(this.retryPolicy)) {
702 this.retryPolicy = retryPolicy;
705 ScheduledFuture<?> refreshJob = this.refreshPriceFuture;
707 long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
708 Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
709 this.refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
711 logger.debug("Price refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
712 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
713 updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
714 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
716 if (refreshJob != null) {
717 refreshJob.cancel(true);
721 private void rescheduleEmissionPrognosisJob() {
722 logger.debug("Scheduling emission prognosis refresh job now and every {}", emissionPrognosisJobInterval);
724 ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
725 if (refreshEmissionPrognosisFuture != null) {
726 refreshEmissionPrognosisFuture.cancel(true);
729 this.refreshEmissionPrognosisFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionPrognosis, 0,
730 emissionPrognosisJobInterval.toSeconds(), TimeUnit.SECONDS);
733 private void rescheduleEmissionRealtimeJob() {
734 logger.debug("Scheduling emission realtime refresh job now and every {}", emissionRealtimeJobInterval);
736 ScheduledFuture<?> refreshEmissionFuture = this.refreshEmissionRealtimeFuture;
737 if (refreshEmissionFuture != null) {
738 refreshEmissionFuture.cancel(true);
741 this.refreshEmissionRealtimeFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionRealtime, 0,
742 emissionRealtimeJobInterval.toSeconds(), TimeUnit.SECONDS);