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