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