2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.solarforecast.internal.forecastsolar;
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;
28 import javax.measure.quantity.Energy;
29 import javax.measure.quantity.Power;
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;
46 * The {@link ForecastSolarObject} holds complete data for forecast
48 * @author Bernd Weymann - Initial contribution
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");
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;
64 public ForecastSolarObject(String id) {
65 expirationDateTime = Instant.now().minusSeconds(1);
69 public ForecastSolarObject(String id, String content, Instant expirationDate) throws SolarForecastException {
70 expirationDateTime = expirationDate;
72 if (!content.isEmpty()) {
73 rawData = Optional.of(content);
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)
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
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());
98 } catch (JSONException je) {
99 throw new SolarForecastException(this,
100 "Error parsing JSON response " + content + " Reason: " + je.getMessage());
105 public boolean isExpired() {
106 return expirationDateTime.isBefore(Instant.now());
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;
118 // floor date doesn't fit
119 throwOutOfRangeException(queryDateTime.toInstant());
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
126 // ceiling date doesn't fit
127 throwOutOfRangeException(queryDateTime.toInstant());
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;
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;
145 // ceiling from wrong date, but floor is valid
146 return f.getValue() / 1000.0;
149 // floor invalid - ceiling not reached
152 } // else both null - date time doesn't fit to forecast data
153 throwOutOfRangeException(queryDateTime.toInstant());
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));
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;
176 // floor date doesn't fit
177 throwOutOfRangeException(queryDateTime.toInstant());
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
184 // ceiling date doesn't fit
185 throwOutOfRangeException(queryDateTime.toInstant());
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;
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());
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));
215 public double getDayTotal(LocalDate queryDate) {
216 if (rawData.isEmpty()) {
217 throw new SolarForecastException(this, "No forecast data available");
219 JSONObject contentJson = new JSONObject(rawData.get());
220 JSONObject resultJson = contentJson.getJSONObject("result");
221 JSONObject wattsDay = resultJson.getJSONObject("watt_hours_day");
223 if (wattsDay.has(queryDate.toString())) {
224 return wattsDay.getDouble(queryDate.toString()) / 1000.0;
226 throw new SolarForecastException(this,
227 "Day " + queryDate + " not available in forecast. " + getTimeRange());
231 public double getRemainingProduction(ZonedDateTime queryDateTime) {
232 double daily = getDayTotal(queryDateTime.toLocalDate());
233 double actual = getActualEnergyValue(queryDateTime);
234 return daily - actual;
237 public String getRaw() {
238 if (rawData.isPresent()) {
239 return rawData.get();
244 public ZoneId getZone() {
249 public String toString() {
250 return "Expiration: " + expirationDateTime + ", Data:" + wattHourMap;
254 * SolarForecast Interface
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");
261 double measure = getDayTotal(localDate);
262 return Utils.getEnergyState(measure);
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");
270 LocalDate beginDate = start.atZone(zone).toLocalDate();
271 LocalDate endDate = end.atZone(zone).toLocalDate();
273 if (beginDate.equals(endDate)) {
274 measure = getDayTotal(beginDate) - getActualEnergyValue(start.atZone(zone))
275 - getRemainingProduction(end.atZone(zone));
277 measure = getRemainingProduction(start.atZone(zone));
278 beginDate = beginDate.plusDays(1);
279 while (beginDate.isBefore(endDate) && measure >= 0) {
280 double day = getDayTotal(beginDate);
284 beginDate = beginDate.plusDays(1);
286 double lastDay = getActualEnergyValue(end.atZone(zone));
291 return Utils.getEnergyState(measure);
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");
299 double measure = getActualPowerValue(timestamp.atZone(zone));
300 return Utils.getPowerState(measure);
304 public Instant getForecastBegin() {
305 if (wattHourMap.isEmpty()) {
308 ZonedDateTime zdt = wattHourMap.firstEntry().getKey();
309 return zdt.toInstant();
313 public Instant getForecastEnd() {
314 if (wattHourMap.isEmpty()) {
317 ZonedDateTime zdt = wattHourMap.lastEntry().getKey();
318 return zdt.toInstant();
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");
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());
332 logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
336 private String getTimeRange() {
337 return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
338 + dateOutputFormatter.format(getForecastEnd());
342 public String getIdentifier() {