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