]> git.basschouten.com Git - openhab-addons.git/blob
8964cd4cb88377a1e7e017a306397221e65dd189
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.energidataservice.internal.handler;
14
15 import static org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants.*;
16 import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
17
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;
32 import java.util.Map;
33 import java.util.Map.Entry;
34 import java.util.Set;
35 import java.util.concurrent.ScheduledFuture;
36 import java.util.concurrent.TimeUnit;
37
38 import javax.measure.Unit;
39
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;
85
86 /**
87  * The {@link EnergiDataServiceHandler} is responsible for handling commands, which are
88  * sent to one of the channels.
89  *
90  * @author Jacob Laursen - Initial contribution
91  */
92 @NonNullByDefault
93 public class EnergiDataServiceHandler extends BaseThingHandler {
94
95     private static final Duration emissionPrognosisJobInterval = Duration.ofMinutes(15);
96     private static final Duration emissionRealtimeJobInterval = Duration.ofMinutes(5);
97
98     private final Logger logger = LoggerFactory.getLogger(EnergiDataServiceHandler.class);
99     private final TimeZoneProvider timeZoneProvider;
100     private final ApiController apiController;
101     private final CacheManager cacheManager;
102
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;
110
111     public EnergiDataServiceHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
112         super(thing);
113         this.timeZoneProvider = timeZoneProvider;
114         this.apiController = new ApiController(httpClient, timeZoneProvider);
115         this.cacheManager = new CacheManager();
116
117         // Default configuration
118         this.config = new EnergiDataServiceConfiguration();
119     }
120
121     @Override
122     public void handleCommand(ChannelUID channelUID, Command command) {
123         if (!(command instanceof RefreshType)) {
124             return;
125         }
126
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();
135         }
136     }
137
138     @Override
139     public void initialize() {
140         config = getConfigAs(EnergiDataServiceConfiguration.class);
141
142         if (config.priceArea.isBlank()) {
143             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
144                     "@text/offline.conf-error.no-price-area");
145             return;
146         }
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");
151             return;
152         }
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");
157             return;
158         }
159
160         updateStatus(ThingStatus.UNKNOWN);
161
162         refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, 0, TimeUnit.SECONDS);
163
164         if (isLinked(CHANNEL_CO2_EMISSION_PROGNOSIS)) {
165             rescheduleEmissionPrognosisJob();
166         }
167         if (isLinked(CHANNEL_CO2_EMISSION_REALTIME)) {
168             rescheduleEmissionRealtimeJob();
169         }
170     }
171
172     @Override
173     public void dispose() {
174         ScheduledFuture<?> refreshPriceFuture = this.refreshPriceFuture;
175         if (refreshPriceFuture != null) {
176             refreshPriceFuture.cancel(true);
177             this.refreshPriceFuture = null;
178         }
179         ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
180         if (refreshEmissionPrognosisFuture != null) {
181             refreshEmissionPrognosisFuture.cancel(true);
182             this.refreshEmissionPrognosisFuture = null;
183         }
184         ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
185         if (refreshEmissionRealtimeFuture != null) {
186             refreshEmissionRealtimeFuture.cancel(true);
187             this.refreshEmissionRealtimeFuture = null;
188         }
189         ScheduledFuture<?> priceUpdateFuture = this.priceUpdateFuture;
190         if (priceUpdateFuture != null) {
191             priceUpdateFuture.cancel(true);
192             this.priceUpdateFuture = null;
193         }
194
195         cacheManager.clear();
196     }
197
198     @Override
199     public Collection<Class<? extends ThingHandlerService>> getServices() {
200         return Set.of(EnergiDataServiceActions.class);
201     }
202
203     @Override
204     public void channelLinked(ChannelUID channelUID) {
205         super.channelLinked(channelUID);
206
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);
212         }
213     }
214
215     @Override
216     public void channelUnlinked(ChannelUID channelUID) {
217         super.channelUnlinked(channelUID);
218
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",
221                     channelUID.getId());
222             ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
223             if (refreshEmissionPrognosisFuture != null) {
224                 refreshEmissionPrognosisFuture.cancel(true);
225                 this.refreshEmissionPrognosisFuture = null;
226             }
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",
230                     channelUID.getId());
231             ScheduledFuture<?> refreshEmissionRealtimeFuture = this.refreshEmissionRealtimeFuture;
232             if (refreshEmissionRealtimeFuture != null) {
233                 refreshEmissionRealtimeFuture.cancel(true);
234                 this.refreshEmissionRealtimeFuture = null;
235             }
236         }
237     }
238
239     private void refreshElectricityPrices() {
240         RetryStrategy retryPolicy;
241         try {
242             if (isLinked(CHANNEL_SPOT_PRICE)) {
243                 downloadSpotPrices();
244             }
245
246             for (DatahubTariff datahubTariff : DatahubTariff.values()) {
247                 if (isLinked(datahubTariff.getChannelId())) {
248                     downloadTariffs(datahubTariff);
249                 }
250             }
251
252             updateStatus(ThingStatus.ONLINE);
253             updatePrices();
254             updateElectricityTimeSeriesFromCache();
255
256             if (isLinked(CHANNEL_SPOT_PRICE)) {
257                 long numberOfFutureSpotPrices = cacheManager.getNumberOfFutureSpotPrices();
258                 LocalTime now = LocalTime.now(NORD_POOL_TIMEZONE);
259
260                 if (numberOfFutureSpotPrices >= 13 || (numberOfFutureSpotPrices == 12
261                         && now.isAfter(DAILY_REFRESH_TIME_CET.minusHours(1)) && now.isBefore(DAILY_REFRESH_TIME_CET))) {
262                     retryPolicy = RetryPolicyFactory.atFixedTime(DAILY_REFRESH_TIME_CET, NORD_POOL_TIMEZONE);
263                 } else {
264                     logger.warn("Spot prices are not available, retry scheduled (see details in Thing properties)");
265                     retryPolicy = RetryPolicyFactory.whenExpectedSpotPriceDataMissing();
266                 }
267             } else {
268                 retryPolicy = RetryPolicyFactory.atFixedTime(LocalTime.MIDNIGHT, timeZoneProvider.getTimeZone());
269             }
270         } catch (DataServiceException e) {
271             if (e.getHttpStatus() != 0) {
272                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
273                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
274             } else {
275                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
276             }
277             if (e.getCause() != null) {
278                 logger.debug("Error retrieving prices", e);
279             }
280             retryPolicy = RetryPolicyFactory.fromThrowable(e);
281         } catch (InterruptedException e) {
282             logger.debug("Refresh job interrupted");
283             Thread.currentThread().interrupt();
284             return;
285         }
286
287         reschedulePriceRefreshJob(retryPolicy);
288     }
289
290     private void downloadSpotPrices() throws InterruptedException, DataServiceException {
291         if (cacheManager.areSpotPricesFullyCached()) {
292             logger.debug("Cached spot prices still valid, skipping download.");
293             return;
294         }
295         DateQueryParameter start;
296         if (cacheManager.areHistoricSpotPricesCached()) {
297             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW);
298         } else {
299             start = DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
300                     Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS));
301         }
302         Map<String, String> properties = editProperties();
303         try {
304             ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, config.getCurrency(),
305                     start, DateQueryParameter.EMPTY, properties);
306             cacheManager.putSpotPrices(spotPriceRecords, config.getCurrency());
307         } finally {
308             updateProperties(properties);
309         }
310     }
311
312     private void downloadTariffs(DatahubTariff datahubTariff) throws InterruptedException, DataServiceException {
313         GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
314         if (globalLocationNumber.isEmpty()) {
315             return;
316         }
317         if (cacheManager.areTariffsValidTomorrow(datahubTariff)) {
318             logger.debug("Cached tariffs of type {} still valid, skipping download.", datahubTariff);
319             cacheManager.updateTariffs(datahubTariff);
320         } else {
321             DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
322             cacheManager.putTariffs(datahubTariff, downloadPriceLists(globalLocationNumber, filter));
323         }
324     }
325
326     private DatahubTariffFilter getDatahubTariffFilter(DatahubTariff datahubTariff) {
327         return switch (datahubTariff) {
328             case GRID_TARIFF -> getGridTariffFilter();
329             case SYSTEM_TARIFF -> DatahubTariffFilterFactory.getSystemTariff();
330             case TRANSMISSION_GRID_TARIFF -> DatahubTariffFilterFactory.getTransmissionGridTariff();
331             case ELECTRICITY_TAX -> DatahubTariffFilterFactory.getElectricityTax();
332             case REDUCED_ELECTRICITY_TAX -> DatahubTariffFilterFactory.getReducedElectricityTax();
333         };
334     }
335
336     private GlobalLocationNumber getGlobalLocationNumber(DatahubTariff datahubTariff) {
337         return switch (datahubTariff) {
338             case GRID_TARIFF -> config.getGridCompanyGLN();
339             default -> config.getEnerginetGLN();
340         };
341     }
342
343     private Collection<DatahubPricelistRecord> downloadPriceLists(GlobalLocationNumber globalLocationNumber,
344             DatahubTariffFilter filter) throws InterruptedException, DataServiceException {
345         Map<String, String> properties = editProperties();
346         try {
347             return apiController.getDatahubPriceLists(globalLocationNumber, ChargeType.Tariff, filter, properties);
348         } finally {
349             updateProperties(properties);
350         }
351     }
352
353     private DatahubTariffFilter getGridTariffFilter() {
354         Channel channel = getThing().getChannel(CHANNEL_GRID_TARIFF);
355         if (channel == null) {
356             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
357         }
358
359         DatahubPriceConfiguration datahubPriceConfiguration = channel.getConfiguration()
360                 .as(DatahubPriceConfiguration.class);
361
362         if (!datahubPriceConfiguration.hasAnyFilterOverrides()) {
363             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
364         }
365
366         DateQueryParameter start = datahubPriceConfiguration.getStart();
367         if (start == null) {
368             logger.warn("Invalid channel configuration parameter 'start' or 'offset': {} (offset: {})",
369                     datahubPriceConfiguration.start, datahubPriceConfiguration.offset);
370             return DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN);
371         }
372
373         Set<ChargeTypeCode> chargeTypeCodes = datahubPriceConfiguration.getChargeTypeCodes();
374         Set<String> notes = datahubPriceConfiguration.getNotes();
375         DatahubTariffFilter filter;
376         if (!chargeTypeCodes.isEmpty() || !notes.isEmpty()) {
377             // Completely override filter.
378             filter = new DatahubTariffFilter(chargeTypeCodes, notes, start);
379         } else {
380             // Only override start date in pre-configured filter.
381             filter = new DatahubTariffFilter(DatahubTariffFilterFactory.getGridTariffByGLN(config.gridCompanyGLN),
382                     start);
383         }
384
385         return new DatahubTariffFilter(filter,
386                 DateQueryParameter.of(filter.getStart(), Duration.ofHours(-CacheManager.NUMBER_OF_HISTORIC_HOURS)));
387     }
388
389     private void refreshCo2EmissionPrognosis() {
390         try {
391             updateCo2Emissions(Dataset.CO2EmissionPrognosis, CHANNEL_CO2_EMISSION_PROGNOSIS,
392                     DateQueryParameter.of(DateQueryParameterType.UTC_NOW, Duration.ofMinutes(-5)));
393             updateStatus(ThingStatus.ONLINE);
394         } catch (DataServiceException e) {
395             if (e.getHttpStatus() != 0) {
396                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
397                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
398             } else {
399                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
400             }
401             if (e.getCause() != null) {
402                 logger.debug("Error retrieving CO2 emission prognosis", e);
403             }
404         } catch (InterruptedException e) {
405             logger.debug("Emission prognosis refresh job interrupted");
406             Thread.currentThread().interrupt();
407             return;
408         }
409     }
410
411     private void refreshCo2EmissionRealtime() {
412         try {
413             updateCo2Emissions(Dataset.CO2Emission, CHANNEL_CO2_EMISSION_REALTIME,
414                     DateQueryParameter.of(DateQueryParameterType.UTC_NOW,
415                             realtimeEmissionsFetchedFirstTime ? Duration.ofMinutes(-5) : Duration.ofHours(-24)));
416             realtimeEmissionsFetchedFirstTime = true;
417             updateStatus(ThingStatus.ONLINE);
418         } catch (DataServiceException e) {
419             if (e.getHttpStatus() != 0) {
420                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
421                         HttpStatus.getCode(e.getHttpStatus()).getMessage());
422             } else {
423                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
424             }
425             if (e.getCause() != null) {
426                 logger.debug("Error retrieving CO2 realtime emissions", e);
427             }
428         } catch (InterruptedException e) {
429             logger.debug("Emission realtime refresh job interrupted");
430             Thread.currentThread().interrupt();
431             return;
432         }
433     }
434
435     private void updateCo2Emissions(Dataset dataset, String channelId, DateQueryParameter dateQueryParameter)
436             throws InterruptedException, DataServiceException {
437         if (!"DK1".equals(config.priceArea) && !"DK2".equals(config.priceArea)) {
438             // Dataset is only for Denmark.
439             return;
440         }
441         Map<String, String> properties = editProperties();
442         CO2EmissionRecord[] emissionRecords = apiController.getCo2Emissions(dataset, config.priceArea,
443                 dateQueryParameter, properties);
444         updateProperties(properties);
445
446         TimeSeries timeSeries = new TimeSeries(REPLACE);
447         Instant now = Instant.now();
448
449         if (dataset == Dataset.CO2Emission && emissionRecords.length > 0) {
450             // Records are sorted descending, first record is current.
451             updateState(channelId, new QuantityType<>(emissionRecords[0].emission(), Units.GRAM_PER_KILOWATT_HOUR));
452         }
453
454         for (CO2EmissionRecord emissionRecord : emissionRecords) {
455             State state = new QuantityType<>(emissionRecord.emission(), Units.GRAM_PER_KILOWATT_HOUR);
456             timeSeries.add(emissionRecord.start(), state);
457
458             if (dataset == Dataset.CO2EmissionPrognosis && now.compareTo(emissionRecord.start()) >= 0
459                     && now.compareTo(emissionRecord.end()) < 0) {
460                 updateState(channelId, state);
461             }
462         }
463         sendTimeSeries(channelId, timeSeries);
464     }
465
466     private void updatePrices() {
467         cacheManager.cleanup();
468
469         updateCurrentSpotPrice();
470         Arrays.stream(DatahubTariff.values())
471                 .forEach(tariff -> updateCurrentTariff(tariff.getChannelId(), cacheManager.getTariff(tariff)));
472
473         reschedulePriceUpdateJob();
474     }
475
476     private void updateCurrentSpotPrice() {
477         if (!isLinked(CHANNEL_SPOT_PRICE)) {
478             return;
479         }
480         BigDecimal spotPrice = cacheManager.getSpotPrice();
481         updatePriceState(CHANNEL_SPOT_PRICE, spotPrice, config.getCurrency());
482     }
483
484     private void updateCurrentTariff(String channelId, @Nullable BigDecimal tariff) {
485         if (!isLinked(channelId)) {
486             return;
487         }
488         updatePriceState(channelId, tariff, CURRENCY_DKK);
489     }
490
491     private void updatePriceState(String channelID, @Nullable BigDecimal price, Currency currency) {
492         updateState(channelID, price != null ? getEnergyPrice(price, currency) : UnDefType.UNDEF);
493     }
494
495     private State getEnergyPrice(BigDecimal price, Currency currency) {
496         String currencyCode = currency.getCurrencyCode();
497         Unit<?> unit = CurrencyUnits.getInstance().getUnit(currencyCode);
498         if (unit == null) {
499             logger.trace("Currency {} is unknown, falling back to DecimalType", currency.getCurrencyCode());
500             return new DecimalType(price);
501         }
502         try {
503             return new QuantityType<>(price + " " + currencyCode + "/kWh");
504         } catch (IllegalArgumentException e) {
505             logger.debug("Unable to create QuantityType, falling back to DecimalType", e);
506             return new DecimalType(price);
507         }
508     }
509
510     /**
511      * Download spot prices in requested period and update corresponding channel with time series.
512      * 
513      * @param startDate Start date of period
514      * @param endDate End date of period
515      * @return number of published states
516      */
517     public int updateSpotPriceTimeSeries(LocalDate startDate, LocalDate endDate)
518             throws InterruptedException, DataServiceException {
519         if (!isLinked(CHANNEL_SPOT_PRICE)) {
520             return 0;
521         }
522         Map<String, String> properties = editProperties();
523         try {
524             Currency currency = config.getCurrency();
525             ElspotpriceRecord[] spotPriceRecords = apiController.getSpotPrices(config.priceArea, currency,
526                     DateQueryParameter.of(startDate), DateQueryParameter.of(endDate.plusDays(1)), properties);
527             boolean isDKK = EnergiDataServiceBindingConstants.CURRENCY_DKK.equals(currency);
528             TimeSeries spotPriceTimeSeries = new TimeSeries(REPLACE);
529             if (spotPriceRecords.length == 0) {
530                 return 0;
531             }
532             for (ElspotpriceRecord record : Arrays.stream(spotPriceRecords)
533                     .sorted(Comparator.comparing(ElspotpriceRecord::hour)).toList()) {
534                 spotPriceTimeSeries.add(record.hour(), getEnergyPrice(
535                         (isDKK ? record.spotPriceDKK() : record.spotPriceEUR()).divide(BigDecimal.valueOf(1000)),
536                         currency));
537             }
538             sendTimeSeries(CHANNEL_SPOT_PRICE, spotPriceTimeSeries);
539             return spotPriceRecords.length;
540         } finally {
541             updateProperties(properties);
542         }
543     }
544
545     /**
546      * Download tariffs in requested period and update corresponding channel with time series.
547      * 
548      * @param datahubTariff Tariff to update
549      * @param startDate Start date of period
550      * @param endDate End date of period
551      * @return number of published states
552      */
553     public int updateTariffTimeSeries(DatahubTariff datahubTariff, LocalDate startDate, LocalDate endDate)
554             throws InterruptedException, DataServiceException {
555         if (!isLinked(datahubTariff.getChannelId())) {
556             return 0;
557         }
558         GlobalLocationNumber globalLocationNumber = getGlobalLocationNumber(datahubTariff);
559         if (globalLocationNumber.isEmpty()) {
560             return 0;
561         }
562         DatahubTariffFilter filter = getDatahubTariffFilter(datahubTariff);
563         DateQueryParameter start = filter.getStart();
564         DateQueryParameterType filterStartDateType = start.getDateType();
565         LocalDate filterStartDate = start.getDate();
566         if (filterStartDateType != null) {
567             // For filters with date relative to current date, override with provided parameters.
568             filter = new DatahubTariffFilter(filter, DateQueryParameter.of(startDate), DateQueryParameter.of(endDate));
569         } else if (filterStartDate != null && startDate.isBefore(filterStartDate)) {
570             throw new IllegalArgumentException("Start date before " + start.getDate() + " is not supported");
571         }
572         Collection<DatahubPricelistRecord> datahubRecords = downloadPriceLists(globalLocationNumber, filter);
573         ZoneId zoneId = timeZoneProvider.getTimeZone();
574         Instant firstHourStart = startDate.atStartOfDay(zoneId).toInstant();
575         Instant lastHourStart = endDate.plusDays(1).atStartOfDay(zoneId).toInstant();
576         Map<Instant, BigDecimal> tariffMap = new PriceListParser().toHourly(datahubRecords, firstHourStart,
577                 lastHourStart);
578
579         return updatePriceTimeSeries(datahubTariff.getChannelId(), tariffMap, CURRENCY_DKK, true);
580     }
581
582     private void updateElectricityTimeSeriesFromCache() {
583         updatePriceTimeSeries(CHANNEL_SPOT_PRICE, cacheManager.getSpotPrices(), config.getCurrency(), false);
584
585         for (DatahubTariff datahubTariff : DatahubTariff.values()) {
586             String channelId = datahubTariff.getChannelId();
587             updatePriceTimeSeries(channelId, cacheManager.getTariffs(datahubTariff), CURRENCY_DKK, true);
588         }
589     }
590
591     private int updatePriceTimeSeries(String channelId, Map<Instant, BigDecimal> priceMap, Currency currency,
592             boolean deduplicate) {
593         if (!isLinked(channelId)) {
594             return 0;
595         }
596         List<Entry<Instant, BigDecimal>> prices = priceMap.entrySet().stream().sorted(Map.Entry.comparingByKey())
597                 .toList();
598         TimeSeries timeSeries = new TimeSeries(REPLACE);
599         BigDecimal previousTariff = null;
600         for (Entry<Instant, BigDecimal> price : prices) {
601             Instant hourStart = price.getKey();
602             BigDecimal priceValue = price.getValue();
603             if (deduplicate && priceValue.equals(previousTariff)) {
604                 // Skip redundant states.
605                 continue;
606             }
607             timeSeries.add(hourStart, getEnergyPrice(priceValue, currency));
608             previousTariff = priceValue;
609         }
610         if (timeSeries.size() > 0) {
611             sendTimeSeries(channelId, timeSeries);
612         }
613         return timeSeries.size();
614     }
615
616     /**
617      * Get the configured {@link Currency} for spot prices.
618      * 
619      * @return Spot price currency
620      */
621     public Currency getCurrency() {
622         return config.getCurrency();
623     }
624
625     /**
626      * Get cached spot prices or try once to download them if not cached
627      * (usually if no items are linked).
628      *
629      * @return Map of future spot prices
630      */
631     public Map<Instant, BigDecimal> getSpotPrices() {
632         try {
633             downloadSpotPrices();
634         } catch (DataServiceException e) {
635             if (logger.isDebugEnabled()) {
636                 logger.warn("Error retrieving spot prices", e);
637             } else {
638                 logger.warn("Error retrieving spot prices: {}", e.getMessage());
639             }
640         } catch (InterruptedException e) {
641             Thread.currentThread().interrupt();
642         }
643
644         return cacheManager.getSpotPrices();
645     }
646
647     /**
648      * Return cached tariffs or try once to download them if not cached
649      * (usually if no items are linked).
650      *
651      * @return Map of future tariffs
652      */
653     public Map<Instant, BigDecimal> getTariffs(DatahubTariff datahubTariff) {
654         try {
655             downloadTariffs(datahubTariff);
656         } catch (DataServiceException e) {
657             if (logger.isDebugEnabled()) {
658                 logger.warn("Error retrieving tariffs", e);
659             } else {
660                 logger.warn("Error retrieving tariffs of type {}: {}", datahubTariff, e.getMessage());
661             }
662         } catch (InterruptedException e) {
663             Thread.currentThread().interrupt();
664         }
665
666         return cacheManager.getTariffs(datahubTariff);
667     }
668
669     /**
670      * Return whether reduced electricity tax is set in configuration.
671      *
672      * @return true if reduced electricity tax applies
673      */
674     public boolean isReducedElectricityTax() {
675         return config.reducedElectricityTax;
676     }
677
678     private void reschedulePriceUpdateJob() {
679         ScheduledFuture<?> priceUpdateJob = this.priceUpdateFuture;
680         if (priceUpdateJob != null) {
681             // Do not interrupt ourselves.
682             priceUpdateJob.cancel(false);
683             this.priceUpdateFuture = null;
684         }
685
686         Instant now = Instant.now();
687         long millisUntilNextClockHour = Duration
688                 .between(now, now.plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.HOURS)).toMillis() + 1;
689         this.priceUpdateFuture = scheduler.schedule(this::updatePrices, millisUntilNextClockHour,
690                 TimeUnit.MILLISECONDS);
691         logger.debug("Price update job rescheduled in {} milliseconds", millisUntilNextClockHour);
692     }
693
694     private void reschedulePriceRefreshJob(RetryStrategy retryPolicy) {
695         // Preserve state of previous retry policy when configuration is the same.
696         if (!retryPolicy.equals(this.retryPolicy)) {
697             this.retryPolicy = retryPolicy;
698         }
699
700         ScheduledFuture<?> refreshJob = this.refreshPriceFuture;
701
702         long secondsUntilNextRefresh = this.retryPolicy.getDuration().getSeconds();
703         Instant timeOfNextRefresh = Instant.now().plusSeconds(secondsUntilNextRefresh);
704         this.refreshPriceFuture = scheduler.schedule(this::refreshElectricityPrices, secondsUntilNextRefresh,
705                 TimeUnit.SECONDS);
706         logger.debug("Price refresh job rescheduled in {} seconds: {}", secondsUntilNextRefresh, timeOfNextRefresh);
707         DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PROPERTY_DATETIME_FORMAT);
708         updateProperty(PROPERTY_NEXT_CALL, LocalDateTime.ofInstant(timeOfNextRefresh, timeZoneProvider.getTimeZone())
709                 .truncatedTo(ChronoUnit.SECONDS).format(formatter));
710
711         if (refreshJob != null) {
712             refreshJob.cancel(true);
713         }
714     }
715
716     private void rescheduleEmissionPrognosisJob() {
717         logger.debug("Scheduling emission prognosis refresh job now and every {}", emissionPrognosisJobInterval);
718
719         ScheduledFuture<?> refreshEmissionPrognosisFuture = this.refreshEmissionPrognosisFuture;
720         if (refreshEmissionPrognosisFuture != null) {
721             refreshEmissionPrognosisFuture.cancel(true);
722         }
723
724         this.refreshEmissionPrognosisFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionPrognosis, 0,
725                 emissionPrognosisJobInterval.toSeconds(), TimeUnit.SECONDS);
726     }
727
728     private void rescheduleEmissionRealtimeJob() {
729         logger.debug("Scheduling emission realtime refresh job now and every {}", emissionRealtimeJobInterval);
730
731         ScheduledFuture<?> refreshEmissionFuture = this.refreshEmissionRealtimeFuture;
732         if (refreshEmissionFuture != null) {
733             refreshEmissionFuture.cancel(true);
734         }
735
736         this.refreshEmissionRealtimeFuture = scheduler.scheduleWithFixedDelay(this::refreshCo2EmissionRealtime, 0,
737                 emissionRealtimeJobInterval.toSeconds(), TimeUnit.SECONDS);
738     }
739 }