]> git.basschouten.com Git - openhab-addons.git/blob
1055e6e6b1ad7a8ab2039a4556c07624da53c474
[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.internal.solcast;
14
15 import java.time.Duration;
16 import java.time.Instant;
17 import java.time.LocalDate;
18 import java.time.ZonedDateTime;
19 import java.time.format.DateTimeFormatter;
20 import java.time.format.DateTimeParseException;
21 import java.time.temporal.ChronoUnit;
22 import java.util.Arrays;
23 import java.util.Map.Entry;
24 import java.util.Optional;
25 import java.util.TreeMap;
26
27 import javax.measure.quantity.Energy;
28 import javax.measure.quantity.Power;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.json.JSONArray;
33 import org.json.JSONObject;
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.utils.Utils;
38 import org.openhab.core.i18n.TimeZoneProvider;
39 import org.openhab.core.library.types.QuantityType;
40 import org.openhab.core.types.TimeSeries;
41 import org.openhab.core.types.TimeSeries.Policy;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
44
45 /**
46  * The {@link SolcastObject} holds complete data for forecast
47  *
48  * @author Bernd Weymann - Initial contribution
49  * @author Bernd Weymann - TimeSeries delivers only future values, otherwise past values are overwritten
50  */
51 @NonNullByDefault
52 public class SolcastObject implements SolarForecast {
53     private static final TreeMap<ZonedDateTime, Double> EMPTY_MAP = new TreeMap<>();
54
55     private final Logger logger = LoggerFactory.getLogger(SolcastObject.class);
56     private final TreeMap<ZonedDateTime, Double> estimationDataMap = new TreeMap<>();
57     private final TreeMap<ZonedDateTime, Double> optimisticDataMap = new TreeMap<>();
58     private final TreeMap<ZonedDateTime, Double> pessimisticDataMap = new TreeMap<>();
59     private final TimeZoneProvider timeZoneProvider;
60
61     private DateTimeFormatter dateOutputFormatter;
62     private String identifier;
63     private Optional<JSONObject> rawData = Optional.of(new JSONObject());
64     private Instant expirationDateTime;
65     private long period = 30;
66
67     public enum QueryMode {
68         Average(SolarForecast.AVERAGE),
69         Optimistic(SolarForecast.OPTIMISTIC),
70         Pessimistic(SolarForecast.PESSIMISTIC),
71         Error("Error");
72
73         String modeDescirption;
74
75         QueryMode(String description) {
76             modeDescirption = description;
77         }
78
79         @Override
80         public String toString() {
81             return modeDescirption;
82         }
83     }
84
85     public SolcastObject(String id, TimeZoneProvider tzp) {
86         // invalid forecast object
87         identifier = id;
88         timeZoneProvider = tzp;
89         dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
90                 .withZone(tzp.getTimeZone());
91         expirationDateTime = Instant.now().minusSeconds(1);
92     }
93
94     public SolcastObject(String id, String content, Instant expiration, TimeZoneProvider tzp) {
95         identifier = id;
96         expirationDateTime = expiration;
97         timeZoneProvider = tzp;
98         dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
99                 .withZone(tzp.getTimeZone());
100         add(content);
101     }
102
103     public void join(String content) {
104         add(content);
105     }
106
107     private void add(String content) {
108         if (!content.isEmpty()) {
109             JSONObject contentJson = new JSONObject(content);
110             JSONArray resultJsonArray;
111
112             // prepare data for raw channel
113             if (contentJson.has("forecasts")) {
114                 resultJsonArray = contentJson.getJSONArray("forecasts");
115                 addJSONArray(resultJsonArray);
116                 rawData.get().put("forecasts", resultJsonArray);
117             }
118             if (contentJson.has("estimated_actuals")) {
119                 resultJsonArray = contentJson.getJSONArray("estimated_actuals");
120                 addJSONArray(resultJsonArray);
121                 rawData.get().put("estimated_actuals", resultJsonArray);
122             }
123         }
124     }
125
126     private void addJSONArray(JSONArray resultJsonArray) {
127         // sort data into TreeMaps
128         for (int i = 0; i < resultJsonArray.length(); i++) {
129             JSONObject jo = resultJsonArray.getJSONObject(i);
130             String periodEnd = jo.getString("period_end");
131             ZonedDateTime periodEndZdt = getZdtFromUTC(periodEnd);
132             if (periodEndZdt == null) {
133                 return;
134             }
135             estimationDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
136
137             // fill pessimistic values
138             if (jo.has("pv_estimate10")) {
139                 pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate10"));
140             } else {
141                 pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
142             }
143
144             // fill optimistic values
145             if (jo.has("pv_estimate90")) {
146                 optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate90"));
147             } else {
148                 optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
149             }
150             if (jo.has("period")) {
151                 period = Duration.parse(jo.getString("period")).toMinutes();
152             }
153         }
154     }
155
156     public boolean isExpired() {
157         return expirationDateTime.isBefore(Instant.now());
158     }
159
160     public double getActualEnergyValue(ZonedDateTime query, QueryMode mode) {
161         // calculate energy from day begin to latest entry BEFORE query
162         ZonedDateTime iterationDateTime = query.withHour(0).withMinute(0).withSecond(0);
163         TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
164         Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
165         if (nextEntry == null) {
166             throwOutOfRangeException(query.toInstant());
167             return -1;
168         }
169         double forecastValue = 0;
170         double previousEstimate = 0;
171         while (nextEntry.getKey().isBefore(query) || nextEntry.getKey().isEqual(query)) {
172             // value are reported in PT30M = 30 minutes interval with kw value
173             // for kw/h it's half the value
174             Double endValue = nextEntry.getValue();
175             // production during period is half of previous and next value
176             double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
177             forecastValue += addedValue;
178             previousEstimate = endValue.doubleValue();
179             iterationDateTime = nextEntry.getKey();
180             nextEntry = dtm.higherEntry(iterationDateTime);
181             if (nextEntry == null) {
182                 break;
183             }
184         }
185         // interpolate minutes AFTER query
186         Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
187         Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
188         if (f != null) {
189             if (c != null) {
190                 long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
191                 // floor == ceiling: no addon calculation needed
192                 if (duration == 0) {
193                     return forecastValue;
194                 }
195                 if (c.getValue() > 0) {
196                     double interpolation = Duration.between(f.getKey(), query).toMinutes() / 60.0;
197                     double interpolationProduction = getActualPowerValue(query, mode) * interpolation;
198                     forecastValue += interpolationProduction;
199                     return forecastValue;
200                 } else {
201                     // if ceiling value is 0 there's no further production in this period
202                     return forecastValue;
203                 }
204             } else {
205                 // if ceiling is null we're at the very end of the day
206                 return forecastValue;
207             }
208         } else {
209             // if floor is null we're at the very beginning of the day => 0
210             return 0;
211         }
212     }
213
214     @Override
215     public TimeSeries getEnergyTimeSeries(QueryMode mode) {
216         TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
217         TimeSeries ts = new TimeSeries(Policy.REPLACE);
218         Instant now = Instant.now(Utils.getClock());
219         dtm.forEach((timestamp, energy) -> {
220             Instant entryTimestamp = timestamp.toInstant();
221             if (Utils.isAfterOrEqual(entryTimestamp, now)) {
222                 ts.add(entryTimestamp, Utils.getEnergyState(getActualEnergyValue(timestamp, mode)));
223             }
224         });
225         return ts;
226     }
227
228     /**
229      * Get power values
230      */
231     public double getActualPowerValue(ZonedDateTime query, QueryMode mode) {
232         if (query.toInstant().isBefore(getForecastBegin()) || query.toInstant().isAfter(getForecastEnd())) {
233             throwOutOfRangeException(query.toInstant());
234         }
235         TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
236         double actualPowerValue = 0;
237         Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
238         Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
239         if (f != null) {
240             if (c != null) {
241                 double powerCeiling = c.getValue();
242                 long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
243                 // floor == ceiling: return power from node, no interpolation needed
244                 if (duration == 0) {
245                     return powerCeiling;
246                 }
247                 if (powerCeiling > 0) {
248                     double powerFloor = f.getValue();
249                     // calculate in minutes from floor to now, e.g. 20 minutes from PT30M 30 minutes
250                     // => take 1/3 of floor and 2/3 of ceiling
251                     double interpolation = Duration.between(f.getKey(), query).toMinutes() / (double) period;
252                     actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
253                     return actualPowerValue;
254                 } else {
255                     // if power ceiling == 0 there's no production in this period
256                     return 0;
257                 }
258             } else {
259                 // if ceiling is null we're at the very end of this day => 0
260                 return 0;
261             }
262         } else {
263             // if floor is null we're at the very beginning of this day => 0
264             return 0;
265         }
266     }
267
268     @Override
269     public TimeSeries getPowerTimeSeries(QueryMode mode) {
270         TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
271         TimeSeries ts = new TimeSeries(Policy.REPLACE);
272         Instant now = Instant.now(Utils.getClock());
273         dtm.forEach((timestamp, power) -> {
274             Instant entryTimestamp = timestamp.toInstant();
275             if (Utils.isAfterOrEqual(entryTimestamp, now)) {
276                 ts.add(entryTimestamp, Utils.getPowerState(power));
277             }
278         });
279         return ts;
280     }
281
282     /**
283      * Daily totals
284      */
285     public double getDayTotal(LocalDate query, QueryMode mode) {
286         TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
287         ZonedDateTime iterationDateTime = query.atStartOfDay(timeZoneProvider.getTimeZone());
288         Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
289         if (nextEntry == null) {
290             throw new SolarForecastException(this, "Day " + query + " not available in forecast. " + getTimeRange());
291         }
292         ZonedDateTime endDateTime = iterationDateTime.plusDays(1);
293         double forecastValue = 0;
294         double previousEstimate = 0;
295         while (nextEntry.getKey().isBefore(endDateTime)) {
296             // value are reported in PT30M = 30 minutes interval with kw value
297             // for kw/h it's half the value
298             Double endValue = nextEntry.getValue();
299             // production during period is half of previous and next value
300             double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
301             forecastValue += addedValue;
302             previousEstimate = endValue.doubleValue();
303             iterationDateTime = nextEntry.getKey();
304             nextEntry = dtm.higherEntry(iterationDateTime);
305             if (nextEntry == null) {
306                 break;
307             }
308         }
309         return forecastValue;
310     }
311
312     public double getRemainingProduction(ZonedDateTime query, QueryMode mode) {
313         return getDayTotal(query.toLocalDate(), mode) - getActualEnergyValue(query, mode);
314     }
315
316     @Override
317     public String toString() {
318         return "Expiration: " + expirationDateTime + ", Data: " + estimationDataMap;
319     }
320
321     public String getRaw() {
322         if (rawData.isPresent()) {
323             return rawData.get().toString();
324         }
325         return "{}";
326     }
327
328     private TreeMap<ZonedDateTime, Double> getDataMap(QueryMode mode) {
329         TreeMap<ZonedDateTime, Double> returnMap = EMPTY_MAP;
330         switch (mode) {
331             case Average:
332                 returnMap = estimationDataMap;
333                 break;
334             case Optimistic:
335                 returnMap = optimisticDataMap;
336                 break;
337             case Pessimistic:
338                 returnMap = pessimisticDataMap;
339                 break;
340             case Error:
341                 // nothing to do
342                 break;
343             default:
344                 // nothing to do
345                 break;
346         }
347         return returnMap;
348     }
349
350     public @Nullable ZonedDateTime getZdtFromUTC(String utc) {
351         try {
352             Instant timestamp = Instant.parse(utc);
353             return timestamp.atZone(timeZoneProvider.getTimeZone());
354         } catch (DateTimeParseException dtpe) {
355             logger.warn("Exception parsing time {} Reason: {}", utc, dtpe.getMessage());
356         }
357         return null;
358     }
359
360     /**
361      * SolarForecast Interface
362      */
363     @Override
364     public QuantityType<Energy> getDay(LocalDate date, String... args) throws IllegalArgumentException {
365         QueryMode mode = evalArguments(args);
366         if (mode.equals(QueryMode.Error)) {
367             if (args.length > 1) {
368                 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
369             } else {
370                 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
371             }
372         } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
373             if (date.isBefore(LocalDate.now())) {
374                 throw new IllegalArgumentException(
375                         "Solcast argument " + mode.toString() + " only available for future values");
376             }
377         }
378         double measure = getDayTotal(date, mode);
379         return Utils.getEnergyState(measure);
380     }
381
382     @Override
383     public QuantityType<Energy> getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
384         if (end.isBefore(start)) {
385             if (args.length > 1) {
386                 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
387             } else {
388                 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
389             }
390         }
391         QueryMode mode = evalArguments(args);
392         if (mode.equals(QueryMode.Error)) {
393             return Utils.getEnergyState(-1);
394         } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
395             if (end.isBefore(Instant.now())) {
396                 throw new IllegalArgumentException(
397                         "Solcast argument " + mode.toString() + " only available for future values");
398             }
399         }
400         LocalDate beginDate = start.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
401         LocalDate endDate = end.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
402         double measure = -1;
403         if (beginDate.isEqual(endDate)) {
404             measure = getDayTotal(beginDate, mode)
405                     - getActualEnergyValue(start.atZone(timeZoneProvider.getTimeZone()), mode)
406                     - getRemainingProduction(end.atZone(timeZoneProvider.getTimeZone()), mode);
407         } else {
408             measure = getRemainingProduction(start.atZone(timeZoneProvider.getTimeZone()), mode);
409             beginDate = beginDate.plusDays(1);
410             while (beginDate.isBefore(endDate) && measure >= 0) {
411                 double day = getDayTotal(beginDate, mode);
412                 if (day > 0) {
413                     measure += day;
414                 }
415                 beginDate = beginDate.plusDays(1);
416             }
417             double lastDay = getActualEnergyValue(end.atZone(timeZoneProvider.getTimeZone()), mode);
418             if (lastDay >= 0) {
419                 measure += lastDay;
420             }
421         }
422         return Utils.getEnergyState(measure);
423     }
424
425     @Override
426     public QuantityType<Power> getPower(Instant timestamp, String... args) throws IllegalArgumentException {
427         // eliminate error cases and return immediately
428         QueryMode mode = evalArguments(args);
429         if (mode.equals(QueryMode.Error)) {
430             if (args.length > 1) {
431                 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
432             } else {
433                 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
434             }
435         } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
436             if (timestamp.isBefore(Instant.now().minus(1, ChronoUnit.MINUTES))) {
437                 throw new IllegalArgumentException(
438                         "Solcast argument " + mode.toString() + " only available for future values");
439             }
440         }
441         double measure = getActualPowerValue(ZonedDateTime.ofInstant(timestamp, timeZoneProvider.getTimeZone()), mode);
442         return Utils.getPowerState(measure);
443     }
444
445     @Override
446     public Instant getForecastBegin() {
447         if (!estimationDataMap.isEmpty()) {
448             return estimationDataMap.firstEntry().getKey().toInstant();
449         }
450         return Instant.MAX;
451     }
452
453     @Override
454     public Instant getForecastEnd() {
455         if (!estimationDataMap.isEmpty()) {
456             return estimationDataMap.lastEntry().getKey().toInstant();
457         }
458         return Instant.MIN;
459     }
460
461     private QueryMode evalArguments(String[] args) {
462         if (args.length > 0) {
463             if (args.length > 1) {
464                 logger.info("Too many arguments {}", Arrays.toString(args));
465                 return QueryMode.Error;
466             }
467
468             if (SolarForecast.OPTIMISTIC.equals(args[0])) {
469                 return QueryMode.Optimistic;
470             } else if (SolarForecast.PESSIMISTIC.equals(args[0])) {
471                 return QueryMode.Pessimistic;
472             } else if (SolarForecast.AVERAGE.equals(args[0])) {
473                 return QueryMode.Average;
474             } else {
475                 logger.info("Argument {} not supported", args[0]);
476                 return QueryMode.Error;
477             }
478         } else {
479             return QueryMode.Average;
480         }
481     }
482
483     @Override
484     public String getIdentifier() {
485         return identifier;
486     }
487
488     private void throwOutOfRangeException(Instant query) {
489         if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
490             throw new SolarForecastException(this, "Forecast invalid time range");
491         }
492         if (query.isBefore(getForecastBegin())) {
493             throw new SolarForecastException(this,
494                     "Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
495         } else if (query.isAfter(getForecastEnd())) {
496             throw new SolarForecastException(this,
497                     "Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
498         } else {
499             logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
500         }
501     }
502
503     private String getTimeRange() {
504         return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
505                 + dateOutputFormatter.format(getForecastEnd());
506     }
507 }