]> git.basschouten.com Git - openhab-addons.git/blob
de15a50c063697a9977bfbf1a1129ff3942046dd
[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.forecastsolar;
14
15 import java.time.Duration;
16 import java.time.Instant;
17 import java.time.LocalDate;
18 import java.time.LocalDateTime;
19 import java.time.ZoneId;
20 import java.time.ZonedDateTime;
21 import java.time.format.DateTimeFormatter;
22 import java.time.format.DateTimeParseException;
23 import java.util.Iterator;
24 import java.util.Map.Entry;
25 import java.util.Optional;
26 import java.util.TreeMap;
27
28 import javax.measure.quantity.Energy;
29 import javax.measure.quantity.Power;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.json.JSONException;
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.solcast.SolcastObject.QueryMode;
38 import org.openhab.binding.solarforecast.internal.utils.Utils;
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 ForecastSolarObject} holds complete data for forecast
47  *
48  * @author Bernd Weymann - Initial contribution
49  */
50 @NonNullByDefault
51 public class ForecastSolarObject implements SolarForecast {
52     private final Logger logger = LoggerFactory.getLogger(ForecastSolarObject.class);
53     private final TreeMap<ZonedDateTime, Double> wattHourMap = new TreeMap<>();
54     private final TreeMap<ZonedDateTime, Double> wattMap = new TreeMap<>();
55     private final DateTimeFormatter dateInputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
56
57     private DateTimeFormatter dateOutputFormatter = DateTimeFormatter
58             .ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT).withZone(ZoneId.systemDefault());
59     private ZoneId zone = ZoneId.systemDefault();
60     private Optional<String> rawData = Optional.empty();
61     private Instant expirationDateTime;
62     private String identifier;
63
64     public ForecastSolarObject(String id) {
65         expirationDateTime = Instant.now().minusSeconds(1);
66         identifier = id;
67     }
68
69     public ForecastSolarObject(String id, String content, Instant expirationDate) throws SolarForecastException {
70         expirationDateTime = expirationDate;
71         identifier = id;
72         if (!content.isEmpty()) {
73             rawData = Optional.of(content);
74             try {
75                 JSONObject contentJson = new JSONObject(content);
76                 JSONObject resultJson = contentJson.getJSONObject("result");
77                 JSONObject wattHourJson = resultJson.getJSONObject("watt_hours");
78                 JSONObject wattJson = resultJson.getJSONObject("watts");
79                 String zoneStr = contentJson.getJSONObject("message").getJSONObject("info").getString("timezone");
80                 zone = ZoneId.of(zoneStr);
81                 dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
82                         .withZone(zone);
83                 Iterator<String> iter = wattHourJson.keys();
84                 // put all values of the current day into sorted tree map
85                 while (iter.hasNext()) {
86                     String dateStr = iter.next();
87                     // convert date time into machine readable format
88                     try {
89                         ZonedDateTime zdt = LocalDateTime.parse(dateStr, dateInputFormatter).atZone(zone);
90                         wattHourMap.put(zdt, wattHourJson.getDouble(dateStr));
91                         wattMap.put(zdt, wattJson.getDouble(dateStr));
92                     } catch (DateTimeParseException dtpe) {
93                         logger.warn("Error parsing time {} Reason: {}", dateStr, dtpe.getMessage());
94                         throw new SolarForecastException(this,
95                                 "Error parsing time " + dateStr + " Reason: " + dtpe.getMessage());
96                     }
97                 }
98             } catch (JSONException je) {
99                 throw new SolarForecastException(this,
100                         "Error parsing JSON response " + content + " Reason: " + je.getMessage());
101             }
102         }
103     }
104
105     public boolean isExpired() {
106         return expirationDateTime.isBefore(Instant.now());
107     }
108
109     public double getActualEnergyValue(ZonedDateTime queryDateTime) throws SolarForecastException {
110         Entry<ZonedDateTime, Double> f = wattHourMap.floorEntry(queryDateTime);
111         Entry<ZonedDateTime, Double> c = wattHourMap.ceilingEntry(queryDateTime);
112         if (f != null && c == null) {
113             // only floor available
114             if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
115                 // floor has valid date
116                 return f.getValue() / 1000.0;
117             } else {
118                 // floor date doesn't fit
119                 throwOutOfRangeException(queryDateTime.toInstant());
120             }
121         } else if (f == null && c != null) {
122             if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
123                 // only ceiling from correct date available - no valid data reached yet
124                 return 0;
125             } else {
126                 // ceiling date doesn't fit
127                 throwOutOfRangeException(queryDateTime.toInstant());
128             }
129         } else if (f != null && c != null) {
130             // ceiling and floor available
131             if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
132                 if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
133                     // we're during suntime!
134                     double production = c.getValue() - f.getValue();
135                     long floorToCeilingDuration = Duration.between(f.getKey(), c.getKey()).toMinutes();
136                     if (floorToCeilingDuration == 0) {
137                         return f.getValue() / 1000.0;
138                     }
139                     long floorToQueryDuration = Duration.between(f.getKey(), queryDateTime).toMinutes();
140                     double interpolation = (double) floorToQueryDuration / (double) floorToCeilingDuration;
141                     double interpolationProduction = production * interpolation;
142                     double actualProduction = f.getValue() + interpolationProduction;
143                     return actualProduction / 1000.0;
144                 } else {
145                     // ceiling from wrong date, but floor is valid
146                     return f.getValue() / 1000.0;
147                 }
148             } else {
149                 // floor invalid - ceiling not reached
150                 return 0;
151             }
152         } // else both null - date time doesn't fit to forecast data
153         throwOutOfRangeException(queryDateTime.toInstant());
154         return -1;
155     }
156
157     @Override
158     public TimeSeries getEnergyTimeSeries(QueryMode mode) {
159         TimeSeries ts = new TimeSeries(Policy.REPLACE);
160         wattHourMap.forEach((timestamp, energy) -> {
161             ts.add(timestamp.toInstant(), Utils.getEnergyState(energy / 1000.0));
162         });
163         return ts;
164     }
165
166     public double getActualPowerValue(ZonedDateTime queryDateTime) {
167         double actualPowerValue = 0;
168         Entry<ZonedDateTime, Double> f = wattMap.floorEntry(queryDateTime);
169         Entry<ZonedDateTime, Double> c = wattMap.ceilingEntry(queryDateTime);
170         if (f != null && c == null) {
171             // only floor available
172             if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
173                 // floor has valid date
174                 return f.getValue() / 1000.0;
175             } else {
176                 // floor date doesn't fit
177                 throwOutOfRangeException(queryDateTime.toInstant());
178             }
179         } else if (f == null && c != null) {
180             if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
181                 // only ceiling from correct date available - no valid data reached yet
182                 return 0;
183             } else {
184                 // ceiling date doesn't fit
185                 throwOutOfRangeException(queryDateTime.toInstant());
186             }
187         } else if (f != null && c != null) {
188             // we're during suntime!
189             long floorToCeilingDuration = Duration.between(f.getKey(), c.getKey()).toMinutes();
190             double powerFloor = f.getValue();
191             if (floorToCeilingDuration == 0) {
192                 return powerFloor / 1000.0;
193             }
194             double powerCeiling = c.getValue();
195             // calculate in minutes from floor to now, e.g. 20 minutes
196             // => take 2/3 of floor and 1/3 of ceiling
197             long floorToQueryDuration = Duration.between(f.getKey(), queryDateTime).toMinutes();
198             double interpolation = (double) floorToQueryDuration / (double) floorToCeilingDuration;
199             actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
200             return actualPowerValue / 1000.0;
201         } // else both null - this shall not happen
202         throwOutOfRangeException(queryDateTime.toInstant());
203         return -1;
204     }
205
206     @Override
207     public TimeSeries getPowerTimeSeries(QueryMode mode) {
208         TimeSeries ts = new TimeSeries(Policy.REPLACE);
209         wattMap.forEach((timestamp, power) -> {
210             ts.add(timestamp.toInstant(), Utils.getPowerState(power / 1000.0));
211         });
212         return ts;
213     }
214
215     public double getDayTotal(LocalDate queryDate) {
216         if (rawData.isEmpty()) {
217             throw new SolarForecastException(this, "No forecast data available");
218         }
219         JSONObject contentJson = new JSONObject(rawData.get());
220         JSONObject resultJson = contentJson.getJSONObject("result");
221         JSONObject wattsDay = resultJson.getJSONObject("watt_hours_day");
222
223         if (wattsDay.has(queryDate.toString())) {
224             return wattsDay.getDouble(queryDate.toString()) / 1000.0;
225         } else {
226             throw new SolarForecastException(this,
227                     "Day " + queryDate + " not available in forecast. " + getTimeRange());
228         }
229     }
230
231     public double getRemainingProduction(ZonedDateTime queryDateTime) {
232         double daily = getDayTotal(queryDateTime.toLocalDate());
233         double actual = getActualEnergyValue(queryDateTime);
234         return daily - actual;
235     }
236
237     public String getRaw() {
238         if (rawData.isPresent()) {
239             return rawData.get();
240         }
241         return "{}";
242     }
243
244     public ZoneId getZone() {
245         return zone;
246     }
247
248     @Override
249     public String toString() {
250         return "Expiration: " + expirationDateTime + ", Data:" + wattHourMap;
251     }
252
253     /**
254      * SolarForecast Interface
255      */
256     @Override
257     public QuantityType<Energy> getDay(LocalDate localDate, String... args) throws IllegalArgumentException {
258         if (args.length > 0) {
259             throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
260         }
261         double measure = getDayTotal(localDate);
262         return Utils.getEnergyState(measure);
263     }
264
265     @Override
266     public QuantityType<Energy> getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
267         if (args.length > 0) {
268             throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
269         }
270         LocalDate beginDate = start.atZone(zone).toLocalDate();
271         LocalDate endDate = end.atZone(zone).toLocalDate();
272         double measure = -1;
273         if (beginDate.equals(endDate)) {
274             measure = getDayTotal(beginDate) - getActualEnergyValue(start.atZone(zone))
275                     - getRemainingProduction(end.atZone(zone));
276         } else {
277             measure = getRemainingProduction(start.atZone(zone));
278             beginDate = beginDate.plusDays(1);
279             while (beginDate.isBefore(endDate) && measure >= 0) {
280                 double day = getDayTotal(beginDate);
281                 if (day > 0) {
282                     measure += day;
283                 }
284                 beginDate = beginDate.plusDays(1);
285             }
286             double lastDay = getActualEnergyValue(end.atZone(zone));
287             if (lastDay >= 0) {
288                 measure += lastDay;
289             }
290         }
291         return Utils.getEnergyState(measure);
292     }
293
294     @Override
295     public QuantityType<Power> getPower(Instant timestamp, String... args) throws IllegalArgumentException {
296         if (args.length > 0) {
297             throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
298         }
299         double measure = getActualPowerValue(timestamp.atZone(zone));
300         return Utils.getPowerState(measure);
301     }
302
303     @Override
304     public Instant getForecastBegin() {
305         if (wattHourMap.isEmpty()) {
306             return Instant.MAX;
307         }
308         ZonedDateTime zdt = wattHourMap.firstEntry().getKey();
309         return zdt.toInstant();
310     }
311
312     @Override
313     public Instant getForecastEnd() {
314         if (wattHourMap.isEmpty()) {
315             return Instant.MIN;
316         }
317         ZonedDateTime zdt = wattHourMap.lastEntry().getKey();
318         return zdt.toInstant();
319     }
320
321     private void throwOutOfRangeException(Instant query) {
322         if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
323             throw new SolarForecastException(this, "Forecast invalid time range");
324         }
325         if (query.isBefore(getForecastBegin())) {
326             throw new SolarForecastException(this,
327                     "Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
328         } else if (query.isAfter(getForecastEnd())) {
329             throw new SolarForecastException(this,
330                     "Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
331         } else {
332             logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
333         }
334     }
335
336     private String getTimeRange() {
337         return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
338                 + dateOutputFormatter.format(getForecastEnd());
339     }
340
341     @Override
342     public String getIdentifier() {
343         return identifier;
344     }
345 }