2 * Copyright (c) 2010-2023 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.action;
15 import static org.hamcrest.CoreMatchers.*;
16 import static org.hamcrest.MatcherAssert.assertThat;
17 import static org.mockito.Mockito.when;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.math.BigDecimal;
22 import java.nio.charset.StandardCharsets;
23 import java.time.Clock;
24 import java.time.Duration;
25 import java.time.Instant;
26 import java.time.LocalDateTime;
27 import java.util.Arrays;
28 import java.util.List;
30 import java.util.Objects;
31 import java.util.stream.Collectors;
33 import javax.measure.quantity.Power;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.junit.jupiter.api.BeforeEach;
37 import org.junit.jupiter.api.Test;
38 import org.junit.jupiter.api.extension.ExtendWith;
39 import org.mockito.Mock;
40 import org.mockito.junit.jupiter.MockitoExtension;
41 import org.mockito.junit.jupiter.MockitoSettings;
42 import org.mockito.quality.Strictness;
43 import org.openhab.binding.energidataservice.internal.DatahubTariff;
44 import org.openhab.binding.energidataservice.internal.EnergiDataServiceBindingConstants;
45 import org.openhab.binding.energidataservice.internal.PriceListParser;
46 import org.openhab.binding.energidataservice.internal.api.dto.DatahubPricelistRecords;
47 import org.openhab.binding.energidataservice.internal.api.serialization.InstantDeserializer;
48 import org.openhab.binding.energidataservice.internal.api.serialization.LocalDateTimeDeserializer;
49 import org.openhab.binding.energidataservice.internal.handler.EnergiDataServiceHandler;
50 import org.openhab.core.library.types.QuantityType;
51 import org.openhab.core.library.unit.Units;
52 import org.slf4j.LoggerFactory;
54 import com.google.gson.Gson;
55 import com.google.gson.GsonBuilder;
57 import ch.qos.logback.classic.Level;
58 import ch.qos.logback.classic.Logger;
61 * Tests for {@link EnergiDataServiceActions}.
63 * @author Jacob Laursen - Initial contribution
66 @ExtendWith(MockitoExtension.class)
67 @MockitoSettings(strictness = Strictness.LENIENT)
68 public class EnergiDataServiceActionsTest {
70 private @NonNullByDefault({}) @Mock EnergiDataServiceHandler handler;
71 private EnergiDataServiceActions actions = new EnergiDataServiceActions();
73 private Gson gson = new GsonBuilder().registerTypeAdapter(Instant.class, new InstantDeserializer())
74 .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeDeserializer()).create();
76 private record SpotPrice(Instant hourStart, BigDecimal spotPrice) {
79 private <T> T getObjectFromJson(String filename, Class<T> clazz) throws IOException {
80 try (InputStream inputStream = EnergiDataServiceActionsTest.class.getResourceAsStream(filename)) {
81 if (inputStream == null) {
82 throw new IOException("Input stream is null");
84 byte[] bytes = inputStream.readAllBytes();
86 throw new IOException("Resulting byte-array empty");
88 String json = new String(bytes, StandardCharsets.UTF_8);
89 return Objects.requireNonNull(gson.fromJson(json, clazz));
95 final Logger logger = (Logger) LoggerFactory.getLogger(EnergiDataServiceActions.class);
96 logger.setLevel(Level.OFF);
98 actions = new EnergiDataServiceActions();
102 void getPricesSpotPrice() throws IOException {
103 mockCommonDatasets(actions);
105 Map<Instant, BigDecimal> actual = actions.getPrices("SpotPrice");
106 assertThat(actual.size(), is(35));
107 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("0.992840027"))));
108 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("1.267680054"))));
112 void getPricesNetTariff() throws IOException {
113 mockCommonDatasets(actions);
115 Map<Instant, BigDecimal> actual = actions.getPrices("NetTariff");
116 assertThat(actual.size(), is(60));
117 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("0.432225"))));
118 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("1.05619"))));
122 void getPricesSystemTariff() throws IOException {
123 mockCommonDatasets(actions);
125 Map<Instant, BigDecimal> actual = actions.getPrices("SystemTariff");
126 assertThat(actual.size(), is(60));
127 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("0.054"))));
128 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("0.054"))));
132 void getPricesElectricityTax() throws IOException {
133 mockCommonDatasets(actions);
135 Map<Instant, BigDecimal> actual = actions.getPrices("ElectricityTax");
136 assertThat(actual.size(), is(60));
137 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("0.008"))));
138 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("0.008"))));
142 void getPricesTransmissionNetTariff() throws IOException {
143 mockCommonDatasets(actions);
145 Map<Instant, BigDecimal> actual = actions.getPrices("TransmissionNetTariff");
146 assertThat(actual.size(), is(60));
147 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("0.058"))));
148 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("0.058"))));
152 void getPricesSpotPriceNetTariff() throws IOException {
153 mockCommonDatasets(actions);
155 Map<Instant, BigDecimal> actual = actions.getPrices("SpotPrice,NetTariff");
156 assertThat(actual.size(), is(35));
157 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("1.425065027"))));
158 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("2.323870054"))));
162 void getPricesSpotPriceNetTariffElectricityTax() throws IOException {
163 mockCommonDatasets(actions);
165 Map<Instant, BigDecimal> actual = actions.getPrices("SpotPrice,NetTariff,ElectricityTax");
166 assertThat(actual.size(), is(35));
167 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("1.433065027"))));
168 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("2.331870054"))));
172 void getPricesTotal() throws IOException {
173 mockCommonDatasets(actions);
175 Map<Instant, BigDecimal> actual = actions.getPrices();
176 assertThat(actual.size(), is(35));
177 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("1.545065027"))));
178 assertThat(actual.get(Instant.parse("2023-02-04T15:00:00Z")), is(equalTo(new BigDecimal("1.708765039"))));
179 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("2.443870054"))));
183 void getPricesTotalFullElectricityTax() throws IOException {
184 mockCommonDatasets(actions, "SpotPrices20231003.json");
186 Map<Instant, BigDecimal> actual = actions.getPrices();
187 assertThat(actual.size(), is(4));
188 assertThat(actual.get(Instant.parse("2023-10-03T20:00:00Z")), is(equalTo(new BigDecimal("0.829059999"))));
192 void getPricesTotalReducedElectricityTax() throws IOException {
193 mockCommonDatasets(actions, "SpotPrices20231003.json", true);
195 Map<Instant, BigDecimal> actual = actions.getPrices();
196 assertThat(actual.size(), is(4));
197 assertThat(actual.get(Instant.parse("2023-10-03T20:00:00Z")), is(equalTo(new BigDecimal("0.140059999"))));
201 void getPricesTotalAllComponents() throws IOException {
202 mockCommonDatasets(actions);
204 Map<Instant, BigDecimal> actual = actions
205 .getPrices("spotprice,nettariff,systemtariff,electricitytax,transmissionnettariff");
206 assertThat(actual.size(), is(35));
207 assertThat(actual.get(Instant.parse("2023-02-04T12:00:00Z")), is(equalTo(new BigDecimal("1.545065027"))));
208 assertThat(actual.get(Instant.parse("2023-02-04T15:00:00Z")), is(equalTo(new BigDecimal("1.708765039"))));
209 assertThat(actual.get(Instant.parse("2023-02-04T16:00:00Z")), is(equalTo(new BigDecimal("2.443870054"))));
213 void getPricesInvalidPriceComponent() throws IOException {
214 mockCommonDatasets(actions);
216 Map<Instant, BigDecimal> actual = actions.getPrices("spotprice,nettarif");
217 assertThat(actual.size(), is(0));
221 void getPricesMixedCurrencies() throws IOException {
222 mockCommonDatasets(actions);
223 when(handler.getCurrency()).thenReturn(EnergiDataServiceBindingConstants.CURRENCY_EUR);
225 Map<Instant, BigDecimal> actual = actions.getPrices("spotprice,nettariff");
226 assertThat(actual.size(), is(0));
230 * Calculate price in period 15:30-16:30 (UTC) with consumption 150 W and the following total prices:
231 * 15:00:00: 1.708765039
232 * 16:00:00: 2.443870054
234 * Result = (1.708765039 / 2) + (2.443870054 / 2) * 0.150
236 * @throws IOException
239 void calculatePriceSimple() throws IOException {
240 mockCommonDatasets(actions);
242 BigDecimal actual = actions.calculatePrice(Instant.parse("2023-02-04T15:30:00Z"),
243 Instant.parse("2023-02-04T16:30:00Z"), new QuantityType<>(150, Units.WATT));
244 assertThat(actual, is(equalTo(new BigDecimal("0.311447631975000000")))); // 0.3114476319750
248 * Calculate price in period 15:00-17:00 (UTC) with consumption 1000 W and the following total prices:
249 * 15:00:00: 1.708765039
250 * 16:00:00: 2.443870054
252 * Result = 1.708765039 + 2.443870054
254 * @throws IOException
257 void calculatePriceFullHours() throws IOException {
258 mockCommonDatasets(actions);
260 BigDecimal actual = actions.calculatePrice(Instant.parse("2023-02-04T15:00:00Z"),
261 Instant.parse("2023-02-04T17:00:00Z"), new QuantityType<>(1, Units.KILOVAR));
262 assertThat(actual, is(equalTo(new BigDecimal("4.152635093000000000")))); // 4.152635093
266 void calculatePriceOutOfRangeStart() throws IOException {
267 mockCommonDatasets(actions);
269 BigDecimal actual = actions.calculatePrice(Instant.parse("2023-02-03T23:59:00Z"),
270 Instant.parse("2023-02-04T12:30:00Z"), new QuantityType<>(1000, Units.WATT));
271 assertThat(actual, is(equalTo(BigDecimal.ZERO)));
275 void calculatePriceOutOfRangeEnd() throws IOException {
276 mockCommonDatasets(actions);
278 BigDecimal actual = actions.calculatePrice(Instant.parse("2023-02-05T22:00:00Z"),
279 Instant.parse("2023-02-05T23:01:00Z"), new QuantityType<>(1000, Units.WATT));
280 assertThat(actual, is(equalTo(BigDecimal.ZERO)));
284 * Miele G 6895 SCVi XXL K2O dishwasher, program ECO.
286 * @throws IOException
289 void calculateCheapestPeriodWithPowerDishwasher() throws IOException {
290 mockCommonDatasets(actions, "SpotPrices20230205.json");
292 List<Duration> durations = List.of(Duration.ofMinutes(37), Duration.ofMinutes(8), Duration.ofMinutes(4),
293 Duration.ofMinutes(2), Duration.ofMinutes(4), Duration.ofMinutes(36), Duration.ofMinutes(41),
294 Duration.ofMinutes(104));
295 List<QuantityType<Power>> consumptions = List.of(QuantityType.valueOf(162.162162, Units.WATT),
296 QuantityType.valueOf(750, Units.WATT), QuantityType.valueOf(1500, Units.WATT),
297 QuantityType.valueOf(3000, Units.WATT), QuantityType.valueOf(1500, Units.WATT),
298 QuantityType.valueOf(166.666666, Units.WATT), QuantityType.valueOf(146.341463, Units.WATT),
299 QuantityType.valueOf(0, Units.WATT));
300 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-05T16:00:00Z"),
301 Instant.parse("2023-02-06T06:00:00Z"), durations, consumptions);
302 assertThat(actual.get("LowestPrice"), is(equalTo(new BigDecimal("1.024218147103792520"))));
303 assertThat(actual.get("CheapestStart"), is(equalTo(Instant.parse("2023-02-05T19:23:00Z"))));
304 assertThat(actual.get("HighestPrice"), is(equalTo(new BigDecimal("1.530671034828983196"))));
305 assertThat(actual.get("MostExpensiveStart"), is(equalTo(Instant.parse("2023-02-05T16:00:00Z"))));
309 void calculateCheapestPeriodWithPowerOutOfRange() throws IOException {
310 mockCommonDatasets(actions);
312 List<Duration> durations = List.of(Duration.ofMinutes(61));
313 List<QuantityType<Power>> consumptions = List.of(QuantityType.valueOf(1000, Units.WATT));
314 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-04T12:00:00Z"),
315 Instant.parse("2023-02-06T00:01:00Z"), durations, consumptions);
316 assertThat(actual.size(), is(equalTo(0)));
320 * Miele G 6895 SCVi XXL K2O dishwasher, program ECO.
322 * @throws IOException
325 void calculateCheapestPeriodWithEnergyDishwasher() throws IOException {
326 mockCommonDatasets(actions, "SpotPrices20230205.json");
328 List<Duration> durations = List.of(Duration.ofMinutes(37), Duration.ofMinutes(8), Duration.ofMinutes(4),
329 Duration.ofMinutes(2), Duration.ofMinutes(4), Duration.ofMinutes(36), Duration.ofMinutes(41));
330 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-05T16:00:00Z"),
331 Instant.parse("2023-02-06T06:00:00Z"), Duration.ofMinutes(236), durations,
332 QuantityType.valueOf(0.1, Units.KILOWATT_HOUR));
333 assertThat(actual.get("LowestPrice"), is(equalTo(new BigDecimal("1.024218147103792520"))));
334 assertThat(actual.get("CheapestStart"), is(equalTo(Instant.parse("2023-02-05T19:23:00Z"))));
335 assertThat(actual.get("HighestPrice"), is(equalTo(new BigDecimal("1.530671034828983196"))));
336 assertThat(actual.get("MostExpensiveStart"), is(equalTo(Instant.parse("2023-02-05T16:00:00Z"))));
340 void calculateCheapestPeriodWithEnergyTotalDurationIsExactSum() throws IOException {
341 mockCommonDatasets(actions, "SpotPrices20230205.json");
343 List<Duration> durations = List.of(Duration.ofMinutes(60), Duration.ofMinutes(60));
344 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-05T16:00:00Z"),
345 Instant.parse("2023-02-06T06:00:00Z"), Duration.ofMinutes(120), durations,
346 QuantityType.valueOf(100, Units.WATT_HOUR));
347 assertThat(actual.get("LowestPrice"), is(equalTo(new BigDecimal("0.293540001200000000"))));
348 assertThat(actual.get("CheapestStart"), is(equalTo(Instant.parse("2023-02-05T19:00:00Z"))));
352 void calculateCheapestPeriodWithEnergyTotalDurationInvalid() throws IOException {
353 mockCommonDatasets(actions, "SpotPrices20230205.json");
355 List<Duration> durations = List.of(Duration.ofMinutes(60), Duration.ofMinutes(60));
356 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-05T16:00:00Z"),
357 Instant.parse("2023-02-06T06:00:00Z"), Duration.ofMinutes(119), durations,
358 QuantityType.valueOf(0.1, Units.KILOWATT_HOUR));
359 assertThat(actual.size(), is(equalTo(0)));
363 * Like {@link #calculateCheapestPeriodWithEnergyDishwasher} but with unknown consumption/timetable map.
365 * @throws IOException
368 void calculateCheapestPeriodAssumingLinearUnknownConsumption() throws IOException {
369 mockCommonDatasets(actions, "SpotPrices20230205.json");
371 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-05T16:00:00Z"),
372 Instant.parse("2023-02-06T06:00:00Z"), Duration.ofMinutes(236));
373 assertThat(actual.get("LowestPrice"), is(nullValue()));
374 assertThat(actual.get("CheapestStart"), is(equalTo(Instant.parse("2023-02-05T19:00:00Z"))));
375 assertThat(actual.get("HighestPrice"), is(nullValue()));
376 assertThat(actual.get("MostExpensiveStart"), is(equalTo(Instant.parse("2023-02-05T16:00:00Z"))));
380 void calculateCheapestPeriodForLinearPowerUsage() throws IOException {
381 mockCommonDatasets(actions);
383 Map<String, Object> actual = actions.calculateCheapestPeriod(Instant.parse("2023-02-04T12:00:00Z"),
384 Instant.parse("2023-02-05T23:00:00Z"), Duration.ofMinutes(61), QuantityType.valueOf(1000, Units.WATT));
385 assertThat(actual.get("LowestPrice"), is(equalTo(new BigDecimal("1.323990859575000000"))));
386 assertThat(actual.get("CheapestStart"), is(equalTo(Instant.parse("2023-02-05T12:00:00Z"))));
387 assertThat(actual.get("HighestPrice"), is(equalTo(new BigDecimal("2.589061780353348000"))));
388 assertThat(actual.get("MostExpensiveStart"), is(equalTo(Instant.parse("2023-02-04T17:00:00Z"))));
391 private void mockCommonDatasets(EnergiDataServiceActions actions) throws IOException {
392 mockCommonDatasets(actions, "SpotPrices20230204.json");
395 private void mockCommonDatasets(EnergiDataServiceActions actions, String spotPricesFilename) throws IOException {
396 mockCommonDatasets(actions, spotPricesFilename, false);
399 private void mockCommonDatasets(EnergiDataServiceActions actions, String spotPricesFilename,
400 boolean isReducedElectricityTax) throws IOException {
401 SpotPrice[] spotPriceRecords = getObjectFromJson(spotPricesFilename, SpotPrice[].class);
402 Map<Instant, BigDecimal> spotPrices = Arrays.stream(spotPriceRecords)
403 .collect(Collectors.toMap(SpotPrice::hourStart, SpotPrice::spotPrice));
405 PriceListParser priceListParser = new PriceListParser(
406 Clock.fixed(spotPriceRecords[0].hourStart, EnergiDataServiceBindingConstants.DATAHUB_TIMEZONE));
407 DatahubPricelistRecords datahubRecords = getObjectFromJson("NetTariffs.json", DatahubPricelistRecords.class);
408 Map<Instant, BigDecimal> netTariffs = priceListParser
409 .toHourly(Arrays.stream(datahubRecords.records()).toList());
410 datahubRecords = getObjectFromJson("SystemTariffs.json", DatahubPricelistRecords.class);
411 Map<Instant, BigDecimal> systemTariffs = priceListParser
412 .toHourly(Arrays.stream(datahubRecords.records()).toList());
413 datahubRecords = getObjectFromJson("ElectricityTaxes.json", DatahubPricelistRecords.class);
414 Map<Instant, BigDecimal> electricityTaxes = priceListParser
415 .toHourly(Arrays.stream(datahubRecords.records()).toList());
416 datahubRecords = getObjectFromJson("ReducedElectricityTaxes.json", DatahubPricelistRecords.class);
417 Map<Instant, BigDecimal> reducedElectricityTaxes = priceListParser
418 .toHourly(Arrays.stream(datahubRecords.records()).toList());
419 datahubRecords = getObjectFromJson("TransmissionNetTariffs.json", DatahubPricelistRecords.class);
420 Map<Instant, BigDecimal> transmissionNetTariffs = priceListParser
421 .toHourly(Arrays.stream(datahubRecords.records()).toList());
423 when(handler.getSpotPrices()).thenReturn(spotPrices);
424 when(handler.getTariffs(DatahubTariff.NET_TARIFF)).thenReturn(netTariffs);
425 when(handler.getTariffs(DatahubTariff.SYSTEM_TARIFF)).thenReturn(systemTariffs);
426 when(handler.getTariffs(DatahubTariff.ELECTRICITY_TAX)).thenReturn(electricityTaxes);
427 when(handler.getTariffs(DatahubTariff.REDUCED_ELECTRICITY_TAX)).thenReturn(reducedElectricityTaxes);
428 when(handler.getTariffs(DatahubTariff.TRANSMISSION_NET_TARIFF)).thenReturn(transmissionNetTariffs);
429 when(handler.getCurrency()).thenReturn(EnergiDataServiceBindingConstants.CURRENCY_DKK);
430 when(handler.isReducedElectricityTax()).thenReturn(isReducedElectricityTax);
431 actions.setThingHandler(handler);