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
49 * @author Bernd Weymann - TimeSeries delivers only future values, otherwise past values are overwritten
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");
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;
65 public ForecastSolarObject(String id) {
66 expirationDateTime = Instant.now().minusSeconds(1);
70 public ForecastSolarObject(String id, String content, Instant expirationDate) throws SolarForecastException {
71 expirationDateTime = expirationDate;
73 if (!content.isEmpty()) {
74 rawData = Optional.of(content);
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)
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
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());
99 } catch (JSONException je) {
100 throw new SolarForecastException(this,
101 "Error parsing JSON response " + content + " Reason: " + je.getMessage());
106 public boolean isExpired() {
107 return expirationDateTime.isBefore(Instant.now());
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;
119 // floor date doesn't fit
120 throwOutOfRangeException(queryDateTime.toInstant());
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
127 // ceiling date doesn't fit
128 throwOutOfRangeException(queryDateTime.toInstant());
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;
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;
146 // ceiling from wrong date, but floor is valid
147 return f.getValue() / 1000.0;
150 // floor invalid - ceiling not reached
153 } // else both null - date time doesn't fit to forecast data
154 throwOutOfRangeException(queryDateTime.toInstant());
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));
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;
181 // floor date doesn't fit
182 throwOutOfRangeException(queryDateTime.toInstant());
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
189 // ceiling date doesn't fit
190 throwOutOfRangeException(queryDateTime.toInstant());
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;
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());
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));
224 public double getDayTotal(LocalDate queryDate) {
225 if (rawData.isEmpty()) {
226 throw new SolarForecastException(this, "No forecast data available");
228 JSONObject contentJson = new JSONObject(rawData.get());
229 JSONObject resultJson = contentJson.getJSONObject("result");
230 JSONObject wattsDay = resultJson.getJSONObject("watt_hours_day");
232 if (wattsDay.has(queryDate.toString())) {
233 return wattsDay.getDouble(queryDate.toString()) / 1000.0;
235 throw new SolarForecastException(this,
236 "Day " + queryDate + " not available in forecast. " + getTimeRange());
240 public double getRemainingProduction(ZonedDateTime queryDateTime) {
241 double daily = getDayTotal(queryDateTime.toLocalDate());
242 double actual = getActualEnergyValue(queryDateTime);
243 return daily - actual;
246 public String getRaw() {
247 if (rawData.isPresent()) {
248 return rawData.get();
253 public ZoneId getZone() {
258 public String toString() {
259 return "Expiration: " + expirationDateTime + ", Data:" + wattHourMap;
263 * SolarForecast Interface
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");
270 double measure = getDayTotal(localDate);
271 return Utils.getEnergyState(measure);
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");
279 LocalDate beginDate = start.atZone(zone).toLocalDate();
280 LocalDate endDate = end.atZone(zone).toLocalDate();
282 if (beginDate.equals(endDate)) {
283 measure = getDayTotal(beginDate) - getActualEnergyValue(start.atZone(zone))
284 - getRemainingProduction(end.atZone(zone));
286 measure = getRemainingProduction(start.atZone(zone));
287 beginDate = beginDate.plusDays(1);
288 while (beginDate.isBefore(endDate) && measure >= 0) {
289 double day = getDayTotal(beginDate);
293 beginDate = beginDate.plusDays(1);
295 double lastDay = getActualEnergyValue(end.atZone(zone));
300 return Utils.getEnergyState(measure);
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");
308 double measure = getActualPowerValue(timestamp.atZone(zone));
309 return Utils.getPowerState(measure);
313 public Instant getForecastBegin() {
314 if (wattHourMap.isEmpty()) {
317 ZonedDateTime zdt = wattHourMap.firstEntry().getKey();
318 return zdt.toInstant();
322 public Instant getForecastEnd() {
323 if (wattHourMap.isEmpty()) {
326 ZonedDateTime zdt = wattHourMap.lastEntry().getKey();
327 return zdt.toInstant();
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");
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());
341 logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
345 private String getTimeRange() {
346 return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
347 + dateOutputFormatter.format(getForecastEnd());
351 public String getIdentifier() {