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