diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 96a0264b851..3db66110ec2 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1641,6 +1641,11 @@
org.openhab.binding.solaredge
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.solarforecast
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.solarlog
diff --git a/bundles/org.openhab.binding.solarforecast/NOTICE b/bundles/org.openhab.binding.solarforecast/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.solarforecast/README.md b/bundles/org.openhab.binding.solarforecast/README.md
new file mode 100644
index 00000000000..1de5035db8c
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/README.md
@@ -0,0 +1,356 @@
+# SolarForecast Binding
+
+This binding provides data from Solar Forecast services.
+Use it to estimate your daily production, plan electric consumers like Electric Vehicle charging, heating or HVAC.
+Look ahead the next days in order to identify surplus / shortages in your energy planning.
+
+Supported Services
+
+- [Solcast](https://solcast.com/)
+ - Free [Hobbyist Plan](https://toolkit.solcast.com.au/register/hobbyist) with registration
+- [Forecast.Solar](https://forecast.solar/)
+ - Public, Personal and Professional [plans](https://forecast.solar/#accounts) available
+
+Display Power values of Forecast and PV Inverter items
+
+
+
+Display Energy values of Forecast and PV inverter items
+Yellow line shows *Daily Total Forecast*.
+
+
+
+## Supported Things
+
+Each service needs one `xx-site` for your location and at least one photovoltaic `xx-plane`.
+
+| Name | Thing Type ID |
+|-----------------------------------|---------------|
+| Solcast service site definition | sc-site |
+| Solcast PV Plane | sc-plane |
+| Forecast Solar site location | fs-site |
+| Forecast Solar PV Plane | fs-plane |
+
+## Solcast Configuration
+
+[Solcast service](https://solcast.com/) requires a personal registration with an e-mail address.
+A free version for your personal home PV system is available in [Hobbyist Plan](https://toolkit.solcast.com.au/register/hobbyist)
+You need to configure your home photovoltaic system within the web interface.
+The `resourceId` for each PV plane is provided afterwards.
+
+In order to receive proper timestamps double check your time zone in *openHAB - Settings - Regional Settings*.
+Correct time zone is necessary to show correct forecast times in UI.
+
+### Solcast Bridge Configuration
+
+| Name | Type | Description | Default | Required | Advanced |
+|------------------------|---------|---------------------------------------|-------------|----------|----------|
+| apiKey | text | API Key | N/A | yes | no |
+| timeZone | text | Time Zone of forecast location | empty | no | yes |
+
+`apiKey` can be obtained in your [Account Settings](https://toolkit.solcast.com.au/account)
+
+`timeZone` can be left empty to evaluate Regional Settings of your openHAB installation.
+See [DateTime](#date-time) section for more information.
+
+### Solcast Plane Configuration
+
+| Name | Type | Description | Default | Required | Advanced |
+|-----------------|---------|--------------------------------------------------------|-----------------|----------|----------|
+| resourceId | text | Resource Id of Solcast rooftop site | N/A | yes | no |
+| refreshInterval | integer | Forecast Refresh Interval in minutes | 120 | yes | no |
+
+`resourceId` for each plane can be obtained in your [Rooftop Sites](https://toolkit.solcast.com.au/rooftop-sites)
+
+`refreshInterval` of forecast data needs to respect the throttling of the Solcast service.
+If you have 25 free calls per day, each plane needs 2 calls per update a refresh interval of 120 minutes will result in 24 calls per day.
+
+## Solcast Channels
+
+Each `sc-plane` reports its own values including a `json` channel holding JSON content.
+The `sc-site` bridge sums up all attached `sc-plane` values and provides total forecast for your home location.
+
+Channels are covering today's actual data with current, remaining and today's total prediction.
+Forecasts are delivered up to 6 days in advance.
+Scenarios are clustered in groups:
+
+- `average` scenario
+- `pessimistic` scenario: 10th percentile
+- `optimistic` scenario: 90th percentile
+
+| Channel | Type | Unit | Description | Advanced |
+|-------------------------|---------------|------|-------------------------------------------------|----------|
+| power-estimate | Number:Power | W | Power forecast for next hours/days | no |
+| energy-estimate | Number:Energy | kWh | Energy forecast for next hours/days | no |
+| power-actual | Number:Power | W | Power prediction for this moment | no |
+| energy-actual | Number:Energy | kWh | Today's forecast till now | no |
+| energy-remain | Number:Energy | kWh | Today's remaining forecast till sunset | no |
+| energy-today | Number:Energy | kWh | Today's forecast in total | no |
+| json | String | - | Plain JSON response without conversions | yes |
+
+## ForecastSolar Configuration
+
+[ForecastSolar service](https://forecast.solar/) provides a [public free](https://forecast.solar/#accounts) plan.
+You can try it without any registration or other preconditions.
+
+### ForecastSolar Bridge Configuration
+
+| Name | Type | Description | Default | Required |
+|------------------------|---------|---------------------------------------|--------------|----------|
+| location | text | Location of Photovoltaic system. | empty | no |
+| apiKey | text | API Key | N/A | no |
+
+`location` defines latitude, longitude values of your PV system.
+In case of empty the location configured in openHAB is obtained.
+
+`apiKey` can be given in case you subscribed to a paid plan.
+
+### ForecastSolar Plane Configuration
+
+| Name | Type | Description | Default | Required | Advanced |
+|-----------------|---------|------------------------------------------------------------------------------|---------|----------|----------|
+| refreshInterval | integer | Forecast Refresh Interval in minutes | 30 | yes | false |
+| declination | integer | Plane Declination: 0 for horizontal till 90 for vertical declination | N/A | yes | false |
+| azimuth | integer | Plane Azimuth: -180 = north, -90 = east, 0 = south, 90 = west, 180 = north | N/A | yes | false |
+| kwp | decimal | Installed Kilowatt Peak | N/A | yes | false |
+| dampAM | decimal | Damping factor of morning hours | N/A | no | true |
+| dampPM | decimal | Damping factor of evening hours | N/A | no | true |
+| horizon | text | Horizon definition as comma separated integer values | N/A | no | true |
+
+`refreshInterval` of forecast data needs to respect the throttling of the ForecastSolar service.
+12 calls per hour allowed from your caller IP address so for 2 planes lowest possible refresh rate is 10 minutes.
+
+#### Advanced Configuration
+
+Advanced configuration parameters are available to *fine tune* your forecast data.
+Read linked documentation in order to know what you're doing.
+
+[Damping factors](https://doc.forecast.solar/doku.php?id=damping) for morning and evening.
+
+[Horizon information](https://doc.forecast.solar/doku.php?id=api) as comma-separated integer list.
+This configuration item is aimed to expert users.
+You need to understand the [horizon concept](https://joint-research-centre.ec.europa.eu/pvgis-photovoltaic-geographical-information-system/getting-started-pvgis/pvgis-user-manual_en#ref-2-using-horizon-information).
+Shadow obstacles like mountains, hills, buildings can be expressed here.
+First step can be a download from [PVGIS tool](https://re.jrc.ec.europa.eu/pvg_tools/en/) and downloading the *terrain shadows*.
+But it doesn't fit 100% to the required configuration.
+Currently there's no tool available which is providing the configuration information 1 to 1.
+So you need to know what you're doing.
+
+## ForecastSolar Channels
+
+Each `fs-plane` reports its own values including a `json` channel holding JSON content.
+The `fs-site` bridge sums up all attached `fs-plane` values and provides the total forecast for your home location.
+
+Channels are covering today's actual data with current, remaining and total prediction.
+Forecasts are delivered up to 3 days for paid personal plans.
+
+| Channel | Type | Unit | Description | Advanced |
+|-------------------------|---------------|------|-------------------------------------------------|----------|
+| power-estimate | Number:Power | W | Power forecast for next hours/days | no |
+| energy-estimate | Number:Energy | kWh | Energy forecast for next hours/days | no |
+| power-actual | Number:Power | W | Power prediction for this moment | no |
+| energy-actual | Number:Energy | kWh | Today's forecast till now | no |
+| energy-remain | Number:Energy | kWh | Today's remaining forecast till sunset | no |
+| energy-today | Number:Energy | kWh | Today's forecast in total | no |
+| json | String | - | Plain JSON response without conversions | yes |
+
+## Thing Actions
+
+All things `sc-site`, `sc-plane`, `fs-site` and `fs-plane` are providing the same Actions.
+Channels are providing actual forecast data and daily forecasts in future.
+Actions provides an interface to execute more sophisticated handling in rules.
+You can execute this for each `xx-plane` for specific plane values or `xx-site` to sum up all attached planes.
+
+See [Date Time](#date-time) section for more information.
+Double check your time zone in *openHAB - Settings - Regional Settings* which is crucial for calculation.
+
+### `getForecastBegin`
+
+Returns `Instant` of the earliest possible forecast data available.
+It's located in the past, e.g. Solcast provides data from the last 7 days.
+`Instant.MAX` is returned in case of no forecast data is available.
+
+### `getForecastEnd`
+
+Returns `Instant` of the latest possible forecast data available.
+`Instant.MIN` is returned in case of no forecast data is available.
+
+### `getPower`
+
+| Parameter | Type | Description |
+|-----------|---------------|--------------------------------------------------------------------------------------------|
+| timestamp | Instant | Timestamp of power query |
+| mode | String | Choose `average`, `optimistic` or `pessimistic` to select forecast scenario. Only Solcast. |
+
+Returns `QuantityType` at the given `Instant` timestamp.
+Respect `getForecastBegin` and `getForecastEnd` to get a valid value.
+
+Check log or catch exceptions for error handling
+
+- `IllegalArgumentException` thrown in case of problems with call arguments
+- `SolarForecastException` thrown in case of problems with timestamp and available forecast data
+
+### `getDay`
+
+| Parameter | Type | Description |
+|-----------|---------------|--------------------------------------------------------------------------------------------|
+| date | LocalDate | Date of the day |
+| mode | String | Choose `average`, `optimistic` or `pessimistic` to select forecast scenario. Only Solcast. |
+
+Returns `QuantityType` at the given `localDate`.
+Respect `getForecastBegin` and `getForecastEnd` to avoid ambiguous values.
+
+Check log or catch exceptions for error handling
+
+- `IllegalArgumentException` thrown in case of problems with call arguments
+- `SolarForecastException` thrown in case of problems with timestamp and available forecast data
+
+### `getEnergy`
+
+| Parameter | Type | Description |
+|-----------------|---------------|--------------------------------------------------------------------------------------------------------------|
+| startTimestamp | Instant | Start timestamp of energy query |
+| endTimestamp | Instant | End timestamp of energy query |
+| mode | String | Choose `optimistic` or `pessimistic` to get values for a positive or negative future scenario. Only Solcast. |
+
+Returns `QuantityType` between the timestamps `startTimestamp` and `endTimestamp`.
+Respect `getForecastBegin` and `getForecastEnd` to avoid ambiguous values.
+
+Check log or catch exceptions for error handling
+
+- `IllegalArgumentException` thrown in case of problems with call arguments
+- `SolarForecastException` thrown in case of problems with timestamp and available forecast data
+
+## Date Time
+
+Each forecast is bound to a certain location which automatically defines the time zone.
+Most common use case is forecast and your location are matching the same time zone.
+Action interface is using `Instant` as timestamps which enables you translating to any time zone.
+This allows you with an easy conversion to query also foreign forecast locations.
+
+Examples are showing
+
+- how to translate `Instant` to `ZonedDateTime` objects and
+- how to translate `ZonedDateTime` to `Instant` objects
+
+## Example
+
+Example is based on Forecast.Solar service without any registration.
+Exchange the configuration data in [thing file](#thing-file) and you're ready to go.
+
+### Thing file
+
+```java
+Bridge solarforecast:fs-site:homeSite "ForecastSolar Home" [ location="54.321,8.976"] {
+ Thing fs-plane homeSouthWest "ForecastSolar Home South-West" [ refreshInterval=15, azimuth=45, declination=35, kwp=5.5]
+ Thing fs-plane homeNorthEast "ForecastSolar Home North-East" [ refreshInterval=15, azimuth=-145, declination=35, kwp=4.425]
+}
+```
+
+### Items file
+
+```java
+// channel items
+Number:Power ForecastSolarHome_Actual_Power "Power prediction for this moment" { channel="solarforecast:fs-site:homeSite:power-actual", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
+Number:Energy ForecastSolarHome_Actual "Today's forecast till now" { channel="solarforecast:fs-site:homeSite:energy-actual", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Energy ForecastSolarHome_Remaining "Today's remaining forecast till sunset" { channel="solarforecast:fs-site:homeSite:energy-remain", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Energy ForecastSolarHome_Today "Today's total energy forecast" { channel="solarforecast:fs-site:homeSite:energy-today", stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
+// calculated by rule
+Number:Energy ForecastSolarHome_Tomorrow "Tomorrow's total energy forecast" { stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
+
+Number:Power ForecastSolarHome_Actual_Power_NE "NE Power prediction for this moment" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:power-actual", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
+Number:Energy ForecastSolarHome_Actual_NE "NE Today's forecast till now" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:energy-actual", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Energy ForecastSolarHome_Remaining_NE "NE Today's remaining forecast till sunset" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:energy-remain", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Energy ForecastSolarHome_Today_NE "NE Today's total energy forecast" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:energy-today", stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
+
+Number:Power ForecastSolarHome_Actual_Power_SW "SW Power prediction for this moment" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:power-actual", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
+Number:Energy ForecastSolarHome_Actual_SW "SW Today's forecast till now" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-actual", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Energy ForecastSolarHome_Remaining_SW "SW Today's remaining forecast till sunset" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-remain", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Energy ForecastSolarHome_Today_SW "SW Today's total energy forecast" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-today", stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
+
+// estimaion items
+Group influxdb
+Number:Power ForecastSolarHome_Power_Estimate "Power estimations" (influxdb) { channel="solarforecast:fs-site:homeSite:power-estimate", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
+Number:Energy ForecastSolarHome_Energy_Estimate "Energy estimations" (influxdb) { channel="solarforecast:fs-site:homeSite:energy-estimate", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+Number:Power ForecastSolarHome_Power_Estimate_SW "SW Power estimations" (influxdb) { channel="solarforecast:fs-plane:homeSite:homeSouthWest:power-estimate", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
+Number:Energy ForecastSolarHome_Energy_Estimate_SW "SW Energy estimations" (influxdb) { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-estimate", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
+```
+
+### Persistence file
+
+```java
+// persistence strategies have a name and definition and are referred to in the "Items" section
+Strategies {
+ everyHour : "0 0 * * * ?"
+ everyDay : "0 0 0 * * ?"
+}
+
+/*
+ * Each line in this section defines for which Item(s) which strategy(ies) should be applied.
+ * You can list single items, use "*" for all items or "groupitem*" for all members of a group
+ * Item (excl. the group Item itself).
+ */
+Items {
+ influxdb* : strategy = restoreOnStartup, forecast
+}
+```
+
+### Actions rule
+
+```java
+rule "Tomorrow Forecast Calculation"
+ when
+ Item ForecastSolarHome_Today received update
+ then
+ val solarforecastActions = getActions("solarforecast","solarforecast:fs-site:homeSite")
+ val energyState = solarforecastActions.getDay(LocalDate.now.plusDays(1))
+ logInfo("SF Tests","{}",energyState)
+ ForecastSolarHome_Tomorrow.postUpdate(energyState)
+end
+```
+
+### Handle exceptions
+
+```java
+import java.time.temporal.ChronoUnit
+
+rule "Exception Handling"
+ when
+ System started
+ then
+ val solcastActions = getActions("solarforecast","solarforecast:sc-site:3cadcde4dc")
+ try {
+ val forecast = solcastActions.getPower(solcastActions.getForecastEnd.plus(30,ChronoUnit.MINUTES))
+ } catch(RuntimeException e) {
+ logError("Exception","Handle {}",e.getMessage)
+ }
+end
+```
+
+### Actions rule with Arguments
+
+```java
+import java.time.temporal.ChronoUnit
+
+rule "Solcast Actions"
+ when
+ Time cron "0 0 23 * * ?" // trigger whatever you like
+ then
+ // Query forecast via Actions
+ val solarforecastActions = getActions("solarforecast","solarforecast:sc-site:homeSite")
+ val startTimestamp = Instant.now
+ val endTimestamp = Instant.now.plus(6, ChronoUnit.DAYS)
+ val sixDayForecast = solarforecastActions.getEnergy(startTimestamp,endTimestamp)
+ logInfo("SF Tests","Forecast Average 6 days "+ sixDayForecast)
+ val sixDayOptimistic = solarforecastActions.getEnergy(startTimestamp,endTimestamp, "optimistic")
+ logInfo("SF Tests","Forecast Optimist 6 days "+ sixDayOptimistic)
+ val sixDayPessimistic = solarforecastActions.getEnergy(startTimestamp,endTimestamp, "pessimistic")
+ logInfo("SF Tests","Forecast Pessimist 6 days "+ sixDayPessimistic)
+
+ // Query forecast TimesSeries Items via historicStata
+ val energyAverage = (Solcast_Site_Average_Energyestimate.historicState(now.plusDays(1)).state as Number)
+ logInfo("SF Tests","Average energy {}",energyAverage)
+ val energyOptimistic = (Solcast_Site_Optimistic_Energyestimate.historicState(now.plusDays(1)).state as Number)
+ logInfo("SF Tests","Optimist energy {}",energyOptimistic)
+end
+```
diff --git a/bundles/org.openhab.binding.solarforecast/doc/SolcastCumulated.png b/bundles/org.openhab.binding.solarforecast/doc/SolcastCumulated.png
new file mode 100644
index 00000000000..d1c692ec3b7
Binary files /dev/null and b/bundles/org.openhab.binding.solarforecast/doc/SolcastCumulated.png differ
diff --git a/bundles/org.openhab.binding.solarforecast/doc/SolcastPower.png b/bundles/org.openhab.binding.solarforecast/doc/SolcastPower.png
new file mode 100644
index 00000000000..fb4ab56f59c
Binary files /dev/null and b/bundles/org.openhab.binding.solarforecast/doc/SolcastPower.png differ
diff --git a/bundles/org.openhab.binding.solarforecast/pom.xml b/bundles/org.openhab.binding.solarforecast/pom.xml
new file mode 100644
index 00000000000..ecd0e93a857
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/pom.xml
@@ -0,0 +1,26 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.binding.solarforecast
+
+ openHAB Add-ons :: Bundles :: SolarForecast Binding
+
+
+
+ org.json
+ json
+ 20231013
+ compile
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/feature/feature.xml b/bundles/org.openhab.binding.solarforecast/src/main/feature/feature.xml
new file mode 100644
index 00000000000..9237c11c88d
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.solarforecast/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastBindingConstants.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastBindingConstants.java
new file mode 100644
index 00000000000..7b4ba46b507
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastBindingConstants.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link SolarForecastBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolarForecastBindingConstants {
+
+ private static final String BINDING_ID = "solarforecast";
+
+ // Things
+ public static final ThingTypeUID FORECAST_SOLAR_SITE = new ThingTypeUID(BINDING_ID, "fs-site");
+ public static final ThingTypeUID FORECAST_SOLAR_PLANE = new ThingTypeUID(BINDING_ID, "fs-plane");
+ public static final ThingTypeUID SOLCAST_SITE = new ThingTypeUID(BINDING_ID, "sc-site");
+ public static final ThingTypeUID SOLCAST_PLANE = new ThingTypeUID(BINDING_ID, "sc-plane");
+ public static final Set SUPPORTED_THING_SET = Set.of(FORECAST_SOLAR_SITE, FORECAST_SOLAR_PLANE,
+ SOLCAST_SITE, SOLCAST_PLANE);
+
+ // Channel groups
+ public static final String GROUP_AVERAGE = "average";
+ public static final String GROUP_OPTIMISTIC = "optimistic";
+ public static final String GROUP_PESSIMISTIC = "pessimistic";
+ public static final String GROUP_RAW = "raw";
+
+ // Channels
+ public static final String CHANNEL_POWER_ESTIMATE = "power-estimate";
+ public static final String CHANNEL_ENERGY_ESTIMATE = "energy-estimate";
+ public static final String CHANNEL_POWER_ACTUAL = "power-actual";
+ public static final String CHANNEL_ENERGY_ACTUAL = "energy-actual";
+ public static final String CHANNEL_ENERGY_REMAIN = "energy-remain";
+ public static final String CHANNEL_ENERGY_TODAY = "energy-today";
+ public static final String CHANNEL_JSON = "json";
+
+ // Other
+ public static final int REFRESH_ACTUAL_INTERVAL = 1;
+ public static final String SLASH = "/";
+ public static final String EMPTY = "";
+ public static final String PATTERN_FORMAT = "yyyy-MM-dd HH:mm:ss";
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastException.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastException.java
new file mode 100644
index 00000000000..06c8c856cf1
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastException.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+
+/**
+ * The {@link SolarForecastException} is thrown if forecast data is invalid
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+@SuppressWarnings("serial")
+public class SolarForecastException extends RuntimeException {
+
+ public SolarForecastException(SolarForecast ref, String message) {
+ super(ref.getIdentifier() + " # " + message);
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastHandlerFactory.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastHandlerFactory.java
new file mode 100644
index 00000000000..9597ab2fdb6
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastHandlerFactory.java
@@ -0,0 +1,83 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal;
+
+import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarBridgeHandler;
+import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneHandler;
+import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastBridgeHandler;
+import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneHandler;
+import org.openhab.core.i18n.LocationProvider;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link SolarForecastHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.solarforecast", service = ThingHandlerFactory.class)
+public class SolarForecastHandlerFactory extends BaseThingHandlerFactory {
+ private final TimeZoneProvider timeZoneProvider;
+ private final HttpClient httpClient;
+ private Optional location = Optional.empty();
+
+ @Activate
+ public SolarForecastHandlerFactory(final @Reference HttpClientFactory hcf, final @Reference LocationProvider lp,
+ final @Reference TimeZoneProvider tzp) {
+ timeZoneProvider = tzp;
+ httpClient = hcf.getCommonHttpClient();
+ PointType pt = lp.getLocation();
+ if (pt != null) {
+ location = Optional.of(pt);
+ }
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SolarForecastBindingConstants.SUPPORTED_THING_SET.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+ if (FORECAST_SOLAR_SITE.equals(thingTypeUID)) {
+ return new ForecastSolarBridgeHandler((Bridge) thing, location);
+ } else if (FORECAST_SOLAR_PLANE.equals(thingTypeUID)) {
+ return new ForecastSolarPlaneHandler(thing, httpClient);
+ } else if (SOLCAST_SITE.equals(thingTypeUID)) {
+ return new SolcastBridgeHandler((Bridge) thing, timeZoneProvider);
+ } else if (SOLCAST_PLANE.equals(thingTypeUID)) {
+ return new SolcastPlaneHandler(thing, httpClient);
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecast.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecast.java
new file mode 100644
index 00000000000..b6d37bb2697
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecast.java
@@ -0,0 +1,110 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.actions;
+
+import java.time.Instant;
+import java.time.LocalDate;
+
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link SolarForecast} Interface needed for Actions
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public interface SolarForecast {
+ /**
+ * Argument can be used to query an average forecast scenario
+ */
+ public static final String AVERAGE = "average";
+ /**
+ * Argument can be used to query an optimistic forecast scenario
+ */
+ public static final String OPTIMISTIC = "optimistic";
+ /**
+ * Argument can be used to query a pessimistic forecast scenario
+ */
+ public static final String PESSIMISTIC = "pessimistic";
+
+ /**
+ * Returns electric energy production for one day
+ *
+ * @param date
+ * @param args possible arguments from this interface
+ * @return QuantityType in kW/h
+ */
+ QuantityType getDay(LocalDate date, String... args);
+
+ /**
+ * Returns electric energy between two timestamps
+ *
+ * @param start
+ * @param end
+ * @param args possible arguments from this interface
+ * @return QuantityType in kW/h
+ */
+ QuantityType getEnergy(Instant start, Instant end, String... args);
+
+ /**
+ * Returns electric power at one specific point of time
+ *
+ * @param timestamp
+ * @param args possible arguments from this interface
+ * @return QuantityType in kW
+ */
+ QuantityType getPower(Instant timestamp, String... args);
+
+ /**
+ * Get the first date and time of forecast data
+ *
+ * @return date time
+ */
+ Instant getForecastBegin();
+
+ /**
+ * Get the last date and time of forecast data
+ *
+ * @return date time
+ */
+ Instant getForecastEnd();
+
+ /**
+ * Get TimeSeries for Power forecast
+ *
+ * @param mode QueryMode for optimistic, pessimistic or average estimation
+ * @return TimeSeries containing QuantityType
+ */
+ TimeSeries getPowerTimeSeries(QueryMode mode);
+
+ /**
+ * Get TimeSeries for Energy forecast
+ *
+ * @param mode QueryMode for optimistic, pessimistic or average estimation
+ * @return TimeSeries containing QuantityType
+ */
+ TimeSeries getEnergyTimeSeries(QueryMode mode);
+
+ /**
+ * SolarForecast identifier
+ *
+ * @return unique String to identify solar plane
+ */
+ String getIdentifier();
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastActions.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastActions.java
new file mode 100644
index 00000000000..c794ebb1c3d
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastActions.java
@@ -0,0 +1,195 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.actions;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+
+import javax.measure.MetricPrefix;
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.types.State;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Actions to query forecast objects
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@ThingActionsScope(name = "solarforecast")
+@NonNullByDefault
+public class SolarForecastActions implements ThingActions {
+ private final Logger logger = LoggerFactory.getLogger(SolarForecastActions.class);
+ private Optional thingHandler = Optional.empty();
+
+ @RuleAction(label = "@text/actionDayLabel", description = "@text/actionDayDesc")
+ public QuantityType getDay(
+ @ActionInput(name = "localDate", label = "@text/actionInputDayLabel", description = "@text/actionInputDayDesc") LocalDate localDate,
+ String... args) {
+ if (thingHandler.isPresent()) {
+ List l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+ if (!l.isEmpty()) {
+ QuantityType measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
+ for (Iterator iterator = l.iterator(); iterator.hasNext();) {
+ SolarForecast solarForecast = iterator.next();
+ QuantityType qt = solarForecast.getDay(localDate, args);
+ if (qt.floatValue() >= 0) {
+ measure = measure.add(qt);
+ } else {
+ // break in case of failure getting values to avoid ambiguous values
+ logger.debug("Ambiguous measure {} found for {}", qt, localDate);
+ return Utils.getEnergyState(-1);
+ }
+ }
+ return measure;
+ } else {
+ logger.debug("No forecasts found for {}", localDate);
+ return Utils.getEnergyState(-1);
+ }
+ } else {
+ logger.trace("Handler missing");
+ return Utils.getEnergyState(-1);
+ }
+ }
+
+ @RuleAction(label = "@text/actionPowerLabel", description = "@text/actionPowerDesc")
+ public QuantityType getPower(
+ @ActionInput(name = "timestamp", label = "@text/actionInputDateTimeLabel", description = "@text/actionInputDateTimeDesc") Instant timestamp,
+ String... args) {
+ if (thingHandler.isPresent()) {
+ List l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+ if (!l.isEmpty()) {
+ QuantityType measure = QuantityType.valueOf(0, MetricPrefix.KILO(Units.WATT));
+ for (Iterator iterator = l.iterator(); iterator.hasNext();) {
+ SolarForecast solarForecast = iterator.next();
+ QuantityType qt = solarForecast.getPower(timestamp, args);
+ if (qt.floatValue() >= 0) {
+ measure = measure.add(qt);
+ } else {
+ // break in case of failure getting values to avoid ambiguous values
+ logger.debug("Ambiguous measure {} found for {}", qt, timestamp);
+ return Utils.getPowerState(-1);
+ }
+ }
+ return measure;
+ } else {
+ logger.debug("No forecasts found for {}", timestamp);
+ return Utils.getPowerState(-1);
+ }
+ } else {
+ logger.trace("Handler missing");
+ return Utils.getPowerState(-1);
+ }
+ }
+
+ @RuleAction(label = "@text/actionEnergyLabel", description = "@text/actionEnergyDesc")
+ public QuantityType getEnergy(
+ @ActionInput(name = "start", label = "@text/actionInputDateTimeBeginLabel", description = "@text/actionInputDateTimeBeginDesc") Instant start,
+ @ActionInput(name = "end", label = "@text/actionInputDateTimeEndLabel", description = "@text/actionInputDateTimeEndDesc") Instant end,
+ String... args) {
+ if (thingHandler.isPresent()) {
+ List l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+ if (!l.isEmpty()) {
+ QuantityType measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
+ for (Iterator iterator = l.iterator(); iterator.hasNext();) {
+ SolarForecast solarForecast = iterator.next();
+ QuantityType qt = solarForecast.getEnergy(start, end, args);
+ if (qt.floatValue() >= 0) {
+ measure = measure.add(qt);
+ } else {
+ // break in case of failure getting values to avoid ambiguous values
+ logger.debug("Ambiguous measure {} found between {} and {}", qt, start, end);
+ return Utils.getEnergyState(-1);
+ }
+ }
+ return measure;
+ } else {
+ logger.debug("No forecasts found for between {} and {}", start, end);
+ return Utils.getEnergyState(-1);
+ }
+ } else {
+ logger.trace("Handler missing");
+ return Utils.getEnergyState(-1);
+ }
+ }
+
+ @RuleAction(label = "@text/actionForecastBeginLabel", description = "@text/actionForecastBeginDesc")
+ public Instant getForecastBegin() {
+ if (thingHandler.isPresent()) {
+ List forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+ return Utils.getCommonStartTime(forecastObjectList);
+ } else {
+ logger.trace("Handler missing - return invalid date MAX");
+ return Instant.MAX;
+ }
+ }
+
+ @RuleAction(label = "@text/actionForecastEndLabel", description = "@text/actionForecastEndDesc")
+ public Instant getForecastEnd() {
+ if (thingHandler.isPresent()) {
+ List forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+ return Utils.getCommonEndTime(forecastObjectList);
+ } else {
+ logger.trace("Handler missing - return invalid date MIN");
+ return Instant.MIN;
+ }
+ }
+
+ public static State getDay(ThingActions actions, LocalDate ld, String... args) {
+ return ((SolarForecastActions) actions).getDay(ld, args);
+ }
+
+ public static State getPower(ThingActions actions, Instant dateTime, String... args) {
+ return ((SolarForecastActions) actions).getPower(dateTime, args);
+ }
+
+ public static State getEnergy(ThingActions actions, Instant begin, Instant end, String... args) {
+ return ((SolarForecastActions) actions).getEnergy(begin, end, args);
+ }
+
+ public static Instant getForecastBegin(ThingActions actions) {
+ return ((SolarForecastActions) actions).getForecastBegin();
+ }
+
+ public static Instant getForecastEnd(ThingActions actions) {
+ return ((SolarForecastActions) actions).getForecastEnd();
+ }
+
+ @Override
+ public void setThingHandler(ThingHandler handler) {
+ thingHandler = Optional.of(handler);
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ if (thingHandler.isPresent()) {
+ return thingHandler.get();
+ }
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastProvider.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastProvider.java
new file mode 100644
index 00000000000..0163cf2bb5b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastProvider.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.actions;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link SolarForecastProvider} Interface needed for Actions
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public interface SolarForecastProvider {
+
+ /**
+ * Provides List of available SolarForecast Interface implementations
+ *
+ * @return list of SolarForecast objects
+ */
+ List getSolarForecasts();
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/ForecastSolarObject.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/ForecastSolarObject.java
new file mode 100644
index 00000000000..de15a50c063
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/ForecastSolarObject.java
@@ -0,0 +1,345 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.forecastsolar;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Iterator;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.TreeMap;
+
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.types.TimeSeries;
+import org.openhab.core.types.TimeSeries.Policy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ForecastSolarObject} holds complete data for forecast
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class ForecastSolarObject implements SolarForecast {
+ private final Logger logger = LoggerFactory.getLogger(ForecastSolarObject.class);
+ private final TreeMap wattHourMap = new TreeMap<>();
+ private final TreeMap wattMap = new TreeMap<>();
+ private final DateTimeFormatter dateInputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private DateTimeFormatter dateOutputFormatter = DateTimeFormatter
+ .ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT).withZone(ZoneId.systemDefault());
+ private ZoneId zone = ZoneId.systemDefault();
+ private Optional rawData = Optional.empty();
+ private Instant expirationDateTime;
+ private String identifier;
+
+ public ForecastSolarObject(String id) {
+ expirationDateTime = Instant.now().minusSeconds(1);
+ identifier = id;
+ }
+
+ public ForecastSolarObject(String id, String content, Instant expirationDate) throws SolarForecastException {
+ expirationDateTime = expirationDate;
+ identifier = id;
+ if (!content.isEmpty()) {
+ rawData = Optional.of(content);
+ try {
+ JSONObject contentJson = new JSONObject(content);
+ JSONObject resultJson = contentJson.getJSONObject("result");
+ JSONObject wattHourJson = resultJson.getJSONObject("watt_hours");
+ JSONObject wattJson = resultJson.getJSONObject("watts");
+ String zoneStr = contentJson.getJSONObject("message").getJSONObject("info").getString("timezone");
+ zone = ZoneId.of(zoneStr);
+ dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
+ .withZone(zone);
+ Iterator iter = wattHourJson.keys();
+ // put all values of the current day into sorted tree map
+ while (iter.hasNext()) {
+ String dateStr = iter.next();
+ // convert date time into machine readable format
+ try {
+ ZonedDateTime zdt = LocalDateTime.parse(dateStr, dateInputFormatter).atZone(zone);
+ wattHourMap.put(zdt, wattHourJson.getDouble(dateStr));
+ wattMap.put(zdt, wattJson.getDouble(dateStr));
+ } catch (DateTimeParseException dtpe) {
+ logger.warn("Error parsing time {} Reason: {}", dateStr, dtpe.getMessage());
+ throw new SolarForecastException(this,
+ "Error parsing time " + dateStr + " Reason: " + dtpe.getMessage());
+ }
+ }
+ } catch (JSONException je) {
+ throw new SolarForecastException(this,
+ "Error parsing JSON response " + content + " Reason: " + je.getMessage());
+ }
+ }
+ }
+
+ public boolean isExpired() {
+ return expirationDateTime.isBefore(Instant.now());
+ }
+
+ public double getActualEnergyValue(ZonedDateTime queryDateTime) throws SolarForecastException {
+ Entry f = wattHourMap.floorEntry(queryDateTime);
+ Entry c = wattHourMap.ceilingEntry(queryDateTime);
+ if (f != null && c == null) {
+ // only floor available
+ if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
+ // floor has valid date
+ return f.getValue() / 1000.0;
+ } else {
+ // floor date doesn't fit
+ throwOutOfRangeException(queryDateTime.toInstant());
+ }
+ } else if (f == null && c != null) {
+ if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
+ // only ceiling from correct date available - no valid data reached yet
+ return 0;
+ } else {
+ // ceiling date doesn't fit
+ throwOutOfRangeException(queryDateTime.toInstant());
+ }
+ } else if (f != null && c != null) {
+ // ceiling and floor available
+ if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
+ if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
+ // we're during suntime!
+ double production = c.getValue() - f.getValue();
+ long floorToCeilingDuration = Duration.between(f.getKey(), c.getKey()).toMinutes();
+ if (floorToCeilingDuration == 0) {
+ return f.getValue() / 1000.0;
+ }
+ long floorToQueryDuration = Duration.between(f.getKey(), queryDateTime).toMinutes();
+ double interpolation = (double) floorToQueryDuration / (double) floorToCeilingDuration;
+ double interpolationProduction = production * interpolation;
+ double actualProduction = f.getValue() + interpolationProduction;
+ return actualProduction / 1000.0;
+ } else {
+ // ceiling from wrong date, but floor is valid
+ return f.getValue() / 1000.0;
+ }
+ } else {
+ // floor invalid - ceiling not reached
+ return 0;
+ }
+ } // else both null - date time doesn't fit to forecast data
+ throwOutOfRangeException(queryDateTime.toInstant());
+ return -1;
+ }
+
+ @Override
+ public TimeSeries getEnergyTimeSeries(QueryMode mode) {
+ TimeSeries ts = new TimeSeries(Policy.REPLACE);
+ wattHourMap.forEach((timestamp, energy) -> {
+ ts.add(timestamp.toInstant(), Utils.getEnergyState(energy / 1000.0));
+ });
+ return ts;
+ }
+
+ public double getActualPowerValue(ZonedDateTime queryDateTime) {
+ double actualPowerValue = 0;
+ Entry f = wattMap.floorEntry(queryDateTime);
+ Entry c = wattMap.ceilingEntry(queryDateTime);
+ if (f != null && c == null) {
+ // only floor available
+ if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
+ // floor has valid date
+ return f.getValue() / 1000.0;
+ } else {
+ // floor date doesn't fit
+ throwOutOfRangeException(queryDateTime.toInstant());
+ }
+ } else if (f == null && c != null) {
+ if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
+ // only ceiling from correct date available - no valid data reached yet
+ return 0;
+ } else {
+ // ceiling date doesn't fit
+ throwOutOfRangeException(queryDateTime.toInstant());
+ }
+ } else if (f != null && c != null) {
+ // we're during suntime!
+ long floorToCeilingDuration = Duration.between(f.getKey(), c.getKey()).toMinutes();
+ double powerFloor = f.getValue();
+ if (floorToCeilingDuration == 0) {
+ return powerFloor / 1000.0;
+ }
+ double powerCeiling = c.getValue();
+ // calculate in minutes from floor to now, e.g. 20 minutes
+ // => take 2/3 of floor and 1/3 of ceiling
+ long floorToQueryDuration = Duration.between(f.getKey(), queryDateTime).toMinutes();
+ double interpolation = (double) floorToQueryDuration / (double) floorToCeilingDuration;
+ actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
+ return actualPowerValue / 1000.0;
+ } // else both null - this shall not happen
+ throwOutOfRangeException(queryDateTime.toInstant());
+ return -1;
+ }
+
+ @Override
+ public TimeSeries getPowerTimeSeries(QueryMode mode) {
+ TimeSeries ts = new TimeSeries(Policy.REPLACE);
+ wattMap.forEach((timestamp, power) -> {
+ ts.add(timestamp.toInstant(), Utils.getPowerState(power / 1000.0));
+ });
+ return ts;
+ }
+
+ public double getDayTotal(LocalDate queryDate) {
+ if (rawData.isEmpty()) {
+ throw new SolarForecastException(this, "No forecast data available");
+ }
+ JSONObject contentJson = new JSONObject(rawData.get());
+ JSONObject resultJson = contentJson.getJSONObject("result");
+ JSONObject wattsDay = resultJson.getJSONObject("watt_hours_day");
+
+ if (wattsDay.has(queryDate.toString())) {
+ return wattsDay.getDouble(queryDate.toString()) / 1000.0;
+ } else {
+ throw new SolarForecastException(this,
+ "Day " + queryDate + " not available in forecast. " + getTimeRange());
+ }
+ }
+
+ public double getRemainingProduction(ZonedDateTime queryDateTime) {
+ double daily = getDayTotal(queryDateTime.toLocalDate());
+ double actual = getActualEnergyValue(queryDateTime);
+ return daily - actual;
+ }
+
+ public String getRaw() {
+ if (rawData.isPresent()) {
+ return rawData.get();
+ }
+ return "{}";
+ }
+
+ public ZoneId getZone() {
+ return zone;
+ }
+
+ @Override
+ public String toString() {
+ return "Expiration: " + expirationDateTime + ", Data:" + wattHourMap;
+ }
+
+ /**
+ * SolarForecast Interface
+ */
+ @Override
+ public QuantityType getDay(LocalDate localDate, String... args) throws IllegalArgumentException {
+ if (args.length > 0) {
+ throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
+ }
+ double measure = getDayTotal(localDate);
+ return Utils.getEnergyState(measure);
+ }
+
+ @Override
+ public QuantityType getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
+ if (args.length > 0) {
+ throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
+ }
+ LocalDate beginDate = start.atZone(zone).toLocalDate();
+ LocalDate endDate = end.atZone(zone).toLocalDate();
+ double measure = -1;
+ if (beginDate.equals(endDate)) {
+ measure = getDayTotal(beginDate) - getActualEnergyValue(start.atZone(zone))
+ - getRemainingProduction(end.atZone(zone));
+ } else {
+ measure = getRemainingProduction(start.atZone(zone));
+ beginDate = beginDate.plusDays(1);
+ while (beginDate.isBefore(endDate) && measure >= 0) {
+ double day = getDayTotal(beginDate);
+ if (day > 0) {
+ measure += day;
+ }
+ beginDate = beginDate.plusDays(1);
+ }
+ double lastDay = getActualEnergyValue(end.atZone(zone));
+ if (lastDay >= 0) {
+ measure += lastDay;
+ }
+ }
+ return Utils.getEnergyState(measure);
+ }
+
+ @Override
+ public QuantityType getPower(Instant timestamp, String... args) throws IllegalArgumentException {
+ if (args.length > 0) {
+ throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
+ }
+ double measure = getActualPowerValue(timestamp.atZone(zone));
+ return Utils.getPowerState(measure);
+ }
+
+ @Override
+ public Instant getForecastBegin() {
+ if (wattHourMap.isEmpty()) {
+ return Instant.MAX;
+ }
+ ZonedDateTime zdt = wattHourMap.firstEntry().getKey();
+ return zdt.toInstant();
+ }
+
+ @Override
+ public Instant getForecastEnd() {
+ if (wattHourMap.isEmpty()) {
+ return Instant.MIN;
+ }
+ ZonedDateTime zdt = wattHourMap.lastEntry().getKey();
+ return zdt.toInstant();
+ }
+
+ private void throwOutOfRangeException(Instant query) {
+ if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
+ throw new SolarForecastException(this, "Forecast invalid time range");
+ }
+ if (query.isBefore(getForecastBegin())) {
+ throw new SolarForecastException(this,
+ "Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
+ } else if (query.isAfter(getForecastEnd())) {
+ throw new SolarForecastException(this,
+ "Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
+ } else {
+ logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
+ }
+ }
+
+ private String getTimeRange() {
+ return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
+ + dateOutputFormatter.format(getForecastEnd());
+ }
+
+ @Override
+ public String getIdentifier() {
+ return identifier;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarBridgeConfiguration.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarBridgeConfiguration.java
new file mode 100644
index 00000000000..89f1fd12082
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarBridgeConfiguration.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.forecastsolar.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+
+/**
+ * The {@link ForecastSolarBridgeConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class ForecastSolarBridgeConfiguration {
+ public String location = "";
+ public String apiKey = SolarForecastBindingConstants.EMPTY;
+ public double inverterKwp = Double.MAX_VALUE;
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarPlaneConfiguration.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarPlaneConfiguration.java
new file mode 100644
index 00000000000..ed2da9a384b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarPlaneConfiguration.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.forecastsolar.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+
+/**
+ * The {@link ForecastSolarPlaneConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class ForecastSolarPlaneConfiguration {
+ public int declination = -1;
+ public int azimuth = -1;
+ public double kwp = 0;
+ public long refreshInterval = 30;
+ public double dampAM = 0.25;
+ public double dampPM = 0.25;
+ public String horizon = SolarForecastBindingConstants.EMPTY;
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarBridgeHandler.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarBridgeHandler.java
new file mode 100644
index 00000000000..487d93e86dd
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarBridgeHandler.java
@@ -0,0 +1,235 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
+
+import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
+import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
+import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarBridgeConfiguration;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.TimeSeries;
+import org.openhab.core.types.TimeSeries.Policy;
+
+/**
+ * The {@link ForecastSolarBridgeHandler} is a non active handler instance. It will be triggerer by the bridge.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class ForecastSolarBridgeHandler extends BaseBridgeHandler implements SolarForecastProvider {
+ private List planes = new ArrayList<>();
+ private Optional homeLocation;
+ private Optional configuration = Optional.empty();
+ private Optional> refreshJob = Optional.empty();
+
+ public ForecastSolarBridgeHandler(Bridge bridge, Optional location) {
+ super(bridge);
+ homeLocation = location;
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(SolarForecastActions.class);
+ }
+
+ @Override
+ public void initialize() {
+ ForecastSolarBridgeConfiguration config = getConfigAs(ForecastSolarBridgeConfiguration.class);
+ PointType locationConfigured;
+
+ // handle location error cases
+ if (config.location.isBlank()) {
+ if (homeLocation.isEmpty()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.site.status.location-missing");
+ return;
+ } else {
+ locationConfigured = homeLocation.get();
+ // continue with openHAB location
+ }
+ } else {
+ try {
+ locationConfigured = new PointType(config.location);
+ // continue with location from configuration
+ } catch (Exception e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ return;
+ }
+ }
+ Configuration editConfig = editConfiguration();
+ editConfig.put("location", locationConfigured.toString());
+ updateConfiguration(editConfig);
+ config = getConfigAs(ForecastSolarBridgeConfiguration.class);
+ configuration = Optional.of(config);
+ updateStatus(ThingStatus.UNKNOWN);
+ refreshJob = Optional
+ .of(scheduler.scheduleWithFixedDelay(this::getData, 0, REFRESH_ACTUAL_INTERVAL, TimeUnit.MINUTES));
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ String channel = channelUID.getIdWithoutGroup();
+ switch (channel) {
+ case CHANNEL_ENERGY_ACTUAL:
+ case CHANNEL_ENERGY_REMAIN:
+ case CHANNEL_ENERGY_TODAY:
+ case CHANNEL_POWER_ACTUAL:
+ getData();
+ break;
+ case CHANNEL_POWER_ESTIMATE:
+ case CHANNEL_ENERGY_ESTIMATE:
+ forecastUpdate();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Get data for all planes. Synchronized to protect plane list from being modified during update
+ */
+ private synchronized void getData() {
+ if (planes.isEmpty()) {
+ return;
+ }
+ boolean update = true;
+ double energySum = 0;
+ double powerSum = 0;
+ double daySum = 0;
+ for (Iterator iterator = planes.iterator(); iterator.hasNext();) {
+ try {
+ ForecastSolarPlaneHandler sfph = iterator.next();
+ ForecastSolarObject fo = sfph.fetchData();
+ ZonedDateTime now = ZonedDateTime.now(fo.getZone());
+ energySum += fo.getActualEnergyValue(now);
+ powerSum += fo.getActualPowerValue(now);
+ daySum += fo.getDayTotal(now.toLocalDate());
+ } catch (SolarForecastException sfe) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
+ "@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
+ update = false;
+ }
+ }
+ if (update) {
+ updateStatus(ThingStatus.ONLINE);
+ updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energySum));
+ updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(daySum - energySum));
+ updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(daySum));
+ updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(powerSum));
+ }
+ }
+
+ public synchronized void forecastUpdate() {
+ if (planes.isEmpty()) {
+ return;
+ }
+ TreeMap> combinedPowerForecast = new TreeMap<>();
+ TreeMap> combinedEnergyForecast = new TreeMap<>();
+ List forecastObjects = new ArrayList<>();
+ for (Iterator iterator = planes.iterator(); iterator.hasNext();) {
+ ForecastSolarPlaneHandler sfph = iterator.next();
+ forecastObjects.addAll(sfph.getSolarForecasts());
+ }
+
+ // bugfix: https://github.com/weymann/OH3-SolarForecast-Drops/issues/5
+ // find common start and end time which fits to all forecast objects to avoid ambiguous values
+ final Instant commonStart = Utils.getCommonStartTime(forecastObjects);
+ final Instant commonEnd = Utils.getCommonEndTime(forecastObjects);
+ forecastObjects.forEach(fc -> {
+ TimeSeries powerTS = fc.getPowerTimeSeries(QueryMode.Average);
+ powerTS.getStates().forEach(entry -> {
+ if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
+ && Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
+ Utils.addState(combinedPowerForecast, entry);
+ }
+ });
+ TimeSeries energyTS = fc.getEnergyTimeSeries(QueryMode.Average);
+ energyTS.getStates().forEach(entry -> {
+ if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
+ && Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
+ Utils.addState(combinedEnergyForecast, entry);
+ }
+ });
+ });
+
+ TimeSeries powerSeries = new TimeSeries(Policy.REPLACE);
+ combinedPowerForecast.forEach((timestamp, state) -> {
+ powerSeries.add(timestamp, state);
+ });
+ sendTimeSeries(CHANNEL_POWER_ESTIMATE, powerSeries);
+
+ TimeSeries energySeries = new TimeSeries(Policy.REPLACE);
+ combinedEnergyForecast.forEach((timestamp, state) -> {
+ energySeries.add(timestamp, state);
+ });
+ sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, energySeries);
+ }
+
+ @Override
+ public void dispose() {
+ refreshJob.ifPresent(job -> job.cancel(true));
+ }
+
+ public synchronized void addPlane(ForecastSolarPlaneHandler sfph) {
+ planes.add(sfph);
+ // update passive PV plane with necessary data
+ if (configuration.isPresent()) {
+ sfph.setLocation(new PointType(configuration.get().location));
+ if (!configuration.get().apiKey.isBlank()) {
+ sfph.setApiKey(configuration.get().apiKey);
+ }
+ }
+ getData();
+ }
+
+ public synchronized void removePlane(ForecastSolarPlaneHandler sfph) {
+ planes.remove(sfph);
+ }
+
+ @Override
+ public synchronized List getSolarForecasts() {
+ List l = new ArrayList();
+ planes.forEach(entry -> {
+ l.addAll(entry.getSolarForecasts());
+ });
+ return l;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneHandler.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneHandler.java
new file mode 100644
index 00000000000..a15f617fdc3
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneHandler.java
@@ -0,0 +1,225 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
+
+import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
+import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
+import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarPlaneConfiguration;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.BridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ForecastSolarPlaneHandler} is a non active handler instance. It will be triggered by the bridge.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class ForecastSolarPlaneHandler extends BaseThingHandler implements SolarForecastProvider {
+ public static final String BASE_URL = "https://api.forecast.solar/";
+
+ private final Logger logger = LoggerFactory.getLogger(ForecastSolarPlaneHandler.class);
+ private final HttpClient httpClient;
+
+ private Optional configuration = Optional.empty();
+ private Optional bridgeHandler = Optional.empty();
+ private Optional location = Optional.empty();
+ private Optional apiKey = Optional.empty();
+ private ForecastSolarObject forecast;
+
+ public ForecastSolarPlaneHandler(Thing thing, HttpClient hc) {
+ super(thing);
+ httpClient = hc;
+ forecast = new ForecastSolarObject(thing.getUID().getAsString());
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(SolarForecastActions.class);
+ }
+
+ @Override
+ public void initialize() {
+ ForecastSolarPlaneConfiguration c = getConfigAs(ForecastSolarPlaneConfiguration.class);
+ configuration = Optional.of(c);
+ Bridge bridge = getBridge();
+ if (bridge != null) {
+ BridgeHandler handler = bridge.getHandler();
+ if (handler != null) {
+ if (handler instanceof ForecastSolarBridgeHandler fsbh) {
+ bridgeHandler = Optional.of(fsbh);
+ updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE,
+ "@text/solarforecast.plane.status.await-feedback");
+ fsbh.addPlane(this);
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.plane.status.wrong-handler" + " [\"" + handler + "\"]");
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.plane.status.bridge-handler-not-found");
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.plane.status.bridge-missing");
+ }
+ }
+
+ @Override
+ public void dispose() {
+ super.dispose();
+ if (bridgeHandler.isPresent()) {
+ bridgeHandler.get().removePlane(this);
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ if (CHANNEL_POWER_ESTIMATE.equals(channelUID.getIdWithoutGroup())) {
+ sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecast.getPowerTimeSeries(QueryMode.Average));
+ } else if (CHANNEL_ENERGY_ESTIMATE.equals(channelUID.getIdWithoutGroup())) {
+ sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecast.getEnergyTimeSeries(QueryMode.Average));
+ } else if (CHANNEL_JSON.equals(channelUID.getIdWithoutGroup())) {
+ updateState(CHANNEL_JSON, StringType.valueOf(forecast.getRaw()));
+ } else {
+ fetchData();
+ }
+ }
+ }
+
+ /**
+ * https://doc.forecast.solar/doku.php?id=api:estimate
+ */
+ protected ForecastSolarObject fetchData() {
+ if (location.isPresent()) {
+ if (forecast.isExpired()) {
+ String url = getBaseUrl() + "estimate/" + location.get().getLatitude() + SLASH
+ + location.get().getLongitude() + SLASH + configuration.get().declination + SLASH
+ + configuration.get().azimuth + SLASH + configuration.get().kwp + "?damping="
+ + configuration.get().dampAM + "," + configuration.get().dampPM;
+ if (!SolarForecastBindingConstants.EMPTY.equals(configuration.get().horizon)) {
+ url += "&horizon=" + configuration.get().horizon;
+ }
+ try {
+ ContentResponse cr = httpClient.GET(url);
+ if (cr.getStatus() == 200) {
+ try {
+ ForecastSolarObject localForecast = new ForecastSolarObject(thing.getUID().getAsString(),
+ cr.getContentAsString(),
+ Instant.now().plus(configuration.get().refreshInterval, ChronoUnit.MINUTES));
+ updateStatus(ThingStatus.ONLINE);
+ updateState(CHANNEL_JSON, StringType.valueOf(cr.getContentAsString()));
+ setForecast(localForecast);
+ } catch (SolarForecastException fse) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
+ "@text/solarforecast.plane.status.json-status [\"" + fse.getMessage() + "\"]");
+ }
+ } else {
+ logger.trace("Call {} failed with status {}. Response: {}", url, cr.getStatus(),
+ cr.getContentAsString());
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/solarforecast.plane.status.http-status [\"" + cr.getStatus() + "\"]");
+ }
+ } catch (ExecutionException | TimeoutException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (InterruptedException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ Thread.currentThread().interrupt();
+ }
+ } else {
+ // else use available forecast
+ updateChannels(forecast);
+ }
+ } else {
+ logger.warn("{} Location not present", thing.getLabel());
+ }
+ return forecast;
+ }
+
+ private void updateChannels(ForecastSolarObject f) {
+ ZonedDateTime now = ZonedDateTime.now(f.getZone());
+ double energyDay = f.getDayTotal(now.toLocalDate());
+ double energyProduced = f.getActualEnergyValue(now);
+ updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energyProduced));
+ updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(energyDay - energyProduced));
+ updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(energyDay));
+ updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(f.getActualPowerValue(now)));
+ }
+
+ /**
+ * Used by Bridge to set location directly
+ *
+ * @param loc
+ */
+ void setLocation(PointType loc) {
+ location = Optional.of(loc);
+ }
+
+ void setApiKey(String key) {
+ apiKey = Optional.of(key);
+ }
+
+ String getBaseUrl() {
+ String url = BASE_URL;
+ if (apiKey.isPresent()) {
+ url += apiKey.get() + SLASH;
+ }
+ return url;
+ }
+
+ protected synchronized void setForecast(ForecastSolarObject f) {
+ forecast = f;
+ sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecast.getPowerTimeSeries(QueryMode.Average));
+ sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecast.getEnergyTimeSeries(QueryMode.Average));
+ bridgeHandler.ifPresent(h -> {
+ h.forecastUpdate();
+ });
+ }
+
+ @Override
+ public synchronized List getSolarForecasts() {
+ return List.of(forecast);
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastConstants.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastConstants.java
new file mode 100644
index 00000000000..f55b807eb9d
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastConstants.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.unit.MetricPrefix;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * The {@link SolcastConstants} class defines common constants for Solcast Service
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastConstants {
+ private static final String BASE_URL = "https://api.solcast.com.au/rooftop_sites/";
+ public static final String FORECAST_URL = BASE_URL + "%s/forecasts?format=json&hours=168";
+ public static final String CURRENT_ESTIMATE_URL = BASE_URL + "%s/estimated_actuals?format=json";
+ public static final String BEARER = "Bearer ";
+ public static final Unit KILOWATT_UNIT = MetricPrefix.KILO(Units.WATT);
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastObject.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastObject.java
new file mode 100644
index 00000000000..667c0e77ff9
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastObject.java
@@ -0,0 +1,498 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.TreeMap;
+
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.types.TimeSeries;
+import org.openhab.core.types.TimeSeries.Policy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SolcastObject} holds complete data for forecast
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastObject implements SolarForecast {
+ private static final TreeMap EMPTY_MAP = new TreeMap<>();
+
+ private final Logger logger = LoggerFactory.getLogger(SolcastObject.class);
+ private final TreeMap estimationDataMap = new TreeMap<>();
+ private final TreeMap optimisticDataMap = new TreeMap<>();
+ private final TreeMap pessimisticDataMap = new TreeMap<>();
+ private final TimeZoneProvider timeZoneProvider;
+
+ private DateTimeFormatter dateOutputFormatter;
+ private String identifier;
+ private Optional rawData = Optional.of(new JSONObject());
+ private Instant expirationDateTime;
+ private long period = 30;
+
+ public enum QueryMode {
+ Average(SolarForecast.AVERAGE),
+ Optimistic(SolarForecast.OPTIMISTIC),
+ Pessimistic(SolarForecast.PESSIMISTIC),
+ Error("Error");
+
+ String modeDescirption;
+
+ QueryMode(String description) {
+ modeDescirption = description;
+ }
+
+ @Override
+ public String toString() {
+ return modeDescirption;
+ }
+ }
+
+ public SolcastObject(String id, TimeZoneProvider tzp) {
+ // invalid forecast object
+ identifier = id;
+ timeZoneProvider = tzp;
+ dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
+ .withZone(tzp.getTimeZone());
+ expirationDateTime = Instant.now().minusSeconds(1);
+ }
+
+ public SolcastObject(String id, String content, Instant expiration, TimeZoneProvider tzp) {
+ identifier = id;
+ expirationDateTime = expiration;
+ timeZoneProvider = tzp;
+ dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
+ .withZone(tzp.getTimeZone());
+ add(content);
+ }
+
+ public void join(String content) {
+ add(content);
+ }
+
+ private void add(String content) {
+ if (!content.isEmpty()) {
+ JSONObject contentJson = new JSONObject(content);
+ JSONArray resultJsonArray;
+
+ // prepare data for raw channel
+ if (contentJson.has("forecasts")) {
+ resultJsonArray = contentJson.getJSONArray("forecasts");
+ addJSONArray(resultJsonArray);
+ rawData.get().put("forecasts", resultJsonArray);
+ }
+ if (contentJson.has("estimated_actuals")) {
+ resultJsonArray = contentJson.getJSONArray("estimated_actuals");
+ addJSONArray(resultJsonArray);
+ rawData.get().put("estimated_actuals", resultJsonArray);
+ }
+ }
+ }
+
+ private void addJSONArray(JSONArray resultJsonArray) {
+ // sort data into TreeMaps
+ for (int i = 0; i < resultJsonArray.length(); i++) {
+ JSONObject jo = resultJsonArray.getJSONObject(i);
+ String periodEnd = jo.getString("period_end");
+ ZonedDateTime periodEndZdt = getZdtFromUTC(periodEnd);
+ if (periodEndZdt == null) {
+ return;
+ }
+ estimationDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
+
+ // fill pessimistic values
+ if (jo.has("pv_estimate10")) {
+ pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate10"));
+ } else {
+ pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
+ }
+
+ // fill optimistic values
+ if (jo.has("pv_estimate90")) {
+ optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate90"));
+ } else {
+ optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
+ }
+ if (jo.has("period")) {
+ period = Duration.parse(jo.getString("period")).toMinutes();
+ }
+ }
+ }
+
+ public boolean isExpired() {
+ return expirationDateTime.isBefore(Instant.now());
+ }
+
+ public double getActualEnergyValue(ZonedDateTime query, QueryMode mode) {
+ // calculate energy from day begin to latest entry BEFORE query
+ ZonedDateTime iterationDateTime = query.withHour(0).withMinute(0).withSecond(0);
+ TreeMap dtm = getDataMap(mode);
+ Entry nextEntry = dtm.higherEntry(iterationDateTime);
+ if (nextEntry == null) {
+ throwOutOfRangeException(query.toInstant());
+ return -1;
+ }
+ double forecastValue = 0;
+ double previousEstimate = 0;
+ while (nextEntry.getKey().isBefore(query) || nextEntry.getKey().isEqual(query)) {
+ // value are reported in PT30M = 30 minutes interval with kw value
+ // for kw/h it's half the value
+ Double endValue = nextEntry.getValue();
+ // production during period is half of previous and next value
+ double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
+ forecastValue += addedValue;
+ previousEstimate = endValue.doubleValue();
+ iterationDateTime = nextEntry.getKey();
+ nextEntry = dtm.higherEntry(iterationDateTime);
+ if (nextEntry == null) {
+ break;
+ }
+ }
+ // interpolate minutes AFTER query
+ Entry f = dtm.floorEntry(query);
+ Entry c = dtm.ceilingEntry(query);
+ if (f != null) {
+ if (c != null) {
+ long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
+ // floor == ceiling: no addon calculation needed
+ if (duration == 0) {
+ return forecastValue;
+ }
+ if (c.getValue() > 0) {
+ double interpolation = Duration.between(f.getKey(), query).toMinutes() / 60.0;
+ double interpolationProduction = getActualPowerValue(query, mode) * interpolation;
+ forecastValue += interpolationProduction;
+ return forecastValue;
+ } else {
+ // if ceiling value is 0 there's no further production in this period
+ return forecastValue;
+ }
+ } else {
+ // if ceiling is null we're at the very end of the day
+ return forecastValue;
+ }
+ } else {
+ // if floor is null we're at the very beginning of the day => 0
+ return 0;
+ }
+ }
+
+ @Override
+ public TimeSeries getEnergyTimeSeries(QueryMode mode) {
+ TreeMap dtm = getDataMap(mode);
+ TimeSeries ts = new TimeSeries(Policy.REPLACE);
+ dtm.forEach((timestamp, energy) -> {
+ ts.add(timestamp.toInstant(), Utils.getEnergyState(getActualEnergyValue(timestamp, mode)));
+ });
+ return ts;
+ }
+
+ /**
+ * Get power values
+ */
+ public double getActualPowerValue(ZonedDateTime query, QueryMode mode) {
+ if (query.toInstant().isBefore(getForecastBegin()) || query.toInstant().isAfter(getForecastEnd())) {
+ throwOutOfRangeException(query.toInstant());
+ }
+ TreeMap dtm = getDataMap(mode);
+ double actualPowerValue = 0;
+ Entry f = dtm.floorEntry(query);
+ Entry c = dtm.ceilingEntry(query);
+ if (f != null) {
+ if (c != null) {
+ double powerCeiling = c.getValue();
+ long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
+ // floor == ceiling: return power from node, no interpolation needed
+ if (duration == 0) {
+ return powerCeiling;
+ }
+ if (powerCeiling > 0) {
+ double powerFloor = f.getValue();
+ // calculate in minutes from floor to now, e.g. 20 minutes from PT30M 30 minutes
+ // => take 1/3 of floor and 2/3 of ceiling
+ double interpolation = Duration.between(f.getKey(), query).toMinutes() / (double) period;
+ actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
+ return actualPowerValue;
+ } else {
+ // if power ceiling == 0 there's no production in this period
+ return 0;
+ }
+ } else {
+ // if ceiling is null we're at the very end of this day => 0
+ return 0;
+ }
+ } else {
+ // if floor is null we're at the very beginning of this day => 0
+ return 0;
+ }
+ }
+
+ @Override
+ public TimeSeries getPowerTimeSeries(QueryMode mode) {
+ TreeMap dtm = getDataMap(mode);
+ TimeSeries ts = new TimeSeries(Policy.REPLACE);
+ dtm.forEach((timestamp, power) -> {
+ ts.add(timestamp.toInstant(), Utils.getPowerState(power));
+ });
+ return ts;
+ }
+
+ /**
+ * Daily totals
+ */
+ public double getDayTotal(LocalDate query, QueryMode mode) {
+ TreeMap dtm = getDataMap(mode);
+ ZonedDateTime iterationDateTime = query.atStartOfDay(timeZoneProvider.getTimeZone());
+ Entry nextEntry = dtm.higherEntry(iterationDateTime);
+ if (nextEntry == null) {
+ throw new SolarForecastException(this, "Day " + query + " not available in forecast. " + getTimeRange());
+ }
+ ZonedDateTime endDateTime = iterationDateTime.plusDays(1);
+ double forecastValue = 0;
+ double previousEstimate = 0;
+ while (nextEntry.getKey().isBefore(endDateTime)) {
+ // value are reported in PT30M = 30 minutes interval with kw value
+ // for kw/h it's half the value
+ Double endValue = nextEntry.getValue();
+ // production during period is half of previous and next value
+ double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
+ forecastValue += addedValue;
+ previousEstimate = endValue.doubleValue();
+ iterationDateTime = nextEntry.getKey();
+ nextEntry = dtm.higherEntry(iterationDateTime);
+ if (nextEntry == null) {
+ break;
+ }
+ }
+ return forecastValue;
+ }
+
+ public double getRemainingProduction(ZonedDateTime query, QueryMode mode) {
+ return getDayTotal(query.toLocalDate(), mode) - getActualEnergyValue(query, mode);
+ }
+
+ @Override
+ public String toString() {
+ return "Expiration: " + expirationDateTime + ", Data: " + estimationDataMap;
+ }
+
+ public String getRaw() {
+ if (rawData.isPresent()) {
+ return rawData.get().toString();
+ }
+ return "{}";
+ }
+
+ private TreeMap getDataMap(QueryMode mode) {
+ TreeMap returnMap = EMPTY_MAP;
+ switch (mode) {
+ case Average:
+ returnMap = estimationDataMap;
+ break;
+ case Optimistic:
+ returnMap = optimisticDataMap;
+ break;
+ case Pessimistic:
+ returnMap = pessimisticDataMap;
+ break;
+ case Error:
+ // nothing to do
+ break;
+ default:
+ // nothing to do
+ break;
+ }
+ return returnMap;
+ }
+
+ public @Nullable ZonedDateTime getZdtFromUTC(String utc) {
+ try {
+ Instant timestamp = Instant.parse(utc);
+ return timestamp.atZone(timeZoneProvider.getTimeZone());
+ } catch (DateTimeParseException dtpe) {
+ logger.warn("Exception parsing time {} Reason: {}", utc, dtpe.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * SolarForecast Interface
+ */
+ @Override
+ public QuantityType getDay(LocalDate date, String... args) throws IllegalArgumentException {
+ QueryMode mode = evalArguments(args);
+ if (mode.equals(QueryMode.Error)) {
+ if (args.length > 1) {
+ throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
+ } else {
+ throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
+ }
+ } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
+ if (date.isBefore(LocalDate.now())) {
+ throw new IllegalArgumentException(
+ "Solcast argument " + mode.toString() + " only available for future values");
+ }
+ }
+ double measure = getDayTotal(date, mode);
+ return Utils.getEnergyState(measure);
+ }
+
+ @Override
+ public QuantityType getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
+ if (end.isBefore(start)) {
+ if (args.length > 1) {
+ throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
+ } else {
+ throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
+ }
+ }
+ QueryMode mode = evalArguments(args);
+ if (mode.equals(QueryMode.Error)) {
+ return Utils.getEnergyState(-1);
+ } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
+ if (end.isBefore(Instant.now())) {
+ throw new IllegalArgumentException(
+ "Solcast argument " + mode.toString() + " only available for future values");
+ }
+ }
+ LocalDate beginDate = start.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
+ LocalDate endDate = end.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
+ double measure = -1;
+ if (beginDate.isEqual(endDate)) {
+ measure = getDayTotal(beginDate, mode)
+ - getActualEnergyValue(start.atZone(timeZoneProvider.getTimeZone()), mode)
+ - getRemainingProduction(end.atZone(timeZoneProvider.getTimeZone()), mode);
+ } else {
+ measure = getRemainingProduction(start.atZone(timeZoneProvider.getTimeZone()), mode);
+ beginDate = beginDate.plusDays(1);
+ while (beginDate.isBefore(endDate) && measure >= 0) {
+ double day = getDayTotal(beginDate, mode);
+ if (day > 0) {
+ measure += day;
+ }
+ beginDate = beginDate.plusDays(1);
+ }
+ double lastDay = getActualEnergyValue(end.atZone(timeZoneProvider.getTimeZone()), mode);
+ if (lastDay >= 0) {
+ measure += lastDay;
+ }
+ }
+ return Utils.getEnergyState(measure);
+ }
+
+ @Override
+ public QuantityType getPower(Instant timestamp, String... args) throws IllegalArgumentException {
+ // eliminate error cases and return immediately
+ QueryMode mode = evalArguments(args);
+ if (mode.equals(QueryMode.Error)) {
+ if (args.length > 1) {
+ throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
+ } else {
+ throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
+ }
+ } else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
+ if (timestamp.isBefore(Instant.now().minus(1, ChronoUnit.MINUTES))) {
+ throw new IllegalArgumentException(
+ "Solcast argument " + mode.toString() + " only available for future values");
+ }
+ }
+ double measure = getActualPowerValue(ZonedDateTime.ofInstant(timestamp, timeZoneProvider.getTimeZone()), mode);
+ return Utils.getPowerState(measure);
+ }
+
+ @Override
+ public Instant getForecastBegin() {
+ if (!estimationDataMap.isEmpty()) {
+ return estimationDataMap.firstEntry().getKey().toInstant();
+ }
+ return Instant.MAX;
+ }
+
+ @Override
+ public Instant getForecastEnd() {
+ if (!estimationDataMap.isEmpty()) {
+ return estimationDataMap.lastEntry().getKey().toInstant();
+ }
+ return Instant.MIN;
+ }
+
+ private QueryMode evalArguments(String[] args) {
+ if (args.length > 0) {
+ if (args.length > 1) {
+ logger.info("Too many arguments {}", Arrays.toString(args));
+ return QueryMode.Error;
+ }
+
+ if (SolarForecast.OPTIMISTIC.equals(args[0])) {
+ return QueryMode.Optimistic;
+ } else if (SolarForecast.PESSIMISTIC.equals(args[0])) {
+ return QueryMode.Pessimistic;
+ } else if (SolarForecast.AVERAGE.equals(args[0])) {
+ return QueryMode.Average;
+ } else {
+ logger.info("Argument {} not supported", args[0]);
+ return QueryMode.Error;
+ }
+ } else {
+ return QueryMode.Average;
+ }
+ }
+
+ @Override
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ private void throwOutOfRangeException(Instant query) {
+ if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
+ throw new SolarForecastException(this, "Forecast invalid time range");
+ }
+ if (query.isBefore(getForecastBegin())) {
+ throw new SolarForecastException(this,
+ "Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
+ } else if (query.isAfter(getForecastEnd())) {
+ throw new SolarForecastException(this,
+ "Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
+ } else {
+ logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
+ }
+ }
+
+ private String getTimeRange() {
+ return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
+ + dateOutputFormatter.format(getForecastEnd());
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastBridgeConfiguration.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastBridgeConfiguration.java
new file mode 100644
index 00000000000..248b718854c
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastBridgeConfiguration.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+
+/**
+ * The {@link SolcastBridgeConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastBridgeConfiguration {
+ public String apiKey = SolarForecastBindingConstants.EMPTY;
+ public String timeZone = SolarForecastBindingConstants.EMPTY;
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastPlaneConfiguration.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastPlaneConfiguration.java
new file mode 100644
index 00000000000..1a2d2c1de8a
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastPlaneConfiguration.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+
+/**
+ * The {@link SolcastPlaneConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastPlaneConfiguration {
+ public String resourceId = SolarForecastBindingConstants.EMPTY;
+ public long refreshInterval = 120;
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastBridgeHandler.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastBridgeHandler.java
new file mode 100644
index 00000000000..de5fe18eab2
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastBridgeHandler.java
@@ -0,0 +1,268 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast.handler;
+
+import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
+
+import java.time.DateTimeException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.solcast.config.SolcastBridgeConfiguration;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.TimeSeries;
+import org.openhab.core.types.TimeSeries.Policy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SolcastBridgeHandler} is a non active handler instance. It will be triggered by the bridge.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastBridgeHandler extends BaseBridgeHandler implements SolarForecastProvider, TimeZoneProvider {
+ private final Logger logger = LoggerFactory.getLogger(SolcastBridgeHandler.class);
+
+ private List planes = new ArrayList<>();
+ private Optional> refreshJob = Optional.empty();
+ private SolcastBridgeConfiguration configuration = new SolcastBridgeConfiguration();
+ private ZoneId timeZone;
+
+ public SolcastBridgeHandler(Bridge bridge, TimeZoneProvider tzp) {
+ super(bridge);
+ timeZone = tzp.getTimeZone();
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(SolarForecastActions.class);
+ }
+
+ @Override
+ public void initialize() {
+ configuration = getConfigAs(SolcastBridgeConfiguration.class);
+ if (!configuration.apiKey.isBlank()) {
+ if (!configuration.timeZone.isBlank()) {
+ try {
+ timeZone = ZoneId.of(configuration.timeZone);
+ } catch (DateTimeException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.site.status.timezone" + " [\"" + configuration.timeZone + "\"]");
+ return;
+ }
+ }
+ updateStatus(ThingStatus.UNKNOWN);
+ refreshJob = Optional
+ .of(scheduler.scheduleWithFixedDelay(this::getData, 0, REFRESH_ACTUAL_INTERVAL, TimeUnit.MINUTES));
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.site.status.api-key-missing");
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ String channel = channelUID.getIdWithoutGroup();
+ switch (channel) {
+ case CHANNEL_ENERGY_ACTUAL:
+ case CHANNEL_ENERGY_REMAIN:
+ case CHANNEL_ENERGY_TODAY:
+ case CHANNEL_POWER_ACTUAL:
+ getData();
+ break;
+ case CHANNEL_POWER_ESTIMATE:
+ case CHANNEL_ENERGY_ESTIMATE:
+ forecastUpdate();
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void dispose() {
+ refreshJob.ifPresent(job -> job.cancel(true));
+ }
+
+ /**
+ * Get data for all planes. Protect plane list from being modified during update
+ */
+ public synchronized void getData() {
+ if (planes.isEmpty()) {
+ logger.debug("No PV plane defined yet");
+ return;
+ }
+ ZonedDateTime now = ZonedDateTime.now(getTimeZone());
+ List modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
+ modes.forEach(mode -> {
+ String group = switch (mode) {
+ case Average -> GROUP_AVERAGE;
+ case Optimistic -> GROUP_OPTIMISTIC;
+ case Pessimistic -> GROUP_PESSIMISTIC;
+ default -> GROUP_AVERAGE;
+ };
+ boolean update = true;
+ double energySum = 0;
+ double powerSum = 0;
+ double daySum = 0;
+ for (Iterator iterator = planes.iterator(); iterator.hasNext();) {
+ try {
+ SolcastPlaneHandler sfph = iterator.next();
+ SolcastObject fo = sfph.fetchData();
+ energySum += fo.getActualEnergyValue(now, mode);
+ powerSum += fo.getActualPowerValue(now, mode);
+ daySum += fo.getDayTotal(now.toLocalDate(), mode);
+ } catch (SolarForecastException sfe) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
+ "@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
+ update = false;
+ }
+ }
+ if (update) {
+ updateStatus(ThingStatus.ONLINE);
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ACTUAL,
+ Utils.getEnergyState(energySum));
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_REMAIN,
+ Utils.getEnergyState(daySum - energySum));
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_TODAY,
+ Utils.getEnergyState(daySum));
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ACTUAL,
+ Utils.getPowerState(powerSum));
+ }
+ });
+ }
+
+ public synchronized void forecastUpdate() {
+ if (planes.isEmpty()) {
+ return;
+ }
+ // get all available forecasts
+ List forecastObjects = new ArrayList<>();
+ for (Iterator iterator = planes.iterator(); iterator.hasNext();) {
+ SolcastPlaneHandler sfph = iterator.next();
+ forecastObjects.addAll(sfph.getSolarForecasts());
+ }
+ // sort in Tree according to times for each scenario
+ List modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
+ modes.forEach(mode -> {
+ TreeMap> combinedPowerForecast = new TreeMap<>();
+ TreeMap> combinedEnergyForecast = new TreeMap<>();
+
+ // bugfix: https://github.com/weymann/OH3-SolarForecast-Drops/issues/5
+ // find common start and end time which fits to all forecast objects to avoid ambiguous values
+ final Instant commonStart = Utils.getCommonStartTime(forecastObjects);
+ final Instant commonEnd = Utils.getCommonEndTime(forecastObjects);
+ forecastObjects.forEach(fc -> {
+ TimeSeries powerTS = fc.getPowerTimeSeries(mode);
+ powerTS.getStates().forEach(entry -> {
+ if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
+ && Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
+ Utils.addState(combinedPowerForecast, entry);
+ }
+ });
+ TimeSeries energyTS = fc.getEnergyTimeSeries(mode);
+ energyTS.getStates().forEach(entry -> {
+ if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
+ && Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
+ Utils.addState(combinedEnergyForecast, entry);
+ }
+ });
+ });
+ // create TimeSeries and distribute
+ TimeSeries powerSeries = new TimeSeries(Policy.REPLACE);
+ combinedPowerForecast.forEach((timestamp, state) -> {
+ powerSeries.add(timestamp, state);
+ });
+
+ TimeSeries energySeries = new TimeSeries(Policy.REPLACE);
+ combinedEnergyForecast.forEach((timestamp, state) -> {
+ energySeries.add(timestamp, state);
+ });
+ switch (mode) {
+ case Average:
+ sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
+ energySeries);
+ sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
+ powerSeries);
+ break;
+ case Optimistic:
+ sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
+ energySeries);
+ sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
+ powerSeries);
+ break;
+ case Pessimistic:
+ sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
+ energySeries);
+ sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
+ powerSeries);
+ break;
+ default:
+ break;
+ }
+ });
+ }
+
+ public synchronized void addPlane(SolcastPlaneHandler sph) {
+ planes.add(sph);
+ }
+
+ public synchronized void removePlane(SolcastPlaneHandler sph) {
+ planes.remove(sph);
+ }
+
+ String getApiKey() {
+ return configuration.apiKey;
+ }
+
+ @Override
+ public synchronized List getSolarForecasts() {
+ List l = new ArrayList<>();
+ planes.forEach(entry -> {
+ l.addAll(entry.getSolarForecasts());
+ });
+ return l;
+ }
+
+ @Override
+ public ZoneId getTimeZone() {
+ return timeZone;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneHandler.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneHandler.java
new file mode 100644
index 00000000000..89c46564cfa
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneHandler.java
@@ -0,0 +1,254 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast.handler;
+
+import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
+import static org.openhab.binding.solarforecast.internal.solcast.SolcastConstants.*;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpHeader;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.solcast.config.SolcastPlaneConfiguration;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.BridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link SolcastPlaneHandler} is a non active handler instance. It will be triggerer by the bridge.
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastPlaneHandler extends BaseThingHandler implements SolarForecastProvider {
+ private final Logger logger = LoggerFactory.getLogger(SolcastPlaneHandler.class);
+ private final HttpClient httpClient;
+ private SolcastPlaneConfiguration configuration = new SolcastPlaneConfiguration();
+ private Optional bridgeHandler = Optional.empty();
+ protected Optional forecast = Optional.empty();
+
+ public SolcastPlaneHandler(Thing thing, HttpClient hc) {
+ super(thing);
+ httpClient = hc;
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(SolarForecastActions.class);
+ }
+
+ @Override
+ public void initialize() {
+ configuration = getConfigAs(SolcastPlaneConfiguration.class);
+
+ // connect Bridge & Status
+ Bridge bridge = getBridge();
+ if (bridge != null) {
+ BridgeHandler handler = bridge.getHandler();
+ if (handler != null) {
+ if (handler instanceof SolcastBridgeHandler sbh) {
+ bridgeHandler = Optional.of(sbh);
+ forecast = Optional.of(new SolcastObject(thing.getUID().getAsString(), sbh));
+ sbh.addPlane(this);
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.plane.status.wrong-handler [\"" + handler + "\"]");
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.plane.status.bridge-handler-not-found");
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/solarforecast.plane.status.bridge-missing");
+ }
+ }
+
+ @Override
+ public void dispose() {
+ super.dispose();
+ bridgeHandler.ifPresent(bridge -> bridge.removePlane(this));
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command instanceof RefreshType) {
+ forecast.ifPresent(forecastObject -> {
+ String group = channelUID.getGroupId();
+ if (group == null) {
+ group = EMPTY;
+ }
+ String channel = channelUID.getIdWithoutGroup();
+ QueryMode mode = QueryMode.Average;
+ switch (group) {
+ case GROUP_AVERAGE:
+ mode = QueryMode.Average;
+ break;
+ case GROUP_OPTIMISTIC:
+ mode = QueryMode.Optimistic;
+ break;
+ case GROUP_PESSIMISTIC:
+ mode = QueryMode.Pessimistic;
+ break;
+ case GROUP_RAW:
+ forecast.ifPresent(f -> {
+ updateState(GROUP_RAW + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_JSON,
+ StringType.valueOf(f.getRaw()));
+ });
+ }
+ switch (channel) {
+ case CHANNEL_ENERGY_ESTIMATE:
+ sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecastObject.getEnergyTimeSeries(mode));
+ break;
+ case CHANNEL_POWER_ESTIMATE:
+ sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecastObject.getPowerTimeSeries(mode));
+ break;
+ default:
+ updateChannels(forecastObject);
+ }
+ });
+ }
+ }
+
+ protected synchronized SolcastObject fetchData() {
+ bridgeHandler.ifPresent(bridge -> {
+ forecast.ifPresent(forecastObject -> {
+ if (forecastObject.isExpired()) {
+ logger.trace("Get new forecast {}", forecastObject.toString());
+ String forecastUrl = String.format(FORECAST_URL, configuration.resourceId);
+ String currentEstimateUrl = String.format(CURRENT_ESTIMATE_URL, configuration.resourceId);
+ try {
+ // get actual estimate
+ Request estimateRequest = httpClient.newRequest(currentEstimateUrl);
+ estimateRequest.header(HttpHeader.AUTHORIZATION, BEARER + bridge.getApiKey());
+ ContentResponse crEstimate = estimateRequest.send();
+ if (crEstimate.getStatus() == 200) {
+ SolcastObject localForecast = new SolcastObject(thing.getUID().getAsString(),
+ crEstimate.getContentAsString(),
+ Instant.now().plus(configuration.refreshInterval, ChronoUnit.MINUTES), bridge);
+
+ // get forecast
+ Request forecastRequest = httpClient.newRequest(forecastUrl);
+ forecastRequest.header(HttpHeader.AUTHORIZATION, BEARER + bridge.getApiKey());
+ ContentResponse crForecast = forecastRequest.send();
+
+ if (crForecast.getStatus() == 200) {
+ localForecast.join(crForecast.getContentAsString());
+ setForecast(localForecast);
+ updateState(GROUP_RAW + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_JSON,
+ StringType.valueOf(forecast.get().getRaw()));
+ updateStatus(ThingStatus.ONLINE);
+ } else {
+ logger.debug("{} Call {} failed {}", thing.getLabel(), forecastUrl,
+ crForecast.getStatus());
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/solarforecast.plane.status.http-status [\"" + crForecast.getStatus()
+ + "\"]");
+ }
+ } else {
+ logger.debug("{} Call {} failed {}", thing.getLabel(), currentEstimateUrl,
+ crEstimate.getStatus());
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/solarforecast.plane.status.http-status [\"" + crEstimate.getStatus()
+ + "\"]");
+ }
+ } catch (ExecutionException | TimeoutException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (InterruptedException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ Thread.currentThread().interrupt();
+ }
+ } else {
+ updateChannels(forecastObject);
+ }
+ });
+ });
+ return forecast.get();
+ }
+
+ protected void updateChannels(SolcastObject f) {
+ if (bridgeHandler.isEmpty()) {
+ return;
+ }
+ ZonedDateTime now = ZonedDateTime.now(bridgeHandler.get().getTimeZone());
+ List modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
+ modes.forEach(mode -> {
+ double energyDay = f.getDayTotal(now.toLocalDate(), mode);
+ double energyProduced = f.getActualEnergyValue(now, mode);
+ String group = switch (mode) {
+ case Average -> GROUP_AVERAGE;
+ case Optimistic -> GROUP_OPTIMISTIC;
+ case Pessimistic -> GROUP_PESSIMISTIC;
+ case Error -> throw new IllegalStateException("mode " + mode + " not expected");
+ };
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ACTUAL,
+ Utils.getEnergyState(energyProduced));
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_REMAIN,
+ Utils.getEnergyState(energyDay - energyProduced));
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_TODAY,
+ Utils.getEnergyState(energyDay));
+ updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ACTUAL,
+ Utils.getPowerState(f.getActualPowerValue(now, QueryMode.Average)));
+ });
+ }
+
+ protected synchronized void setForecast(SolcastObject f) {
+ forecast = Optional.of(f);
+ sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
+ f.getPowerTimeSeries(QueryMode.Average));
+ sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
+ f.getEnergyTimeSeries(QueryMode.Average));
+ sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
+ f.getPowerTimeSeries(QueryMode.Optimistic));
+ sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
+ f.getEnergyTimeSeries(QueryMode.Optimistic));
+ sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
+ f.getPowerTimeSeries(QueryMode.Pessimistic));
+ sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
+ f.getEnergyTimeSeries(QueryMode.Pessimistic));
+ bridgeHandler.ifPresent(h -> {
+ h.forecastUpdate();
+ });
+ }
+
+ @Override
+ public synchronized List getSolarForecasts() {
+ return List.of(forecast.get());
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/utils/Utils.java b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/utils/Utils.java
new file mode 100644
index 00000000000..44844a6db25
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/utils/Utils.java
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.utils;
+
+import java.time.Instant;
+import java.util.Iterator;
+import java.util.List;
+import java.util.TreeMap;
+
+import javax.measure.MetricPrefix;
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.TimeSeries.Entry;
+
+/**
+ * The {@link Utils} Helpers for Solcast and ForecastSolar
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class Utils {
+ public static QuantityType getEnergyState(double d) {
+ if (d < 0) {
+ return QuantityType.valueOf(-1, Units.KILOWATT_HOUR);
+ }
+ return QuantityType.valueOf(Math.round(d * 1000) / 1000.0, Units.KILOWATT_HOUR);
+ }
+
+ public static QuantityType getPowerState(double d) {
+ if (d < 0) {
+ return QuantityType.valueOf(-1, MetricPrefix.KILO(Units.WATT));
+ }
+ return QuantityType.valueOf(Math.round(d * 1000) / 1000.0, MetricPrefix.KILO(Units.WATT));
+ }
+
+ public static void addState(TreeMap> map, Entry entry) {
+ Instant timestamp = entry.timestamp();
+ QuantityType> qt1 = map.get(timestamp);
+ if (qt1 != null) {
+ QuantityType> qt2 = (QuantityType>) entry.state();
+ double combinedValue = qt1.doubleValue() + qt2.doubleValue();
+ map.put(timestamp, QuantityType.valueOf(combinedValue, qt2.getUnit()));
+ } else {
+ map.put(timestamp, (QuantityType>) entry.state());
+ }
+ }
+
+ public static boolean isBeforeOrEqual(Instant query, Instant reference) {
+ return !query.isAfter(reference);
+ }
+
+ public static boolean isAfterOrEqual(Instant query, Instant reference) {
+ return !query.isBefore(reference);
+ }
+
+ public static Instant getCommonStartTime(List forecastObjects) {
+ if (forecastObjects.isEmpty()) {
+ return Instant.MAX;
+ }
+ Instant start = Instant.MIN;
+ for (Iterator iterator = forecastObjects.iterator(); iterator.hasNext();) {
+ SolarForecast sf = iterator.next();
+ // if start is maximum there's no forecast data available - return immediately
+ if (sf.getForecastBegin().equals(Instant.MAX)) {
+ return Instant.MAX;
+ } else if (sf.getForecastBegin().isAfter(start)) {
+ // take latest timestamp from all forecasts
+ start = sf.getForecastBegin();
+ }
+ }
+ return start;
+ }
+
+ public static Instant getCommonEndTime(List forecastObjects) {
+ if (forecastObjects.isEmpty()) {
+ return Instant.MIN;
+ }
+ Instant end = Instant.MAX;
+ for (Iterator iterator = forecastObjects.iterator(); iterator.hasNext();) {
+ SolarForecast sf = iterator.next();
+ // if end is minimum there's no forecast data available - return immediately
+ if (sf.getForecastEnd().equals(Instant.MIN)) {
+ return Instant.MIN;
+ } else if (sf.getForecastEnd().isBefore(end)) {
+ // take earliest timestamp from all forecast
+ end = sf.getForecastEnd();
+ }
+ }
+ return end;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 00000000000..faaa013112b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,10 @@
+
+
+
+ binding
+ SolarForecast Binding
+ Solar Forecast for your location
+ cloud
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-plane-config.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-plane-config.xml
new file mode 100644
index 00000000000..3e413fcb328
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-plane-config.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ Data refresh rate of forecast data in minutes
+ 30
+
+
+
+ 0 for horizontal till 90 for vertical declination
+
+
+
+ -180 = north, -90 = east, 0 = south, 90 = west, 180 = north
+
+
+
+ Installed module power of this plane
+
+
+
+ Damping factor of morning hours
+ 0.25
+ true
+
+
+
+ Damping factor of evening hours
+ 0.25
+ true
+
+
+
+ Horizon definition as comma-separated integer values
+ true
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-site-config.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-site-config.xml
new file mode 100644
index 00000000000..081ac44e099
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-site-config.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ location
+
+ Location of photovoltaic system. Location from openHAB settings is used in case of empty value.
+
+
+
+ If you have a paid subscription plan
+
+
+
+ Inverter maximum kilowatt peak capability
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-plane-config.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-plane-config.xml
new file mode 100644
index 00000000000..d17426c82e4
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-plane-config.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Resource Id of Solcast rooftop site
+
+
+
+ Data refresh rate of forecast data in minutes
+ 120
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-site-config.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-site-config.xml
new file mode 100644
index 00000000000..956eec83c08
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-site-config.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ API key from your subscription
+
+
+
+ Time zone of forecast location
+ true
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/i18n/solarforecast.properties b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/i18n/solarforecast.properties
new file mode 100644
index 00000000000..a2fe132236b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/i18n/solarforecast.properties
@@ -0,0 +1,108 @@
+# add-on
+
+addon.solarforecast.name = SolarForecast Binding
+addon.solarforecast.description = Solar Forecast for your location
+
+# thing types
+
+thing-type.solarforecast.fs-plane.label = ForecastSolar PV Plane
+thing-type.solarforecast.fs-plane.description = PV Plane as part of Multi Plane Bridge
+thing-type.solarforecast.fs-site.label = ForecastSolar Site
+thing-type.solarforecast.fs-site.description = Site location for Forecast Solar
+thing-type.solarforecast.sc-plane.label = Solcast PV Plane
+thing-type.solarforecast.sc-plane.description = PV Plane as part of Multi Plane Bridge
+thing-type.solarforecast.sc-site.label = Solcast Site
+thing-type.solarforecast.sc-site.description = Solcast service site definition
+
+# thing types config
+
+thing-type.config.solarforecast.fs-plane.azimuth.label = Plane Azimuth
+thing-type.config.solarforecast.fs-plane.azimuth.description = -180 = north, -90 = east, 0 = south, 90 = west, 180 = north
+thing-type.config.solarforecast.fs-plane.dampAM.label = Morning Damping Factor
+thing-type.config.solarforecast.fs-plane.dampAM.description = Damping factor of morning hours
+thing-type.config.solarforecast.fs-plane.dampPM.label = Evening Damping Factor
+thing-type.config.solarforecast.fs-plane.dampPM.description = Damping factor of evening hours
+thing-type.config.solarforecast.fs-plane.declination.label = Plane Declination
+thing-type.config.solarforecast.fs-plane.declination.description = 0 for horizontal till 90 for vertical declination
+thing-type.config.solarforecast.fs-plane.horizon.label = Horizon
+thing-type.config.solarforecast.fs-plane.horizon.description = Horizon definition as comma-separated integer values
+thing-type.config.solarforecast.fs-plane.kwp.label = Installed Kilowatt Peak
+thing-type.config.solarforecast.fs-plane.kwp.description = Installed module power of this plane
+thing-type.config.solarforecast.fs-plane.refreshInterval.label = Forecast Refresh Interval
+thing-type.config.solarforecast.fs-plane.refreshInterval.description = Data refresh rate of forecast data in minutes
+thing-type.config.solarforecast.fs-site.apiKey.label = API Key
+thing-type.config.solarforecast.fs-site.apiKey.description = If you have a paid subscription plan
+thing-type.config.solarforecast.fs-site.inverterKwp.label = Inverter Kilowatt Peak
+thing-type.config.solarforecast.fs-site.inverterKwp.description = Inverter maximum kilowatt peak capability
+thing-type.config.solarforecast.fs-site.location.label = PV Location
+thing-type.config.solarforecast.fs-site.location.description = Location of photovoltaic system
+thing-type.config.solarforecast.sc-plane.refreshInterval.label = Forecast Refresh Interval
+thing-type.config.solarforecast.sc-plane.refreshInterval.description = Data refresh rate of forecast data in minutes
+thing-type.config.solarforecast.sc-plane.resourceId.label = Rooftop Resource Id
+thing-type.config.solarforecast.sc-plane.resourceId.description = Resource Id of Solcast rooftop site
+thing-type.config.solarforecast.sc-site.apiKey.label = API Key
+thing-type.config.solarforecast.sc-site.apiKey.description = API key from your subscription
+thing-type.config.solarforecast.sc-site.timeZone.label = Time Zone
+thing-type.config.solarforecast.sc-site.timeZone.description = Time zone of forecast location
+
+# channel group types
+
+channel-group-type.solarforecast.average-values.label = Average Forecast Values
+channel-group-type.solarforecast.average-values.description = Forecast values showing average case data
+channel-group-type.solarforecast.optimistic-values.label = Optimistic Forecast Values
+channel-group-type.solarforecast.optimistic-values.description = Forecast values showing 90th percentile case data
+channel-group-type.solarforecast.pessimistic-values.label = Pessimistic Forecast Values
+channel-group-type.solarforecast.pessimistic-values.description = Forecast values showing 10th percentile case data
+channel-group-type.solarforecast.raw-values.label = Raw Forecast Values
+channel-group-type.solarforecast.raw-values.description = Raw response from service provider
+
+# channel types
+
+channel-type.solarforecast.energy-actual.label = Actual Energy Forecast
+channel-type.solarforecast.energy-actual.description = Today's forecast till now
+channel-type.solarforecast.energy-estimate.label = Energy Forecast
+channel-type.solarforecast.energy-estimate.description = Energy forecast for next hours/days
+channel-type.solarforecast.energy-remain.label = Remaining Energy Forecast
+channel-type.solarforecast.energy-remain.description = Today's remaining forecast till sunset
+channel-type.solarforecast.energy-today.label = Todays Energy Forecast
+channel-type.solarforecast.energy-today.description = Today's total energy forecast
+channel-type.solarforecast.json.label = Raw JSON Response
+channel-type.solarforecast.json.description = Plain JSON response without conversions
+channel-type.solarforecast.power-actual.label = Actual Power
+channel-type.solarforecast.power-actual.description = Power prediction for this moment
+channel-type.solarforecast.power-estimate.label = Power Forecast
+channel-type.solarforecast.power-estimate.description = Power forecast for next hours/days
+
+# status details
+
+solarforecast.site.status.api-key-missing = API key is mandatory
+solarforecast.site.status.timezone = Time zone {0} not found
+solarforecast.site.status.location-missing = Location neither configured in openHAB nor configuration
+solarforecast.site.status.exception = Exception during update: {0}
+solarforecast.plane.status.bridge-missing = Bridge not set
+solarforecast.plane.status.bridge-handler-not-found = Bridge handler not found
+solarforecast.plane.status.wrong-handler = Wrong handler {0}
+solarforecast.plane.status.await-feedback = Await first feedback
+solarforecast.plane.status.http-status = HTTP Status Code {0}
+solarforecast.plane.status.json-status = JSON error: {0}
+
+# thing actions
+
+actionDayLabel = Daily Energy Production
+actionDayDesc = Returns energy production for complete day in kWh
+actionInputDayLabel = Date
+actionInputDayDesc = LocalDate for daily energy query
+actionPowerLabel = Power
+actionPowerDesc = Returns power in W for a specific point in time
+actionInputDateTimeLabel = Date Time
+actionInputDateTimeDesc = Instant timestamp for power query
+actionEnergyLabel = Energy Production
+actionEnergyDesc = Returns energy productions between two different timestamps
+actionInputDateTimeBeginLabel = Timestamp Begin
+actionInputDateTimeBeginDesc = Instant timestamp as starting point of the energy query
+actionInputDateTimeEndLabel = TimeStamp End
+actionInputDateTimeEndDesc = Instant timestamp as end point of the energy query
+actionForecastBeginLabel = Forecast Startpoint
+actionForecastBeginDesc = Returns earliest timestamp of forecast data
+actionForecastEndLabel = Forecast End
+actionForecastEndDesc = Returns latest timestamp of forecast data
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/average-group.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/average-group.xml
new file mode 100644
index 00000000000..8f00a6c3f59
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/average-group.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ Forecast values showing average case data
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/channel-types.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/channel-types.xml
new file mode 100644
index 00000000000..b6302715b91
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/channel-types.xml
@@ -0,0 +1,48 @@
+
+
+
+
+ Number:Power
+
+ Power prediction for this moment
+
+
+
+ Number:Power
+
+ Power forecast for next hours/days
+
+
+
+ Number:Energy
+
+ Today's forecast till now
+
+
+
+ Number:Energy
+
+ Today's remaining forecast till sunset
+
+
+
+ Number:Energy
+
+ Today's total energy forecast
+
+
+
+ Number:Energy
+
+ Energy forecast for next hours/days
+
+
+
+ String
+
+ Plain JSON response without conversions
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-plane-type.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-plane-type.xml
new file mode 100644
index 00000000000..f37bd94d41b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-plane-type.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+ One PV Plane of Multi Plane Bridge
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-site-type.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-site-type.xml
new file mode 100644
index 00000000000..0e7c2b91f19
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-site-type.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Site location for Forecast Solar
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/optimistic-group.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/optimistic-group.xml
new file mode 100644
index 00000000000..6ca53734dcd
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/optimistic-group.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ Forecast values showing 90th percentile case data
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/pessimistic-group.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/pessimistic-group.xml
new file mode 100644
index 00000000000..e5c61debd81
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/pessimistic-group.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ Forecast values showing 10th percentile case data
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/raw-group.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/raw-group.xml
new file mode 100644
index 00000000000..3427c90e0a9
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/raw-group.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ Raw response from service provider
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-plane-type.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-plane-type.xml
new file mode 100644
index 00000000000..c549cc8fe19
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-plane-type.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+ One PV Plane of Multi Plane Bridge
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-site-type.xml b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-site-type.xml
new file mode 100644
index 00000000000..aab7c4418d3
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-site-type.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Solcast service site definition
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/CallbackMock.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/CallbackMock.java
new file mode 100644
index 00000000000..a47b9d9c38b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/CallbackMock.java
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.core.ConfigDescription;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelGroupUID;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.type.ChannelGroupTypeUID;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
+import org.openhab.core.types.TimeSeries.Policy;
+
+/**
+ * The {@link CallbackMock} is a helper for unit tests to receive callbacks
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class CallbackMock implements ThingHandlerCallback {
+
+ Map seriesMap = new HashMap();
+
+ @Override
+ public void stateUpdated(ChannelUID channelUID, State state) {
+ }
+
+ @Override
+ public void postCommand(ChannelUID channelUID, Command command) {
+ }
+
+ @Override
+ public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
+ seriesMap.put(channelUID.getAsString(), timeSeries);
+ }
+
+ public TimeSeries getTimeSeries(String cuid) {
+ TimeSeries ts = seriesMap.get(cuid);
+ if (ts == null) {
+ ts = new TimeSeries(Policy.REPLACE);
+ }
+ return ts;
+ }
+
+ @Override
+ public void statusUpdated(Thing thing, ThingStatusInfo thingStatus) {
+ }
+
+ @Override
+ public void thingUpdated(Thing thing) {
+ }
+
+ @Override
+ public void validateConfigurationParameters(Thing thing, Map configurationParameters) {
+ }
+
+ @Override
+ public void validateConfigurationParameters(Channel channel, Map configurationParameters) {
+ }
+
+ @Override
+ public @Nullable ConfigDescription getConfigDescription(ChannelTypeUID channelTypeUID) {
+ return null;
+ }
+
+ @Override
+ public @Nullable ConfigDescription getConfigDescription(ThingTypeUID thingTypeUID) {
+ return null;
+ }
+
+ @Override
+ public void configurationUpdated(Thing thing) {
+ }
+
+ @Override
+ public void migrateThingType(Thing thing, ThingTypeUID thingTypeUID, Configuration configuration) {
+ }
+
+ @Override
+ public void channelTriggered(Thing thing, ChannelUID channelUID, String event) {
+ }
+
+ @Override
+ public ChannelBuilder createChannelBuilder(ChannelUID channelUID, ChannelTypeUID channelTypeUID) {
+ return ChannelBuilder.create(channelUID);
+ }
+
+ @Override
+ public ChannelBuilder editChannel(Thing thing, ChannelUID channelUID) {
+ return ChannelBuilder.create(channelUID);
+ }
+
+ @Override
+ public List createChannelBuilders(ChannelGroupUID channelGroupUID,
+ ChannelGroupTypeUID channelGroupTypeUID) {
+ return List.of();
+ }
+
+ @Override
+ public boolean isChannelLinked(ChannelUID channelUID) {
+ return false;
+ }
+
+ @Override
+ public @Nullable Bridge getBridge(ThingUID bridgeUID) {
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/FileReader.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/FileReader.java
new file mode 100644
index 00000000000..1ae51267989
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/FileReader.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+
+/**
+ * The {@link FileReader} Helper Util to read test resource files
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class FileReader {
+
+ public static String readFileInString(String filename) {
+ try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(filename), "UTF-8"));) {
+ StringBuilder buf = new StringBuilder();
+ String sCurrentLine;
+
+ while ((sCurrentLine = br.readLine()) != null) {
+ buf.append(sCurrentLine);
+ }
+ return buf.toString();
+ } catch (IOException e) {
+ // fail if file cannot be read
+ assertFalse(filename.isBlank(), "Read failure " + filename);
+ }
+ return SolarForecastBindingConstants.EMPTY;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/ForecastSolarTest.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/ForecastSolarTest.java
new file mode 100644
index 00000000000..608bc02ebec
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/ForecastSolarTest.java
@@ -0,0 +1,498 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.Iterator;
+import java.util.Optional;
+
+import javax.measure.quantity.Energy;
+import javax.measure.quantity.Power;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
+import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
+import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarBridgeHandler;
+import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneHandler;
+import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneMock;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.utils.Utils;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.internal.BridgeImpl;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link ForecastSolarTest} tests responses from forecast solar object
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+class ForecastSolarTest {
+ private static final double TOLERANCE = 0.001;
+ public static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
+ public static final QuantityType POWER_UNDEF = Utils.getPowerState(-1);
+ public static final QuantityType ENERGY_UNDEF = Utils.getEnergyState(-1);
+
+ public static final String TOO_EARLY_INDICATOR = "too early";
+ public static final String TOO_LATE_INDICATOR = "too late";
+ public static final String INVALID_RANGE_INDICATOR = "invalid time range";
+ public static final String NO_GORECAST_INDICATOR = "No forecast data";
+ public static final String DAY_MISSING_INDICATOR = "not available in forecast";
+
+ @Test
+ void testForecastObject() {
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 17, 00).atZone(TEST_ZONE);
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
+ queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
+ // "2022-07-17 21:32:00": 63583,
+ assertEquals(63.583, fo.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
+ // "2022-07-17 17:00:00": 52896,
+ assertEquals(52.896, fo.getActualEnergyValue(queryDateTime), TOLERANCE, "Current Production");
+ // 63583 - 52896 = 10687
+ assertEquals(10.687, fo.getRemainingProduction(queryDateTime), TOLERANCE, "Current Production");
+ // sum cross check
+ assertEquals(fo.getDayTotal(queryDateTime.toLocalDate()),
+ fo.getActualEnergyValue(queryDateTime) + fo.getRemainingProduction(queryDateTime), TOLERANCE,
+ "actual + remain = total");
+
+ queryDateTime = LocalDateTime.of(2022, 7, 18, 19, 00).atZone(TEST_ZONE);
+ // "2022-07-18 19:00:00": 63067,
+ assertEquals(63.067, fo.getActualEnergyValue(queryDateTime), TOLERANCE, "Actual production");
+ // "2022-07-18 21:31:00": 65554
+ assertEquals(65.554, fo.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
+ }
+
+ @Test
+ void testActualPower() {
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 10, 00).atZone(TEST_ZONE);
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
+ queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
+ // "2022-07-17 10:00:00": 4874,
+ assertEquals(4.874, fo.getActualPowerValue(queryDateTime), TOLERANCE, "Actual estimation");
+
+ queryDateTime = LocalDateTime.of(2022, 7, 18, 14, 00).atZone(TEST_ZONE);
+ // "2022-07-18 14:00:00": 7054,
+ assertEquals(7.054, fo.getActualPowerValue(queryDateTime), TOLERANCE, "Actual estimation");
+ }
+
+ @Test
+ void testInterpolation() {
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 0).atZone(TEST_ZONE);
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
+ queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
+
+ // test steady value increase
+ double previousValue = 0;
+ for (int i = 0; i < 60; i++) {
+ queryDateTime = queryDateTime.plusMinutes(1);
+ assertTrue(previousValue < fo.getActualEnergyValue(queryDateTime));
+ previousValue = fo.getActualEnergyValue(queryDateTime);
+ }
+
+ queryDateTime = LocalDateTime.of(2022, 7, 18, 6, 23).atZone(TEST_ZONE);
+ // "2022-07-18 06:00:00": 132,
+ // "2022-07-18 07:00:00": 1188,
+ // 1188 - 132 = 1056 | 1056 * 23 / 60 = 404 | 404 + 131 = 535
+ assertEquals(0.535, fo.getActualEnergyValue(queryDateTime), 0.002, "Actual estimation");
+ }
+
+ @Test
+ void testForecastSum() {
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
+ queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
+ QuantityType actual = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
+ QuantityType st = Utils.getEnergyState(fo.getActualEnergyValue(queryDateTime));
+ assertTrue(st instanceof QuantityType);
+ actual = actual.add(st);
+ assertEquals(49.431, actual.floatValue(), TOLERANCE, "Current Production");
+ actual = actual.add(st);
+ assertEquals(98.862, actual.floatValue(), TOLERANCE, "Doubled Current Production");
+ }
+
+ @Test
+ void testCornerCases() {
+ // invalid object
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test");
+ ZonedDateTime query = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
+ try {
+ double d = fo.getActualEnergyValue(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(INVALID_RANGE_INDICATOR),
+ "Expected: " + INVALID_RANGE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getRemainingProduction(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(NO_GORECAST_INDICATOR),
+ "Expected: " + NO_GORECAST_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getDayTotal(query.toLocalDate());
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(NO_GORECAST_INDICATOR),
+ "Expected: " + NO_GORECAST_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getDayTotal(query.plusDays(1).toLocalDate());
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(NO_GORECAST_INDICATOR),
+ "Expected: " + NO_GORECAST_INDICATOR + " Received: " + sfe.getMessage());
+ }
+
+ // valid object - query date one day too early
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ query = LocalDateTime.of(2022, 7, 16, 23, 59).atZone(TEST_ZONE);
+ fo = new ForecastSolarObject("fs-test", content, query.toInstant());
+ try {
+ double d = fo.getActualEnergyValue(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_EARLY_INDICATOR),
+ "Expected: " + TOO_EARLY_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getRemainingProduction(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(DAY_MISSING_INDICATOR),
+ "Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getActualPowerValue(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_EARLY_INDICATOR),
+ "Expected: " + TOO_EARLY_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getDayTotal(query.toLocalDate());
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(DAY_MISSING_INDICATOR),
+ "Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
+ }
+
+ // one minute later we reach a valid date
+ query = query.plusMinutes(1);
+ assertEquals(63.583, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
+ assertEquals(0.0, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
+ assertEquals(63.583, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
+ assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
+
+ // valid object - query date one day too late
+ query = LocalDateTime.of(2022, 7, 19, 0, 0).atZone(TEST_ZONE);
+ try {
+ double d = fo.getActualEnergyValue(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getRemainingProduction(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(DAY_MISSING_INDICATOR),
+ "Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getActualPowerValue(query);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = fo.getDayTotal(query.toLocalDate());
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(DAY_MISSING_INDICATOR),
+ "Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
+ }
+
+ // one minute earlier we reach a valid date
+ query = query.minusMinutes(1);
+ assertEquals(65.554, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
+ assertEquals(65.554, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
+ assertEquals(0.0, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
+ assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
+
+ // test times between 2 dates
+ query = LocalDateTime.of(2022, 7, 17, 23, 59).atZone(TEST_ZONE);
+ assertEquals(63.583, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
+ assertEquals(63.583, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
+ assertEquals(0.0, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
+ assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
+ query = query.plusMinutes(1);
+ assertEquals(65.554, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
+ assertEquals(0.0, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
+ assertEquals(65.554, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
+ assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
+ }
+
+ @Test
+ void testExceptions() {
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test", content, queryDateTime.toInstant());
+ assertEquals("2022-07-17T05:31:00",
+ fo.getForecastBegin().atZone(TEST_ZONE).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
+ "Forecast begin");
+ assertEquals("2022-07-18T21:31:00",
+ fo.getForecastEnd().atZone(TEST_ZONE).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), "Forecast end");
+ assertEquals(QuantityType.valueOf(63.583, Units.KILOWATT_HOUR).toString(),
+ fo.getDay(queryDateTime.toLocalDate()).toFullString(), "Actual out of scope");
+
+ queryDateTime = LocalDateTime.of(2022, 7, 10, 0, 0).atZone(TEST_ZONE);
+ // "watt_hours_day": {
+ // "2022-07-17": 63583,
+ // "2022-07-18": 65554
+ // }
+ try {
+ fo.getEnergy(queryDateTime.toInstant(), queryDateTime.plusDays(2).toInstant());
+ fail("Too early exception missing");
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains("not available"), "not available expected: " + sfe.getMessage());
+ }
+ try {
+ fo.getDay(queryDateTime.toLocalDate(), "optimistic");
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("ForecastSolar doesn't accept arguments", e.getMessage(), "optimistic");
+ }
+ try {
+ fo.getDay(queryDateTime.toLocalDate(), "pessimistic");
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("ForecastSolar doesn't accept arguments", e.getMessage(), "pessimistic");
+ }
+ try {
+ fo.getDay(queryDateTime.toLocalDate(), "total", "rubbish");
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("ForecastSolar doesn't accept arguments", e.getMessage(), "rubbish");
+ }
+ }
+
+ @Test
+ void testTimeSeries() {
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
+ ForecastSolarObject fo = new ForecastSolarObject("fs-test", content, queryDateTime.toInstant());
+
+ TimeSeries powerSeries = fo.getPowerTimeSeries(QueryMode.Average);
+ assertEquals(36, powerSeries.size()); // 18 values each day for 2 days
+ powerSeries.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kW", ((QuantityType>) s).getUnit().toString());
+ });
+
+ TimeSeries energySeries = fo.getEnergyTimeSeries(QueryMode.Average);
+ assertEquals(36, energySeries.size());
+ energySeries.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kWh", ((QuantityType>) s).getUnit().toString());
+ });
+ }
+
+ @Test
+ void testPowerTimeSeries() {
+ ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
+ new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
+ Optional.of(PointType.valueOf("1,2")));
+ CallbackMock cm = new CallbackMock();
+ fsbh.setCallback(cm);
+
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
+ ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
+ fsbh.addPlane(fsph1);
+ fsbh.forecastUpdate();
+ TimeSeries ts1 = cm.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
+
+ ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1);
+ fsbh.addPlane(fsph2);
+ fsbh.forecastUpdate();
+ TimeSeries ts2 = cm.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
+ Iterator iter1 = ts1.getStates().iterator();
+ Iterator iter2 = ts2.getStates().iterator();
+ while (iter1.hasNext()) {
+ TimeSeries.Entry e1 = iter1.next();
+ TimeSeries.Entry e2 = iter2.next();
+ assertEquals("kW", ((QuantityType>) e1.state()).getUnit().toString(), "Power Unit");
+ assertEquals("kW", ((QuantityType>) e2.state()).getUnit().toString(), "Power Unit");
+ assertEquals(((QuantityType>) e1.state()).doubleValue(), ((QuantityType>) e2.state()).doubleValue() / 2,
+ 0.1, "Power Value");
+ }
+ }
+
+ @Test
+ void testCommonForecastStartEnd() {
+ ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
+ new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
+ Optional.of(PointType.valueOf("1,2")));
+ CallbackMock cmSite = new CallbackMock();
+ fsbh.setCallback(cmSite);
+ String contentOne = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ForecastSolarObject fso1One = new ForecastSolarObject("fs-test", contentOne,
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1One);
+ fsbh.addPlane(fsph1);
+ fsbh.forecastUpdate();
+
+ String contentTwo = FileReader.readFileInString("src/test/resources/forecastsolar/resultNextDay.json");
+ ForecastSolarObject fso1Two = new ForecastSolarObject("fs-plane", contentTwo,
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1Two);
+ CallbackMock cmPlane = new CallbackMock();
+ fsph2.setCallback(cmPlane);
+ ((ForecastSolarPlaneMock) fsph2).updateForecast(fso1Two);
+ fsbh.addPlane(fsph2);
+ fsbh.forecastUpdate();
+
+ TimeSeries tsPlaneOne = cmPlane.getTimeSeries("test::plane:power-estimate");
+ TimeSeries tsSite = cmSite.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
+ Iterator planeIter = tsPlaneOne.getStates().iterator();
+ Iterator siteIter = tsSite.getStates().iterator();
+ while (siteIter.hasNext()) {
+ TimeSeries.Entry planeEntry = planeIter.next();
+ TimeSeries.Entry siteEntry = siteIter.next();
+ assertEquals("kW", ((QuantityType>) planeEntry.state()).getUnit().toString(), "Power Unit");
+ assertEquals("kW", ((QuantityType>) siteEntry.state()).getUnit().toString(), "Power Unit");
+ assertEquals(((QuantityType>) planeEntry.state()).doubleValue(),
+ ((QuantityType>) siteEntry.state()).doubleValue() / 2, 0.1, "Power Value");
+ }
+ // only one day shall be reported which is available in both planes
+ LocalDate ld = LocalDate.of(2022, 7, 18);
+ assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), tsSite.getBegin().truncatedTo(ChronoUnit.DAYS),
+ "TimeSeries start");
+ assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), tsSite.getEnd().truncatedTo(ChronoUnit.DAYS),
+ "TimeSeries end");
+ }
+
+ @Test
+ void testActions() {
+ ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
+ new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
+ Optional.of(PointType.valueOf("1,2")));
+ CallbackMock cmSite = new CallbackMock();
+ fsbh.setCallback(cmSite);
+ String contentOne = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ForecastSolarObject fso1One = new ForecastSolarObject("fs-test", contentOne,
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1One);
+ fsbh.addPlane(fsph1);
+ fsbh.forecastUpdate();
+
+ String contentTwo = FileReader.readFileInString("src/test/resources/forecastsolar/resultNextDay.json");
+ ForecastSolarObject fso1Two = new ForecastSolarObject("fs-plane", contentTwo,
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1Two);
+ CallbackMock cmPlane = new CallbackMock();
+ fsph2.setCallback(cmPlane);
+ ((ForecastSolarPlaneMock) fsph2).updateForecast(fso1Two);
+ fsbh.addPlane(fsph2);
+ fsbh.forecastUpdate();
+
+ SolarForecastActions sfa = new SolarForecastActions();
+ sfa.setThingHandler(fsbh);
+ // only one day shall be reported which is available in both planes
+ LocalDate ld = LocalDate.of(2022, 7, 18);
+ assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), sfa.getForecastBegin().truncatedTo(ChronoUnit.DAYS),
+ "TimeSeries start");
+ assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), sfa.getForecastEnd().truncatedTo(ChronoUnit.DAYS),
+ "TimeSeries end");
+ }
+
+ @Test
+ void testEnergyTimeSeries() {
+ ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
+ new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
+ Optional.of(PointType.valueOf("1,2")));
+ CallbackMock cm = new CallbackMock();
+ fsbh.setCallback(cm);
+
+ String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
+ ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
+ ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
+ fsbh.addPlane(fsph1);
+ fsbh.forecastUpdate();
+ TimeSeries ts1 = cm.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
+
+ ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1);
+ fsbh.addPlane(fsph2);
+ fsbh.forecastUpdate();
+ TimeSeries ts2 = cm.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
+ Iterator iter1 = ts1.getStates().iterator();
+ Iterator iter2 = ts2.getStates().iterator();
+ while (iter1.hasNext()) {
+ TimeSeries.Entry e1 = iter1.next();
+ TimeSeries.Entry e2 = iter2.next();
+ assertEquals("kWh", ((QuantityType>) e1.state()).getUnit().toString(), "Power Unit");
+ assertEquals("kWh", ((QuantityType>) e2.state()).getUnit().toString(), "Power Unit");
+ assertEquals(((QuantityType>) e1.state()).doubleValue(), ((QuantityType>) e2.state()).doubleValue() / 2,
+ 0.1, "Power Value");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/SolcastTest.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/SolcastTest.java
new file mode 100644
index 00000000000..a6606d54f37
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/SolcastTest.java
@@ -0,0 +1,717 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import javax.measure.quantity.Energy;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.json.JSONObject;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.SolarForecastException;
+import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastConstants;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
+import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastBridgeHandler;
+import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneHandler;
+import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneMock;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.internal.BridgeImpl;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TimeSeries;
+
+/**
+ * The {@link SolcastTest} tests responses from forecast solar website
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+class SolcastTest {
+ public static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
+ private static final TimeZP TIMEZONEPROVIDER = new TimeZP();
+ // double comparison tolerance = 1 Watt
+ private static final double TOLERANCE = 0.001;
+
+ public static final String TOO_LATE_INDICATOR = "too late";
+ public static final String DAY_MISSING_INDICATOR = "not available in forecast";
+
+ /**
+ * "2022-07-18T00:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T00:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T01:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T01:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T02:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T02:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T03:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T03:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T04:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T04:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T05:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T05:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T06:00+02:00[Europe/Berlin]": 0.0205,
+ * "2022-07-18T06:30+02:00[Europe/Berlin]": 0.1416,
+ * "2022-07-18T07:00+02:00[Europe/Berlin]": 0.4478,
+ * "2022-07-18T07:30+02:00[Europe/Berlin]": 0.763,
+ * "2022-07-18T08:00+02:00[Europe/Berlin]": 1.1367,
+ * "2022-07-18T08:30+02:00[Europe/Berlin]": 1.4044,
+ * "2022-07-18T09:00+02:00[Europe/Berlin]": 1.6632,
+ * "2022-07-18T09:30+02:00[Europe/Berlin]": 1.8667,
+ * "2022-07-18T10:00+02:00[Europe/Berlin]": 2.0729,
+ * "2022-07-18T10:30+02:00[Europe/Berlin]": 2.2377,
+ * "2022-07-18T11:00+02:00[Europe/Berlin]": 2.3516,
+ * "2022-07-18T11:30+02:00[Europe/Berlin]": 2.4295,
+ * "2022-07-18T12:00+02:00[Europe/Berlin]": 2.5136,
+ * "2022-07-18T12:30+02:00[Europe/Berlin]": 2.5295,
+ * "2022-07-18T13:00+02:00[Europe/Berlin]": 2.526,
+ * "2022-07-18T13:30+02:00[Europe/Berlin]": 2.4879,
+ * "2022-07-18T14:00+02:00[Europe/Berlin]": 2.4092,
+ * "2022-07-18T14:30+02:00[Europe/Berlin]": 2.3309,
+ * "2022-07-18T15:00+02:00[Europe/Berlin]": 2.1984,
+ * "2022-07-18T15:30+02:00[Europe/Berlin]": 2.0416,
+ * "2022-07-18T16:00+02:00[Europe/Berlin]": 1.9076,
+ * "2022-07-18T16:30+02:00[Europe/Berlin]": 1.7416,
+ * "2022-07-18T17:00+02:00[Europe/Berlin]": 1.5414,
+ * "2022-07-18T17:30+02:00[Europe/Berlin]": 1.3683,
+ * "2022-07-18T18:00+02:00[Europe/Berlin]": 1.1603,
+ * "2022-07-18T18:30+02:00[Europe/Berlin]": 0.9527,
+ * "2022-07-18T19:00+02:00[Europe/Berlin]": 0.7705,
+ * "2022-07-18T19:30+02:00[Europe/Berlin]": 0.5673,
+ * "2022-07-18T20:00+02:00[Europe/Berlin]": 0.3588,
+ * "2022-07-18T20:30+02:00[Europe/Berlin]": 0.1948,
+ * "2022-07-18T21:00+02:00[Europe/Berlin]": 0.0654,
+ * "2022-07-18T21:30+02:00[Europe/Berlin]": 0.0118,
+ * "2022-07-18T22:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T22:30+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T23:00+02:00[Europe/Berlin]": 0,
+ * "2022-07-18T23:30+02:00[Europe/Berlin]": 0
+ **/
+ @Test
+ void testForecastObject() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ scfo.join(content);
+ // test one day, step ahead in time and cross check channel values
+ double dayTotal = scfo.getDayTotal(now.toLocalDate(), QueryMode.Average);
+ double actual = scfo.getActualEnergyValue(now, QueryMode.Average);
+ double remain = scfo.getRemainingProduction(now, QueryMode.Average);
+ assertEquals(0.0, actual, TOLERANCE, "Begin of day actual");
+ assertEquals(23.107, remain, TOLERANCE, "Begin of day remaining");
+ assertEquals(23.107, dayTotal, TOLERANCE, "Day total");
+ assertEquals(0.0, scfo.getActualPowerValue(now, QueryMode.Average), TOLERANCE, "Begin of day power");
+ double previousPower = 0;
+ for (int i = 0; i < 47; i++) {
+ now = now.plusMinutes(30);
+ double power = scfo.getActualPowerValue(now, QueryMode.Average) / 2.0;
+ double powerAddOn = ((power + previousPower) / 2.0);
+ actual += powerAddOn;
+ assertEquals(actual, scfo.getActualEnergyValue(now, QueryMode.Average), TOLERANCE, "Actual at " + now);
+ remain -= powerAddOn;
+ assertEquals(remain, scfo.getRemainingProduction(now, QueryMode.Average), TOLERANCE, "Remain at " + now);
+ assertEquals(dayTotal, actual + remain, TOLERANCE, "Total sum at " + now);
+ previousPower = power;
+ }
+ }
+
+ @Test
+ void testPower() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 23, 16, 00).atZone(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ scfo.join(content);
+
+ /**
+ * {
+ * "pv_estimate": 1.9176,
+ * "pv_estimate10": 0.8644,
+ * "pv_estimate90": 2.0456,
+ * "period_end": "2022-07-23T14:00:00.0000000Z",
+ * "period": "PT30M"
+ * },
+ * {
+ * "pv_estimate": 1.7544,
+ * "pv_estimate10": 0.7708,
+ * "pv_estimate90": 1.864,
+ * "period_end": "2022-07-23T14:30:00.0000000Z",
+ * "period": "PT30M"
+ */
+ assertEquals(1.9176, scfo.getActualPowerValue(now, QueryMode.Average), TOLERANCE, "Estimate power " + now);
+ assertEquals(1.9176, scfo.getPower(now.toInstant(), "average").doubleValue(), TOLERANCE,
+ "Estimate power " + now);
+ assertEquals(1.754, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Average), TOLERANCE,
+ "Estimate power " + now.plusMinutes(30));
+
+ assertEquals(2.046, scfo.getActualPowerValue(now, QueryMode.Optimistic), TOLERANCE, "Optimistic power " + now);
+ assertEquals(1.864, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Optimistic), TOLERANCE,
+ "Optimistic power " + now.plusMinutes(30));
+
+ assertEquals(0.864, scfo.getActualPowerValue(now, QueryMode.Pessimistic), TOLERANCE,
+ "Pessimistic power " + now);
+ assertEquals(0.771, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Pessimistic), TOLERANCE,
+ "Pessimistic power " + now.plusMinutes(30));
+
+ /**
+ * {
+ * "pv_estimate": 1.9318,
+ * "period_end": "2022-07-17T14:30:00.0000000Z",
+ * "period": "PT30M"
+ * },
+ * {
+ * "pv_estimate": 1.724,
+ * "period_end": "2022-07-17T15:00:00.0000000Z",
+ * "period": "PT30M"
+ * },
+ **/
+ // get same values for optimistic / pessimistic and estimate in the past
+ ZonedDateTime past = LocalDateTime.of(2022, 7, 17, 16, 30).atZone(TEST_ZONE);
+ assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Average), TOLERANCE, "Estimate power " + past);
+ assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Average), TOLERANCE,
+ "Estimate power " + now.plusMinutes(30));
+
+ assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Optimistic), TOLERANCE,
+ "Optimistic power " + past);
+ assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Optimistic), TOLERANCE,
+ "Optimistic power " + past.plusMinutes(30));
+
+ assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Pessimistic), TOLERANCE,
+ "Pessimistic power " + past);
+ assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Pessimistic), TOLERANCE,
+ "Pessimistic power " + past.plusMinutes(30));
+ }
+
+ /**
+ * Data from TreeMap for manual validation
+ * 2022-07-17T04:30+02:00[Europe/Berlin]=0.0,
+ * 2022-07-17T05:00+02:00[Europe/Berlin]=0.0,
+ * 2022-07-17T05:30+02:00[Europe/Berlin]=0.0,
+ * 2022-07-17T06:00+02:00[Europe/Berlin]=0.0262,
+ * 2022-07-17T06:30+02:00[Europe/Berlin]=0.4252,
+ * 2022-07-17T07:00+02:00[Europe/Berlin]=0.7772, <<<
+ * 2022-07-17T07:30+02:00[Europe/Berlin]=1.0663,
+ * 2022-07-17T08:00+02:00[Europe/Berlin]=1.3848,
+ * 2022-07-17T08:30+02:00[Europe/Berlin]=1.6401,
+ * 2022-07-17T09:00+02:00[Europe/Berlin]=1.8614,
+ * 2022-07-17T09:30+02:00[Europe/Berlin]=2.0613,
+ * 2022-07-17T10:00+02:00[Europe/Berlin]=2.2365,
+ * 2022-07-17T10:30+02:00[Europe/Berlin]=2.3766,
+ * 2022-07-17T11:00+02:00[Europe/Berlin]=2.4719,
+ * 2022-07-17T11:30+02:00[Europe/Berlin]=2.5438,
+ * 2022-07-17T12:00+02:00[Europe/Berlin]=2.602,
+ * 2022-07-17T12:30+02:00[Europe/Berlin]=2.6213,
+ * 2022-07-17T13:00+02:00[Europe/Berlin]=2.6061,
+ * 2022-07-17T13:30+02:00[Europe/Berlin]=2.6181,
+ * 2022-07-17T14:00+02:00[Europe/Berlin]=2.5378,
+ * 2022-07-17T14:30+02:00[Europe/Berlin]=2.4651,
+ * 2022-07-17T15:00+02:00[Europe/Berlin]=2.3656,
+ * 2022-07-17T15:30+02:00[Europe/Berlin]=2.2374,
+ * 2022-07-17T16:00+02:00[Europe/Berlin]=2.1015,
+ * 2022-07-17T16:30+02:00[Europe/Berlin]=1.9318,
+ * 2022-07-17T17:00+02:00[Europe/Berlin]=1.724,
+ * 2022-07-17T17:30+02:00[Europe/Berlin]=1.5031,
+ * 2022-07-17T18:00+02:00[Europe/Berlin]=1.2834,
+ * 2022-07-17T18:30+02:00[Europe/Berlin]=1.0839,
+ * 2022-07-17T19:00+02:00[Europe/Berlin]=0.8581,
+ * 2022-07-17T19:30+02:00[Europe/Berlin]=0.6164,
+ * 2022-07-17T20:00+02:00[Europe/Berlin]=0.4465,
+ * 2022-07-17T20:30+02:00[Europe/Berlin]=0.2543,
+ * 2022-07-17T21:00+02:00[Europe/Berlin]=0.0848,
+ * 2022-07-17T21:30+02:00[Europe/Berlin]=0.0132,
+ * 2022-07-17T22:00+02:00[Europe/Berlin]=0.0,
+ * 2022-07-17T22:30+02:00[Europe/Berlin]=0.0
+ *
+ * <<< = 0.0262 + 0.4252 + 0.7772 = 1.2286 / 2 = 0.6143
+ */
+ @Test
+ void testForecastTreeMap() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 17, 7, 0).atZone(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ assertEquals(0.42, scfo.getActualEnergyValue(now, QueryMode.Average), TOLERANCE, "Actual estimation");
+ assertEquals(25.413, scfo.getDayTotal(now.toLocalDate(), QueryMode.Average), TOLERANCE, "Day total");
+ }
+
+ @Test
+ void testJoin() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ try {
+ double d = scfo.getActualEnergyValue(now, QueryMode.Average);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ scfo.join(content);
+ assertEquals(18.946, scfo.getActualEnergyValue(now, QueryMode.Average), 0.01, "Actual data");
+ assertEquals(23.107, scfo.getDayTotal(now.toLocalDate(), QueryMode.Average), 0.01, "Today data");
+ JSONObject rawJson = new JSONObject(scfo.getRaw());
+ assertTrue(rawJson.has("forecasts"));
+ assertTrue(rawJson.has("estimated_actuals"));
+ }
+
+ @Test
+ void testActions() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ try {
+ double d = scfo.getActualEnergyValue(now, QueryMode.Average);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ scfo.join(content);
+
+ assertEquals("2022-07-10T23:30+02:00[Europe/Berlin]", scfo.getForecastBegin().atZone(TEST_ZONE).toString(),
+ "Forecast begin");
+ assertEquals("2022-07-24T23:00+02:00[Europe/Berlin]", scfo.getForecastEnd().atZone(TEST_ZONE).toString(),
+ "Forecast end");
+ // test daily forecasts + cumulated getEnergy
+ double totalEnergy = 0;
+ ZonedDateTime start = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
+ for (int i = 0; i < 6; i++) {
+ QuantityType qt = scfo.getDay(start.toLocalDate().plusDays(i));
+ QuantityType eqt = scfo.getEnergy(start.plusDays(i).toInstant(), start.plusDays(i + 1).toInstant());
+
+ // check if energy calculation fits to daily query
+ assertEquals(qt.doubleValue(), eqt.doubleValue(), TOLERANCE, "Total " + i + " days forecast");
+ totalEnergy += qt.doubleValue();
+
+ // check if sum is fitting to total energy query
+ qt = scfo.getEnergy(start.toInstant(), start.plusDays(i + 1).toInstant());
+ assertEquals(totalEnergy, qt.doubleValue(), TOLERANCE * 2, "Total " + i + " days forecast");
+ }
+ }
+
+ @Test
+ void testOptimisticPessimistic() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ scfo.join(content);
+ assertEquals(19.389, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Average), TOLERANCE,
+ "Estimation");
+ assertEquals(7.358, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Pessimistic), TOLERANCE,
+ "Estimation");
+ assertEquals(22.283, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Optimistic), TOLERANCE,
+ "Estimation");
+ assertEquals(23.316, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Average), TOLERANCE,
+ "Estimation");
+ assertEquals(9.8, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Pessimistic), TOLERANCE,
+ "Estimation");
+ assertEquals(23.949, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Optimistic), TOLERANCE,
+ "Estimation");
+
+ // access in past shall be rejected
+ Instant past = Instant.now().minus(5, ChronoUnit.MINUTES);
+ try {
+ scfo.getPower(past, SolarForecast.OPTIMISTIC);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("Solcast argument optimistic only available for future values", e.getMessage(),
+ "Optimistic Power");
+ }
+ try {
+ scfo.getPower(past, SolarForecast.PESSIMISTIC);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("Solcast argument pessimistic only available for future values", e.getMessage(),
+ "Pessimistic Power");
+ }
+ try {
+ scfo.getPower(past, "total", "rubbish");
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("Solcast doesn't support 2 arguments", e.getMessage(), "Too many qrguments");
+ }
+ try {
+ scfo.getPower(past.plus(2, ChronoUnit.HOURS), "rubbish");
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertEquals("Solcast doesn't support argument rubbish", e.getMessage(), "Rubbish argument");
+ }
+ try {
+ scfo.getPower(past);
+ fail("Exception expected");
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ }
+
+ @Test
+ void testInavlid() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = ZonedDateTime.now(TEST_ZONE);
+ SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ try {
+ double d = scfo.getActualEnergyValue(now, QueryMode.Average);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ scfo.join(content);
+ try {
+ double d = scfo.getActualEnergyValue(now, QueryMode.Average);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(TOO_LATE_INDICATOR),
+ "Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ try {
+ double d = scfo.getDayTotal(now.toLocalDate(), QueryMode.Average);
+ fail("Exception expected instead of " + d);
+ } catch (SolarForecastException sfe) {
+ String message = sfe.getMessage();
+ assertNotNull(message);
+ assertTrue(message.contains(DAY_MISSING_INDICATOR),
+ "Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
+ }
+ }
+
+ @Test
+ void testPowerInterpolation() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 15, 0).atZone(TEST_ZONE);
+ SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ sco.join(content);
+
+ double startValue = sco.getActualPowerValue(now, QueryMode.Average);
+ double endValue = sco.getActualPowerValue(now.plusMinutes(30), QueryMode.Average);
+ for (int i = 0; i < 31; i++) {
+ double interpolation = i / 30.0;
+ double expected = ((1 - interpolation) * startValue) + (interpolation * endValue);
+ assertEquals(expected, sco.getActualPowerValue(now.plusMinutes(i), QueryMode.Average), TOLERANCE,
+ "Step " + i);
+ }
+ }
+
+ @Test
+ void testEnergyInterpolation() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 5, 30).atZone(TEST_ZONE);
+ SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ sco.join(content);
+
+ double maxDiff = 0;
+ double productionExpected = 0;
+ for (int i = 0; i < 1000; i++) {
+ double forecast = sco.getActualEnergyValue(now.plusMinutes(i), QueryMode.Average);
+ double addOnExpected = sco.getActualPowerValue(now.plusMinutes(i), QueryMode.Average) / 60.0;
+ productionExpected += addOnExpected;
+ double diff = forecast - productionExpected;
+ maxDiff = Math.max(diff, maxDiff);
+ assertEquals(productionExpected, sco.getActualEnergyValue(now.plusMinutes(i), QueryMode.Average),
+ 100 * TOLERANCE, "Step " + i);
+ }
+ }
+
+ @Test
+ void testRawChannel() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ sco.join(content);
+ JSONObject joined = new JSONObject(sco.getRaw());
+ assertTrue(joined.has("forecasts"), "Forecasts available");
+ assertTrue(joined.has("estimated_actuals"), "Actual data available");
+ }
+
+ @Test
+ void testUpdates() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ sco.join(content);
+ JSONObject joined = new JSONObject(sco.getRaw());
+ assertTrue(joined.has("forecasts"), "Forecasts available");
+ assertTrue(joined.has("estimated_actuals"), "Actual data available");
+ }
+
+ @Test
+ void testUnitDetection() {
+ assertEquals("kW", SolcastConstants.KILOWATT_UNIT.toString(), "Kilowatt");
+ assertEquals("W", Units.WATT.toString(), "Watt");
+ }
+
+ @Test
+ void testTimes() {
+ String utcTimeString = "2022-07-17T19:30:00.0000000Z";
+ SolcastObject so = new SolcastObject("sc-test", TIMEZONEPROVIDER);
+ ZonedDateTime zdt = so.getZdtFromUTC(utcTimeString);
+ assertNotNull(zdt);
+ assertEquals("2022-07-17T21:30+02:00[Europe/Berlin]", zdt.toString(), "ZonedDateTime");
+ LocalDateTime ldt = zdt.toLocalDateTime();
+ assertEquals("2022-07-17T21:30", ldt.toString(), "LocalDateTime");
+ LocalTime lt = zdt.toLocalTime();
+ assertEquals("21:30", lt.toString(), "LocalTime");
+ }
+
+ @Test
+ void testPowerTimeSeries() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ sco.join(content);
+
+ TimeSeries powerSeries = sco.getPowerTimeSeries(QueryMode.Average);
+ List> estimateL = new ArrayList<>();
+ assertEquals(672, powerSeries.size());
+ powerSeries.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kW", ((QuantityType>) s).getUnit().toString());
+ if (s instanceof QuantityType> qt) {
+ estimateL.add(qt);
+ } else {
+ fail();
+ }
+ });
+
+ TimeSeries powerSeries10 = sco.getPowerTimeSeries(QueryMode.Pessimistic);
+ List> estimate10 = new ArrayList<>();
+ assertEquals(672, powerSeries10.size());
+ powerSeries10.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kW", ((QuantityType>) s).getUnit().toString());
+ if (s instanceof QuantityType> qt) {
+ estimate10.add(qt);
+ } else {
+ fail();
+ }
+ });
+
+ TimeSeries powerSeries90 = sco.getPowerTimeSeries(QueryMode.Optimistic);
+ List> estimate90 = new ArrayList<>();
+ assertEquals(672, powerSeries90.size());
+ powerSeries90.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kW", ((QuantityType>) s).getUnit().toString());
+ if (s instanceof QuantityType> qt) {
+ estimate90.add(qt);
+ } else {
+ fail();
+ }
+ });
+
+ for (int i = 0; i < estimateL.size(); i++) {
+ double lowValue = estimate10.get(i).doubleValue();
+ double estValue = estimateL.get(i).doubleValue();
+ double highValue = estimate90.get(i).doubleValue();
+ assertTrue(lowValue <= estValue && estValue <= highValue);
+ }
+ }
+
+ @Test
+ void testEnergyTimeSeries() {
+ String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
+ ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
+ SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
+ content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ sco.join(content);
+
+ TimeSeries energySeries = sco.getEnergyTimeSeries(QueryMode.Average);
+ List> estimateL = new ArrayList<>();
+ assertEquals(672, energySeries.size()); // 18 values each day for 2 days
+ energySeries.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kWh", ((QuantityType>) s).getUnit().toString());
+ if (s instanceof QuantityType> qt) {
+ estimateL.add(qt);
+ } else {
+ fail();
+ }
+ });
+
+ TimeSeries energySeries10 = sco.getEnergyTimeSeries(QueryMode.Pessimistic);
+ List> estimate10 = new ArrayList<>();
+ assertEquals(672, energySeries10.size()); // 18 values each day for 2 days
+ energySeries10.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kWh", ((QuantityType>) s).getUnit().toString());
+ if (s instanceof QuantityType> qt) {
+ estimate10.add(qt);
+ } else {
+ fail();
+ }
+ });
+
+ TimeSeries energySeries90 = sco.getEnergyTimeSeries(QueryMode.Optimistic);
+ List> estimate90 = new ArrayList<>();
+ assertEquals(672, energySeries90.size()); // 18 values each day for 2 days
+ energySeries90.getStates().forEachOrdered(entry -> {
+ State s = entry.state();
+ assertTrue(s instanceof QuantityType>);
+ assertEquals("kWh", ((QuantityType>) s).getUnit().toString());
+ if (s instanceof QuantityType> qt) {
+ estimate90.add(qt);
+ } else {
+ fail();
+ }
+ });
+
+ for (int i = 0; i < estimateL.size(); i++) {
+ double lowValue = estimate10.get(i).doubleValue();
+ double estValue = estimateL.get(i).doubleValue();
+ double highValue = estimate90.get(i).doubleValue();
+ assertTrue(lowValue <= estValue && estValue <= highValue);
+ }
+ }
+
+ @Test
+ void testCombinedPowerTimeSeries() {
+ BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
+ SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
+ bi.setHandler(scbh);
+ CallbackMock cm = new CallbackMock();
+ scbh.setCallback(cm);
+ SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
+ CallbackMock cm1 = new CallbackMock();
+ scph1.initialize();
+ scph1.setCallback(cm1);
+ scbh.getData();
+
+ SolcastPlaneHandler scph2 = new SolcastPlaneMock(bi);
+ CallbackMock cm2 = new CallbackMock();
+ scph2.initialize();
+ scph2.setCallback(cm2);
+ scbh.getData();
+
+ TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#power-estimate");
+ TimeSeries ts2 = cm2.getTimeSeries("solarforecast:sc-plane:thing:average#power-estimate");
+ assertEquals(336, ts1.size(), "TimeSeries size");
+ assertEquals(336, ts2.size(), "TimeSeries size");
+ Iterator iter1 = ts1.getStates().iterator();
+ Iterator iter2 = ts2.getStates().iterator();
+ while (iter1.hasNext()) {
+ TimeSeries.Entry e1 = iter1.next();
+ TimeSeries.Entry e2 = iter2.next();
+ assertEquals("kW", ((QuantityType>) e1.state()).getUnit().toString(), "Power Unit");
+ assertEquals("kW", ((QuantityType>) e2.state()).getUnit().toString(), "Power Unit");
+ assertEquals(((QuantityType>) e1.state()).doubleValue(), ((QuantityType>) e2.state()).doubleValue() * 2,
+ 0.01, "Power Value");
+ }
+ scbh.dispose();
+ scph1.dispose();
+ scph2.dispose();
+ }
+
+ @Test
+ void testCombinedEnergyTimeSeries() {
+ BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
+ SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
+ bi.setHandler(scbh);
+ CallbackMock cm = new CallbackMock();
+ scbh.setCallback(cm);
+
+ SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
+ CallbackMock cm1 = new CallbackMock();
+ scph1.initialize();
+ scph1.setCallback(cm1);
+
+ SolcastPlaneHandler scph2 = new SolcastPlaneMock(bi);
+ CallbackMock cm2 = new CallbackMock();
+ scph2.initialize();
+ scph2.setCallback(cm2);
+
+ // simulate trigger of refresh job
+ scbh.getData();
+
+ TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#energy-estimate");
+ TimeSeries ts2 = cm2.getTimeSeries("solarforecast:sc-plane:thing:average#energy-estimate");
+ assertEquals(336, ts1.size(), "TimeSeries size");
+ assertEquals(336, ts2.size(), "TimeSeries size");
+
+ Iterator iter1 = ts1.getStates().iterator();
+ Iterator iter2 = ts2.getStates().iterator();
+ while (iter1.hasNext()) {
+ TimeSeries.Entry e1 = iter1.next();
+ TimeSeries.Entry e2 = iter2.next();
+ assertEquals("kWh", ((QuantityType>) e1.state()).getUnit().toString(), "Power Unit");
+ assertEquals("kWh", ((QuantityType>) e2.state()).getUnit().toString(), "Power Unit");
+ assertEquals(((QuantityType>) e1.state()).doubleValue(), ((QuantityType>) e2.state()).doubleValue() * 2,
+ 0.1, "Power Value");
+ }
+ scbh.dispose();
+ scph1.dispose();
+ scph2.dispose();
+ }
+
+ @Test
+ void testSingleEnergyTimeSeries() {
+ BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
+ SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
+ bi.setHandler(scbh);
+ CallbackMock cm = new CallbackMock();
+ scbh.setCallback(cm);
+
+ SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
+ CallbackMock cm1 = new CallbackMock();
+ scph1.initialize();
+ scph1.setCallback(cm1);
+
+ // simulate trigger of refresh job
+ scbh.getData();
+
+ TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#energy-estimate");
+ assertEquals(336, ts1.size(), "TimeSeries size");
+ Iterator iter1 = ts1.getStates().iterator();
+ while (iter1.hasNext()) {
+ TimeSeries.Entry e1 = iter1.next();
+ assertEquals("kWh", ((QuantityType>) e1.state()).getUnit().toString(), "Power Unit");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/TimeZP.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/TimeZP.java
new file mode 100644
index 00000000000..44712a326f5
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/TimeZP.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast;
+
+import java.time.ZoneId;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.i18n.TimeZoneProvider;
+
+/**
+ * The {@link TimeZP} TimeZoneProvider for tests
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class TimeZP implements TimeZoneProvider {
+
+ @Override
+ public ZoneId getTimeZone() {
+ return SolcastTest.TEST_ZONE;
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneMock.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneMock.java
new file mode 100644
index 00000000000..7b4edfef804
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneMock.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
+
+import static org.mockito.Mockito.mock;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.solarforecast.CallbackMock;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.internal.ThingImpl;
+
+/**
+ * The {@link ForecastSolarPlaneMock} mocks Plane Handler for solar.forecast
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class ForecastSolarPlaneMock extends ForecastSolarPlaneHandler {
+
+ public ForecastSolarPlaneMock(ForecastSolarObject fso) {
+ super(new ThingImpl(SolarForecastBindingConstants.FORECAST_SOLAR_PLANE, new ThingUID("test", "plane")),
+ mock(HttpClient.class));
+ super.setCallback(new CallbackMock());
+ setLocation(PointType.valueOf("1.23,9.87"));
+ super.setForecast(fso);
+ }
+
+ public void updateForecast(ForecastSolarObject fso) {
+ super.setForecast(fso);
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneMock.java b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneMock.java
new file mode 100644
index 00000000000..b1b48a9778b
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneMock.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.solarforecast.internal.solcast.handler;
+
+import static org.mockito.Mockito.mock;
+
+import java.time.Instant;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.solarforecast.FileReader;
+import org.openhab.binding.solarforecast.TimeZP;
+import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
+import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.internal.BridgeImpl;
+import org.openhab.core.thing.internal.ThingImpl;
+
+/**
+ * The {@link SolcastPlaneMock} mocks Plane Handler for solcast
+ *
+ * @author Bernd Weymann - Initial contribution
+ */
+@NonNullByDefault
+public class SolcastPlaneMock extends SolcastPlaneHandler {
+ Bridge bridge;
+
+ // solarforecast:sc-site:bridge
+ public SolcastPlaneMock(BridgeImpl b) {
+ super(new ThingImpl(SolarForecastBindingConstants.SOLCAST_PLANE,
+ new ThingUID("solarforecast", "sc-plane", "thing")), mock(HttpClient.class));
+ bridge = b;
+ }
+
+ @Override
+ public @Nullable Bridge getBridge() {
+ return bridge;
+ }
+
+ @Override
+ protected SolcastObject fetchData() {
+ forecast.ifPresent(forecastObject -> {
+ if (forecastObject.isExpired()) {
+ String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
+ SolcastObject sco1 = new SolcastObject("sc-test", content, Instant.now().plusSeconds(3600),
+ new TimeZP());
+ super.setForecast(sco1);
+ // new forecast
+ } else {
+ super.updateChannels(forecastObject);
+ }
+ });
+ return forecast.get();
+ }
+}
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/result.json b/bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/result.json
new file mode 100644
index 00000000000..ccdb1a9c289
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/result.json
@@ -0,0 +1,100 @@
+{
+ "result": {
+ "watts": {
+ "2022-07-17 05:31:00": 0,
+ "2022-07-17 06:00:00": 615,
+ "2022-07-17 07:00:00": 1570,
+ "2022-07-17 08:00:00": 2913,
+ "2022-07-17 09:00:00": 4103,
+ "2022-07-17 10:00:00": 4874,
+ "2022-07-17 11:00:00": 5424,
+ "2022-07-17 12:00:00": 5895,
+ "2022-07-17 13:00:00": 6075,
+ "2022-07-17 14:00:00": 6399,
+ "2022-07-17 15:00:00": 6575,
+ "2022-07-17 16:00:00": 5986,
+ "2022-07-17 17:00:00": 5251,
+ "2022-07-17 18:00:00": 3956,
+ "2022-07-17 19:00:00": 2555,
+ "2022-07-17 20:00:00": 1260,
+ "2022-07-17 21:00:00": 379,
+ "2022-07-17 21:32:00": 0,
+ "2022-07-18 05:32:00": 0,
+ "2022-07-18 06:00:00": 567,
+ "2022-07-18 07:00:00": 1544,
+ "2022-07-18 08:00:00": 2754,
+ "2022-07-18 09:00:00": 3958,
+ "2022-07-18 10:00:00": 5085,
+ "2022-07-18 11:00:00": 6058,
+ "2022-07-18 12:00:00": 6698,
+ "2022-07-18 13:00:00": 7029,
+ "2022-07-18 14:00:00": 7054,
+ "2022-07-18 15:00:00": 6692,
+ "2022-07-18 16:00:00": 5978,
+ "2022-07-18 17:00:00": 4937,
+ "2022-07-18 18:00:00": 3698,
+ "2022-07-18 19:00:00": 2333,
+ "2022-07-18 20:00:00": 1078,
+ "2022-07-18 21:00:00": 320,
+ "2022-07-18 21:31:00": 0
+ },
+ "watt_hours": {
+ "2022-07-17 05:31:00": 0,
+ "2022-07-17 06:00:00": 149,
+ "2022-07-17 07:00:00": 1241,
+ "2022-07-17 08:00:00": 3483,
+ "2022-07-17 09:00:00": 6991,
+ "2022-07-17 10:00:00": 11479,
+ "2022-07-17 11:00:00": 16628,
+ "2022-07-17 12:00:00": 22288,
+ "2022-07-17 13:00:00": 28273,
+ "2022-07-17 14:00:00": 34510,
+ "2022-07-17 15:00:00": 40997,
+ "2022-07-17 16:00:00": 47277,
+ "2022-07-17 17:00:00": 52896,
+ "2022-07-17 18:00:00": 57499,
+ "2022-07-17 19:00:00": 60755,
+ "2022-07-17 20:00:00": 62662,
+ "2022-07-17 21:00:00": 63482,
+ "2022-07-17 21:32:00": 63583,
+ "2022-07-18 05:32:00": 0,
+ "2022-07-18 06:00:00": 132,
+ "2022-07-18 07:00:00": 1188,
+ "2022-07-18 08:00:00": 3337,
+ "2022-07-18 09:00:00": 6693,
+ "2022-07-18 10:00:00": 11214,
+ "2022-07-18 11:00:00": 16786,
+ "2022-07-18 12:00:00": 23164,
+ "2022-07-18 13:00:00": 30027,
+ "2022-07-18 14:00:00": 37069,
+ "2022-07-18 15:00:00": 43942,
+ "2022-07-18 16:00:00": 50277,
+ "2022-07-18 17:00:00": 55734,
+ "2022-07-18 18:00:00": 60052,
+ "2022-07-18 19:00:00": 63067,
+ "2022-07-18 20:00:00": 64773,
+ "2022-07-18 21:00:00": 65472,
+ "2022-07-18 21:31:00": 65554
+ },
+ "watt_hours_day": {
+ "2022-07-17": 63583,
+ "2022-07-18": 65554
+ }
+ },
+ "message": {
+ "code": 0,
+ "type": "success",
+ "text": "",
+ "info": {
+ "latitude": 54.321,
+ "longitude": 8.765,
+ "place": "Whereever",
+ "timezone": "Europe/Berlin"
+ },
+ "ratelimit": {
+ "period": 3600,
+ "limit": 12,
+ "remaining": 10
+ }
+ }
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/resultNextDay.json b/bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/resultNextDay.json
new file mode 100644
index 00000000000..412612f378d
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/resultNextDay.json
@@ -0,0 +1,100 @@
+{
+ "result": {
+ "watts": {
+ "2022-07-19 05:31:00": 0,
+ "2022-07-19 06:00:00": 615,
+ "2022-07-19 07:00:00": 1570,
+ "2022-07-19 08:00:00": 2913,
+ "2022-07-19 09:00:00": 4103,
+ "2022-07-19 10:00:00": 4874,
+ "2022-07-19 11:00:00": 5424,
+ "2022-07-19 12:00:00": 5895,
+ "2022-07-19 13:00:00": 6075,
+ "2022-07-19 14:00:00": 6399,
+ "2022-07-19 15:00:00": 6575,
+ "2022-07-19 16:00:00": 5986,
+ "2022-07-19 17:00:00": 5251,
+ "2022-07-19 18:00:00": 3956,
+ "2022-07-19 19:00:00": 2555,
+ "2022-07-19 20:00:00": 1260,
+ "2022-07-19 21:00:00": 379,
+ "2022-07-19 21:32:00": 0,
+ "2022-07-18 05:32:00": 0,
+ "2022-07-18 06:00:00": 567,
+ "2022-07-18 07:00:00": 1544,
+ "2022-07-18 08:00:00": 2754,
+ "2022-07-18 09:00:00": 3958,
+ "2022-07-18 10:00:00": 5085,
+ "2022-07-18 11:00:00": 6058,
+ "2022-07-18 12:00:00": 6698,
+ "2022-07-18 13:00:00": 7029,
+ "2022-07-18 14:00:00": 7054,
+ "2022-07-18 15:00:00": 6692,
+ "2022-07-18 16:00:00": 5978,
+ "2022-07-18 17:00:00": 4937,
+ "2022-07-18 18:00:00": 3698,
+ "2022-07-18 19:00:00": 2333,
+ "2022-07-18 20:00:00": 1078,
+ "2022-07-18 21:00:00": 320,
+ "2022-07-18 21:31:00": 0
+ },
+ "watt_hours": {
+ "2022-07-19 05:31:00": 0,
+ "2022-07-19 06:00:00": 149,
+ "2022-07-19 07:00:00": 1241,
+ "2022-07-19 08:00:00": 3483,
+ "2022-07-19 09:00:00": 6991,
+ "2022-07-19 10:00:00": 11479,
+ "2022-07-19 11:00:00": 16628,
+ "2022-07-19 12:00:00": 22288,
+ "2022-07-19 13:00:00": 28273,
+ "2022-07-19 14:00:00": 34510,
+ "2022-07-19 15:00:00": 40997,
+ "2022-07-19 16:00:00": 47277,
+ "2022-07-19 17:00:00": 52896,
+ "2022-07-19 18:00:00": 57499,
+ "2022-07-19 19:00:00": 60755,
+ "2022-07-19 20:00:00": 62662,
+ "2022-07-19 21:00:00": 63482,
+ "2022-07-19 21:32:00": 63583,
+ "2022-07-18 05:32:00": 0,
+ "2022-07-18 06:00:00": 132,
+ "2022-07-18 07:00:00": 1188,
+ "2022-07-18 08:00:00": 3337,
+ "2022-07-18 09:00:00": 6693,
+ "2022-07-18 10:00:00": 11214,
+ "2022-07-18 11:00:00": 16786,
+ "2022-07-18 12:00:00": 23164,
+ "2022-07-18 13:00:00": 30027,
+ "2022-07-18 14:00:00": 37069,
+ "2022-07-18 15:00:00": 43942,
+ "2022-07-18 16:00:00": 50277,
+ "2022-07-18 17:00:00": 55734,
+ "2022-07-18 18:00:00": 60052,
+ "2022-07-18 19:00:00": 63067,
+ "2022-07-18 20:00:00": 64773,
+ "2022-07-18 21:00:00": 65472,
+ "2022-07-18 21:31:00": 65554
+ },
+ "watt_hours_day": {
+ "2022-07-19": 63583,
+ "2022-07-18": 65554
+ }
+ },
+ "message": {
+ "code": 0,
+ "type": "success",
+ "text": "",
+ "info": {
+ "latitude": 54.321,
+ "longitude": 8.765,
+ "place": "Whereever",
+ "timezone": "Europe/Berlin"
+ },
+ "ratelimit": {
+ "period": 3600,
+ "limit": 12,
+ "remaining": 10
+ }
+ }
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/estimated-actuals.json b/bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/estimated-actuals.json
new file mode 100644
index 00000000000..83857b305ce
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/estimated-actuals.json
@@ -0,0 +1,1684 @@
+{
+ "estimated_actuals": [
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0132,
+ "period_end": "2022-07-17T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0848,
+ "period_end": "2022-07-17T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2543,
+ "period_end": "2022-07-17T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4465,
+ "period_end": "2022-07-17T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6164,
+ "period_end": "2022-07-17T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8581,
+ "period_end": "2022-07-17T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0839,
+ "period_end": "2022-07-17T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2834,
+ "period_end": "2022-07-17T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5031,
+ "period_end": "2022-07-17T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.724,
+ "period_end": "2022-07-17T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9318,
+ "period_end": "2022-07-17T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1015,
+ "period_end": "2022-07-17T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2374,
+ "period_end": "2022-07-17T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3656,
+ "period_end": "2022-07-17T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4651,
+ "period_end": "2022-07-17T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5378,
+ "period_end": "2022-07-17T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6181,
+ "period_end": "2022-07-17T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6061,
+ "period_end": "2022-07-17T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6213,
+ "period_end": "2022-07-17T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.602,
+ "period_end": "2022-07-17T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5438,
+ "period_end": "2022-07-17T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4719,
+ "period_end": "2022-07-17T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3766,
+ "period_end": "2022-07-17T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2365,
+ "period_end": "2022-07-17T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0613,
+ "period_end": "2022-07-17T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8614,
+ "period_end": "2022-07-17T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6401,
+ "period_end": "2022-07-17T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3848,
+ "period_end": "2022-07-17T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0663,
+ "period_end": "2022-07-17T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7772,
+ "period_end": "2022-07-17T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4252,
+ "period_end": "2022-07-17T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0262,
+ "period_end": "2022-07-17T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-17T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0332,
+ "period_end": "2022-07-16T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1097,
+ "period_end": "2022-07-16T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2983,
+ "period_end": "2022-07-16T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.47,
+ "period_end": "2022-07-16T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6658,
+ "period_end": "2022-07-16T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9006,
+ "period_end": "2022-07-16T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1604,
+ "period_end": "2022-07-16T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.357,
+ "period_end": "2022-07-16T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.564,
+ "period_end": "2022-07-16T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7801,
+ "period_end": "2022-07-16T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9812,
+ "period_end": "2022-07-16T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9825,
+ "period_end": "2022-07-16T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9112,
+ "period_end": "2022-07-16T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.077,
+ "period_end": "2022-07-16T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9073,
+ "period_end": "2022-07-16T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4129,
+ "period_end": "2022-07-16T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5011,
+ "period_end": "2022-07-16T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8266,
+ "period_end": "2022-07-16T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0153,
+ "period_end": "2022-07-16T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1025,
+ "period_end": "2022-07-16T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0439,
+ "period_end": "2022-07-16T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5081,
+ "period_end": "2022-07-16T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3942,
+ "period_end": "2022-07-16T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4576,
+ "period_end": "2022-07-16T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6787,
+ "period_end": "2022-07-16T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1379,
+ "period_end": "2022-07-16T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4369,
+ "period_end": "2022-07-16T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9787,
+ "period_end": "2022-07-16T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.724,
+ "period_end": "2022-07-16T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.22,
+ "period_end": "2022-07-16T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3797,
+ "period_end": "2022-07-16T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0256,
+ "period_end": "2022-07-16T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-16T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0046,
+ "period_end": "2022-07-15T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0345,
+ "period_end": "2022-07-15T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1346,
+ "period_end": "2022-07-15T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3021,
+ "period_end": "2022-07-15T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4937,
+ "period_end": "2022-07-15T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6943,
+ "period_end": "2022-07-15T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8941,
+ "period_end": "2022-07-15T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1308,
+ "period_end": "2022-07-15T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3697,
+ "period_end": "2022-07-15T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5757,
+ "period_end": "2022-07-15T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7271,
+ "period_end": "2022-07-15T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9182,
+ "period_end": "2022-07-15T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1814,
+ "period_end": "2022-07-15T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2006,
+ "period_end": "2022-07-15T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2107,
+ "period_end": "2022-07-15T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1333,
+ "period_end": "2022-07-15T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.369,
+ "period_end": "2022-07-15T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0206,
+ "period_end": "2022-07-15T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0481,
+ "period_end": "2022-07-15T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.329,
+ "period_end": "2022-07-15T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7864,
+ "period_end": "2022-07-15T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1888,
+ "period_end": "2022-07-15T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3456,
+ "period_end": "2022-07-15T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4461,
+ "period_end": "2022-07-15T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0576,
+ "period_end": "2022-07-15T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2638,
+ "period_end": "2022-07-15T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8807,
+ "period_end": "2022-07-15T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7287,
+ "period_end": "2022-07-15T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2221,
+ "period_end": "2022-07-15T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1837,
+ "period_end": "2022-07-15T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0757,
+ "period_end": "2022-07-15T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0979,
+ "period_end": "2022-07-15T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0307,
+ "period_end": "2022-07-15T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-15T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0023,
+ "period_end": "2022-07-14T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0342,
+ "period_end": "2022-07-14T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1368,
+ "period_end": "2022-07-14T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3008,
+ "period_end": "2022-07-14T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3319,
+ "period_end": "2022-07-14T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7022,
+ "period_end": "2022-07-14T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9083,
+ "period_end": "2022-07-14T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1591,
+ "period_end": "2022-07-14T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3839,
+ "period_end": "2022-07-14T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.549,
+ "period_end": "2022-07-14T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6209,
+ "period_end": "2022-07-14T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9147,
+ "period_end": "2022-07-14T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7257,
+ "period_end": "2022-07-14T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0642,
+ "period_end": "2022-07-14T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1923,
+ "period_end": "2022-07-14T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6164,
+ "period_end": "2022-07-14T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9841,
+ "period_end": "2022-07-14T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3529,
+ "period_end": "2022-07-14T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4802,
+ "period_end": "2022-07-14T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5034,
+ "period_end": "2022-07-14T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5874,
+ "period_end": "2022-07-14T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6305,
+ "period_end": "2022-07-14T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4497,
+ "period_end": "2022-07-14T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0006,
+ "period_end": "2022-07-14T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5463,
+ "period_end": "2022-07-14T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4454,
+ "period_end": "2022-07-14T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6282,
+ "period_end": "2022-07-14T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4334,
+ "period_end": "2022-07-14T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4264,
+ "period_end": "2022-07-14T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2235,
+ "period_end": "2022-07-14T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0836,
+ "period_end": "2022-07-14T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0494,
+ "period_end": "2022-07-14T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0068,
+ "period_end": "2022-07-14T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-14T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0203,
+ "period_end": "2022-07-13T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0942,
+ "period_end": "2022-07-13T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2893,
+ "period_end": "2022-07-13T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3287,
+ "period_end": "2022-07-13T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6298,
+ "period_end": "2022-07-13T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8687,
+ "period_end": "2022-07-13T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0799,
+ "period_end": "2022-07-13T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1941,
+ "period_end": "2022-07-13T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4071,
+ "period_end": "2022-07-13T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.642,
+ "period_end": "2022-07-13T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8272,
+ "period_end": "2022-07-13T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9812,
+ "period_end": "2022-07-13T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2045,
+ "period_end": "2022-07-13T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3269,
+ "period_end": "2022-07-13T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1805,
+ "period_end": "2022-07-13T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3253,
+ "period_end": "2022-07-13T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2855,
+ "period_end": "2022-07-13T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4151,
+ "period_end": "2022-07-13T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8633,
+ "period_end": "2022-07-13T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.548,
+ "period_end": "2022-07-13T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4838,
+ "period_end": "2022-07-13T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7732,
+ "period_end": "2022-07-13T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1873,
+ "period_end": "2022-07-13T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8801,
+ "period_end": "2022-07-13T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7869,
+ "period_end": "2022-07-13T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7447,
+ "period_end": "2022-07-13T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7483,
+ "period_end": "2022-07-13T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3534,
+ "period_end": "2022-07-13T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1224,
+ "period_end": "2022-07-13T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1822,
+ "period_end": "2022-07-13T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0288,
+ "period_end": "2022-07-13T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0044,
+ "period_end": "2022-07-13T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-13T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.025,
+ "period_end": "2022-07-12T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0942,
+ "period_end": "2022-07-12T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.181,
+ "period_end": "2022-07-12T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4866,
+ "period_end": "2022-07-12T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6711,
+ "period_end": "2022-07-12T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.898,
+ "period_end": "2022-07-12T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1145,
+ "period_end": "2022-07-12T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3165,
+ "period_end": "2022-07-12T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.507,
+ "period_end": "2022-07-12T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7555,
+ "period_end": "2022-07-12T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9616,
+ "period_end": "2022-07-12T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1316,
+ "period_end": "2022-07-12T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2636,
+ "period_end": "2022-07-12T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3862,
+ "period_end": "2022-07-12T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.499,
+ "period_end": "2022-07-12T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.584,
+ "period_end": "2022-07-12T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6534,
+ "period_end": "2022-07-12T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6568,
+ "period_end": "2022-07-12T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6734,
+ "period_end": "2022-07-12T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6413,
+ "period_end": "2022-07-12T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.6053,
+ "period_end": "2022-07-12T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2459,
+ "period_end": "2022-07-12T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2619,
+ "period_end": "2022-07-12T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.073,
+ "period_end": "2022-07-12T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1316,
+ "period_end": "2022-07-12T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9143,
+ "period_end": "2022-07-12T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7024,
+ "period_end": "2022-07-12T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.453,
+ "period_end": "2022-07-12T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2017,
+ "period_end": "2022-07-12T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8564,
+ "period_end": "2022-07-12T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4738,
+ "period_end": "2022-07-12T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0769,
+ "period_end": "2022-07-12T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-12T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0045,
+ "period_end": "2022-07-11T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0443,
+ "period_end": "2022-07-11T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1255,
+ "period_end": "2022-07-11T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.29,
+ "period_end": "2022-07-11T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4389,
+ "period_end": "2022-07-11T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6142,
+ "period_end": "2022-07-11T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5635,
+ "period_end": "2022-07-11T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8217,
+ "period_end": "2022-07-11T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0935,
+ "period_end": "2022-07-11T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3116,
+ "period_end": "2022-07-11T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3947,
+ "period_end": "2022-07-11T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6082,
+ "period_end": "2022-07-11T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3857,
+ "period_end": "2022-07-11T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9869,
+ "period_end": "2022-07-11T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.574,
+ "period_end": "2022-07-11T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.566,
+ "period_end": "2022-07-11T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5114,
+ "period_end": "2022-07-11T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7266,
+ "period_end": "2022-07-11T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3484,
+ "period_end": "2022-07-11T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2986,
+ "period_end": "2022-07-11T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1635,
+ "period_end": "2022-07-11T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3318,
+ "period_end": "2022-07-11T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2608,
+ "period_end": "2022-07-11T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2389,
+ "period_end": "2022-07-11T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0139,
+ "period_end": "2022-07-11T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1048,
+ "period_end": "2022-07-11T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6094,
+ "period_end": "2022-07-11T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6392,
+ "period_end": "2022-07-11T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3935,
+ "period_end": "2022-07-11T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.0654,
+ "period_end": "2022-07-11T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7801,
+ "period_end": "2022-07-11T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3273,
+ "period_end": "2022-07-11T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0323,
+ "period_end": "2022-07-11T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-11T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-10T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-10T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-10T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-10T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "period_end": "2022-07-10T21:30:00.0000000Z",
+ "period": "PT30M"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/forecasts.json b/bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/forecasts.json
new file mode 100644
index 00000000000..9fa129cc477
--- /dev/null
+++ b/bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/forecasts.json
@@ -0,0 +1,2356 @@
+{
+ "forecasts": [
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-17T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-17T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-17T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-17T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-17T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0205,
+ "pv_estimate10": 0.0047,
+ "pv_estimate90": 0.0205,
+ "period_end": "2022-07-18T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1416,
+ "pv_estimate10": 0.0579,
+ "pv_estimate90": 0.1848,
+ "period_end": "2022-07-18T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4478,
+ "pv_estimate10": 0.1449,
+ "pv_estimate90": 0.5472,
+ "period_end": "2022-07-18T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.763,
+ "pv_estimate10": 0.3284,
+ "pv_estimate90": 0.8842,
+ "period_end": "2022-07-18T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1367,
+ "pv_estimate10": 0.5292,
+ "pv_estimate90": 1.2464,
+ "period_end": "2022-07-18T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4044,
+ "pv_estimate10": 0.7642,
+ "pv_estimate90": 1.5202,
+ "period_end": "2022-07-18T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6632,
+ "pv_estimate10": 1.0131,
+ "pv_estimate90": 1.7651,
+ "period_end": "2022-07-18T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8667,
+ "pv_estimate10": 1.2179,
+ "pv_estimate90": 1.9681,
+ "period_end": "2022-07-18T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0729,
+ "pv_estimate10": 1.4322,
+ "pv_estimate90": 2.1579,
+ "period_end": "2022-07-18T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2377,
+ "pv_estimate10": 1.5748,
+ "pv_estimate90": 2.2838,
+ "period_end": "2022-07-18T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3516,
+ "pv_estimate10": 1.7452,
+ "pv_estimate90": 2.4013,
+ "period_end": "2022-07-18T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4295,
+ "pv_estimate10": 1.8484,
+ "pv_estimate90": 2.4794,
+ "period_end": "2022-07-18T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5136,
+ "pv_estimate10": 1.9304,
+ "pv_estimate90": 2.5415,
+ "period_end": "2022-07-18T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5295,
+ "pv_estimate10": 2.0067,
+ "pv_estimate90": 2.5558,
+ "period_end": "2022-07-18T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.526,
+ "pv_estimate10": 2.0308,
+ "pv_estimate90": 2.5485,
+ "period_end": "2022-07-18T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4879,
+ "pv_estimate10": 2.0368,
+ "pv_estimate90": 2.5133,
+ "period_end": "2022-07-18T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4092,
+ "pv_estimate10": 2.0135,
+ "pv_estimate90": 2.4482,
+ "period_end": "2022-07-18T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3309,
+ "pv_estimate10": 1.9633,
+ "pv_estimate90": 2.3677,
+ "period_end": "2022-07-18T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1984,
+ "pv_estimate10": 1.8494,
+ "pv_estimate90": 2.2333,
+ "period_end": "2022-07-18T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0416,
+ "pv_estimate10": 1.7461,
+ "pv_estimate90": 2.1,
+ "period_end": "2022-07-18T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9076,
+ "pv_estimate10": 1.6195,
+ "pv_estimate90": 1.9674,
+ "period_end": "2022-07-18T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7416,
+ "pv_estimate10": 1.4758,
+ "pv_estimate90": 1.7931,
+ "period_end": "2022-07-18T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5414,
+ "pv_estimate10": 1.3132,
+ "pv_estimate90": 1.5823,
+ "period_end": "2022-07-18T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3683,
+ "pv_estimate10": 1.1483,
+ "pv_estimate90": 1.3963,
+ "period_end": "2022-07-18T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1603,
+ "pv_estimate10": 0.956,
+ "pv_estimate90": 1.1803,
+ "period_end": "2022-07-18T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9527,
+ "pv_estimate10": 0.7762,
+ "pv_estimate90": 0.9654,
+ "period_end": "2022-07-18T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7705,
+ "pv_estimate10": 0.5919,
+ "pv_estimate90": 0.7733,
+ "period_end": "2022-07-18T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5673,
+ "pv_estimate10": 0.3992,
+ "pv_estimate90": 0.5678,
+ "period_end": "2022-07-18T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3588,
+ "pv_estimate10": 0.2221,
+ "pv_estimate90": 0.37674,
+ "period_end": "2022-07-18T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1948,
+ "pv_estimate10": 0.0952,
+ "pv_estimate90": 0.1999,
+ "period_end": "2022-07-18T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0654,
+ "pv_estimate10": 0.0423,
+ "pv_estimate90": 0.0676,
+ "period_end": "2022-07-18T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0118,
+ "pv_estimate10": 0.0084,
+ "pv_estimate90": 0.0118,
+ "period_end": "2022-07-18T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-18T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0175,
+ "pv_estimate10": 0.0045,
+ "pv_estimate90": 0.0175,
+ "period_end": "2022-07-19T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1377,
+ "pv_estimate10": 0.0561,
+ "pv_estimate90": 0.1377,
+ "period_end": "2022-07-19T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4737,
+ "pv_estimate10": 0.1767,
+ "pv_estimate90": 0.4737,
+ "period_end": "2022-07-19T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.792,
+ "pv_estimate10": 0.3811,
+ "pv_estimate90": 0.792,
+ "period_end": "2022-07-19T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1438,
+ "pv_estimate10": 0.6405,
+ "pv_estimate90": 1.1438,
+ "period_end": "2022-07-19T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4346,
+ "pv_estimate10": 0.8964,
+ "pv_estimate90": 1.4346,
+ "period_end": "2022-07-19T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6679,
+ "pv_estimate10": 1.1527,
+ "pv_estimate90": 1.6679,
+ "period_end": "2022-07-19T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8955,
+ "pv_estimate10": 1.3956,
+ "pv_estimate90": 1.8955,
+ "period_end": "2022-07-19T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0584,
+ "pv_estimate10": 1.6084,
+ "pv_estimate90": 2.0584,
+ "period_end": "2022-07-19T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1963,
+ "pv_estimate10": 1.7982,
+ "pv_estimate90": 2.1963,
+ "period_end": "2022-07-19T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3135,
+ "pv_estimate10": 1.9441,
+ "pv_estimate90": 2.3135,
+ "period_end": "2022-07-19T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.393,
+ "pv_estimate10": 2.0729,
+ "pv_estimate90": 2.393,
+ "period_end": "2022-07-19T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4412,
+ "pv_estimate10": 2.1543,
+ "pv_estimate90": 2.4412,
+ "period_end": "2022-07-19T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4754,
+ "pv_estimate10": 2.2173,
+ "pv_estimate90": 2.4754,
+ "period_end": "2022-07-19T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4695,
+ "pv_estimate10": 2.2363,
+ "pv_estimate90": 2.4695,
+ "period_end": "2022-07-19T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4306,
+ "pv_estimate10": 2.2238,
+ "pv_estimate90": 2.4306,
+ "period_end": "2022-07-19T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3763,
+ "pv_estimate10": 2.1976,
+ "pv_estimate90": 2.3763,
+ "period_end": "2022-07-19T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3003,
+ "pv_estimate10": 2.1378,
+ "pv_estimate90": 2.3003,
+ "period_end": "2022-07-19T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1886,
+ "pv_estimate10": 2.0286,
+ "pv_estimate90": 2.1886,
+ "period_end": "2022-07-19T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.06,
+ "pv_estimate10": 1.9223,
+ "pv_estimate90": 2.06,
+ "period_end": "2022-07-19T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9249,
+ "pv_estimate10": 1.8002,
+ "pv_estimate90": 1.9249,
+ "period_end": "2022-07-19T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7487,
+ "pv_estimate10": 1.6508,
+ "pv_estimate90": 1.7487,
+ "period_end": "2022-07-19T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.557,
+ "pv_estimate10": 1.4728,
+ "pv_estimate90": 1.557,
+ "period_end": "2022-07-19T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3751,
+ "pv_estimate10": 1.3098,
+ "pv_estimate90": 1.3751,
+ "period_end": "2022-07-19T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1584,
+ "pv_estimate10": 1.1127,
+ "pv_estimate90": 1.1584,
+ "period_end": "2022-07-19T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9441,
+ "pv_estimate10": 0.9165,
+ "pv_estimate90": 0.9441,
+ "period_end": "2022-07-19T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7338,
+ "pv_estimate10": 0.7171,
+ "pv_estimate90": 0.7338,
+ "period_end": "2022-07-19T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5643,
+ "pv_estimate10": 0.5355,
+ "pv_estimate90": 0.5643,
+ "period_end": "2022-07-19T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.355,
+ "pv_estimate10": 0.3264,
+ "pv_estimate90": 0.355,
+ "period_end": "2022-07-19T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2006,
+ "pv_estimate10": 0.1561,
+ "pv_estimate90": 0.2006,
+ "period_end": "2022-07-19T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0642,
+ "pv_estimate10": 0.056,
+ "pv_estimate90": 0.0642,
+ "period_end": "2022-07-19T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0095,
+ "pv_estimate10": 0.0062,
+ "pv_estimate90": 0.0095,
+ "period_end": "2022-07-19T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-19T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0044,
+ "pv_estimate10": 0.0022,
+ "pv_estimate90": 0.0151,
+ "period_end": "2022-07-20T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1128,
+ "pv_estimate10": 0.0329,
+ "pv_estimate90": 0.1553,
+ "period_end": "2022-07-20T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3939,
+ "pv_estimate10": 0.0762,
+ "pv_estimate90": 0.4737,
+ "period_end": "2022-07-20T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7242,
+ "pv_estimate10": 0.1319,
+ "pv_estimate90": 0.8376,
+ "period_end": "2022-07-20T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9885,
+ "pv_estimate10": 0.2423,
+ "pv_estimate90": 1.1318,
+ "period_end": "2022-07-20T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2297,
+ "pv_estimate10": 0.36,
+ "pv_estimate90": 1.4031,
+ "period_end": "2022-07-20T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4211,
+ "pv_estimate10": 0.4615,
+ "pv_estimate90": 1.6512,
+ "period_end": "2022-07-20T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5682,
+ "pv_estimate10": 0.5595,
+ "pv_estimate90": 1.8406,
+ "period_end": "2022-07-20T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6963,
+ "pv_estimate10": 0.628,
+ "pv_estimate90": 2.0071,
+ "period_end": "2022-07-20T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8038,
+ "pv_estimate10": 0.6912,
+ "pv_estimate90": 2.1486,
+ "period_end": "2022-07-20T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.867,
+ "pv_estimate10": 0.691,
+ "pv_estimate90": 2.2611,
+ "period_end": "2022-07-20T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9107,
+ "pv_estimate10": 0.707,
+ "pv_estimate90": 2.3226,
+ "period_end": "2022-07-20T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9349,
+ "pv_estimate10": 0.719,
+ "pv_estimate90": 2.3591,
+ "period_end": "2022-07-20T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9591,
+ "pv_estimate10": 0.7227,
+ "pv_estimate90": 2.3784,
+ "period_end": "2022-07-20T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9951,
+ "pv_estimate10": 0.7658,
+ "pv_estimate90": 2.3608,
+ "period_end": "2022-07-20T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0016,
+ "pv_estimate10": 0.7767,
+ "pv_estimate90": 2.3226,
+ "period_end": "2022-07-20T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9624,
+ "pv_estimate10": 0.765,
+ "pv_estimate90": 2.2519,
+ "period_end": "2022-07-20T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.927,
+ "pv_estimate10": 0.7802,
+ "pv_estimate90": 2.187,
+ "period_end": "2022-07-20T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.876,
+ "pv_estimate10": 0.784,
+ "pv_estimate90": 2.0918,
+ "period_end": "2022-07-20T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7972,
+ "pv_estimate10": 0.7834,
+ "pv_estimate90": 1.9873,
+ "period_end": "2022-07-20T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6934,
+ "pv_estimate10": 0.7207,
+ "pv_estimate90": 1.8705,
+ "period_end": "2022-07-20T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.573,
+ "pv_estimate10": 0.693,
+ "pv_estimate90": 1.7139,
+ "period_end": "2022-07-20T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4334,
+ "pv_estimate10": 0.6639,
+ "pv_estimate90": 1.5257,
+ "period_end": "2022-07-20T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2773,
+ "pv_estimate10": 0.5927,
+ "pv_estimate90": 1.3469,
+ "period_end": "2022-07-20T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.077,
+ "pv_estimate10": 0.4745,
+ "pv_estimate90": 1.1327,
+ "period_end": "2022-07-20T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.8892,
+ "pv_estimate10": 0.3671,
+ "pv_estimate90": 0.9373,
+ "period_end": "2022-07-20T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6825,
+ "pv_estimate10": 0.2454,
+ "pv_estimate90": 0.7374,
+ "period_end": "2022-07-20T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4909,
+ "pv_estimate10": 0.1358,
+ "pv_estimate90": 0.5488,
+ "period_end": "2022-07-20T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2984,
+ "pv_estimate10": 0.0778,
+ "pv_estimate90": 0.341,
+ "period_end": "2022-07-20T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1269,
+ "pv_estimate10": 0.044,
+ "pv_estimate90": 0.1543,
+ "period_end": "2022-07-20T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0543,
+ "pv_estimate10": 0.0192,
+ "pv_estimate90": 0.0638,
+ "period_end": "2022-07-20T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0072,
+ "pv_estimate10": 0.0022,
+ "pv_estimate90": 0.0079,
+ "period_end": "2022-07-20T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-20T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0022,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0.0022,
+ "period_end": "2022-07-21T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1015,
+ "pv_estimate10": 0.0179,
+ "pv_estimate90": 0.1911,
+ "period_end": "2022-07-21T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.295,
+ "pv_estimate10": 0.0471,
+ "pv_estimate90": 0.4675,
+ "period_end": "2022-07-21T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6214,
+ "pv_estimate10": 0.0978,
+ "pv_estimate90": 0.8657,
+ "period_end": "2022-07-21T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9143,
+ "pv_estimate10": 0.1984,
+ "pv_estimate90": 1.1813,
+ "period_end": "2022-07-21T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1982,
+ "pv_estimate10": 0.3472,
+ "pv_estimate90": 1.4691,
+ "period_end": "2022-07-21T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5092,
+ "pv_estimate10": 0.5175,
+ "pv_estimate90": 1.7602,
+ "period_end": "2022-07-21T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7137,
+ "pv_estimate10": 0.6504,
+ "pv_estimate90": 1.9701,
+ "period_end": "2022-07-21T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9236,
+ "pv_estimate10": 0.8177,
+ "pv_estimate90": 2.1465,
+ "period_end": "2022-07-21T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.071,
+ "pv_estimate10": 0.9283,
+ "pv_estimate90": 2.3015,
+ "period_end": "2022-07-21T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2137,
+ "pv_estimate10": 1.0682,
+ "pv_estimate90": 2.4064,
+ "period_end": "2022-07-21T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3307,
+ "pv_estimate10": 1.179,
+ "pv_estimate90": 2.5079,
+ "period_end": "2022-07-21T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3836,
+ "pv_estimate10": 1.267,
+ "pv_estimate90": 2.5587,
+ "period_end": "2022-07-21T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.406,
+ "pv_estimate10": 1.2955,
+ "pv_estimate90": 2.5943,
+ "period_end": "2022-07-21T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3884,
+ "pv_estimate10": 1.2957,
+ "pv_estimate90": 2.5844,
+ "period_end": "2022-07-21T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3529,
+ "pv_estimate10": 1.2832,
+ "pv_estimate90": 2.5529,
+ "period_end": "2022-07-21T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2804,
+ "pv_estimate10": 1.2464,
+ "pv_estimate90": 2.4864,
+ "period_end": "2022-07-21T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2065,
+ "pv_estimate10": 1.23,
+ "pv_estimate90": 2.4041,
+ "period_end": "2022-07-21T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1312,
+ "pv_estimate10": 1.2279,
+ "pv_estimate90": 2.3012,
+ "period_end": "2022-07-21T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0178,
+ "pv_estimate10": 1.2028,
+ "pv_estimate90": 2.1646,
+ "period_end": "2022-07-21T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8701,
+ "pv_estimate10": 1.1297,
+ "pv_estimate90": 1.9989,
+ "period_end": "2022-07-21T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7169,
+ "pv_estimate10": 1.0696,
+ "pv_estimate90": 1.82,
+ "period_end": "2022-07-21T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5509,
+ "pv_estimate10": 0.9652,
+ "pv_estimate90": 1.6333,
+ "period_end": "2022-07-21T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3622,
+ "pv_estimate10": 0.8778,
+ "pv_estimate90": 1.421,
+ "period_end": "2022-07-21T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1869,
+ "pv_estimate10": 0.7675,
+ "pv_estimate90": 1.2202,
+ "period_end": "2022-07-21T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9759,
+ "pv_estimate10": 0.6284,
+ "pv_estimate90": 0.9923,
+ "period_end": "2022-07-21T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7675,
+ "pv_estimate10": 0.4705,
+ "pv_estimate90": 0.7693,
+ "period_end": "2022-07-21T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5842,
+ "pv_estimate10": 0.3289,
+ "pv_estimate90": 0.6134100000000001,
+ "period_end": "2022-07-21T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3788,
+ "pv_estimate10": 0.1871,
+ "pv_estimate90": 0.39774000000000004,
+ "period_end": "2022-07-21T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1769,
+ "pv_estimate10": 0.0833,
+ "pv_estimate90": 0.18574500000000002,
+ "period_end": "2022-07-21T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0654,
+ "pv_estimate10": 0.0353,
+ "pv_estimate90": 0.0682,
+ "period_end": "2022-07-21T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0073,
+ "pv_estimate10": 0.0044,
+ "pv_estimate90": 0.0073,
+ "period_end": "2022-07-21T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-21T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0916,
+ "pv_estimate10": 0.0183,
+ "pv_estimate90": 0.1886,
+ "period_end": "2022-07-22T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.2989,
+ "pv_estimate10": 0.0481,
+ "pv_estimate90": 0.4564,
+ "period_end": "2022-07-22T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6014,
+ "pv_estimate10": 0.0885,
+ "pv_estimate90": 0.8581,
+ "period_end": "2022-07-22T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9027,
+ "pv_estimate10": 0.1654,
+ "pv_estimate90": 1.1849,
+ "period_end": "2022-07-22T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2082,
+ "pv_estimate10": 0.2747,
+ "pv_estimate90": 1.5032,
+ "period_end": "2022-07-22T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4825,
+ "pv_estimate10": 0.4286,
+ "pv_estimate90": 1.7619,
+ "period_end": "2022-07-22T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6896,
+ "pv_estimate10": 0.5904,
+ "pv_estimate90": 1.9707,
+ "period_end": "2022-07-22T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9098,
+ "pv_estimate10": 0.7387,
+ "pv_estimate90": 2.1499,
+ "period_end": "2022-07-22T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0837,
+ "pv_estimate10": 0.864,
+ "pv_estimate90": 2.3044,
+ "period_end": "2022-07-22T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1977,
+ "pv_estimate10": 1.0058,
+ "pv_estimate90": 2.408,
+ "period_end": "2022-07-22T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3147,
+ "pv_estimate10": 1.1181,
+ "pv_estimate90": 2.5101,
+ "period_end": "2022-07-22T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3448,
+ "pv_estimate10": 1.1903,
+ "pv_estimate90": 2.5683,
+ "period_end": "2022-07-22T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3972,
+ "pv_estimate10": 1.2428,
+ "pv_estimate90": 2.6017,
+ "period_end": "2022-07-22T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3859,
+ "pv_estimate10": 1.2758,
+ "pv_estimate90": 2.6074,
+ "period_end": "2022-07-22T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3501,
+ "pv_estimate10": 1.2875,
+ "pv_estimate90": 2.5663,
+ "period_end": "2022-07-22T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2958,
+ "pv_estimate10": 1.2599,
+ "pv_estimate90": 2.5085,
+ "period_end": "2022-07-22T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2433,
+ "pv_estimate10": 1.2452,
+ "pv_estimate90": 2.437,
+ "period_end": "2022-07-22T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.1632,
+ "pv_estimate10": 1.2148,
+ "pv_estimate90": 2.3408,
+ "period_end": "2022-07-22T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0674,
+ "pv_estimate10": 1.1698,
+ "pv_estimate90": 2.2236,
+ "period_end": "2022-07-22T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9279,
+ "pv_estimate10": 1.0698,
+ "pv_estimate90": 2.0602,
+ "period_end": "2022-07-22T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7788,
+ "pv_estimate10": 0.9934,
+ "pv_estimate90": 1.8858,
+ "period_end": "2022-07-22T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.617,
+ "pv_estimate10": 0.8803,
+ "pv_estimate90": 1.7043,
+ "period_end": "2022-07-22T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4098,
+ "pv_estimate10": 0.7462,
+ "pv_estimate90": 1.4715,
+ "period_end": "2022-07-22T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.2308,
+ "pv_estimate10": 0.6129,
+ "pv_estimate90": 1.2703,
+ "period_end": "2022-07-22T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.009,
+ "pv_estimate10": 0.4701,
+ "pv_estimate90": 1.032,
+ "period_end": "2022-07-22T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7849,
+ "pv_estimate10": 0.3106,
+ "pv_estimate90": 0.7979,
+ "period_end": "2022-07-22T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5561,
+ "pv_estimate10": 0.1804,
+ "pv_estimate90": 0.5645,
+ "period_end": "2022-07-22T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3568,
+ "pv_estimate10": 0.0847,
+ "pv_estimate90": 0.3891,
+ "period_end": "2022-07-22T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1571,
+ "pv_estimate10": 0.0435,
+ "pv_estimate90": 0.1792,
+ "period_end": "2022-07-22T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0584,
+ "pv_estimate10": 0.016,
+ "pv_estimate90": 0.0691,
+ "period_end": "2022-07-22T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0046,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0.0052,
+ "period_end": "2022-07-22T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-22T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0745,
+ "pv_estimate10": 0.0095,
+ "pv_estimate90": 0.1473,
+ "period_end": "2022-07-23T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.205,
+ "pv_estimate10": 0.0234,
+ "pv_estimate90": 0.4975,
+ "period_end": "2022-07-23T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.4228,
+ "pv_estimate10": 0.0421,
+ "pv_estimate90": 0.831,
+ "period_end": "2022-07-23T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.6554,
+ "pv_estimate10": 0.0671,
+ "pv_estimate90": 1.1687,
+ "period_end": "2022-07-23T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9,
+ "pv_estimate10": 0.0995,
+ "pv_estimate90": 1.4997,
+ "period_end": "2022-07-23T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1658,
+ "pv_estimate10": 0.1753,
+ "pv_estimate90": 1.7737,
+ "period_end": "2022-07-23T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3921,
+ "pv_estimate10": 0.2519,
+ "pv_estimate90": 1.989,
+ "period_end": "2022-07-23T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5882,
+ "pv_estimate10": 0.3003,
+ "pv_estimate90": 2.191,
+ "period_end": "2022-07-23T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7774,
+ "pv_estimate10": 0.3709,
+ "pv_estimate90": 2.3467,
+ "period_end": "2022-07-23T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9103,
+ "pv_estimate10": 0.4432,
+ "pv_estimate90": 2.4766,
+ "period_end": "2022-07-23T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0696,
+ "pv_estimate10": 0.5417,
+ "pv_estimate90": 2.5662,
+ "period_end": "2022-07-23T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2036,
+ "pv_estimate10": 0.6428,
+ "pv_estimate90": 2.6162,
+ "period_end": "2022-07-23T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2956,
+ "pv_estimate10": 0.742,
+ "pv_estimate90": 2.638,
+ "period_end": "2022-07-23T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3641,
+ "pv_estimate10": 0.8636,
+ "pv_estimate90": 2.6384,
+ "period_end": "2022-07-23T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3786,
+ "pv_estimate10": 0.9431,
+ "pv_estimate90": 2.6029,
+ "period_end": "2022-07-23T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3756,
+ "pv_estimate10": 1.0408,
+ "pv_estimate90": 2.5349,
+ "period_end": "2022-07-23T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3078,
+ "pv_estimate10": 1.083,
+ "pv_estimate90": 2.4555,
+ "period_end": "2022-07-23T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2021,
+ "pv_estimate10": 1.02,
+ "pv_estimate90": 2.3478,
+ "period_end": "2022-07-23T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0661,
+ "pv_estimate10": 0.9478,
+ "pv_estimate90": 2.2098,
+ "period_end": "2022-07-23T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9176,
+ "pv_estimate10": 0.8644,
+ "pv_estimate90": 2.0456,
+ "period_end": "2022-07-23T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7544,
+ "pv_estimate10": 0.7708,
+ "pv_estimate90": 1.864,
+ "period_end": "2022-07-23T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.5797,
+ "pv_estimate10": 0.6449,
+ "pv_estimate90": 1.67,
+ "period_end": "2022-07-23T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3794,
+ "pv_estimate10": 0.5578,
+ "pv_estimate90": 1.4415,
+ "period_end": "2022-07-23T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1748,
+ "pv_estimate10": 0.4676,
+ "pv_estimate90": 1.2103,
+ "period_end": "2022-07-23T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9969,
+ "pv_estimate10": 0.3775,
+ "pv_estimate90": 1.0136,
+ "period_end": "2022-07-23T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7737,
+ "pv_estimate10": 0.267,
+ "pv_estimate90": 0.7737,
+ "period_end": "2022-07-23T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5574,
+ "pv_estimate10": 0.1618,
+ "pv_estimate90": 0.5852700000000001,
+ "period_end": "2022-07-23T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3731,
+ "pv_estimate10": 0.0868,
+ "pv_estimate90": 0.3801,
+ "period_end": "2022-07-23T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1728,
+ "pv_estimate10": 0.0444,
+ "pv_estimate90": 0.1776,
+ "period_end": "2022-07-23T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0637,
+ "pv_estimate10": 0.0177,
+ "pv_estimate90": 0.067,
+ "period_end": "2022-07-23T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0045,
+ "pv_estimate10": 0.0022,
+ "pv_estimate90": 0.0051,
+ "period_end": "2022-07-23T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T21:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T21:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T22:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T22:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T23:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-23T23:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T00:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T00:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T01:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T01:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T02:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T02:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T03:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T03:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T04:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1045,
+ "pv_estimate10": 0.0139,
+ "pv_estimate90": 0.1166,
+ "period_end": "2022-07-24T04:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.425,
+ "pv_estimate10": 0.037,
+ "pv_estimate90": 0.4824,
+ "period_end": "2022-07-24T05:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7432,
+ "pv_estimate10": 0.0756,
+ "pv_estimate90": 0.8193,
+ "period_end": "2022-07-24T05:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1067,
+ "pv_estimate10": 0.1335,
+ "pv_estimate90": 1.1952,
+ "period_end": "2022-07-24T06:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.4001,
+ "pv_estimate10": 0.2525,
+ "pv_estimate90": 1.4846,
+ "period_end": "2022-07-24T06:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.6791,
+ "pv_estimate10": 0.4098,
+ "pv_estimate90": 1.7452,
+ "period_end": "2022-07-24T07:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.8959,
+ "pv_estimate10": 0.5464,
+ "pv_estimate90": 1.9626,
+ "period_end": "2022-07-24T07:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.0809,
+ "pv_estimate10": 0.6923,
+ "pv_estimate90": 2.1505,
+ "period_end": "2022-07-24T08:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2587,
+ "pv_estimate10": 0.794,
+ "pv_estimate90": 2.3058,
+ "period_end": "2022-07-24T08:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.402,
+ "pv_estimate10": 0.9349,
+ "pv_estimate90": 2.4313,
+ "period_end": "2022-07-24T09:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4872,
+ "pv_estimate10": 1.0086,
+ "pv_estimate90": 2.5121,
+ "period_end": "2022-07-24T09:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5515,
+ "pv_estimate10": 1.1335,
+ "pv_estimate90": 2.5799,
+ "period_end": "2022-07-24T10:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5741,
+ "pv_estimate10": 1.1814,
+ "pv_estimate90": 2.6013,
+ "period_end": "2022-07-24T10:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5783,
+ "pv_estimate10": 1.2452,
+ "pv_estimate90": 2.6042,
+ "period_end": "2022-07-24T11:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.5354,
+ "pv_estimate10": 1.261,
+ "pv_estimate90": 2.5604,
+ "period_end": "2022-07-24T11:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.4904,
+ "pv_estimate10": 1.2898,
+ "pv_estimate90": 2.5113,
+ "period_end": "2022-07-24T12:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.3935,
+ "pv_estimate10": 1.2728,
+ "pv_estimate90": 2.416,
+ "period_end": "2022-07-24T12:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.2599,
+ "pv_estimate10": 1.257,
+ "pv_estimate90": 2.2968,
+ "period_end": "2022-07-24T13:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 2.141,
+ "pv_estimate10": 1.1996,
+ "pv_estimate90": 2.1749,
+ "period_end": "2022-07-24T13:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.9726,
+ "pv_estimate10": 1.126,
+ "pv_estimate90": 2.0039,
+ "period_end": "2022-07-24T14:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.7724,
+ "pv_estimate10": 0.9872,
+ "pv_estimate90": 1.8281,
+ "period_end": "2022-07-24T14:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.578,
+ "pv_estimate10": 0.8206,
+ "pv_estimate90": 1.638,
+ "period_end": "2022-07-24T15:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.3572,
+ "pv_estimate10": 0.6504,
+ "pv_estimate90": 1.4139,
+ "period_end": "2022-07-24T15:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 1.1495,
+ "pv_estimate10": 0.4931,
+ "pv_estimate90": 1.1906,
+ "period_end": "2022-07-24T16:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.9617,
+ "pv_estimate10": 0.3544,
+ "pv_estimate90": 0.9953,
+ "period_end": "2022-07-24T16:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.7468,
+ "pv_estimate10": 0.2231,
+ "pv_estimate90": 0.7637,
+ "period_end": "2022-07-24T17:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.5141,
+ "pv_estimate10": 0.1067,
+ "pv_estimate90": 0.5419,
+ "period_end": "2022-07-24T17:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.3009,
+ "pv_estimate10": 0.0587,
+ "pv_estimate90": 0.331,
+ "period_end": "2022-07-24T18:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.1232,
+ "pv_estimate10": 0.0307,
+ "pv_estimate90": 0.174,
+ "period_end": "2022-07-24T18:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0467,
+ "pv_estimate10": 0.011,
+ "pv_estimate90": 0.0648,
+ "period_end": "2022-07-24T19:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0.0022,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0.0028,
+ "period_end": "2022-07-24T19:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T20:00:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T20:30:00.0000000Z",
+ "period": "PT30M"
+ },
+ {
+ "pv_estimate": 0,
+ "pv_estimate10": 0,
+ "pv_estimate90": 0,
+ "period_end": "2022-07-24T21:00:00.0000000Z",
+ "period": "PT30M"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 0fa1f20f9dd..0db4af22a90 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -363,6 +363,7 @@
org.openhab.binding.sncf
org.openhab.binding.snmp
org.openhab.binding.solaredge
+ org.openhab.binding.solarforecast
org.openhab.binding.solarlog
org.openhab.binding.solarmax
org.openhab.binding.solarwatt