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
51 public class SolcastObject implements SolarForecast {
52 private static final TreeMap<ZonedDateTime, Double> EMPTY_MAP = new TreeMap<>();
54 private final Logger logger = LoggerFactory.getLogger(SolcastObject.class);
55 private final TreeMap<ZonedDateTime, Double> estimationDataMap = new TreeMap<>();
56 private final TreeMap<ZonedDateTime, Double> optimisticDataMap = new TreeMap<>();
57 private final TreeMap<ZonedDateTime, Double> pessimisticDataMap = new TreeMap<>();
58 private final TimeZoneProvider timeZoneProvider;
60 private DateTimeFormatter dateOutputFormatter;
61 private String identifier;
62 private Optional<JSONObject> rawData = Optional.of(new JSONObject());
63 private Instant expirationDateTime;
64 private long period = 30;
66 public enum QueryMode {
67 Average(SolarForecast.AVERAGE),
68 Optimistic(SolarForecast.OPTIMISTIC),
69 Pessimistic(SolarForecast.PESSIMISTIC),
72 String modeDescirption;
74 QueryMode(String description) {
75 modeDescirption = description;
79 public String toString() {
80 return modeDescirption;
84 public SolcastObject(String id, TimeZoneProvider tzp) {
85 // invalid forecast object
87 timeZoneProvider = tzp;
88 dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
89 .withZone(tzp.getTimeZone());
90 expirationDateTime = Instant.now().minusSeconds(1);
93 public SolcastObject(String id, String content, Instant expiration, TimeZoneProvider tzp) {
95 expirationDateTime = expiration;
96 timeZoneProvider = tzp;
97 dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
98 .withZone(tzp.getTimeZone());
102 public void join(String content) {
106 private void add(String content) {
107 if (!content.isEmpty()) {
108 JSONObject contentJson = new JSONObject(content);
109 JSONArray resultJsonArray;
111 // prepare data for raw channel
112 if (contentJson.has("forecasts")) {
113 resultJsonArray = contentJson.getJSONArray("forecasts");
114 addJSONArray(resultJsonArray);
115 rawData.get().put("forecasts", resultJsonArray);
117 if (contentJson.has("estimated_actuals")) {
118 resultJsonArray = contentJson.getJSONArray("estimated_actuals");
119 addJSONArray(resultJsonArray);
120 rawData.get().put("estimated_actuals", resultJsonArray);
125 private void addJSONArray(JSONArray resultJsonArray) {
126 // sort data into TreeMaps
127 for (int i = 0; i < resultJsonArray.length(); i++) {
128 JSONObject jo = resultJsonArray.getJSONObject(i);
129 String periodEnd = jo.getString("period_end");
130 ZonedDateTime periodEndZdt = getZdtFromUTC(periodEnd);
131 if (periodEndZdt == null) {
134 estimationDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
136 // fill pessimistic values
137 if (jo.has("pv_estimate10")) {
138 pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate10"));
140 pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
143 // fill optimistic values
144 if (jo.has("pv_estimate90")) {
145 optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate90"));
147 optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
149 if (jo.has("period")) {
150 period = Duration.parse(jo.getString("period")).toMinutes();
155 public boolean isExpired() {
156 return expirationDateTime.isBefore(Instant.now());
159 public double getActualEnergyValue(ZonedDateTime query, QueryMode mode) {
160 // calculate energy from day begin to latest entry BEFORE query
161 ZonedDateTime iterationDateTime = query.withHour(0).withMinute(0).withSecond(0);
162 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
163 Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
164 if (nextEntry == null) {
165 throwOutOfRangeException(query.toInstant());
168 double forecastValue = 0;
169 double previousEstimate = 0;
170 while (nextEntry.getKey().isBefore(query) || nextEntry.getKey().isEqual(query)) {
171 // value are reported in PT30M = 30 minutes interval with kw value
172 // for kw/h it's half the value
173 Double endValue = nextEntry.getValue();
174 // production during period is half of previous and next value
175 double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
176 forecastValue += addedValue;
177 previousEstimate = endValue.doubleValue();
178 iterationDateTime = nextEntry.getKey();
179 nextEntry = dtm.higherEntry(iterationDateTime);
180 if (nextEntry == null) {
184 // interpolate minutes AFTER query
185 Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
186 Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
189 long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
190 // floor == ceiling: no addon calculation needed
192 return forecastValue;
194 if (c.getValue() > 0) {
195 double interpolation = Duration.between(f.getKey(), query).toMinutes() / 60.0;
196 double interpolationProduction = getActualPowerValue(query, mode) * interpolation;
197 forecastValue += interpolationProduction;
198 return forecastValue;
200 // if ceiling value is 0 there's no further production in this period
201 return forecastValue;
204 // if ceiling is null we're at the very end of the day
205 return forecastValue;
208 // if floor is null we're at the very beginning of the day => 0
214 public TimeSeries getEnergyTimeSeries(QueryMode mode) {
215 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
216 TimeSeries ts = new TimeSeries(Policy.REPLACE);
217 dtm.forEach((timestamp, energy) -> {
218 ts.add(timestamp.toInstant(), Utils.getEnergyState(getActualEnergyValue(timestamp, mode)));
226 public double getActualPowerValue(ZonedDateTime query, QueryMode mode) {
227 if (query.toInstant().isBefore(getForecastBegin()) || query.toInstant().isAfter(getForecastEnd())) {
228 throwOutOfRangeException(query.toInstant());
230 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
231 double actualPowerValue = 0;
232 Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
233 Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
236 double powerCeiling = c.getValue();
237 long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
238 // floor == ceiling: return power from node, no interpolation needed
242 if (powerCeiling > 0) {
243 double powerFloor = f.getValue();
244 // calculate in minutes from floor to now, e.g. 20 minutes from PT30M 30 minutes
245 // => take 1/3 of floor and 2/3 of ceiling
246 double interpolation = Duration.between(f.getKey(), query).toMinutes() / (double) period;
247 actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
248 return actualPowerValue;
250 // if power ceiling == 0 there's no production in this period
254 // if ceiling is null we're at the very end of this day => 0
258 // if floor is null we're at the very beginning of this day => 0
264 public TimeSeries getPowerTimeSeries(QueryMode mode) {
265 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
266 TimeSeries ts = new TimeSeries(Policy.REPLACE);
267 dtm.forEach((timestamp, power) -> {
268 ts.add(timestamp.toInstant(), Utils.getPowerState(power));
276 public double getDayTotal(LocalDate query, QueryMode mode) {
277 TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
278 ZonedDateTime iterationDateTime = query.atStartOfDay(timeZoneProvider.getTimeZone());
279 Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
280 if (nextEntry == null) {
281 throw new SolarForecastException(this, "Day " + query + " not available in forecast. " + getTimeRange());
283 ZonedDateTime endDateTime = iterationDateTime.plusDays(1);
284 double forecastValue = 0;
285 double previousEstimate = 0;
286 while (nextEntry.getKey().isBefore(endDateTime)) {
287 // value are reported in PT30M = 30 minutes interval with kw value
288 // for kw/h it's half the value
289 Double endValue = nextEntry.getValue();
290 // production during period is half of previous and next value
291 double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
292 forecastValue += addedValue;
293 previousEstimate = endValue.doubleValue();
294 iterationDateTime = nextEntry.getKey();
295 nextEntry = dtm.higherEntry(iterationDateTime);
296 if (nextEntry == null) {
300 return forecastValue;
303 public double getRemainingProduction(ZonedDateTime query, QueryMode mode) {
304 return getDayTotal(query.toLocalDate(), mode) - getActualEnergyValue(query, mode);
308 public String toString() {
309 return "Expiration: " + expirationDateTime + ", Data: " + estimationDataMap;
312 public String getRaw() {
313 if (rawData.isPresent()) {
314 return rawData.get().toString();
319 private TreeMap<ZonedDateTime, Double> getDataMap(QueryMode mode) {
320 TreeMap<ZonedDateTime, Double> returnMap = EMPTY_MAP;
323 returnMap = estimationDataMap;
326 returnMap = optimisticDataMap;
329 returnMap = pessimisticDataMap;
341 public @Nullable ZonedDateTime getZdtFromUTC(String utc) {
343 Instant timestamp = Instant.parse(utc);
344 return timestamp.atZone(timeZoneProvider.getTimeZone());
345 } catch (DateTimeParseException dtpe) {
346 logger.warn("Exception parsing time {} Reason: {}", utc, dtpe.getMessage());
352 * SolarForecast Interface
355 public QuantityType<Energy> getDay(LocalDate date, String... args) throws IllegalArgumentException {
356 QueryMode mode = evalArguments(args);
357 if (mode.equals(QueryMode.Error)) {
358 if (args.length > 1) {
359 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
361 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
363 } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
364 if (date.isBefore(LocalDate.now())) {
365 throw new IllegalArgumentException(
366 "Solcast argument " + mode.toString() + " only available for future values");
369 double measure = getDayTotal(date, mode);
370 return Utils.getEnergyState(measure);
374 public QuantityType<Energy> getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
375 if (end.isBefore(start)) {
376 if (args.length > 1) {
377 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
379 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
382 QueryMode mode = evalArguments(args);
383 if (mode.equals(QueryMode.Error)) {
384 return Utils.getEnergyState(-1);
385 } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
386 if (end.isBefore(Instant.now())) {
387 throw new IllegalArgumentException(
388 "Solcast argument " + mode.toString() + " only available for future values");
391 LocalDate beginDate = start.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
392 LocalDate endDate = end.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
394 if (beginDate.isEqual(endDate)) {
395 measure = getDayTotal(beginDate, mode)
396 - getActualEnergyValue(start.atZone(timeZoneProvider.getTimeZone()), mode)
397 - getRemainingProduction(end.atZone(timeZoneProvider.getTimeZone()), mode);
399 measure = getRemainingProduction(start.atZone(timeZoneProvider.getTimeZone()), mode);
400 beginDate = beginDate.plusDays(1);
401 while (beginDate.isBefore(endDate) && measure >= 0) {
402 double day = getDayTotal(beginDate, mode);
406 beginDate = beginDate.plusDays(1);
408 double lastDay = getActualEnergyValue(end.atZone(timeZoneProvider.getTimeZone()), mode);
413 return Utils.getEnergyState(measure);
417 public QuantityType<Power> getPower(Instant timestamp, String... args) throws IllegalArgumentException {
418 // eliminate error cases and return immediately
419 QueryMode mode = evalArguments(args);
420 if (mode.equals(QueryMode.Error)) {
421 if (args.length > 1) {
422 throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
424 throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
426 } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
427 if (timestamp.isBefore(Instant.now().minus(1, ChronoUnit.MINUTES))) {
428 throw new IllegalArgumentException(
429 "Solcast argument " + mode.toString() + " only available for future values");
432 double measure = getActualPowerValue(ZonedDateTime.ofInstant(timestamp, timeZoneProvider.getTimeZone()), mode);
433 return Utils.getPowerState(measure);
437 public Instant getForecastBegin() {
438 if (!estimationDataMap.isEmpty()) {
439 return estimationDataMap.firstEntry().getKey().toInstant();
445 public Instant getForecastEnd() {
446 if (!estimationDataMap.isEmpty()) {
447 return estimationDataMap.lastEntry().getKey().toInstant();
452 private QueryMode evalArguments(String[] args) {
453 if (args.length > 0) {
454 if (args.length > 1) {
455 logger.info("Too many arguments {}", Arrays.toString(args));
456 return QueryMode.Error;
459 if (SolarForecast.OPTIMISTIC.equals(args[0])) {
460 return QueryMode.Optimistic;
461 } else if (SolarForecast.PESSIMISTIC.equals(args[0])) {
462 return QueryMode.Pessimistic;
463 } else if (SolarForecast.AVERAGE.equals(args[0])) {
464 return QueryMode.Average;
466 logger.info("Argument {} not supported", args[0]);
467 return QueryMode.Error;
470 return QueryMode.Average;
475 public String getIdentifier() {
479 private void throwOutOfRangeException(Instant query) {
480 if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
481 throw new SolarForecastException(this, "Forecast invalid time range");
483 if (query.isBefore(getForecastBegin())) {
484 throw new SolarForecastException(this,
485 "Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
486 } else if (query.isAfter(getForecastEnd())) {
487 throw new SolarForecastException(this,
488 "Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
490 logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
494 private String getTimeRange() {
495 return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
496 + dateOutputFormatter.format(getForecastEnd());