]> git.basschouten.com Git - openhab-addons.git/commitdiff
[solarforecast] Initial contribution (#13308)
authorBernd Weymann <bernd.weymann@gmail.com>
Thu, 2 May 2024 18:26:09 +0000 (20:26 +0200)
committerGitHub <noreply@github.com>
Thu, 2 May 2024 18:26:09 +0000 (20:26 +0200)
Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
52 files changed:
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.solarforecast/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/README.md [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/doc/SolcastCumulated.png [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/doc/SolcastPower.png [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastException.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/SolarForecastHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecast.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastActions.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/actions/SolarForecastProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/ForecastSolarObject.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/config/ForecastSolarPlaneConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/SolcastObject.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/config/SolcastPlaneConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/java/org/openhab/binding/solarforecast/internal/utils/Utils.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-plane-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/fs-site-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-plane-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/config/sc-site-config.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/i18n/solarforecast.properties [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/average-group.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/channel-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-plane-type.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/fs-site-type.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/optimistic-group.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/pessimistic-group.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/raw-group.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-plane-type.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/main/resources/OH-INF/thing/sc-site-type.xml [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/CallbackMock.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/FileReader.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/ForecastSolarTest.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/SolcastTest.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/TimeZP.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/forecastsolar/handler/ForecastSolarPlaneMock.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/java/org/openhab/binding/solarforecast/internal/solcast/handler/SolcastPlaneMock.java [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/result.json [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/resources/forecastsolar/resultNextDay.json [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/estimated-actuals.json [new file with mode: 0644]
bundles/org.openhab.binding.solarforecast/src/test/resources/solcast/forecasts.json [new file with mode: 0644]
bundles/pom.xml

index 96a0264b851e922205928f6fc4dfd8423903a770..3db66110ec21fce3b9d5cbf67f889b8f7de3d00f 100644 (file)
       <artifactId>org.openhab.binding.solaredge</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.solarforecast</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.solarlog</artifactId>
diff --git a/bundles/org.openhab.binding.solarforecast/NOTICE b/bundles/org.openhab.binding.solarforecast/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..1de5035
--- /dev/null
@@ -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
+
+<img src="./doc/SolcastPower.png" width="640" height="400"/>
+
+Display Energy values of Forecast and PV inverter items
+Yellow line shows *Daily Total Forecast*.
+
+<img src="./doc/SolcastCumulated.png" width="640" height="400"/>
+
+## 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<Power>` 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<Energy>` 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<Energy>` 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 (file)
index 0000000..d1c692e
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 (file)
index 0000000..fb4ab56
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 (file)
index 0000000..ecd0e93
--- /dev/null
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.solarforecast</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: SolarForecast Binding</name>
+  <dependencies>
+    <!-- version needs to match with other projects like org.openhab.io.openhabcloud.pom.xml -->
+    <dependency>
+      <groupId>org.json</groupId>
+      <artifactId>json</artifactId>
+      <version>20231013</version>
+      <scope>compile</scope>
+    </dependency>
+  </dependencies>
+
+</project>
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 (file)
index 0000000..9237c11
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.solarforecast-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-solarforecast" description="SolarForecast Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.solarforecast/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..7b4ba46
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..06c8c85
--- /dev/null
@@ -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 (file)
index 0000000..9597ab2
--- /dev/null
@@ -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<PointType> 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 (file)
index 0000000..b6d37bb
--- /dev/null
@@ -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<Energy> in kW/h
+     */
+    QuantityType<Energy> getDay(LocalDate date, String... args);
+
+    /**
+     * Returns electric energy between two timestamps
+     *
+     * @param start
+     * @param end
+     * @param args possible arguments from this interface
+     * @return QuantityType<Energy> in kW/h
+     */
+    QuantityType<Energy> 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<Power> in kW
+     */
+    QuantityType<Power> 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<Power>
+     */
+    TimeSeries getPowerTimeSeries(QueryMode mode);
+
+    /**
+     * Get TimeSeries for Energy forecast
+     *
+     * @param mode QueryMode for optimistic, pessimistic or average estimation
+     * @return TimeSeries containing QuantityType<Energy>
+     */
+    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 (file)
index 0000000..c794ebb
--- /dev/null
@@ -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> thingHandler = Optional.empty();
+
+    @RuleAction(label = "@text/actionDayLabel", description = "@text/actionDayDesc")
+    public QuantityType<Energy> getDay(
+            @ActionInput(name = "localDate", label = "@text/actionInputDayLabel", description = "@text/actionInputDayDesc") LocalDate localDate,
+            String... args) {
+        if (thingHandler.isPresent()) {
+            List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+            if (!l.isEmpty()) {
+                QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
+                for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
+                    SolarForecast solarForecast = iterator.next();
+                    QuantityType<Energy> 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<Power> getPower(
+            @ActionInput(name = "timestamp", label = "@text/actionInputDateTimeLabel", description = "@text/actionInputDateTimeDesc") Instant timestamp,
+            String... args) {
+        if (thingHandler.isPresent()) {
+            List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+            if (!l.isEmpty()) {
+                QuantityType<Power> measure = QuantityType.valueOf(0, MetricPrefix.KILO(Units.WATT));
+                for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
+                    SolarForecast solarForecast = iterator.next();
+                    QuantityType<Power> 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<Energy> 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<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
+            if (!l.isEmpty()) {
+                QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
+                for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
+                    SolarForecast solarForecast = iterator.next();
+                    QuantityType<Energy> 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<SolarForecast> 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<SolarForecast> 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 (file)
index 0000000..0163cf2
--- /dev/null
@@ -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<SolarForecast> 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 (file)
index 0000000..de15a50
--- /dev/null
@@ -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<ZonedDateTime, Double> wattHourMap = new TreeMap<>();
+    private final TreeMap<ZonedDateTime, Double> 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<String> 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<String> 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<ZonedDateTime, Double> f = wattHourMap.floorEntry(queryDateTime);
+        Entry<ZonedDateTime, Double> 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<ZonedDateTime, Double> f = wattMap.floorEntry(queryDateTime);
+        Entry<ZonedDateTime, Double> 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<Energy> 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<Energy> 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<Power> 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 (file)
index 0000000..89f1fd1
--- /dev/null
@@ -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 (file)
index 0000000..ed2da9a
--- /dev/null
@@ -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 (file)
index 0000000..487d93e
--- /dev/null
@@ -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<ForecastSolarPlaneHandler> planes = new ArrayList<>();
+    private Optional<PointType> homeLocation;
+    private Optional<ForecastSolarBridgeConfiguration> configuration = Optional.empty();
+    private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
+
+    public ForecastSolarBridgeHandler(Bridge bridge, Optional<PointType> location) {
+        super(bridge);
+        homeLocation = location;
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> 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<ForecastSolarPlaneHandler> 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<Instant, QuantityType<?>> combinedPowerForecast = new TreeMap<>();
+        TreeMap<Instant, QuantityType<?>> combinedEnergyForecast = new TreeMap<>();
+        List<SolarForecast> forecastObjects = new ArrayList<>();
+        for (Iterator<ForecastSolarPlaneHandler> 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<SolarForecast> getSolarForecasts() {
+        List<SolarForecast> l = new ArrayList<SolarForecast>();
+        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 (file)
index 0000000..a15f617
--- /dev/null
@@ -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<ForecastSolarPlaneConfiguration> configuration = Optional.empty();
+    private Optional<ForecastSolarBridgeHandler> bridgeHandler = Optional.empty();
+    private Optional<PointType> location = Optional.empty();
+    private Optional<String> 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<Class<? extends ThingHandlerService>> 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<SolarForecast> 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 (file)
index 0000000..f55b807
--- /dev/null
@@ -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<Power> 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 (file)
index 0000000..667c0e7
--- /dev/null
@@ -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<ZonedDateTime, Double> EMPTY_MAP = new TreeMap<>();
+
+    private final Logger logger = LoggerFactory.getLogger(SolcastObject.class);
+    private final TreeMap<ZonedDateTime, Double> estimationDataMap = new TreeMap<>();
+    private final TreeMap<ZonedDateTime, Double> optimisticDataMap = new TreeMap<>();
+    private final TreeMap<ZonedDateTime, Double> pessimisticDataMap = new TreeMap<>();
+    private final TimeZoneProvider timeZoneProvider;
+
+    private DateTimeFormatter dateOutputFormatter;
+    private String identifier;
+    private Optional<JSONObject> 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<ZonedDateTime, Double> dtm = getDataMap(mode);
+        Entry<ZonedDateTime, Double> 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<ZonedDateTime, Double> f = dtm.floorEntry(query);
+        Entry<ZonedDateTime, Double> 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<ZonedDateTime, Double> 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<ZonedDateTime, Double> dtm = getDataMap(mode);
+        double actualPowerValue = 0;
+        Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
+        Entry<ZonedDateTime, Double> 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<ZonedDateTime, Double> 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<ZonedDateTime, Double> dtm = getDataMap(mode);
+        ZonedDateTime iterationDateTime = query.atStartOfDay(timeZoneProvider.getTimeZone());
+        Entry<ZonedDateTime, Double> 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<ZonedDateTime, Double> getDataMap(QueryMode mode) {
+        TreeMap<ZonedDateTime, Double> 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<Energy> 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<Energy> 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<Power> 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 (file)
index 0000000..248b718
--- /dev/null
@@ -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 (file)
index 0000000..1a2d2c1
--- /dev/null
@@ -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 (file)
index 0000000..de5fe18
--- /dev/null
@@ -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<SolcastPlaneHandler> planes = new ArrayList<>();
+    private Optional<ScheduledFuture<?>> 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<Class<? extends ThingHandlerService>> 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<QueryMode> 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<SolcastPlaneHandler> 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<SolarForecast> forecastObjects = new ArrayList<>();
+        for (Iterator<SolcastPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
+            SolcastPlaneHandler sfph = iterator.next();
+            forecastObjects.addAll(sfph.getSolarForecasts());
+        }
+        // sort in Tree according to times for each scenario
+        List<QueryMode> modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
+        modes.forEach(mode -> {
+            TreeMap<Instant, QuantityType<?>> combinedPowerForecast = new TreeMap<>();
+            TreeMap<Instant, QuantityType<?>> 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<SolarForecast> getSolarForecasts() {
+        List<SolarForecast> 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 (file)
index 0000000..89c4656
--- /dev/null
@@ -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<SolcastBridgeHandler> bridgeHandler = Optional.empty();
+    protected Optional<SolcastObject> forecast = Optional.empty();
+
+    public SolcastPlaneHandler(Thing thing, HttpClient hc) {
+        super(thing);
+        httpClient = hc;
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> 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<QueryMode> 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<SolarForecast> 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 (file)
index 0000000..44844a6
--- /dev/null
@@ -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<Energy> 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<Power> 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<Instant, QuantityType<?>> 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<SolarForecast> forecastObjects) {
+        if (forecastObjects.isEmpty()) {
+            return Instant.MAX;
+        }
+        Instant start = Instant.MIN;
+        for (Iterator<SolarForecast> 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<SolarForecast> forecastObjects) {
+        if (forecastObjects.isEmpty()) {
+            return Instant.MIN;
+        }
+        Instant end = Instant.MAX;
+        for (Iterator<SolarForecast> 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 (file)
index 0000000..faaa013
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="solarforecast" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>binding</type>
+       <name>SolarForecast Binding</name>
+       <description>Solar Forecast for your location</description>
+       <connection>cloud</connection>
+</addon:addon>
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 (file)
index 0000000..3e413fc
--- /dev/null
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:solarforecast:fs-plane">
+               <parameter name="refreshInterval" type="integer" min="1" unit="min" required="true">
+                       <label>Forecast Refresh Interval</label>
+                       <description>Data refresh rate of forecast data in minutes</description>
+                       <default>30</default>
+               </parameter>
+               <parameter name="declination" type="integer" min="0" max="90" required="true">
+                       <label>Plane Declination</label>
+                       <description>0 for horizontal till 90 for vertical declination</description>
+               </parameter>
+               <parameter name="azimuth" type="integer" min="-180" max="180" required="true">
+                       <label>Plane Azimuth</label>
+                       <description>-180 = north, -90 = east, 0 = south, 90 = west, 180 = north</description>
+               </parameter>
+               <parameter name="kwp" type="decimal" step="0.001" required="true">
+                       <label>Installed Kilowatt Peak</label>
+                       <description>Installed module power of this plane</description>
+               </parameter>
+               <parameter name="dampAM" type="decimal" step="0.01" min="0" max="1">
+                       <label>Morning Damping Factor</label>
+                       <description>Damping factor of morning hours</description>
+                       <default>0.25</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="dampPM" type="decimal" step="0.01" min="0" max="1">
+                       <label>Evening Damping Factor</label>
+                       <description>Damping factor of evening hours</description>
+                       <default>0.25</default>
+                       <advanced>true</advanced>
+               </parameter>
+               <parameter name="horizon" type="text">
+                       <label>Horizon</label>
+                       <description>Horizon definition as comma-separated integer values</description>
+                       <advanced>true</advanced>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..081ac44
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:solarforecast:fs-site">
+               <parameter name="location" type="text">
+                       <context>location</context>
+                       <label>PV Location</label>
+                       <description>Location of photovoltaic system. Location from openHAB settings is used in case of empty value.</description>
+               </parameter>
+               <parameter name="apiKey" type="text">
+                       <label>API Key</label>
+                       <description>If you have a paid subscription plan</description>
+               </parameter>
+               <parameter name="inverterKwp" type="decimal" step="0.1">
+                       <label>Inverter Kilowatt Peak</label>
+                       <description>Inverter maximum kilowatt peak capability</description>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..d17426c
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:solarforecast:sc-plane">
+               <parameter name="resourceId" type="text" required="true">
+                       <label>Rooftop Resource Id</label>
+                       <description>Resource Id of Solcast rooftop site</description>
+               </parameter>
+               <parameter name="refreshInterval" type="integer" min="1" unit="min" required="true">
+                       <label>Forecast Refresh Interval</label>
+                       <description>Data refresh rate of forecast data in minutes</description>
+                       <default>120</default>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..956eec8
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<config-description:config-descriptions
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
+
+       <config-description uri="thing-type:solarforecast:sc-site">
+               <parameter name="apiKey" type="text" required="true">
+                       <label>API Key</label>
+                       <description>API key from your subscription</description>
+               </parameter>
+               <parameter name="timeZone" type="text" required="false">
+                       <label>Time Zone</label>
+                       <description>Time zone of forecast location</description>
+                       <advanced>true</advanced>
+               </parameter>
+       </config-description>
+</config-description:config-descriptions>
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 (file)
index 0000000..a2fe132
--- /dev/null
@@ -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 (file)
index 0000000..8f00a6c
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+       <channel-group-type id="average-values">
+               <label>Average Forecast Values</label>
+               <description>Forecast values showing average case data</description>
+               <channels>
+                       <channel id="power-estimate" typeId="power-estimate"/>
+                       <channel id="energy-estimate" typeId="energy-estimate"/>
+                       <channel id="power-actual" typeId="power-actual"/>
+                       <channel id="energy-actual" typeId="energy-actual"/>
+                       <channel id="energy-remain" typeId="energy-remain"/>
+                       <channel id="energy-today" typeId="energy-today"/>
+               </channels>
+       </channel-group-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..b630271
--- /dev/null
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <channel-type id="power-actual">
+               <item-type>Number:Power</item-type>
+               <label>Actual Power</label>
+               <description>Power prediction for this moment</description>
+               <state pattern="%.0f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="power-estimate">
+               <item-type>Number:Power</item-type>
+               <label>Power Forecast</label>
+               <description>Power forecast for next hours/days</description>
+               <state pattern="%.0f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="energy-actual">
+               <item-type>Number:Energy</item-type>
+               <label>Actual Energy Forecast</label>
+               <description>Today's forecast till now</description>
+               <state pattern="%.3f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="energy-remain">
+               <item-type>Number:Energy</item-type>
+               <label>Remaining Energy Forecast</label>
+               <description>Today's remaining forecast till sunset</description>
+               <state pattern="%.3f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="energy-today">
+               <item-type>Number:Energy</item-type>
+               <label>Todays Energy Forecast</label>
+               <description>Today's total energy forecast</description>
+               <state pattern="%.3f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="energy-estimate">
+               <item-type>Number:Energy</item-type>
+               <label>Energy Forecast</label>
+               <description>Energy forecast for next hours/days</description>
+               <state pattern="%.3f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="json" advanced="true">
+               <item-type>String</item-type>
+               <label>Raw JSON Response</label>
+               <description>Plain JSON response without conversions</description>
+       </channel-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..f37bd94
--- /dev/null
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="fs-plane">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="fs-site"/>
+               </supported-bridge-type-refs>
+
+               <label>ForecastSolar PV Plane</label>
+               <description>One PV Plane of Multi Plane Bridge</description>
+
+               <channels>
+                       <channel id="power-estimate" typeId="power-estimate"/>
+                       <channel id="energy-estimate" typeId="energy-estimate"/>
+                       <channel id="power-actual" typeId="power-actual"/>
+                       <channel id="energy-actual" typeId="energy-actual"/>
+                       <channel id="energy-remain" typeId="energy-remain"/>
+                       <channel id="energy-today" typeId="energy-today"/>
+                       <channel id="json" typeId="json"/>
+               </channels>
+
+               <config-description-ref uri="thing-type:solarforecast:fs-plane"/>
+       </thing-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..0e7c2b9
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="fs-site">
+               <label>ForecastSolar Site</label>
+               <description>Site location for Forecast Solar</description>
+
+               <channels>
+                       <channel id="power-estimate" typeId="power-estimate"/>
+                       <channel id="energy-estimate" typeId="energy-estimate"/>
+                       <channel id="power-actual" typeId="power-actual"/>
+                       <channel id="energy-actual" typeId="energy-actual"/>
+                       <channel id="energy-remain" typeId="energy-remain"/>
+                       <channel id="energy-today" typeId="energy-today"/>
+               </channels>
+
+               <config-description-ref uri="thing-type:solarforecast:fs-site"/>
+       </bridge-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..6ca5373
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+       <channel-group-type id="optimistic-values">
+               <label>Optimistic Forecast Values</label>
+               <description>Forecast values showing 90th percentile case data</description>
+               <channels>
+                       <channel id="power-estimate" typeId="power-estimate"/>
+                       <channel id="energy-estimate" typeId="energy-estimate"/>
+                       <channel id="power-actual" typeId="power-actual"/>
+                       <channel id="energy-actual" typeId="energy-actual"/>
+                       <channel id="energy-remain" typeId="energy-remain"/>
+                       <channel id="energy-today" typeId="energy-today"/>
+               </channels>
+       </channel-group-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..e5c61de
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+       <channel-group-type id="pessimistic-values">
+               <label>Pessimistic Forecast Values</label>
+               <description>Forecast values showing 10th percentile case data</description>
+               <channels>
+                       <channel id="power-estimate" typeId="power-estimate"/>
+                       <channel id="energy-estimate" typeId="energy-estimate"/>
+                       <channel id="power-actual" typeId="power-actual"/>
+                       <channel id="energy-actual" typeId="energy-actual"/>
+                       <channel id="energy-remain" typeId="energy-remain"/>
+                       <channel id="energy-today" typeId="energy-today"/>
+               </channels>
+       </channel-group-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..3427c90
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+       <channel-group-type id="raw-values">
+               <label>Raw Forecast Values</label>
+               <description>Raw response from service provider</description>
+               <channels>
+                       <channel id="json" typeId="json"/>
+               </channels>
+       </channel-group-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..c549cc8
--- /dev/null
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="sc-plane">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="sc-site"/>
+               </supported-bridge-type-refs>
+
+               <label>Solcast PV Plane</label>
+               <description>One PV Plane of Multi Plane Bridge</description>
+
+               <channel-groups>
+                       <channel-group id="average" typeId="average-values"/>
+                       <channel-group id="optimistic" typeId="optimistic-values"/>
+                       <channel-group id="pessimistic" typeId="pessimistic-values"/>
+                       <channel-group id="raw" typeId="raw-values"/>
+               </channel-groups>
+
+               <config-description-ref uri="thing-type:solarforecast:sc-plane"/>
+       </thing-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..aab7c44
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="solarforecast"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="sc-site">
+               <label>Solcast Site</label>
+               <description>Solcast service site definition</description>
+
+               <channel-groups>
+                       <channel-group id="average" typeId="average-values"/>
+                       <channel-group id="optimistic" typeId="optimistic-values"/>
+                       <channel-group id="pessimistic" typeId="pessimistic-values"/>
+               </channel-groups>
+               <config-description-ref uri="thing-type:solarforecast:sc-site"/>
+       </bridge-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..a47b9d9
--- /dev/null
@@ -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<String, TimeSeries> seriesMap = new HashMap<String, TimeSeries>();
+
+    @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<String, Object> configurationParameters) {
+    }
+
+    @Override
+    public void validateConfigurationParameters(Channel channel, Map<String, Object> 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<ChannelBuilder> 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 (file)
index 0000000..1ae5126
--- /dev/null
@@ -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 (file)
index 0000000..608bc02
--- /dev/null
@@ -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> POWER_UNDEF = Utils.getPowerState(-1);
+    public static final QuantityType<Energy> 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<Energy> actual = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
+        QuantityType<Energy> 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<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
+        Iterator<TimeSeries.Entry> 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<TimeSeries.Entry> planeIter = tsPlaneOne.getStates().iterator();
+        Iterator<TimeSeries.Entry> 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<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
+        Iterator<TimeSeries.Entry> 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 (file)
index 0000000..a6606d5
--- /dev/null
@@ -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<Energy> qt = scfo.getDay(start.toLocalDate().plusDays(i));
+            QuantityType<Energy> 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<QuantityType<?>> 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<QuantityType<?>> 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<QuantityType<?>> 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<QuantityType<?>> 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<QuantityType<?>> 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<QuantityType<?>> 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<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
+        Iterator<TimeSeries.Entry> 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<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
+        Iterator<TimeSeries.Entry> 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<TimeSeries.Entry> 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 (file)
index 0000000..44712a3
--- /dev/null
@@ -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 (file)
index 0000000..7b4edfe
--- /dev/null
@@ -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 (file)
index 0000000..b1b48a9
--- /dev/null
@@ -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 (file)
index 0000000..ccdb1a9
--- /dev/null
@@ -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 (file)
index 0000000..412612f
--- /dev/null
@@ -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 (file)
index 0000000..83857b3
--- /dev/null
@@ -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 (file)
index 0000000..9fa129c
--- /dev/null
@@ -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
index 0fa1f20f9dd1fa7703f0a35f76c4c14e228eb6d2..0db4af22a903a8771e156219c6ba6aacba6e24d9 100644 (file)
     <module>org.openhab.binding.sncf</module>
     <module>org.openhab.binding.snmp</module>
     <module>org.openhab.binding.solaredge</module>
+    <module>org.openhab.binding.solarforecast</module>
     <module>org.openhab.binding.solarlog</module>
     <module>org.openhab.binding.solarmax</module>
     <module>org.openhab.binding.solarwatt</module>