]> git.basschouten.com Git - openhab-addons.git/blob
01763ae3e967b7e13714cc00662ce8befde4e4c8
[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.solarforecast;
14
15 import static org.junit.jupiter.api.Assertions.*;
16
17 import java.time.Clock;
18 import java.time.Instant;
19 import java.time.LocalDateTime;
20 import java.time.LocalTime;
21 import java.time.ZoneId;
22 import java.time.ZonedDateTime;
23 import java.time.temporal.ChronoUnit;
24 import java.util.ArrayList;
25 import java.util.Iterator;
26 import java.util.List;
27
28 import javax.measure.quantity.Energy;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.json.JSONObject;
32 import org.junit.jupiter.api.BeforeAll;
33 import org.junit.jupiter.api.Test;
34 import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
35 import org.openhab.binding.solarforecast.internal.SolarForecastException;
36 import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
37 import org.openhab.binding.solarforecast.internal.solcast.SolcastConstants;
38 import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
39 import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
40 import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastBridgeHandler;
41 import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneHandler;
42 import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneMock;
43 import org.openhab.binding.solarforecast.internal.utils.Utils;
44 import org.openhab.core.library.types.QuantityType;
45 import org.openhab.core.library.unit.Units;
46 import org.openhab.core.thing.internal.BridgeImpl;
47 import org.openhab.core.types.State;
48 import org.openhab.core.types.TimeSeries;
49
50 /**
51  * The {@link SolcastTest} tests responses from forecast solar website
52  *
53  * @author Bernd Weymann - Initial contribution
54  */
55 @NonNullByDefault
56 class SolcastTest {
57     public static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
58     private static final TimeZP TIMEZONEPROVIDER = new TimeZP();
59     // double comparison tolerance = 1 Watt
60     private static final double TOLERANCE = 0.001;
61
62     public static final String TOO_LATE_INDICATOR = "too late";
63     public static final String DAY_MISSING_INDICATOR = "not available in forecast";
64
65     @BeforeAll
66     static void setFixedTimeJul17() {
67         // Instant matching the date of test resources
68         Instant fixedInstant = Instant.parse("2022-07-17T21:00:00Z");
69         Clock fixedClock = Clock.fixed(fixedInstant, TEST_ZONE);
70         Utils.setClock(fixedClock);
71     }
72
73     static void setFixedTimeJul18() {
74         // Instant matching the date of test resources
75         Instant fixedInstant = Instant.parse("2022-07-18T14:23:00Z");
76         Clock fixedClock = Clock.fixed(fixedInstant, TEST_ZONE);
77         Utils.setClock(fixedClock);
78     }
79
80     /**
81      * "2022-07-18T00:00+02:00[Europe/Berlin]": 0,
82      * "2022-07-18T00:30+02:00[Europe/Berlin]": 0,
83      * "2022-07-18T01:00+02:00[Europe/Berlin]": 0,
84      * "2022-07-18T01:30+02:00[Europe/Berlin]": 0,
85      * "2022-07-18T02:00+02:00[Europe/Berlin]": 0,
86      * "2022-07-18T02:30+02:00[Europe/Berlin]": 0,
87      * "2022-07-18T03:00+02:00[Europe/Berlin]": 0,
88      * "2022-07-18T03:30+02:00[Europe/Berlin]": 0,
89      * "2022-07-18T04:00+02:00[Europe/Berlin]": 0,
90      * "2022-07-18T04:30+02:00[Europe/Berlin]": 0,
91      * "2022-07-18T05:00+02:00[Europe/Berlin]": 0,
92      * "2022-07-18T05:30+02:00[Europe/Berlin]": 0,
93      * "2022-07-18T06:00+02:00[Europe/Berlin]": 0.0205,
94      * "2022-07-18T06:30+02:00[Europe/Berlin]": 0.1416,
95      * "2022-07-18T07:00+02:00[Europe/Berlin]": 0.4478,
96      * "2022-07-18T07:30+02:00[Europe/Berlin]": 0.763,
97      * "2022-07-18T08:00+02:00[Europe/Berlin]": 1.1367,
98      * "2022-07-18T08:30+02:00[Europe/Berlin]": 1.4044,
99      * "2022-07-18T09:00+02:00[Europe/Berlin]": 1.6632,
100      * "2022-07-18T09:30+02:00[Europe/Berlin]": 1.8667,
101      * "2022-07-18T10:00+02:00[Europe/Berlin]": 2.0729,
102      * "2022-07-18T10:30+02:00[Europe/Berlin]": 2.2377,
103      * "2022-07-18T11:00+02:00[Europe/Berlin]": 2.3516,
104      * "2022-07-18T11:30+02:00[Europe/Berlin]": 2.4295,
105      * "2022-07-18T12:00+02:00[Europe/Berlin]": 2.5136,
106      * "2022-07-18T12:30+02:00[Europe/Berlin]": 2.5295,
107      * "2022-07-18T13:00+02:00[Europe/Berlin]": 2.526,
108      * "2022-07-18T13:30+02:00[Europe/Berlin]": 2.4879,
109      * "2022-07-18T14:00+02:00[Europe/Berlin]": 2.4092,
110      * "2022-07-18T14:30+02:00[Europe/Berlin]": 2.3309,
111      * "2022-07-18T15:00+02:00[Europe/Berlin]": 2.1984,
112      * "2022-07-18T15:30+02:00[Europe/Berlin]": 2.0416,
113      * "2022-07-18T16:00+02:00[Europe/Berlin]": 1.9076,
114      * "2022-07-18T16:30+02:00[Europe/Berlin]": 1.7416,
115      * "2022-07-18T17:00+02:00[Europe/Berlin]": 1.5414,
116      * "2022-07-18T17:30+02:00[Europe/Berlin]": 1.3683,
117      * "2022-07-18T18:00+02:00[Europe/Berlin]": 1.1603,
118      * "2022-07-18T18:30+02:00[Europe/Berlin]": 0.9527,
119      * "2022-07-18T19:00+02:00[Europe/Berlin]": 0.7705,
120      * "2022-07-18T19:30+02:00[Europe/Berlin]": 0.5673,
121      * "2022-07-18T20:00+02:00[Europe/Berlin]": 0.3588,
122      * "2022-07-18T20:30+02:00[Europe/Berlin]": 0.1948,
123      * "2022-07-18T21:00+02:00[Europe/Berlin]": 0.0654,
124      * "2022-07-18T21:30+02:00[Europe/Berlin]": 0.0118,
125      * "2022-07-18T22:00+02:00[Europe/Berlin]": 0,
126      * "2022-07-18T22:30+02:00[Europe/Berlin]": 0,
127      * "2022-07-18T23:00+02:00[Europe/Berlin]": 0,
128      * "2022-07-18T23:30+02:00[Europe/Berlin]": 0
129      **/
130     @Test
131     void testForecastObject() {
132         String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
133         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
134         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
135         content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
136         scfo.join(content);
137         // test one day, step ahead in time and cross check channel values
138         double dayTotal = scfo.getDayTotal(now.toLocalDate(), QueryMode.Average);
139         double actual = scfo.getActualEnergyValue(now, QueryMode.Average);
140         double remain = scfo.getRemainingProduction(now, QueryMode.Average);
141         assertEquals(0.0, actual, TOLERANCE, "Begin of day actual");
142         assertEquals(23.107, remain, TOLERANCE, "Begin of day remaining");
143         assertEquals(23.107, dayTotal, TOLERANCE, "Day total");
144         assertEquals(0.0, scfo.getActualPowerValue(now, QueryMode.Average), TOLERANCE, "Begin of day power");
145         double previousPower = 0;
146         for (int i = 0; i < 47; i++) {
147             now = now.plusMinutes(30);
148             double power = scfo.getActualPowerValue(now, QueryMode.Average) / 2.0;
149             double powerAddOn = ((power + previousPower) / 2.0);
150             actual += powerAddOn;
151             assertEquals(actual, scfo.getActualEnergyValue(now, QueryMode.Average), TOLERANCE, "Actual at " + now);
152             remain -= powerAddOn;
153             assertEquals(remain, scfo.getRemainingProduction(now, QueryMode.Average), TOLERANCE, "Remain at " + now);
154             assertEquals(dayTotal, actual + remain, TOLERANCE, "Total sum at " + now);
155             previousPower = power;
156         }
157     }
158
159     @Test
160     void testPower() {
161         String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
162         ZonedDateTime now = LocalDateTime.of(2022, 7, 23, 16, 00).atZone(TEST_ZONE);
163         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
164         content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
165         scfo.join(content);
166
167         /**
168          * {
169          * "pv_estimate": 1.9176,
170          * "pv_estimate10": 0.8644,
171          * "pv_estimate90": 2.0456,
172          * "period_end": "2022-07-23T14:00:00.0000000Z",
173          * "period": "PT30M"
174          * },
175          * {
176          * "pv_estimate": 1.7544,
177          * "pv_estimate10": 0.7708,
178          * "pv_estimate90": 1.864,
179          * "period_end": "2022-07-23T14:30:00.0000000Z",
180          * "period": "PT30M"
181          */
182         assertEquals(1.9176, scfo.getActualPowerValue(now, QueryMode.Average), TOLERANCE, "Estimate power " + now);
183         assertEquals(1.9176, scfo.getPower(now.toInstant(), "average").doubleValue(), TOLERANCE,
184                 "Estimate power " + now);
185         assertEquals(1.754, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Average), TOLERANCE,
186                 "Estimate power " + now.plusMinutes(30));
187
188         assertEquals(2.046, scfo.getActualPowerValue(now, QueryMode.Optimistic), TOLERANCE, "Optimistic power " + now);
189         assertEquals(1.864, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Optimistic), TOLERANCE,
190                 "Optimistic power " + now.plusMinutes(30));
191
192         assertEquals(0.864, scfo.getActualPowerValue(now, QueryMode.Pessimistic), TOLERANCE,
193                 "Pessimistic power " + now);
194         assertEquals(0.771, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Pessimistic), TOLERANCE,
195                 "Pessimistic power " + now.plusMinutes(30));
196
197         /**
198          * {
199          * "pv_estimate": 1.9318,
200          * "period_end": "2022-07-17T14:30:00.0000000Z",
201          * "period": "PT30M"
202          * },
203          * {
204          * "pv_estimate": 1.724,
205          * "period_end": "2022-07-17T15:00:00.0000000Z",
206          * "period": "PT30M"
207          * },
208          **/
209         // get same values for optimistic / pessimistic and estimate in the past
210         ZonedDateTime past = LocalDateTime.of(2022, 7, 17, 16, 30).atZone(TEST_ZONE);
211         assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Average), TOLERANCE, "Estimate power " + past);
212         assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Average), TOLERANCE,
213                 "Estimate power " + now.plusMinutes(30));
214
215         assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Optimistic), TOLERANCE,
216                 "Optimistic power " + past);
217         assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Optimistic), TOLERANCE,
218                 "Optimistic power " + past.plusMinutes(30));
219
220         assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Pessimistic), TOLERANCE,
221                 "Pessimistic power " + past);
222         assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Pessimistic), TOLERANCE,
223                 "Pessimistic power " + past.plusMinutes(30));
224     }
225
226     /**
227      * Data from TreeMap for manual validation
228      * 2022-07-17T04:30+02:00[Europe/Berlin]=0.0,
229      * 2022-07-17T05:00+02:00[Europe/Berlin]=0.0,
230      * 2022-07-17T05:30+02:00[Europe/Berlin]=0.0,
231      * 2022-07-17T06:00+02:00[Europe/Berlin]=0.0262,
232      * 2022-07-17T06:30+02:00[Europe/Berlin]=0.4252,
233      * 2022-07-17T07:00+02:00[Europe/Berlin]=0.7772, <<<
234      * 2022-07-17T07:30+02:00[Europe/Berlin]=1.0663,
235      * 2022-07-17T08:00+02:00[Europe/Berlin]=1.3848,
236      * 2022-07-17T08:30+02:00[Europe/Berlin]=1.6401,
237      * 2022-07-17T09:00+02:00[Europe/Berlin]=1.8614,
238      * 2022-07-17T09:30+02:00[Europe/Berlin]=2.0613,
239      * 2022-07-17T10:00+02:00[Europe/Berlin]=2.2365,
240      * 2022-07-17T10:30+02:00[Europe/Berlin]=2.3766,
241      * 2022-07-17T11:00+02:00[Europe/Berlin]=2.4719,
242      * 2022-07-17T11:30+02:00[Europe/Berlin]=2.5438,
243      * 2022-07-17T12:00+02:00[Europe/Berlin]=2.602,
244      * 2022-07-17T12:30+02:00[Europe/Berlin]=2.6213,
245      * 2022-07-17T13:00+02:00[Europe/Berlin]=2.6061,
246      * 2022-07-17T13:30+02:00[Europe/Berlin]=2.6181,
247      * 2022-07-17T14:00+02:00[Europe/Berlin]=2.5378,
248      * 2022-07-17T14:30+02:00[Europe/Berlin]=2.4651,
249      * 2022-07-17T15:00+02:00[Europe/Berlin]=2.3656,
250      * 2022-07-17T15:30+02:00[Europe/Berlin]=2.2374,
251      * 2022-07-17T16:00+02:00[Europe/Berlin]=2.1015,
252      * 2022-07-17T16:30+02:00[Europe/Berlin]=1.9318,
253      * 2022-07-17T17:00+02:00[Europe/Berlin]=1.724,
254      * 2022-07-17T17:30+02:00[Europe/Berlin]=1.5031,
255      * 2022-07-17T18:00+02:00[Europe/Berlin]=1.2834,
256      * 2022-07-17T18:30+02:00[Europe/Berlin]=1.0839,
257      * 2022-07-17T19:00+02:00[Europe/Berlin]=0.8581,
258      * 2022-07-17T19:30+02:00[Europe/Berlin]=0.6164,
259      * 2022-07-17T20:00+02:00[Europe/Berlin]=0.4465,
260      * 2022-07-17T20:30+02:00[Europe/Berlin]=0.2543,
261      * 2022-07-17T21:00+02:00[Europe/Berlin]=0.0848,
262      * 2022-07-17T21:30+02:00[Europe/Berlin]=0.0132,
263      * 2022-07-17T22:00+02:00[Europe/Berlin]=0.0,
264      * 2022-07-17T22:30+02:00[Europe/Berlin]=0.0
265      *
266      * <<< = 0.0262 + 0.4252 + 0.7772 = 1.2286 / 2 = 0.6143
267      */
268     @Test
269     void testForecastTreeMap() {
270         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
271         ZonedDateTime now = LocalDateTime.of(2022, 7, 17, 7, 0).atZone(TEST_ZONE);
272         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
273         assertEquals(0.42, scfo.getActualEnergyValue(now, QueryMode.Average), TOLERANCE, "Actual estimation");
274         assertEquals(25.413, scfo.getDayTotal(now.toLocalDate(), QueryMode.Average), TOLERANCE, "Day total");
275     }
276
277     @Test
278     void testJoin() {
279         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
280         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
281         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
282         try {
283             double d = scfo.getActualEnergyValue(now, QueryMode.Average);
284             fail("Exception expected instead of " + d);
285         } catch (SolarForecastException sfe) {
286             String message = sfe.getMessage();
287             assertNotNull(message);
288             assertTrue(message.contains(TOO_LATE_INDICATOR),
289                     "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
290         }
291         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
292         scfo.join(content);
293         assertEquals(18.946, scfo.getActualEnergyValue(now, QueryMode.Average), 0.01, "Actual data");
294         assertEquals(23.107, scfo.getDayTotal(now.toLocalDate(), QueryMode.Average), 0.01, "Today data");
295         JSONObject rawJson = new JSONObject(scfo.getRaw());
296         assertTrue(rawJson.has("forecasts"));
297         assertTrue(rawJson.has("estimated_actuals"));
298     }
299
300     @Test
301     void testActions() {
302         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
303         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
304         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
305         try {
306             double d = scfo.getActualEnergyValue(now, QueryMode.Average);
307             fail("Exception expected instead of " + d);
308         } catch (SolarForecastException sfe) {
309             String message = sfe.getMessage();
310             assertNotNull(message);
311             assertTrue(message.contains(TOO_LATE_INDICATOR),
312                     "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
313         }
314
315         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
316         scfo.join(content);
317
318         assertEquals("2022-07-10T23:30+02:00[Europe/Berlin]", scfo.getForecastBegin().atZone(TEST_ZONE).toString(),
319                 "Forecast begin");
320         assertEquals("2022-07-24T23:00+02:00[Europe/Berlin]", scfo.getForecastEnd().atZone(TEST_ZONE).toString(),
321                 "Forecast end");
322         // test daily forecasts + cumulated getEnergy
323         double totalEnergy = 0;
324         ZonedDateTime start = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
325         for (int i = 0; i < 6; i++) {
326             QuantityType<Energy> qt = scfo.getDay(start.toLocalDate().plusDays(i));
327             QuantityType<Energy> eqt = scfo.getEnergy(start.plusDays(i).toInstant(), start.plusDays(i + 1).toInstant());
328
329             // check if energy calculation fits to daily query
330             assertEquals(qt.doubleValue(), eqt.doubleValue(), TOLERANCE, "Total " + i + " days forecast");
331             totalEnergy += qt.doubleValue();
332
333             // check if sum is fitting to total energy query
334             qt = scfo.getEnergy(start.toInstant(), start.plusDays(i + 1).toInstant());
335             assertEquals(totalEnergy, qt.doubleValue(), TOLERANCE * 2, "Total " + i + " days forecast");
336         }
337     }
338
339     @Test
340     void testOptimisticPessimistic() {
341         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
342         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
343         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
344         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
345         scfo.join(content);
346         assertEquals(19.389, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Average), TOLERANCE,
347                 "Estimation");
348         assertEquals(7.358, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Pessimistic), TOLERANCE,
349                 "Estimation");
350         assertEquals(22.283, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Optimistic), TOLERANCE,
351                 "Estimation");
352         assertEquals(23.316, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Average), TOLERANCE,
353                 "Estimation");
354         assertEquals(9.8, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Pessimistic), TOLERANCE,
355                 "Estimation");
356         assertEquals(23.949, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Optimistic), TOLERANCE,
357                 "Estimation");
358
359         // access in past shall be rejected
360         Instant past = Instant.now().minus(5, ChronoUnit.MINUTES);
361         try {
362             scfo.getPower(past, SolarForecast.OPTIMISTIC);
363             fail();
364         } catch (IllegalArgumentException e) {
365             assertEquals("Solcast argument optimistic only available for future values", e.getMessage(),
366                     "Optimistic Power");
367         }
368         try {
369             scfo.getPower(past, SolarForecast.PESSIMISTIC);
370             fail();
371         } catch (IllegalArgumentException e) {
372             assertEquals("Solcast argument pessimistic only available for future values", e.getMessage(),
373                     "Pessimistic Power");
374         }
375         try {
376             scfo.getPower(past, "total", "rubbish");
377             fail();
378         } catch (IllegalArgumentException e) {
379             assertEquals("Solcast doesn't support 2 arguments", e.getMessage(), "Too many qrguments");
380         }
381         try {
382             scfo.getPower(past.plus(2, ChronoUnit.HOURS), "rubbish");
383             fail();
384         } catch (IllegalArgumentException e) {
385             assertEquals("Solcast doesn't support argument rubbish", e.getMessage(), "Rubbish argument");
386         }
387         try {
388             scfo.getPower(past);
389             fail("Exception expected");
390         } catch (SolarForecastException sfe) {
391             String message = sfe.getMessage();
392             assertNotNull(message);
393             assertTrue(message.contains(TOO_LATE_INDICATOR),
394                     "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
395         }
396     }
397
398     @Test
399     void testInavlid() {
400         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
401         ZonedDateTime now = ZonedDateTime.now(TEST_ZONE);
402         SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
403         try {
404             double d = scfo.getActualEnergyValue(now, QueryMode.Average);
405             fail("Exception expected instead of " + d);
406         } catch (SolarForecastException sfe) {
407             String message = sfe.getMessage();
408             assertNotNull(message);
409             assertTrue(message.contains(TOO_LATE_INDICATOR),
410                     "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
411         }
412         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
413         scfo.join(content);
414         try {
415             double d = scfo.getActualEnergyValue(now, QueryMode.Average);
416             fail("Exception expected instead of " + d);
417         } catch (SolarForecastException sfe) {
418             String message = sfe.getMessage();
419             assertNotNull(message);
420             assertTrue(message.contains(TOO_LATE_INDICATOR),
421                     "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
422         }
423         try {
424             double d = scfo.getDayTotal(now.toLocalDate(), QueryMode.Average);
425             fail("Exception expected instead of " + d);
426         } catch (SolarForecastException sfe) {
427             String message = sfe.getMessage();
428             assertNotNull(message);
429             assertTrue(message.contains(DAY_MISSING_INDICATOR),
430                     "Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
431         }
432     }
433
434     @Test
435     void testPowerInterpolation() {
436         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
437         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 15, 0).atZone(TEST_ZONE);
438         SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
439         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
440         sco.join(content);
441
442         double startValue = sco.getActualPowerValue(now, QueryMode.Average);
443         double endValue = sco.getActualPowerValue(now.plusMinutes(30), QueryMode.Average);
444         for (int i = 0; i < 31; i++) {
445             double interpolation = i / 30.0;
446             double expected = ((1 - interpolation) * startValue) + (interpolation * endValue);
447             assertEquals(expected, sco.getActualPowerValue(now.plusMinutes(i), QueryMode.Average), TOLERANCE,
448                     "Step " + i);
449         }
450     }
451
452     @Test
453     void testEnergyInterpolation() {
454         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
455         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 5, 30).atZone(TEST_ZONE);
456         SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
457         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
458         sco.join(content);
459
460         double maxDiff = 0;
461         double productionExpected = 0;
462         for (int i = 0; i < 1000; i++) {
463             double forecast = sco.getActualEnergyValue(now.plusMinutes(i), QueryMode.Average);
464             double addOnExpected = sco.getActualPowerValue(now.plusMinutes(i), QueryMode.Average) / 60.0;
465             productionExpected += addOnExpected;
466             double diff = forecast - productionExpected;
467             maxDiff = Math.max(diff, maxDiff);
468             assertEquals(productionExpected, sco.getActualEnergyValue(now.plusMinutes(i), QueryMode.Average),
469                     100 * TOLERANCE, "Step " + i);
470         }
471     }
472
473     @Test
474     void testRawChannel() {
475         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
476         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
477         SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
478         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
479         sco.join(content);
480         JSONObject joined = new JSONObject(sco.getRaw());
481         assertTrue(joined.has("forecasts"), "Forecasts available");
482         assertTrue(joined.has("estimated_actuals"), "Actual data available");
483     }
484
485     @Test
486     void testUpdates() {
487         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
488         ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
489         SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
490         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
491         sco.join(content);
492         JSONObject joined = new JSONObject(sco.getRaw());
493         assertTrue(joined.has("forecasts"), "Forecasts available");
494         assertTrue(joined.has("estimated_actuals"), "Actual data available");
495     }
496
497     @Test
498     void testUnitDetection() {
499         assertEquals("kW", SolcastConstants.KILOWATT_UNIT.toString(), "Kilowatt");
500         assertEquals("W", Units.WATT.toString(), "Watt");
501     }
502
503     @Test
504     void testTimes() {
505         String utcTimeString = "2022-07-17T19:30:00.0000000Z";
506         SolcastObject so = new SolcastObject("sc-test", TIMEZONEPROVIDER);
507         ZonedDateTime zdt = so.getZdtFromUTC(utcTimeString);
508         assertNotNull(zdt);
509         assertEquals("2022-07-17T21:30+02:00[Europe/Berlin]", zdt.toString(), "ZonedDateTime");
510         LocalDateTime ldt = zdt.toLocalDateTime();
511         assertEquals("2022-07-17T21:30", ldt.toString(), "LocalDateTime");
512         LocalTime lt = zdt.toLocalTime();
513         assertEquals("21:30", lt.toString(), "LocalTime");
514     }
515
516     @Test
517     void testPowerTimeSeries() {
518         setFixedTimeJul18();
519         Instant now = Instant.now(Utils.getClock());
520         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
521         SolcastObject sco = new SolcastObject("sc-test", content, now, TIMEZONEPROVIDER);
522         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
523         sco.join(content);
524
525         TimeSeries powerSeries = sco.getPowerTimeSeries(QueryMode.Average);
526         List<QuantityType<?>> estimateL = new ArrayList<>();
527         assertEquals(302, powerSeries.size());
528         powerSeries.getStates().forEachOrdered(entry -> {
529             assertTrue(entry.timestamp().isAfter(Instant.now(Utils.getClock())));
530             State s = entry.state();
531             assertTrue(s instanceof QuantityType<?>);
532             assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
533             if (s instanceof QuantityType<?> qt) {
534                 estimateL.add(qt);
535             } else {
536                 fail();
537             }
538         });
539
540         TimeSeries powerSeries10 = sco.getPowerTimeSeries(QueryMode.Pessimistic);
541         List<QuantityType<?>> estimate10 = new ArrayList<>();
542         assertEquals(302, powerSeries10.size());
543         powerSeries10.getStates().forEachOrdered(entry -> {
544             assertTrue(entry.timestamp().isAfter(Instant.now(Utils.getClock())));
545             State s = entry.state();
546             assertTrue(s instanceof QuantityType<?>);
547             assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
548             if (s instanceof QuantityType<?> qt) {
549                 estimate10.add(qt);
550             } else {
551                 fail();
552             }
553         });
554
555         TimeSeries powerSeries90 = sco.getPowerTimeSeries(QueryMode.Optimistic);
556         List<QuantityType<?>> estimate90 = new ArrayList<>();
557         assertEquals(302, powerSeries90.size());
558         powerSeries90.getStates().forEachOrdered(entry -> {
559             assertTrue(entry.timestamp().isAfter(Instant.now(Utils.getClock())));
560             State s = entry.state();
561             assertTrue(s instanceof QuantityType<?>);
562             assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
563             if (s instanceof QuantityType<?> qt) {
564                 estimate90.add(qt);
565             } else {
566                 fail();
567             }
568         });
569
570         for (int i = 0; i < estimateL.size(); i++) {
571             double lowValue = estimate10.get(i).doubleValue();
572             double estValue = estimateL.get(i).doubleValue();
573             double highValue = estimate90.get(i).doubleValue();
574             assertTrue(lowValue <= estValue && estValue <= highValue);
575         }
576     }
577
578     @Test
579     void testEnergyTimeSeries() {
580         setFixedTimeJul18();
581         Instant now = Instant.now(Utils.getClock());
582         String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
583         SolcastObject sco = new SolcastObject("sc-test", content, now, TIMEZONEPROVIDER);
584         content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
585         sco.join(content);
586
587         TimeSeries energySeries = sco.getEnergyTimeSeries(QueryMode.Average);
588         List<QuantityType<?>> estimateL = new ArrayList<>();
589         assertEquals(302, energySeries.size()); // 48 values each day for next 7 days
590         energySeries.getStates().forEachOrdered(entry -> {
591             assertTrue(Utils.isAfterOrEqual(entry.timestamp(), now));
592             State s = entry.state();
593             assertTrue(s instanceof QuantityType<?>);
594             assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
595             if (s instanceof QuantityType<?> qt) {
596                 estimateL.add(qt);
597             } else {
598                 fail();
599             }
600         });
601
602         TimeSeries energySeries10 = sco.getEnergyTimeSeries(QueryMode.Pessimistic);
603         List<QuantityType<?>> estimate10 = new ArrayList<>();
604         assertEquals(302, energySeries10.size()); // 48 values each day for next 7 days
605         energySeries10.getStates().forEachOrdered(entry -> {
606             assertTrue(Utils.isAfterOrEqual(entry.timestamp(), now));
607             State s = entry.state();
608             assertTrue(s instanceof QuantityType<?>);
609             assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
610             if (s instanceof QuantityType<?> qt) {
611                 estimate10.add(qt);
612             } else {
613                 fail();
614             }
615         });
616
617         TimeSeries energySeries90 = sco.getEnergyTimeSeries(QueryMode.Optimistic);
618         List<QuantityType<?>> estimate90 = new ArrayList<>();
619         assertEquals(302, energySeries90.size()); // 48 values each day for next 7 days
620         energySeries90.getStates().forEachOrdered(entry -> {
621             assertTrue(Utils.isAfterOrEqual(entry.timestamp(), now));
622             State s = entry.state();
623             assertTrue(s instanceof QuantityType<?>);
624             assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
625             if (s instanceof QuantityType<?> qt) {
626                 estimate90.add(qt);
627             } else {
628                 fail();
629             }
630         });
631
632         for (int i = 0; i < estimateL.size(); i++) {
633             double lowValue = estimate10.get(i).doubleValue();
634             double estValue = estimateL.get(i).doubleValue();
635             double highValue = estimate90.get(i).doubleValue();
636             assertTrue(lowValue <= estValue && estValue <= highValue);
637         }
638     }
639
640     @Test
641     void testCombinedPowerTimeSeries() {
642         setFixedTimeJul18();
643         BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
644         SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
645         bi.setHandler(scbh);
646         CallbackMock cm = new CallbackMock();
647         scbh.setCallback(cm);
648         SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
649         CallbackMock cm1 = new CallbackMock();
650         scph1.initialize();
651         scph1.setCallback(cm1);
652         scbh.getData();
653
654         SolcastPlaneHandler scph2 = new SolcastPlaneMock(bi);
655         CallbackMock cm2 = new CallbackMock();
656         scph2.initialize();
657         scph2.setCallback(cm2);
658         scbh.getData();
659
660         TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#power-estimate");
661         TimeSeries ts2 = cm2.getTimeSeries("solarforecast:sc-plane:thing:average#power-estimate");
662         assertEquals(302, ts1.size(), "TimeSeries size");
663         assertEquals(302, ts2.size(), "TimeSeries size");
664         Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
665         Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
666         while (iter1.hasNext()) {
667             TimeSeries.Entry e1 = iter1.next();
668             TimeSeries.Entry e2 = iter2.next();
669             assertEquals("kW", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
670             assertEquals("kW", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
671             assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() * 2,
672                     0.01, "Power Value");
673         }
674         scbh.dispose();
675         scph1.dispose();
676         scph2.dispose();
677     }
678
679     @Test
680     void testCombinedEnergyTimeSeries() {
681         setFixedTimeJul18();
682         BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
683         SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
684         bi.setHandler(scbh);
685         CallbackMock cm = new CallbackMock();
686         scbh.setCallback(cm);
687
688         SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
689         CallbackMock cm1 = new CallbackMock();
690         scph1.initialize();
691         scph1.setCallback(cm1);
692
693         SolcastPlaneHandler scph2 = new SolcastPlaneMock(bi);
694         CallbackMock cm2 = new CallbackMock();
695         scph2.initialize();
696         scph2.setCallback(cm2);
697
698         // simulate trigger of refresh job
699         scbh.getData();
700
701         TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#energy-estimate");
702         TimeSeries ts2 = cm2.getTimeSeries("solarforecast:sc-plane:thing:average#energy-estimate");
703         assertEquals(302, ts1.size(), "TimeSeries size");
704         assertEquals(302, ts2.size(), "TimeSeries size");
705
706         Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
707         Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
708         while (iter1.hasNext()) {
709             TimeSeries.Entry e1 = iter1.next();
710             TimeSeries.Entry e2 = iter2.next();
711             assertEquals("kWh", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
712             assertEquals("kWh", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
713             assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() * 2,
714                     0.1, "Power Value");
715         }
716         scbh.dispose();
717         scph1.dispose();
718         scph2.dispose();
719     }
720
721     @Test
722     void testSingleEnergyTimeSeries() {
723         setFixedTimeJul18();
724         BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
725         SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
726         bi.setHandler(scbh);
727         CallbackMock cm = new CallbackMock();
728         scbh.setCallback(cm);
729
730         SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
731         CallbackMock cm1 = new CallbackMock();
732         scph1.initialize();
733         scph1.setCallback(cm1);
734
735         // simulate trigger of refresh job
736         scbh.getData();
737
738         TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#energy-estimate");
739         assertEquals(302, ts1.size(), "TimeSeries size");
740         Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
741         while (iter1.hasNext()) {
742             TimeSeries.Entry e1 = iter1.next();
743             assertEquals("kWh", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
744         }
745     }
746 }