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.solcast;
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;
27 import javax.measure.quantity.Energy;
28 import javax.measure.quantity.Power;
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;
46 * The {@link SolcastObject} 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 SolcastObject implements SolarForecast {
53 private static final TreeMap<ZonedDateTime, Double> EMPTY_MAP = new TreeMap<>();
55 private final Logger logger = LoggerFactory.getLogger(SolcastObject.class);
56 private final TreeMap<ZonedDateTime, Double> estimationDataMap = new TreeMap<>();
57 private final TreeMap<ZonedDateTime, Double> optimisticDataMap = new TreeMap<>();
58 private final TreeMap<ZonedDateTime, Double> pessimisticDataMap = new TreeMap<>();
59 private final TimeZoneProvider timeZoneProvider;
61 private DateTimeFormatter dateOutputFormatter;
62 private String identifier;
63 private Optional<JSONObject> rawData = Optional.of(new JSONObject());
64 private Instant expirationDateTime;
65 private long period = 30;
67 public enum QueryMode {
68 Average(SolarForecast.AVERAGE),
69 Optimistic(SolarForecast.OPTIMISTIC),
70 Pessimistic(SolarForecast.PESSIMISTIC),
73 String modeDescirption;
75 QueryMode(String description) {
76 modeDescirption = description;
80 public String toString() {
81 return modeDescirption;
85 public SolcastObject(String id, TimeZoneProvider tzp) {
86 // invalid forecast object
88 timeZoneProvider = tzp;
89 dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
90 .withZone(tzp.getTimeZone());
91 expirationDateTime = Instant.now().minusSeconds(1);
94 public SolcastObject(String id, String content, Instant expiration, TimeZoneProvider tzp) {
96 expirationDateTime = expiration;
97 timeZoneProvider = tzp;
98 dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
99 .withZone(tzp.getTimeZone());
103 public void join(String content) {
107 private void add(String content) {
108 if (!content.isEmpty()) {
109 JSONObject contentJson = new JSONObject(content);
110 JSONArray resultJsonArray;
112 // prepare data for raw channel
113 if (contentJson.has("forecasts")) {
114 resultJsonArray = contentJson.getJSONArray("forecasts");
115 addJSONArray(resultJsonArray);
116 rawData.get().put("forecasts", resultJsonArray);
118 if (contentJson.has("estimated_actuals")) {
119 resultJsonArray = contentJson.getJSONArray("estimated_actuals");
120 addJSONArray(resultJsonArray);
121 rawData.get().put("estimated_actuals", resultJsonArray);
126 private void addJSONArray(JSONArray resultJsonArray) {
127 // sort data into TreeMaps
128 for (int i = 0; i < resultJsonArray.length(); i++) {
129 JSONObject jo = resultJsonArray.getJSONObject(i);
130 String periodEnd = jo.getString("period_end");
131 ZonedDateTime periodEndZdt = getZdtFromUTC(periodEnd);
132 if (periodEndZdt == null) {
135 estimationDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
137 // fill pessimistic values
138 if (jo.has("pv_estimate10")) {
139 pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate10"));
141 pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
144 // fill optimistic values
145 if (jo.has("pv_estimate90")) {
146 optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate90"));
148 optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
150 if (jo.has("period")) {
151 period = Duration.parse(jo.getString("period")).toMinutes();
156 public boolean isExpired() {
157 return expirationDateTime.isBefore(Instant.now());
160 public double getActualEnergyValue(ZonedDateTime query, QueryMode mode) {
161 // calculate energy from day begin to latest entry BEFORE query
162 ZonedDateTime iterationDateTime = query.withHour(0).withMinute(0).withSecond(0);
163 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
164 Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
165 if (nextEntry == null) {
166 throwOutOfRangeException(query.toInstant());
169 double forecastValue = 0;
170 double previousEstimate = 0;
171 while (nextEntry.getKey().isBefore(query) || nextEntry.getKey().isEqual(query)) {
172 // value are reported in PT30M = 30 minutes interval with kw value
173 // for kw/h it's half the value
174 Double endValue = nextEntry.getValue();
175 // production during period is half of previous and next value
176 double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
177 forecastValue += addedValue;
178 previousEstimate = endValue.doubleValue();
179 iterationDateTime = nextEntry.getKey();
180 nextEntry = dtm.higherEntry(iterationDateTime);
181 if (nextEntry == null) {
185 // interpolate minutes AFTER query
186 Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
187 Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
190 long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
191 // floor == ceiling: no addon calculation needed
193 return forecastValue;
195 if (c.getValue() > 0) {
196 double interpolation = Duration.between(f.getKey(), query).toMinutes() / 60.0;
197 double interpolationProduction = getActualPowerValue(query, mode) * interpolation;
198 forecastValue += interpolationProduction;
199 return forecastValue;
201 // if ceiling value is 0 there's no further production in this period
202 return forecastValue;
205 // if ceiling is null we're at the very end of the day
206 return forecastValue;
209 // if floor is null we're at the very beginning of the day => 0
215 public TimeSeries getEnergyTimeSeries(QueryMode mode) {
216 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
217 TimeSeries ts = new TimeSeries(Policy.REPLACE);
218 Instant now = Instant.now(Utils.getClock());
219 dtm.forEach((timestamp, energy) -> {
220 Instant entryTimestamp = timestamp.toInstant();
221 if (Utils.isAfterOrEqual(entryTimestamp, now)) {
222 ts.add(entryTimestamp, Utils.getEnergyState(getActualEnergyValue(timestamp, mode)));
231 public double getActualPowerValue(ZonedDateTime query, QueryMode mode) {
232 if (query.toInstant().isBefore(getForecastBegin()) || query.toInstant().isAfter(getForecastEnd())) {
233 throwOutOfRangeException(query.toInstant());
235 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
236 double actualPowerValue = 0;
237 Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
238 Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
241 double powerCeiling = c.getValue();
242 long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
243 // floor == ceiling: return power from node, no interpolation needed
247 if (powerCeiling > 0) {
248 double powerFloor = f.getValue();
249 // calculate in minutes from floor to now, e.g. 20 minutes from PT30M 30 minutes
250 // => take 1/3 of floor and 2/3 of ceiling
251 double interpolation = Duration.between(f.getKey(), query).toMinutes() / (double) period;
252 actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
253 return actualPowerValue;
255 // if power ceiling == 0 there's no production in this period
259 // if ceiling is null we're at the very end of this day => 0
263 // if floor is null we're at the very beginning of this day => 0
269 public TimeSeries getPowerTimeSeries(QueryMode mode) {
270 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
271 TimeSeries ts = new TimeSeries(Policy.REPLACE);
272 Instant now = Instant.now(Utils.getClock());
273 dtm.forEach((timestamp, power) -> {
274 Instant entryTimestamp = timestamp.toInstant();
275 if (Utils.isAfterOrEqual(entryTimestamp, now)) {
276 ts.add(entryTimestamp, Utils.getPowerState(power));
285 public double getDayTotal(LocalDate query, QueryMode mode) {
286 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
287 ZonedDateTime iterationDateTime = query.atStartOfDay(timeZoneProvider.getTimeZone());
288 Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
289 if (nextEntry == null) {
290 throw new SolarForecastException(this, "Day " + query + " not available in forecast. " + getTimeRange());
292 ZonedDateTime endDateTime = iterationDateTime.plusDays(1);
293 double forecastValue = 0;
294 double previousEstimate = 0;
295 while (nextEntry.getKey().isBefore(endDateTime)) {
296 // value are reported in PT30M = 30 minutes interval with kw value
297 // for kw/h it's half the value
298 Double endValue = nextEntry.getValue();
299 // production during period is half of previous and next value
300 double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
301 forecastValue += addedValue;
302 previousEstimate = endValue.doubleValue();
303 iterationDateTime = nextEntry.getKey();
304 nextEntry = dtm.higherEntry(iterationDateTime);
305 if (nextEntry == null) {
309 return forecastValue;
312 public double getRemainingProduction(ZonedDateTime query, QueryMode mode) {
313 return getDayTotal(query.toLocalDate(), mode) - getActualEnergyValue(query, mode);
317 public String toString() {
318 return "Expiration: " + expirationDateTime + ", Data: " + estimationDataMap;
321 public String getRaw() {
322 if (rawData.isPresent()) {
323 return rawData.get().toString();
328 private TreeMap<ZonedDateTime, Double> getDataMap(QueryMode mode) {
329 TreeMap<ZonedDateTime, Double> returnMap = EMPTY_MAP;
332 returnMap = estimationDataMap;
335 returnMap = optimisticDataMap;
338 returnMap = pessimisticDataMap;
350 public @Nullable ZonedDateTime getZdtFromUTC(String utc) {
352 Instant timestamp = Instant.parse(utc);
353 return timestamp.atZone(timeZoneProvider.getTimeZone());
354 } catch (DateTimeParseException dtpe) {
355 logger.warn("Exception parsing time {} Reason: {}", utc, dtpe.getMessage());
361 * SolarForecast Interface
364 public QuantityType<Energy> getDay(LocalDate date, String... args) throws IllegalArgumentException {
365 QueryMode mode = evalArguments(args);
366 if (mode.equals(QueryMode.Error)) {
367 if (args.length > 1) {
368 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
370 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
372 } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
373 if (date.isBefore(LocalDate.now())) {
374 throw new IllegalArgumentException(
375 "Solcast argument " + mode.toString() + " only available for future values");
378 double measure = getDayTotal(date, mode);
379 return Utils.getEnergyState(measure);
383 public QuantityType<Energy> getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
384 if (end.isBefore(start)) {
385 if (args.length > 1) {
386 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
388 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
391 QueryMode mode = evalArguments(args);
392 if (mode.equals(QueryMode.Error)) {
393 return Utils.getEnergyState(-1);
394 } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
395 if (end.isBefore(Instant.now())) {
396 throw new IllegalArgumentException(
397 "Solcast argument " + mode.toString() + " only available for future values");
400 LocalDate beginDate = start.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
401 LocalDate endDate = end.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
403 if (beginDate.isEqual(endDate)) {
404 measure = getDayTotal(beginDate, mode)
405 - getActualEnergyValue(start.atZone(timeZoneProvider.getTimeZone()), mode)
406 - getRemainingProduction(end.atZone(timeZoneProvider.getTimeZone()), mode);
408 measure = getRemainingProduction(start.atZone(timeZoneProvider.getTimeZone()), mode);
409 beginDate = beginDate.plusDays(1);
410 while (beginDate.isBefore(endDate) && measure >= 0) {
411 double day = getDayTotal(beginDate, mode);
415 beginDate = beginDate.plusDays(1);
417 double lastDay = getActualEnergyValue(end.atZone(timeZoneProvider.getTimeZone()), mode);
422 return Utils.getEnergyState(measure);
426 public QuantityType<Power> getPower(Instant timestamp, String... args) throws IllegalArgumentException {
427 // eliminate error cases and return immediately
428 QueryMode mode = evalArguments(args);
429 if (mode.equals(QueryMode.Error)) {
430 if (args.length > 1) {
431 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
433 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
435 } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
436 if (timestamp.isBefore(Instant.now().minus(1, ChronoUnit.MINUTES))) {
437 throw new IllegalArgumentException(
438 "Solcast argument " + mode.toString() + " only available for future values");
441 double measure = getActualPowerValue(ZonedDateTime.ofInstant(timestamp, timeZoneProvider.getTimeZone()), mode);
442 return Utils.getPowerState(measure);
446 public Instant getForecastBegin() {
447 if (!estimationDataMap.isEmpty()) {
448 return estimationDataMap.firstEntry().getKey().toInstant();
454 public Instant getForecastEnd() {
455 if (!estimationDataMap.isEmpty()) {
456 return estimationDataMap.lastEntry().getKey().toInstant();
461 private QueryMode evalArguments(String[] args) {
462 if (args.length > 0) {
463 if (args.length > 1) {
464 logger.info("Too many arguments {}", Arrays.toString(args));
465 return QueryMode.Error;
468 if (SolarForecast.OPTIMISTIC.equals(args[0])) {
469 return QueryMode.Optimistic;
470 } else if (SolarForecast.PESSIMISTIC.equals(args[0])) {
471 return QueryMode.Pessimistic;
472 } else if (SolarForecast.AVERAGE.equals(args[0])) {
473 return QueryMode.Average;
475 logger.info("Argument {} not supported", args[0]);
476 return QueryMode.Error;
479 return QueryMode.Average;
484 public String getIdentifier() {
488 private void throwOutOfRangeException(Instant query) {
489 if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
490 throw new SolarForecastException(this, "Forecast invalid time range");
492 if (query.isBefore(getForecastBegin())) {
493 throw new SolarForecastException(this,
494 "Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
495 } else if (query.isAfter(getForecastEnd())) {
496 throw new SolarForecastException(this,
497 "Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
499 logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
503 private String getTimeRange() {
504 return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
505 + dateOutputFormatter.format(getForecastEnd());