mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 15:11:59 +01:00
[solarforecast] Initial contribution (#13308)
Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
parent
4d7864ba1f
commit
71d335df9e
@ -1641,6 +1641,11 @@
|
|||||||
<artifactId>org.openhab.binding.solaredge</artifactId>
|
<artifactId>org.openhab.binding.solaredge</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openhab.addons.bundles</groupId>
|
||||||
|
<artifactId>org.openhab.binding.solarforecast</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.openhab.addons.bundles</groupId>
|
<groupId>org.openhab.addons.bundles</groupId>
|
||||||
<artifactId>org.openhab.binding.solarlog</artifactId>
|
<artifactId>org.openhab.binding.solarlog</artifactId>
|
||||||
|
13
bundles/org.openhab.binding.solarforecast/NOTICE
Normal file
13
bundles/org.openhab.binding.solarforecast/NOTICE
Normal file
@ -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
|
356
bundles/org.openhab.binding.solarforecast/README.md
Normal file
356
bundles/org.openhab.binding.solarforecast/README.md
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
# SolarForecast Binding
|
||||||
|
|
||||||
|
This binding provides data from Solar Forecast services.
|
||||||
|
Use it to estimate your daily production, plan electric consumers like Electric Vehicle charging, heating or HVAC.
|
||||||
|
Look ahead the next days in order to identify surplus / shortages in your energy planning.
|
||||||
|
|
||||||
|
Supported Services
|
||||||
|
|
||||||
|
- [Solcast](https://solcast.com/)
|
||||||
|
- Free [Hobbyist Plan](https://toolkit.solcast.com.au/register/hobbyist) with registration
|
||||||
|
- [Forecast.Solar](https://forecast.solar/)
|
||||||
|
- Public, Personal and Professional [plans](https://forecast.solar/#accounts) available
|
||||||
|
|
||||||
|
Display Power values of Forecast and PV Inverter items
|
||||||
|
|
||||||
|
<img src="./doc/SolcastPower.png" width="640" height="400"/>
|
||||||
|
|
||||||
|
Display Energy values of Forecast and PV inverter items
|
||||||
|
Yellow line shows *Daily Total Forecast*.
|
||||||
|
|
||||||
|
<img src="./doc/SolcastCumulated.png" width="640" height="400"/>
|
||||||
|
|
||||||
|
## Supported Things
|
||||||
|
|
||||||
|
Each service needs one `xx-site` for your location and at least one photovoltaic `xx-plane`.
|
||||||
|
|
||||||
|
| Name | Thing Type ID |
|
||||||
|
|-----------------------------------|---------------|
|
||||||
|
| Solcast service site definition | sc-site |
|
||||||
|
| Solcast PV Plane | sc-plane |
|
||||||
|
| Forecast Solar site location | fs-site |
|
||||||
|
| Forecast Solar PV Plane | fs-plane |
|
||||||
|
|
||||||
|
## Solcast Configuration
|
||||||
|
|
||||||
|
[Solcast service](https://solcast.com/) requires a personal registration with an e-mail address.
|
||||||
|
A free version for your personal home PV system is available in [Hobbyist Plan](https://toolkit.solcast.com.au/register/hobbyist)
|
||||||
|
You need to configure your home photovoltaic system within the web interface.
|
||||||
|
The `resourceId` for each PV plane is provided afterwards.
|
||||||
|
|
||||||
|
In order to receive proper timestamps double check your time zone in *openHAB - Settings - Regional Settings*.
|
||||||
|
Correct time zone is necessary to show correct forecast times in UI.
|
||||||
|
|
||||||
|
### Solcast Bridge Configuration
|
||||||
|
|
||||||
|
| Name | Type | Description | Default | Required | Advanced |
|
||||||
|
|------------------------|---------|---------------------------------------|-------------|----------|----------|
|
||||||
|
| apiKey | text | API Key | N/A | yes | no |
|
||||||
|
| timeZone | text | Time Zone of forecast location | empty | no | yes |
|
||||||
|
|
||||||
|
`apiKey` can be obtained in your [Account Settings](https://toolkit.solcast.com.au/account)
|
||||||
|
|
||||||
|
`timeZone` can be left empty to evaluate Regional Settings of your openHAB installation.
|
||||||
|
See [DateTime](#date-time) section for more information.
|
||||||
|
|
||||||
|
### Solcast Plane Configuration
|
||||||
|
|
||||||
|
| Name | Type | Description | Default | Required | Advanced |
|
||||||
|
|-----------------|---------|--------------------------------------------------------|-----------------|----------|----------|
|
||||||
|
| resourceId | text | Resource Id of Solcast rooftop site | N/A | yes | no |
|
||||||
|
| refreshInterval | integer | Forecast Refresh Interval in minutes | 120 | yes | no |
|
||||||
|
|
||||||
|
`resourceId` for each plane can be obtained in your [Rooftop Sites](https://toolkit.solcast.com.au/rooftop-sites)
|
||||||
|
|
||||||
|
`refreshInterval` of forecast data needs to respect the throttling of the Solcast service.
|
||||||
|
If you have 25 free calls per day, each plane needs 2 calls per update a refresh interval of 120 minutes will result in 24 calls per day.
|
||||||
|
|
||||||
|
## Solcast Channels
|
||||||
|
|
||||||
|
Each `sc-plane` reports its own values including a `json` channel holding JSON content.
|
||||||
|
The `sc-site` bridge sums up all attached `sc-plane` values and provides total forecast for your home location.
|
||||||
|
|
||||||
|
Channels are covering today's actual data with current, remaining and today's total prediction.
|
||||||
|
Forecasts are delivered up to 6 days in advance.
|
||||||
|
Scenarios are clustered in groups:
|
||||||
|
|
||||||
|
- `average` scenario
|
||||||
|
- `pessimistic` scenario: 10th percentile
|
||||||
|
- `optimistic` scenario: 90th percentile
|
||||||
|
|
||||||
|
| Channel | Type | Unit | Description | Advanced |
|
||||||
|
|-------------------------|---------------|------|-------------------------------------------------|----------|
|
||||||
|
| power-estimate | Number:Power | W | Power forecast for next hours/days | no |
|
||||||
|
| energy-estimate | Number:Energy | kWh | Energy forecast for next hours/days | no |
|
||||||
|
| power-actual | Number:Power | W | Power prediction for this moment | no |
|
||||||
|
| energy-actual | Number:Energy | kWh | Today's forecast till now | no |
|
||||||
|
| energy-remain | Number:Energy | kWh | Today's remaining forecast till sunset | no |
|
||||||
|
| energy-today | Number:Energy | kWh | Today's forecast in total | no |
|
||||||
|
| json | String | - | Plain JSON response without conversions | yes |
|
||||||
|
|
||||||
|
## ForecastSolar Configuration
|
||||||
|
|
||||||
|
[ForecastSolar service](https://forecast.solar/) provides a [public free](https://forecast.solar/#accounts) plan.
|
||||||
|
You can try it without any registration or other preconditions.
|
||||||
|
|
||||||
|
### ForecastSolar Bridge Configuration
|
||||||
|
|
||||||
|
| Name | Type | Description | Default | Required |
|
||||||
|
|------------------------|---------|---------------------------------------|--------------|----------|
|
||||||
|
| location | text | Location of Photovoltaic system. | empty | no |
|
||||||
|
| apiKey | text | API Key | N/A | no |
|
||||||
|
|
||||||
|
`location` defines latitude, longitude values of your PV system.
|
||||||
|
In case of empty the location configured in openHAB is obtained.
|
||||||
|
|
||||||
|
`apiKey` can be given in case you subscribed to a paid plan.
|
||||||
|
|
||||||
|
### ForecastSolar Plane Configuration
|
||||||
|
|
||||||
|
| Name | Type | Description | Default | Required | Advanced |
|
||||||
|
|-----------------|---------|------------------------------------------------------------------------------|---------|----------|----------|
|
||||||
|
| refreshInterval | integer | Forecast Refresh Interval in minutes | 30 | yes | false |
|
||||||
|
| declination | integer | Plane Declination: 0 for horizontal till 90 for vertical declination | N/A | yes | false |
|
||||||
|
| azimuth | integer | Plane Azimuth: -180 = north, -90 = east, 0 = south, 90 = west, 180 = north | N/A | yes | false |
|
||||||
|
| kwp | decimal | Installed Kilowatt Peak | N/A | yes | false |
|
||||||
|
| dampAM | decimal | Damping factor of morning hours | N/A | no | true |
|
||||||
|
| dampPM | decimal | Damping factor of evening hours | N/A | no | true |
|
||||||
|
| horizon | text | Horizon definition as comma separated integer values | N/A | no | true |
|
||||||
|
|
||||||
|
`refreshInterval` of forecast data needs to respect the throttling of the ForecastSolar service.
|
||||||
|
12 calls per hour allowed from your caller IP address so for 2 planes lowest possible refresh rate is 10 minutes.
|
||||||
|
|
||||||
|
#### Advanced Configuration
|
||||||
|
|
||||||
|
Advanced configuration parameters are available to *fine tune* your forecast data.
|
||||||
|
Read linked documentation in order to know what you're doing.
|
||||||
|
|
||||||
|
[Damping factors](https://doc.forecast.solar/doku.php?id=damping) for morning and evening.
|
||||||
|
|
||||||
|
[Horizon information](https://doc.forecast.solar/doku.php?id=api) as comma-separated integer list.
|
||||||
|
This configuration item is aimed to expert users.
|
||||||
|
You need to understand the [horizon concept](https://joint-research-centre.ec.europa.eu/pvgis-photovoltaic-geographical-information-system/getting-started-pvgis/pvgis-user-manual_en#ref-2-using-horizon-information).
|
||||||
|
Shadow obstacles like mountains, hills, buildings can be expressed here.
|
||||||
|
First step can be a download from [PVGIS tool](https://re.jrc.ec.europa.eu/pvg_tools/en/) and downloading the *terrain shadows*.
|
||||||
|
But it doesn't fit 100% to the required configuration.
|
||||||
|
Currently there's no tool available which is providing the configuration information 1 to 1.
|
||||||
|
So you need to know what you're doing.
|
||||||
|
|
||||||
|
## ForecastSolar Channels
|
||||||
|
|
||||||
|
Each `fs-plane` reports its own values including a `json` channel holding JSON content.
|
||||||
|
The `fs-site` bridge sums up all attached `fs-plane` values and provides the total forecast for your home location.
|
||||||
|
|
||||||
|
Channels are covering today's actual data with current, remaining and total prediction.
|
||||||
|
Forecasts are delivered up to 3 days for paid personal plans.
|
||||||
|
|
||||||
|
| Channel | Type | Unit | Description | Advanced |
|
||||||
|
|-------------------------|---------------|------|-------------------------------------------------|----------|
|
||||||
|
| power-estimate | Number:Power | W | Power forecast for next hours/days | no |
|
||||||
|
| energy-estimate | Number:Energy | kWh | Energy forecast for next hours/days | no |
|
||||||
|
| power-actual | Number:Power | W | Power prediction for this moment | no |
|
||||||
|
| energy-actual | Number:Energy | kWh | Today's forecast till now | no |
|
||||||
|
| energy-remain | Number:Energy | kWh | Today's remaining forecast till sunset | no |
|
||||||
|
| energy-today | Number:Energy | kWh | Today's forecast in total | no |
|
||||||
|
| json | String | - | Plain JSON response without conversions | yes |
|
||||||
|
|
||||||
|
## Thing Actions
|
||||||
|
|
||||||
|
All things `sc-site`, `sc-plane`, `fs-site` and `fs-plane` are providing the same Actions.
|
||||||
|
Channels are providing actual forecast data and daily forecasts in future.
|
||||||
|
Actions provides an interface to execute more sophisticated handling in rules.
|
||||||
|
You can execute this for each `xx-plane` for specific plane values or `xx-site` to sum up all attached planes.
|
||||||
|
|
||||||
|
See [Date Time](#date-time) section for more information.
|
||||||
|
Double check your time zone in *openHAB - Settings - Regional Settings* which is crucial for calculation.
|
||||||
|
|
||||||
|
### `getForecastBegin`
|
||||||
|
|
||||||
|
Returns `Instant` of the earliest possible forecast data available.
|
||||||
|
It's located in the past, e.g. Solcast provides data from the last 7 days.
|
||||||
|
`Instant.MAX` is returned in case of no forecast data is available.
|
||||||
|
|
||||||
|
### `getForecastEnd`
|
||||||
|
|
||||||
|
Returns `Instant` of the latest possible forecast data available.
|
||||||
|
`Instant.MIN` is returned in case of no forecast data is available.
|
||||||
|
|
||||||
|
### `getPower`
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|---------------|--------------------------------------------------------------------------------------------|
|
||||||
|
| timestamp | Instant | Timestamp of power query |
|
||||||
|
| mode | String | Choose `average`, `optimistic` or `pessimistic` to select forecast scenario. Only Solcast. |
|
||||||
|
|
||||||
|
Returns `QuantityType<Power>` at the given `Instant` timestamp.
|
||||||
|
Respect `getForecastBegin` and `getForecastEnd` to get a valid value.
|
||||||
|
|
||||||
|
Check log or catch exceptions for error handling
|
||||||
|
|
||||||
|
- `IllegalArgumentException` thrown in case of problems with call arguments
|
||||||
|
- `SolarForecastException` thrown in case of problems with timestamp and available forecast data
|
||||||
|
|
||||||
|
### `getDay`
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|---------------|--------------------------------------------------------------------------------------------|
|
||||||
|
| date | LocalDate | Date of the day |
|
||||||
|
| mode | String | Choose `average`, `optimistic` or `pessimistic` to select forecast scenario. Only Solcast. |
|
||||||
|
|
||||||
|
Returns `QuantityType<Energy>` at the given `localDate`.
|
||||||
|
Respect `getForecastBegin` and `getForecastEnd` to avoid ambiguous values.
|
||||||
|
|
||||||
|
Check log or catch exceptions for error handling
|
||||||
|
|
||||||
|
- `IllegalArgumentException` thrown in case of problems with call arguments
|
||||||
|
- `SolarForecastException` thrown in case of problems with timestamp and available forecast data
|
||||||
|
|
||||||
|
### `getEnergy`
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------------|---------------|--------------------------------------------------------------------------------------------------------------|
|
||||||
|
| startTimestamp | Instant | Start timestamp of energy query |
|
||||||
|
| endTimestamp | Instant | End timestamp of energy query |
|
||||||
|
| mode | String | Choose `optimistic` or `pessimistic` to get values for a positive or negative future scenario. Only Solcast. |
|
||||||
|
|
||||||
|
Returns `QuantityType<Energy>` between the timestamps `startTimestamp` and `endTimestamp`.
|
||||||
|
Respect `getForecastBegin` and `getForecastEnd` to avoid ambiguous values.
|
||||||
|
|
||||||
|
Check log or catch exceptions for error handling
|
||||||
|
|
||||||
|
- `IllegalArgumentException` thrown in case of problems with call arguments
|
||||||
|
- `SolarForecastException` thrown in case of problems with timestamp and available forecast data
|
||||||
|
|
||||||
|
## Date Time
|
||||||
|
|
||||||
|
Each forecast is bound to a certain location which automatically defines the time zone.
|
||||||
|
Most common use case is forecast and your location are matching the same time zone.
|
||||||
|
Action interface is using `Instant` as timestamps which enables you translating to any time zone.
|
||||||
|
This allows you with an easy conversion to query also foreign forecast locations.
|
||||||
|
|
||||||
|
Examples are showing
|
||||||
|
|
||||||
|
- how to translate `Instant` to `ZonedDateTime` objects and
|
||||||
|
- how to translate `ZonedDateTime` to `Instant` objects
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Example is based on Forecast.Solar service without any registration.
|
||||||
|
Exchange the configuration data in [thing file](#thing-file) and you're ready to go.
|
||||||
|
|
||||||
|
### Thing file
|
||||||
|
|
||||||
|
```java
|
||||||
|
Bridge solarforecast:fs-site:homeSite "ForecastSolar Home" [ location="54.321,8.976"] {
|
||||||
|
Thing fs-plane homeSouthWest "ForecastSolar Home South-West" [ refreshInterval=15, azimuth=45, declination=35, kwp=5.5]
|
||||||
|
Thing fs-plane homeNorthEast "ForecastSolar Home North-East" [ refreshInterval=15, azimuth=-145, declination=35, kwp=4.425]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Items file
|
||||||
|
|
||||||
|
```java
|
||||||
|
// channel items
|
||||||
|
Number:Power ForecastSolarHome_Actual_Power "Power prediction for this moment" { channel="solarforecast:fs-site:homeSite:power-actual", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
|
||||||
|
Number:Energy ForecastSolarHome_Actual "Today's forecast till now" { channel="solarforecast:fs-site:homeSite:energy-actual", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Energy ForecastSolarHome_Remaining "Today's remaining forecast till sunset" { channel="solarforecast:fs-site:homeSite:energy-remain", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Energy ForecastSolarHome_Today "Today's total energy forecast" { channel="solarforecast:fs-site:homeSite:energy-today", stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
|
||||||
|
// calculated by rule
|
||||||
|
Number:Energy ForecastSolarHome_Tomorrow "Tomorrow's total energy forecast" { stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
|
||||||
|
|
||||||
|
Number:Power ForecastSolarHome_Actual_Power_NE "NE Power prediction for this moment" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:power-actual", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
|
||||||
|
Number:Energy ForecastSolarHome_Actual_NE "NE Today's forecast till now" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:energy-actual", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Energy ForecastSolarHome_Remaining_NE "NE Today's remaining forecast till sunset" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:energy-remain", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Energy ForecastSolarHome_Today_NE "NE Today's total energy forecast" { channel="solarforecast:fs-plane:homeSite:homeNorthEast:energy-today", stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
|
||||||
|
|
||||||
|
Number:Power ForecastSolarHome_Actual_Power_SW "SW Power prediction for this moment" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:power-actual", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
|
||||||
|
Number:Energy ForecastSolarHome_Actual_SW "SW Today's forecast till now" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-actual", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Energy ForecastSolarHome_Remaining_SW "SW Today's remaining forecast till sunset" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-remain", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Energy ForecastSolarHome_Today_SW "SW Today's total energy forecast" { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-today", stateDescription=" "[ pattern="%.1f %unit%" ], unit="kWh" }
|
||||||
|
|
||||||
|
// estimaion items
|
||||||
|
Group influxdb
|
||||||
|
Number:Power ForecastSolarHome_Power_Estimate "Power estimations" (influxdb) { channel="solarforecast:fs-site:homeSite:power-estimate", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
|
||||||
|
Number:Energy ForecastSolarHome_Energy_Estimate "Energy estimations" (influxdb) { channel="solarforecast:fs-site:homeSite:energy-estimate", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
Number:Power ForecastSolarHome_Power_Estimate_SW "SW Power estimations" (influxdb) { channel="solarforecast:fs-plane:homeSite:homeSouthWest:power-estimate", stateDescription=" "[ pattern="%.0f %unit%" ], unit="W" }
|
||||||
|
Number:Energy ForecastSolarHome_Energy_Estimate_SW "SW Energy estimations" (influxdb) { channel="solarforecast:fs-plane:homeSite:homeSouthWest:energy-estimate", stateDescription=" "[ pattern="%.3f %unit%" ], unit="kWh" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistence file
|
||||||
|
|
||||||
|
```java
|
||||||
|
// persistence strategies have a name and definition and are referred to in the "Items" section
|
||||||
|
Strategies {
|
||||||
|
everyHour : "0 0 * * * ?"
|
||||||
|
everyDay : "0 0 0 * * ?"
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Each line in this section defines for which Item(s) which strategy(ies) should be applied.
|
||||||
|
* You can list single items, use "*" for all items or "groupitem*" for all members of a group
|
||||||
|
* Item (excl. the group Item itself).
|
||||||
|
*/
|
||||||
|
Items {
|
||||||
|
influxdb* : strategy = restoreOnStartup, forecast
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions rule
|
||||||
|
|
||||||
|
```java
|
||||||
|
rule "Tomorrow Forecast Calculation"
|
||||||
|
when
|
||||||
|
Item ForecastSolarHome_Today received update
|
||||||
|
then
|
||||||
|
val solarforecastActions = getActions("solarforecast","solarforecast:fs-site:homeSite")
|
||||||
|
val energyState = solarforecastActions.getDay(LocalDate.now.plusDays(1))
|
||||||
|
logInfo("SF Tests","{}",energyState)
|
||||||
|
ForecastSolarHome_Tomorrow.postUpdate(energyState)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handle exceptions
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
rule "Exception Handling"
|
||||||
|
when
|
||||||
|
System started
|
||||||
|
then
|
||||||
|
val solcastActions = getActions("solarforecast","solarforecast:sc-site:3cadcde4dc")
|
||||||
|
try {
|
||||||
|
val forecast = solcastActions.getPower(solcastActions.getForecastEnd.plus(30,ChronoUnit.MINUTES))
|
||||||
|
} catch(RuntimeException e) {
|
||||||
|
logError("Exception","Handle {}",e.getMessage)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions rule with Arguments
|
||||||
|
|
||||||
|
```java
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
rule "Solcast Actions"
|
||||||
|
when
|
||||||
|
Time cron "0 0 23 * * ?" // trigger whatever you like
|
||||||
|
then
|
||||||
|
// Query forecast via Actions
|
||||||
|
val solarforecastActions = getActions("solarforecast","solarforecast:sc-site:homeSite")
|
||||||
|
val startTimestamp = Instant.now
|
||||||
|
val endTimestamp = Instant.now.plus(6, ChronoUnit.DAYS)
|
||||||
|
val sixDayForecast = solarforecastActions.getEnergy(startTimestamp,endTimestamp)
|
||||||
|
logInfo("SF Tests","Forecast Average 6 days "+ sixDayForecast)
|
||||||
|
val sixDayOptimistic = solarforecastActions.getEnergy(startTimestamp,endTimestamp, "optimistic")
|
||||||
|
logInfo("SF Tests","Forecast Optimist 6 days "+ sixDayOptimistic)
|
||||||
|
val sixDayPessimistic = solarforecastActions.getEnergy(startTimestamp,endTimestamp, "pessimistic")
|
||||||
|
logInfo("SF Tests","Forecast Pessimist 6 days "+ sixDayPessimistic)
|
||||||
|
|
||||||
|
// Query forecast TimesSeries Items via historicStata
|
||||||
|
val energyAverage = (Solcast_Site_Average_Energyestimate.historicState(now.plusDays(1)).state as Number)
|
||||||
|
logInfo("SF Tests","Average energy {}",energyAverage)
|
||||||
|
val energyOptimistic = (Solcast_Site_Optimistic_Energyestimate.historicState(now.plusDays(1)).state as Number)
|
||||||
|
logInfo("SF Tests","Optimist energy {}",energyOptimistic)
|
||||||
|
end
|
||||||
|
```
|
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
BIN
bundles/org.openhab.binding.solarforecast/doc/SolcastPower.png
Normal file
BIN
bundles/org.openhab.binding.solarforecast/doc/SolcastPower.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 82 KiB |
26
bundles/org.openhab.binding.solarforecast/pom.xml
Normal file
26
bundles/org.openhab.binding.solarforecast/pom.xml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.openhab.addons.bundles</groupId>
|
||||||
|
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||||
|
<version>4.2.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>org.openhab.binding.solarforecast</artifactId>
|
||||||
|
|
||||||
|
<name>openHAB Add-ons :: Bundles :: SolarForecast Binding</name>
|
||||||
|
<dependencies>
|
||||||
|
<!-- version needs to match with other projects like org.openhab.io.openhabcloud.pom.xml -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.json</groupId>
|
||||||
|
<artifactId>json</artifactId>
|
||||||
|
<version>20231013</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
</project>
|
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<features name="org.openhab.binding.solarforecast-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||||
|
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||||
|
|
||||||
|
<feature name="openhab-binding-solarforecast" description="SolarForecast Binding" version="${project.version}">
|
||||||
|
<feature>openhab-runtime-base</feature>
|
||||||
|
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.solarforecast/${project.version}</bundle>
|
||||||
|
</feature>
|
||||||
|
</features>
|
@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.core.thing.ThingTypeUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolarForecastBindingConstants} class defines common constants, which are
|
||||||
|
* used across the whole binding.
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class SolarForecastBindingConstants {
|
||||||
|
|
||||||
|
private static final String BINDING_ID = "solarforecast";
|
||||||
|
|
||||||
|
// Things
|
||||||
|
public static final ThingTypeUID FORECAST_SOLAR_SITE = new ThingTypeUID(BINDING_ID, "fs-site");
|
||||||
|
public static final ThingTypeUID FORECAST_SOLAR_PLANE = new ThingTypeUID(BINDING_ID, "fs-plane");
|
||||||
|
public static final ThingTypeUID SOLCAST_SITE = new ThingTypeUID(BINDING_ID, "sc-site");
|
||||||
|
public static final ThingTypeUID SOLCAST_PLANE = new ThingTypeUID(BINDING_ID, "sc-plane");
|
||||||
|
public static final Set<ThingTypeUID> SUPPORTED_THING_SET = Set.of(FORECAST_SOLAR_SITE, FORECAST_SOLAR_PLANE,
|
||||||
|
SOLCAST_SITE, SOLCAST_PLANE);
|
||||||
|
|
||||||
|
// Channel groups
|
||||||
|
public static final String GROUP_AVERAGE = "average";
|
||||||
|
public static final String GROUP_OPTIMISTIC = "optimistic";
|
||||||
|
public static final String GROUP_PESSIMISTIC = "pessimistic";
|
||||||
|
public static final String GROUP_RAW = "raw";
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
public static final String CHANNEL_POWER_ESTIMATE = "power-estimate";
|
||||||
|
public static final String CHANNEL_ENERGY_ESTIMATE = "energy-estimate";
|
||||||
|
public static final String CHANNEL_POWER_ACTUAL = "power-actual";
|
||||||
|
public static final String CHANNEL_ENERGY_ACTUAL = "energy-actual";
|
||||||
|
public static final String CHANNEL_ENERGY_REMAIN = "energy-remain";
|
||||||
|
public static final String CHANNEL_ENERGY_TODAY = "energy-today";
|
||||||
|
public static final String CHANNEL_JSON = "json";
|
||||||
|
|
||||||
|
// Other
|
||||||
|
public static final int REFRESH_ACTUAL_INTERVAL = 1;
|
||||||
|
public static final String SLASH = "/";
|
||||||
|
public static final String EMPTY = "";
|
||||||
|
public static final String PATTERN_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal;
|
||||||
|
|
||||||
|
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarBridgeHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastBridgeHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneHandler;
|
||||||
|
import org.openhab.core.i18n.LocationProvider;
|
||||||
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
|
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||||
|
import org.openhab.core.library.types.PointType;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
|
import org.openhab.core.thing.Thing;
|
||||||
|
import org.openhab.core.thing.ThingTypeUID;
|
||||||
|
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandler;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||||
|
import org.osgi.service.component.annotations.Activate;
|
||||||
|
import org.osgi.service.component.annotations.Component;
|
||||||
|
import org.osgi.service.component.annotations.Reference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolarForecastHandlerFactory} is responsible for creating things and thing
|
||||||
|
* handlers.
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
@Component(configurationPid = "binding.solarforecast", service = ThingHandlerFactory.class)
|
||||||
|
public class SolarForecastHandlerFactory extends BaseThingHandlerFactory {
|
||||||
|
private final TimeZoneProvider timeZoneProvider;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private Optional<PointType> location = Optional.empty();
|
||||||
|
|
||||||
|
@Activate
|
||||||
|
public SolarForecastHandlerFactory(final @Reference HttpClientFactory hcf, final @Reference LocationProvider lp,
|
||||||
|
final @Reference TimeZoneProvider tzp) {
|
||||||
|
timeZoneProvider = tzp;
|
||||||
|
httpClient = hcf.getCommonHttpClient();
|
||||||
|
PointType pt = lp.getLocation();
|
||||||
|
if (pt != null) {
|
||||||
|
location = Optional.of(pt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||||
|
return SolarForecastBindingConstants.SUPPORTED_THING_SET.contains(thingTypeUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||||
|
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||||
|
if (FORECAST_SOLAR_SITE.equals(thingTypeUID)) {
|
||||||
|
return new ForecastSolarBridgeHandler((Bridge) thing, location);
|
||||||
|
} else if (FORECAST_SOLAR_PLANE.equals(thingTypeUID)) {
|
||||||
|
return new ForecastSolarPlaneHandler(thing, httpClient);
|
||||||
|
} else if (SOLCAST_SITE.equals(thingTypeUID)) {
|
||||||
|
return new SolcastBridgeHandler((Bridge) thing, timeZoneProvider);
|
||||||
|
} else if (SOLCAST_PLANE.equals(thingTypeUID)) {
|
||||||
|
return new SolcastPlaneHandler(thing, httpClient);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.actions;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolarForecast} Interface needed for Actions
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public interface SolarForecast {
|
||||||
|
/**
|
||||||
|
* Argument can be used to query an average forecast scenario
|
||||||
|
*/
|
||||||
|
public static final String AVERAGE = "average";
|
||||||
|
/**
|
||||||
|
* Argument can be used to query an optimistic forecast scenario
|
||||||
|
*/
|
||||||
|
public static final String OPTIMISTIC = "optimistic";
|
||||||
|
/**
|
||||||
|
* Argument can be used to query a pessimistic forecast scenario
|
||||||
|
*/
|
||||||
|
public static final String PESSIMISTIC = "pessimistic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns electric energy production for one day
|
||||||
|
*
|
||||||
|
* @param date
|
||||||
|
* @param args possible arguments from this interface
|
||||||
|
* @return QuantityType<Energy> in kW/h
|
||||||
|
*/
|
||||||
|
QuantityType<Energy> getDay(LocalDate date, String... args);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns electric energy between two timestamps
|
||||||
|
*
|
||||||
|
* @param start
|
||||||
|
* @param end
|
||||||
|
* @param args possible arguments from this interface
|
||||||
|
* @return QuantityType<Energy> in kW/h
|
||||||
|
*/
|
||||||
|
QuantityType<Energy> getEnergy(Instant start, Instant end, String... args);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns electric power at one specific point of time
|
||||||
|
*
|
||||||
|
* @param timestamp
|
||||||
|
* @param args possible arguments from this interface
|
||||||
|
* @return QuantityType<Power> in kW
|
||||||
|
*/
|
||||||
|
QuantityType<Power> getPower(Instant timestamp, String... args);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first date and time of forecast data
|
||||||
|
*
|
||||||
|
* @return date time
|
||||||
|
*/
|
||||||
|
Instant getForecastBegin();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last date and time of forecast data
|
||||||
|
*
|
||||||
|
* @return date time
|
||||||
|
*/
|
||||||
|
Instant getForecastEnd();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TimeSeries for Power forecast
|
||||||
|
*
|
||||||
|
* @param mode QueryMode for optimistic, pessimistic or average estimation
|
||||||
|
* @return TimeSeries containing QuantityType<Power>
|
||||||
|
*/
|
||||||
|
TimeSeries getPowerTimeSeries(QueryMode mode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get TimeSeries for Energy forecast
|
||||||
|
*
|
||||||
|
* @param mode QueryMode for optimistic, pessimistic or average estimation
|
||||||
|
* @return TimeSeries containing QuantityType<Energy>
|
||||||
|
*/
|
||||||
|
TimeSeries getEnergyTimeSeries(QueryMode mode);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SolarForecast identifier
|
||||||
|
*
|
||||||
|
* @return unique String to identify solar plane
|
||||||
|
*/
|
||||||
|
String getIdentifier();
|
||||||
|
}
|
@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.actions;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import javax.measure.MetricPrefix;
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.automation.annotation.ActionInput;
|
||||||
|
import org.openhab.core.automation.annotation.RuleAction;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.library.unit.Units;
|
||||||
|
import org.openhab.core.thing.binding.ThingActions;
|
||||||
|
import org.openhab.core.thing.binding.ThingActionsScope;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandler;
|
||||||
|
import org.openhab.core.types.State;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions to query forecast objects
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@ThingActionsScope(name = "solarforecast")
|
||||||
|
@NonNullByDefault
|
||||||
|
public class SolarForecastActions implements ThingActions {
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(SolarForecastActions.class);
|
||||||
|
private Optional<ThingHandler> thingHandler = Optional.empty();
|
||||||
|
|
||||||
|
@RuleAction(label = "@text/actionDayLabel", description = "@text/actionDayDesc")
|
||||||
|
public QuantityType<Energy> getDay(
|
||||||
|
@ActionInput(name = "localDate", label = "@text/actionInputDayLabel", description = "@text/actionInputDayDesc") LocalDate localDate,
|
||||||
|
String... args) {
|
||||||
|
if (thingHandler.isPresent()) {
|
||||||
|
List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||||
|
if (!l.isEmpty()) {
|
||||||
|
QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||||
|
for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
|
||||||
|
SolarForecast solarForecast = iterator.next();
|
||||||
|
QuantityType<Energy> qt = solarForecast.getDay(localDate, args);
|
||||||
|
if (qt.floatValue() >= 0) {
|
||||||
|
measure = measure.add(qt);
|
||||||
|
} else {
|
||||||
|
// break in case of failure getting values to avoid ambiguous values
|
||||||
|
logger.debug("Ambiguous measure {} found for {}", qt, localDate);
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return measure;
|
||||||
|
} else {
|
||||||
|
logger.debug("No forecasts found for {}", localDate);
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.trace("Handler missing");
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RuleAction(label = "@text/actionPowerLabel", description = "@text/actionPowerDesc")
|
||||||
|
public QuantityType<Power> getPower(
|
||||||
|
@ActionInput(name = "timestamp", label = "@text/actionInputDateTimeLabel", description = "@text/actionInputDateTimeDesc") Instant timestamp,
|
||||||
|
String... args) {
|
||||||
|
if (thingHandler.isPresent()) {
|
||||||
|
List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||||
|
if (!l.isEmpty()) {
|
||||||
|
QuantityType<Power> measure = QuantityType.valueOf(0, MetricPrefix.KILO(Units.WATT));
|
||||||
|
for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
|
||||||
|
SolarForecast solarForecast = iterator.next();
|
||||||
|
QuantityType<Power> qt = solarForecast.getPower(timestamp, args);
|
||||||
|
if (qt.floatValue() >= 0) {
|
||||||
|
measure = measure.add(qt);
|
||||||
|
} else {
|
||||||
|
// break in case of failure getting values to avoid ambiguous values
|
||||||
|
logger.debug("Ambiguous measure {} found for {}", qt, timestamp);
|
||||||
|
return Utils.getPowerState(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return measure;
|
||||||
|
} else {
|
||||||
|
logger.debug("No forecasts found for {}", timestamp);
|
||||||
|
return Utils.getPowerState(-1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.trace("Handler missing");
|
||||||
|
return Utils.getPowerState(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RuleAction(label = "@text/actionEnergyLabel", description = "@text/actionEnergyDesc")
|
||||||
|
public QuantityType<Energy> getEnergy(
|
||||||
|
@ActionInput(name = "start", label = "@text/actionInputDateTimeBeginLabel", description = "@text/actionInputDateTimeBeginDesc") Instant start,
|
||||||
|
@ActionInput(name = "end", label = "@text/actionInputDateTimeEndLabel", description = "@text/actionInputDateTimeEndDesc") Instant end,
|
||||||
|
String... args) {
|
||||||
|
if (thingHandler.isPresent()) {
|
||||||
|
List<SolarForecast> l = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||||
|
if (!l.isEmpty()) {
|
||||||
|
QuantityType<Energy> measure = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||||
|
for (Iterator<SolarForecast> iterator = l.iterator(); iterator.hasNext();) {
|
||||||
|
SolarForecast solarForecast = iterator.next();
|
||||||
|
QuantityType<Energy> qt = solarForecast.getEnergy(start, end, args);
|
||||||
|
if (qt.floatValue() >= 0) {
|
||||||
|
measure = measure.add(qt);
|
||||||
|
} else {
|
||||||
|
// break in case of failure getting values to avoid ambiguous values
|
||||||
|
logger.debug("Ambiguous measure {} found between {} and {}", qt, start, end);
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return measure;
|
||||||
|
} else {
|
||||||
|
logger.debug("No forecasts found for between {} and {}", start, end);
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.trace("Handler missing");
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RuleAction(label = "@text/actionForecastBeginLabel", description = "@text/actionForecastBeginDesc")
|
||||||
|
public Instant getForecastBegin() {
|
||||||
|
if (thingHandler.isPresent()) {
|
||||||
|
List<SolarForecast> forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||||
|
return Utils.getCommonStartTime(forecastObjectList);
|
||||||
|
} else {
|
||||||
|
logger.trace("Handler missing - return invalid date MAX");
|
||||||
|
return Instant.MAX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RuleAction(label = "@text/actionForecastEndLabel", description = "@text/actionForecastEndDesc")
|
||||||
|
public Instant getForecastEnd() {
|
||||||
|
if (thingHandler.isPresent()) {
|
||||||
|
List<SolarForecast> forecastObjectList = ((SolarForecastProvider) thingHandler.get()).getSolarForecasts();
|
||||||
|
return Utils.getCommonEndTime(forecastObjectList);
|
||||||
|
} else {
|
||||||
|
logger.trace("Handler missing - return invalid date MIN");
|
||||||
|
return Instant.MIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static State getDay(ThingActions actions, LocalDate ld, String... args) {
|
||||||
|
return ((SolarForecastActions) actions).getDay(ld, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static State getPower(ThingActions actions, Instant dateTime, String... args) {
|
||||||
|
return ((SolarForecastActions) actions).getPower(dateTime, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static State getEnergy(ThingActions actions, Instant begin, Instant end, String... args) {
|
||||||
|
return ((SolarForecastActions) actions).getEnergy(begin, end, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Instant getForecastBegin(ThingActions actions) {
|
||||||
|
return ((SolarForecastActions) actions).getForecastBegin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Instant getForecastEnd(ThingActions actions) {
|
||||||
|
return ((SolarForecastActions) actions).getForecastEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setThingHandler(ThingHandler handler) {
|
||||||
|
thingHandler = Optional.of(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable ThingHandler getThingHandler() {
|
||||||
|
if (thingHandler.isPresent()) {
|
||||||
|
return thingHandler.get();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.actions;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolarForecastProvider} Interface needed for Actions
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public interface SolarForecastProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides List of available SolarForecast Interface implementations
|
||||||
|
*
|
||||||
|
* @return list of SolarForecast objects
|
||||||
|
*/
|
||||||
|
List<SolarForecast> getSolarForecasts();
|
||||||
|
}
|
@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.forecastsolar;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map.Entry;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
import org.openhab.core.types.TimeSeries.Policy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link ForecastSolarObject} holds complete data for forecast
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class ForecastSolarObject implements SolarForecast {
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(ForecastSolarObject.class);
|
||||||
|
private final TreeMap<ZonedDateTime, Double> wattHourMap = new TreeMap<>();
|
||||||
|
private final TreeMap<ZonedDateTime, Double> wattMap = new TreeMap<>();
|
||||||
|
private final DateTimeFormatter dateInputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private DateTimeFormatter dateOutputFormatter = DateTimeFormatter
|
||||||
|
.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT).withZone(ZoneId.systemDefault());
|
||||||
|
private ZoneId zone = ZoneId.systemDefault();
|
||||||
|
private Optional<String> rawData = Optional.empty();
|
||||||
|
private Instant expirationDateTime;
|
||||||
|
private String identifier;
|
||||||
|
|
||||||
|
public ForecastSolarObject(String id) {
|
||||||
|
expirationDateTime = Instant.now().minusSeconds(1);
|
||||||
|
identifier = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForecastSolarObject(String id, String content, Instant expirationDate) throws SolarForecastException {
|
||||||
|
expirationDateTime = expirationDate;
|
||||||
|
identifier = id;
|
||||||
|
if (!content.isEmpty()) {
|
||||||
|
rawData = Optional.of(content);
|
||||||
|
try {
|
||||||
|
JSONObject contentJson = new JSONObject(content);
|
||||||
|
JSONObject resultJson = contentJson.getJSONObject("result");
|
||||||
|
JSONObject wattHourJson = resultJson.getJSONObject("watt_hours");
|
||||||
|
JSONObject wattJson = resultJson.getJSONObject("watts");
|
||||||
|
String zoneStr = contentJson.getJSONObject("message").getJSONObject("info").getString("timezone");
|
||||||
|
zone = ZoneId.of(zoneStr);
|
||||||
|
dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
|
||||||
|
.withZone(zone);
|
||||||
|
Iterator<String> iter = wattHourJson.keys();
|
||||||
|
// put all values of the current day into sorted tree map
|
||||||
|
while (iter.hasNext()) {
|
||||||
|
String dateStr = iter.next();
|
||||||
|
// convert date time into machine readable format
|
||||||
|
try {
|
||||||
|
ZonedDateTime zdt = LocalDateTime.parse(dateStr, dateInputFormatter).atZone(zone);
|
||||||
|
wattHourMap.put(zdt, wattHourJson.getDouble(dateStr));
|
||||||
|
wattMap.put(zdt, wattJson.getDouble(dateStr));
|
||||||
|
} catch (DateTimeParseException dtpe) {
|
||||||
|
logger.warn("Error parsing time {} Reason: {}", dateStr, dtpe.getMessage());
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Error parsing time " + dateStr + " Reason: " + dtpe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (JSONException je) {
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Error parsing JSON response " + content + " Reason: " + je.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired() {
|
||||||
|
return expirationDateTime.isBefore(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getActualEnergyValue(ZonedDateTime queryDateTime) throws SolarForecastException {
|
||||||
|
Entry<ZonedDateTime, Double> f = wattHourMap.floorEntry(queryDateTime);
|
||||||
|
Entry<ZonedDateTime, Double> c = wattHourMap.ceilingEntry(queryDateTime);
|
||||||
|
if (f != null && c == null) {
|
||||||
|
// only floor available
|
||||||
|
if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
|
||||||
|
// floor has valid date
|
||||||
|
return f.getValue() / 1000.0;
|
||||||
|
} else {
|
||||||
|
// floor date doesn't fit
|
||||||
|
throwOutOfRangeException(queryDateTime.toInstant());
|
||||||
|
}
|
||||||
|
} else if (f == null && c != null) {
|
||||||
|
if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
|
||||||
|
// only ceiling from correct date available - no valid data reached yet
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
// ceiling date doesn't fit
|
||||||
|
throwOutOfRangeException(queryDateTime.toInstant());
|
||||||
|
}
|
||||||
|
} else if (f != null && c != null) {
|
||||||
|
// ceiling and floor available
|
||||||
|
if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
|
||||||
|
if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
|
||||||
|
// we're during suntime!
|
||||||
|
double production = c.getValue() - f.getValue();
|
||||||
|
long floorToCeilingDuration = Duration.between(f.getKey(), c.getKey()).toMinutes();
|
||||||
|
if (floorToCeilingDuration == 0) {
|
||||||
|
return f.getValue() / 1000.0;
|
||||||
|
}
|
||||||
|
long floorToQueryDuration = Duration.between(f.getKey(), queryDateTime).toMinutes();
|
||||||
|
double interpolation = (double) floorToQueryDuration / (double) floorToCeilingDuration;
|
||||||
|
double interpolationProduction = production * interpolation;
|
||||||
|
double actualProduction = f.getValue() + interpolationProduction;
|
||||||
|
return actualProduction / 1000.0;
|
||||||
|
} else {
|
||||||
|
// ceiling from wrong date, but floor is valid
|
||||||
|
return f.getValue() / 1000.0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// floor invalid - ceiling not reached
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} // else both null - date time doesn't fit to forecast data
|
||||||
|
throwOutOfRangeException(queryDateTime.toInstant());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeSeries getEnergyTimeSeries(QueryMode mode) {
|
||||||
|
TimeSeries ts = new TimeSeries(Policy.REPLACE);
|
||||||
|
wattHourMap.forEach((timestamp, energy) -> {
|
||||||
|
ts.add(timestamp.toInstant(), Utils.getEnergyState(energy / 1000.0));
|
||||||
|
});
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getActualPowerValue(ZonedDateTime queryDateTime) {
|
||||||
|
double actualPowerValue = 0;
|
||||||
|
Entry<ZonedDateTime, Double> f = wattMap.floorEntry(queryDateTime);
|
||||||
|
Entry<ZonedDateTime, Double> c = wattMap.ceilingEntry(queryDateTime);
|
||||||
|
if (f != null && c == null) {
|
||||||
|
// only floor available
|
||||||
|
if (f.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
|
||||||
|
// floor has valid date
|
||||||
|
return f.getValue() / 1000.0;
|
||||||
|
} else {
|
||||||
|
// floor date doesn't fit
|
||||||
|
throwOutOfRangeException(queryDateTime.toInstant());
|
||||||
|
}
|
||||||
|
} else if (f == null && c != null) {
|
||||||
|
if (c.getKey().toLocalDate().equals(queryDateTime.toLocalDate())) {
|
||||||
|
// only ceiling from correct date available - no valid data reached yet
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
// ceiling date doesn't fit
|
||||||
|
throwOutOfRangeException(queryDateTime.toInstant());
|
||||||
|
}
|
||||||
|
} else if (f != null && c != null) {
|
||||||
|
// we're during suntime!
|
||||||
|
long floorToCeilingDuration = Duration.between(f.getKey(), c.getKey()).toMinutes();
|
||||||
|
double powerFloor = f.getValue();
|
||||||
|
if (floorToCeilingDuration == 0) {
|
||||||
|
return powerFloor / 1000.0;
|
||||||
|
}
|
||||||
|
double powerCeiling = c.getValue();
|
||||||
|
// calculate in minutes from floor to now, e.g. 20 minutes
|
||||||
|
// => take 2/3 of floor and 1/3 of ceiling
|
||||||
|
long floorToQueryDuration = Duration.between(f.getKey(), queryDateTime).toMinutes();
|
||||||
|
double interpolation = (double) floorToQueryDuration / (double) floorToCeilingDuration;
|
||||||
|
actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
|
||||||
|
return actualPowerValue / 1000.0;
|
||||||
|
} // else both null - this shall not happen
|
||||||
|
throwOutOfRangeException(queryDateTime.toInstant());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeSeries getPowerTimeSeries(QueryMode mode) {
|
||||||
|
TimeSeries ts = new TimeSeries(Policy.REPLACE);
|
||||||
|
wattMap.forEach((timestamp, power) -> {
|
||||||
|
ts.add(timestamp.toInstant(), Utils.getPowerState(power / 1000.0));
|
||||||
|
});
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getDayTotal(LocalDate queryDate) {
|
||||||
|
if (rawData.isEmpty()) {
|
||||||
|
throw new SolarForecastException(this, "No forecast data available");
|
||||||
|
}
|
||||||
|
JSONObject contentJson = new JSONObject(rawData.get());
|
||||||
|
JSONObject resultJson = contentJson.getJSONObject("result");
|
||||||
|
JSONObject wattsDay = resultJson.getJSONObject("watt_hours_day");
|
||||||
|
|
||||||
|
if (wattsDay.has(queryDate.toString())) {
|
||||||
|
return wattsDay.getDouble(queryDate.toString()) / 1000.0;
|
||||||
|
} else {
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Day " + queryDate + " not available in forecast. " + getTimeRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getRemainingProduction(ZonedDateTime queryDateTime) {
|
||||||
|
double daily = getDayTotal(queryDateTime.toLocalDate());
|
||||||
|
double actual = getActualEnergyValue(queryDateTime);
|
||||||
|
return daily - actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRaw() {
|
||||||
|
if (rawData.isPresent()) {
|
||||||
|
return rawData.get();
|
||||||
|
}
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public ZoneId getZone() {
|
||||||
|
return zone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Expiration: " + expirationDateTime + ", Data:" + wattHourMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SolarForecast Interface
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public QuantityType<Energy> getDay(LocalDate localDate, String... args) throws IllegalArgumentException {
|
||||||
|
if (args.length > 0) {
|
||||||
|
throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
|
||||||
|
}
|
||||||
|
double measure = getDayTotal(localDate);
|
||||||
|
return Utils.getEnergyState(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public QuantityType<Energy> getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
|
||||||
|
if (args.length > 0) {
|
||||||
|
throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
|
||||||
|
}
|
||||||
|
LocalDate beginDate = start.atZone(zone).toLocalDate();
|
||||||
|
LocalDate endDate = end.atZone(zone).toLocalDate();
|
||||||
|
double measure = -1;
|
||||||
|
if (beginDate.equals(endDate)) {
|
||||||
|
measure = getDayTotal(beginDate) - getActualEnergyValue(start.atZone(zone))
|
||||||
|
- getRemainingProduction(end.atZone(zone));
|
||||||
|
} else {
|
||||||
|
measure = getRemainingProduction(start.atZone(zone));
|
||||||
|
beginDate = beginDate.plusDays(1);
|
||||||
|
while (beginDate.isBefore(endDate) && measure >= 0) {
|
||||||
|
double day = getDayTotal(beginDate);
|
||||||
|
if (day > 0) {
|
||||||
|
measure += day;
|
||||||
|
}
|
||||||
|
beginDate = beginDate.plusDays(1);
|
||||||
|
}
|
||||||
|
double lastDay = getActualEnergyValue(end.atZone(zone));
|
||||||
|
if (lastDay >= 0) {
|
||||||
|
measure += lastDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Utils.getEnergyState(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public QuantityType<Power> getPower(Instant timestamp, String... args) throws IllegalArgumentException {
|
||||||
|
if (args.length > 0) {
|
||||||
|
throw new IllegalArgumentException("ForecastSolar doesn't accept arguments");
|
||||||
|
}
|
||||||
|
double measure = getActualPowerValue(timestamp.atZone(zone));
|
||||||
|
return Utils.getPowerState(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant getForecastBegin() {
|
||||||
|
if (wattHourMap.isEmpty()) {
|
||||||
|
return Instant.MAX;
|
||||||
|
}
|
||||||
|
ZonedDateTime zdt = wattHourMap.firstEntry().getKey();
|
||||||
|
return zdt.toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant getForecastEnd() {
|
||||||
|
if (wattHourMap.isEmpty()) {
|
||||||
|
return Instant.MIN;
|
||||||
|
}
|
||||||
|
ZonedDateTime zdt = wattHourMap.lastEntry().getKey();
|
||||||
|
return zdt.toInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwOutOfRangeException(Instant query) {
|
||||||
|
if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
|
||||||
|
throw new SolarForecastException(this, "Forecast invalid time range");
|
||||||
|
}
|
||||||
|
if (query.isBefore(getForecastBegin())) {
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
|
||||||
|
} else if (query.isAfter(getForecastEnd())) {
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
|
||||||
|
} else {
|
||||||
|
logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getTimeRange() {
|
||||||
|
return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
|
||||||
|
+ dateOutputFormatter.format(getForecastEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getIdentifier() {
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||||
|
|
||||||
|
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarBridgeConfiguration;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.config.core.Configuration;
|
||||||
|
import org.openhab.core.library.types.PointType;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
|
import org.openhab.core.thing.ChannelUID;
|
||||||
|
import org.openhab.core.thing.ThingStatus;
|
||||||
|
import org.openhab.core.thing.ThingStatusDetail;
|
||||||
|
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||||
|
import org.openhab.core.types.Command;
|
||||||
|
import org.openhab.core.types.RefreshType;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
import org.openhab.core.types.TimeSeries.Policy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link ForecastSolarBridgeHandler} is a non active handler instance. It will be triggerer by the bridge.
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class ForecastSolarBridgeHandler extends BaseBridgeHandler implements SolarForecastProvider {
|
||||||
|
private List<ForecastSolarPlaneHandler> planes = new ArrayList<>();
|
||||||
|
private Optional<PointType> homeLocation;
|
||||||
|
private Optional<ForecastSolarBridgeConfiguration> configuration = Optional.empty();
|
||||||
|
private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
|
||||||
|
|
||||||
|
public ForecastSolarBridgeHandler(Bridge bridge, Optional<PointType> location) {
|
||||||
|
super(bridge);
|
||||||
|
homeLocation = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||||
|
return List.of(SolarForecastActions.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() {
|
||||||
|
ForecastSolarBridgeConfiguration config = getConfigAs(ForecastSolarBridgeConfiguration.class);
|
||||||
|
PointType locationConfigured;
|
||||||
|
|
||||||
|
// handle location error cases
|
||||||
|
if (config.location.isBlank()) {
|
||||||
|
if (homeLocation.isEmpty()) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.site.status.location-missing");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
locationConfigured = homeLocation.get();
|
||||||
|
// continue with openHAB location
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
locationConfigured = new PointType(config.location);
|
||||||
|
// continue with location from configuration
|
||||||
|
} catch (Exception e) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Configuration editConfig = editConfiguration();
|
||||||
|
editConfig.put("location", locationConfigured.toString());
|
||||||
|
updateConfiguration(editConfig);
|
||||||
|
config = getConfigAs(ForecastSolarBridgeConfiguration.class);
|
||||||
|
configuration = Optional.of(config);
|
||||||
|
updateStatus(ThingStatus.UNKNOWN);
|
||||||
|
refreshJob = Optional
|
||||||
|
.of(scheduler.scheduleWithFixedDelay(this::getData, 0, REFRESH_ACTUAL_INTERVAL, TimeUnit.MINUTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||||
|
if (command instanceof RefreshType) {
|
||||||
|
String channel = channelUID.getIdWithoutGroup();
|
||||||
|
switch (channel) {
|
||||||
|
case CHANNEL_ENERGY_ACTUAL:
|
||||||
|
case CHANNEL_ENERGY_REMAIN:
|
||||||
|
case CHANNEL_ENERGY_TODAY:
|
||||||
|
case CHANNEL_POWER_ACTUAL:
|
||||||
|
getData();
|
||||||
|
break;
|
||||||
|
case CHANNEL_POWER_ESTIMATE:
|
||||||
|
case CHANNEL_ENERGY_ESTIMATE:
|
||||||
|
forecastUpdate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data for all planes. Synchronized to protect plane list from being modified during update
|
||||||
|
*/
|
||||||
|
private synchronized void getData() {
|
||||||
|
if (planes.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean update = true;
|
||||||
|
double energySum = 0;
|
||||||
|
double powerSum = 0;
|
||||||
|
double daySum = 0;
|
||||||
|
for (Iterator<ForecastSolarPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||||
|
try {
|
||||||
|
ForecastSolarPlaneHandler sfph = iterator.next();
|
||||||
|
ForecastSolarObject fo = sfph.fetchData();
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(fo.getZone());
|
||||||
|
energySum += fo.getActualEnergyValue(now);
|
||||||
|
powerSum += fo.getActualPowerValue(now);
|
||||||
|
daySum += fo.getDayTotal(now.toLocalDate());
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||||
|
"@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
|
||||||
|
update = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (update) {
|
||||||
|
updateStatus(ThingStatus.ONLINE);
|
||||||
|
updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energySum));
|
||||||
|
updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(daySum - energySum));
|
||||||
|
updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(daySum));
|
||||||
|
updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(powerSum));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void forecastUpdate() {
|
||||||
|
if (planes.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TreeMap<Instant, QuantityType<?>> combinedPowerForecast = new TreeMap<>();
|
||||||
|
TreeMap<Instant, QuantityType<?>> combinedEnergyForecast = new TreeMap<>();
|
||||||
|
List<SolarForecast> forecastObjects = new ArrayList<>();
|
||||||
|
for (Iterator<ForecastSolarPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||||
|
ForecastSolarPlaneHandler sfph = iterator.next();
|
||||||
|
forecastObjects.addAll(sfph.getSolarForecasts());
|
||||||
|
}
|
||||||
|
|
||||||
|
// bugfix: https://github.com/weymann/OH3-SolarForecast-Drops/issues/5
|
||||||
|
// find common start and end time which fits to all forecast objects to avoid ambiguous values
|
||||||
|
final Instant commonStart = Utils.getCommonStartTime(forecastObjects);
|
||||||
|
final Instant commonEnd = Utils.getCommonEndTime(forecastObjects);
|
||||||
|
forecastObjects.forEach(fc -> {
|
||||||
|
TimeSeries powerTS = fc.getPowerTimeSeries(QueryMode.Average);
|
||||||
|
powerTS.getStates().forEach(entry -> {
|
||||||
|
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||||
|
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||||
|
Utils.addState(combinedPowerForecast, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
TimeSeries energyTS = fc.getEnergyTimeSeries(QueryMode.Average);
|
||||||
|
energyTS.getStates().forEach(entry -> {
|
||||||
|
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||||
|
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||||
|
Utils.addState(combinedEnergyForecast, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries powerSeries = new TimeSeries(Policy.REPLACE);
|
||||||
|
combinedPowerForecast.forEach((timestamp, state) -> {
|
||||||
|
powerSeries.add(timestamp, state);
|
||||||
|
});
|
||||||
|
sendTimeSeries(CHANNEL_POWER_ESTIMATE, powerSeries);
|
||||||
|
|
||||||
|
TimeSeries energySeries = new TimeSeries(Policy.REPLACE);
|
||||||
|
combinedEnergyForecast.forEach((timestamp, state) -> {
|
||||||
|
energySeries.add(timestamp, state);
|
||||||
|
});
|
||||||
|
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, energySeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose() {
|
||||||
|
refreshJob.ifPresent(job -> job.cancel(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void addPlane(ForecastSolarPlaneHandler sfph) {
|
||||||
|
planes.add(sfph);
|
||||||
|
// update passive PV plane with necessary data
|
||||||
|
if (configuration.isPresent()) {
|
||||||
|
sfph.setLocation(new PointType(configuration.get().location));
|
||||||
|
if (!configuration.get().apiKey.isBlank()) {
|
||||||
|
sfph.setApiKey(configuration.get().apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void removePlane(ForecastSolarPlaneHandler sfph) {
|
||||||
|
planes.remove(sfph);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<SolarForecast> getSolarForecasts() {
|
||||||
|
List<SolarForecast> l = new ArrayList<SolarForecast>();
|
||||||
|
planes.forEach(entry -> {
|
||||||
|
l.addAll(entry.getSolarForecasts());
|
||||||
|
});
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.forecastsolar.handler;
|
||||||
|
|
||||||
|
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
|
import org.eclipse.jetty.client.api.ContentResponse;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.config.ForecastSolarPlaneConfiguration;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.library.types.PointType;
|
||||||
|
import org.openhab.core.library.types.StringType;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
|
import org.openhab.core.thing.ChannelUID;
|
||||||
|
import org.openhab.core.thing.Thing;
|
||||||
|
import org.openhab.core.thing.ThingStatus;
|
||||||
|
import org.openhab.core.thing.ThingStatusDetail;
|
||||||
|
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||||
|
import org.openhab.core.thing.binding.BridgeHandler;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||||
|
import org.openhab.core.types.Command;
|
||||||
|
import org.openhab.core.types.RefreshType;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link ForecastSolarPlaneHandler} is a non active handler instance. It will be triggered by the bridge.
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class ForecastSolarPlaneHandler extends BaseThingHandler implements SolarForecastProvider {
|
||||||
|
public static final String BASE_URL = "https://api.forecast.solar/";
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(ForecastSolarPlaneHandler.class);
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
private Optional<ForecastSolarPlaneConfiguration> configuration = Optional.empty();
|
||||||
|
private Optional<ForecastSolarBridgeHandler> bridgeHandler = Optional.empty();
|
||||||
|
private Optional<PointType> location = Optional.empty();
|
||||||
|
private Optional<String> apiKey = Optional.empty();
|
||||||
|
private ForecastSolarObject forecast;
|
||||||
|
|
||||||
|
public ForecastSolarPlaneHandler(Thing thing, HttpClient hc) {
|
||||||
|
super(thing);
|
||||||
|
httpClient = hc;
|
||||||
|
forecast = new ForecastSolarObject(thing.getUID().getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||||
|
return List.of(SolarForecastActions.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() {
|
||||||
|
ForecastSolarPlaneConfiguration c = getConfigAs(ForecastSolarPlaneConfiguration.class);
|
||||||
|
configuration = Optional.of(c);
|
||||||
|
Bridge bridge = getBridge();
|
||||||
|
if (bridge != null) {
|
||||||
|
BridgeHandler handler = bridge.getHandler();
|
||||||
|
if (handler != null) {
|
||||||
|
if (handler instanceof ForecastSolarBridgeHandler fsbh) {
|
||||||
|
bridgeHandler = Optional.of(fsbh);
|
||||||
|
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE,
|
||||||
|
"@text/solarforecast.plane.status.await-feedback");
|
||||||
|
fsbh.addPlane(this);
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.wrong-handler" + " [\"" + handler + "\"]");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.bridge-handler-not-found");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.bridge-missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
if (bridgeHandler.isPresent()) {
|
||||||
|
bridgeHandler.get().removePlane(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||||
|
if (command instanceof RefreshType) {
|
||||||
|
if (CHANNEL_POWER_ESTIMATE.equals(channelUID.getIdWithoutGroup())) {
|
||||||
|
sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecast.getPowerTimeSeries(QueryMode.Average));
|
||||||
|
} else if (CHANNEL_ENERGY_ESTIMATE.equals(channelUID.getIdWithoutGroup())) {
|
||||||
|
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecast.getEnergyTimeSeries(QueryMode.Average));
|
||||||
|
} else if (CHANNEL_JSON.equals(channelUID.getIdWithoutGroup())) {
|
||||||
|
updateState(CHANNEL_JSON, StringType.valueOf(forecast.getRaw()));
|
||||||
|
} else {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://doc.forecast.solar/doku.php?id=api:estimate
|
||||||
|
*/
|
||||||
|
protected ForecastSolarObject fetchData() {
|
||||||
|
if (location.isPresent()) {
|
||||||
|
if (forecast.isExpired()) {
|
||||||
|
String url = getBaseUrl() + "estimate/" + location.get().getLatitude() + SLASH
|
||||||
|
+ location.get().getLongitude() + SLASH + configuration.get().declination + SLASH
|
||||||
|
+ configuration.get().azimuth + SLASH + configuration.get().kwp + "?damping="
|
||||||
|
+ configuration.get().dampAM + "," + configuration.get().dampPM;
|
||||||
|
if (!SolarForecastBindingConstants.EMPTY.equals(configuration.get().horizon)) {
|
||||||
|
url += "&horizon=" + configuration.get().horizon;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ContentResponse cr = httpClient.GET(url);
|
||||||
|
if (cr.getStatus() == 200) {
|
||||||
|
try {
|
||||||
|
ForecastSolarObject localForecast = new ForecastSolarObject(thing.getUID().getAsString(),
|
||||||
|
cr.getContentAsString(),
|
||||||
|
Instant.now().plus(configuration.get().refreshInterval, ChronoUnit.MINUTES));
|
||||||
|
updateStatus(ThingStatus.ONLINE);
|
||||||
|
updateState(CHANNEL_JSON, StringType.valueOf(cr.getContentAsString()));
|
||||||
|
setForecast(localForecast);
|
||||||
|
} catch (SolarForecastException fse) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||||
|
"@text/solarforecast.plane.status.json-status [\"" + fse.getMessage() + "\"]");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.trace("Call {} failed with status {}. Response: {}", url, cr.getStatus(),
|
||||||
|
cr.getContentAsString());
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.http-status [\"" + cr.getStatus() + "\"]");
|
||||||
|
}
|
||||||
|
} catch (ExecutionException | TimeoutException e) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// else use available forecast
|
||||||
|
updateChannels(forecast);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn("{} Location not present", thing.getLabel());
|
||||||
|
}
|
||||||
|
return forecast;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateChannels(ForecastSolarObject f) {
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(f.getZone());
|
||||||
|
double energyDay = f.getDayTotal(now.toLocalDate());
|
||||||
|
double energyProduced = f.getActualEnergyValue(now);
|
||||||
|
updateState(CHANNEL_ENERGY_ACTUAL, Utils.getEnergyState(energyProduced));
|
||||||
|
updateState(CHANNEL_ENERGY_REMAIN, Utils.getEnergyState(energyDay - energyProduced));
|
||||||
|
updateState(CHANNEL_ENERGY_TODAY, Utils.getEnergyState(energyDay));
|
||||||
|
updateState(CHANNEL_POWER_ACTUAL, Utils.getPowerState(f.getActualPowerValue(now)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used by Bridge to set location directly
|
||||||
|
*
|
||||||
|
* @param loc
|
||||||
|
*/
|
||||||
|
void setLocation(PointType loc) {
|
||||||
|
location = Optional.of(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setApiKey(String key) {
|
||||||
|
apiKey = Optional.of(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getBaseUrl() {
|
||||||
|
String url = BASE_URL;
|
||||||
|
if (apiKey.isPresent()) {
|
||||||
|
url += apiKey.get() + SLASH;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected synchronized void setForecast(ForecastSolarObject f) {
|
||||||
|
forecast = f;
|
||||||
|
sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecast.getPowerTimeSeries(QueryMode.Average));
|
||||||
|
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecast.getEnergyTimeSeries(QueryMode.Average));
|
||||||
|
bridgeHandler.ifPresent(h -> {
|
||||||
|
h.forecastUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<SolarForecast> getSolarForecasts() {
|
||||||
|
return List.of(forecast);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.solcast;
|
||||||
|
|
||||||
|
import javax.measure.Unit;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.core.library.unit.MetricPrefix;
|
||||||
|
import org.openhab.core.library.unit.Units;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolcastConstants} class defines common constants for Solcast Service
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class SolcastConstants {
|
||||||
|
private static final String BASE_URL = "https://api.solcast.com.au/rooftop_sites/";
|
||||||
|
public static final String FORECAST_URL = BASE_URL + "%s/forecasts?format=json&hours=168";
|
||||||
|
public static final String CURRENT_ESTIMATE_URL = BASE_URL + "%s/estimated_actuals?format=json";
|
||||||
|
public static final String BEARER = "Bearer ";
|
||||||
|
public static final Unit<Power> KILOWATT_UNIT = MetricPrefix.KILO(Units.WATT);
|
||||||
|
}
|
@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.solcast;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Map.Entry;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
import org.openhab.core.types.TimeSeries.Policy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolcastObject} holds complete data for forecast
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class SolcastObject implements SolarForecast {
|
||||||
|
private static final TreeMap<ZonedDateTime, Double> EMPTY_MAP = new TreeMap<>();
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(SolcastObject.class);
|
||||||
|
private final TreeMap<ZonedDateTime, Double> estimationDataMap = new TreeMap<>();
|
||||||
|
private final TreeMap<ZonedDateTime, Double> optimisticDataMap = new TreeMap<>();
|
||||||
|
private final TreeMap<ZonedDateTime, Double> pessimisticDataMap = new TreeMap<>();
|
||||||
|
private final TimeZoneProvider timeZoneProvider;
|
||||||
|
|
||||||
|
private DateTimeFormatter dateOutputFormatter;
|
||||||
|
private String identifier;
|
||||||
|
private Optional<JSONObject> rawData = Optional.of(new JSONObject());
|
||||||
|
private Instant expirationDateTime;
|
||||||
|
private long period = 30;
|
||||||
|
|
||||||
|
public enum QueryMode {
|
||||||
|
Average(SolarForecast.AVERAGE),
|
||||||
|
Optimistic(SolarForecast.OPTIMISTIC),
|
||||||
|
Pessimistic(SolarForecast.PESSIMISTIC),
|
||||||
|
Error("Error");
|
||||||
|
|
||||||
|
String modeDescirption;
|
||||||
|
|
||||||
|
QueryMode(String description) {
|
||||||
|
modeDescirption = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return modeDescirption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SolcastObject(String id, TimeZoneProvider tzp) {
|
||||||
|
// invalid forecast object
|
||||||
|
identifier = id;
|
||||||
|
timeZoneProvider = tzp;
|
||||||
|
dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
|
||||||
|
.withZone(tzp.getTimeZone());
|
||||||
|
expirationDateTime = Instant.now().minusSeconds(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SolcastObject(String id, String content, Instant expiration, TimeZoneProvider tzp) {
|
||||||
|
identifier = id;
|
||||||
|
expirationDateTime = expiration;
|
||||||
|
timeZoneProvider = tzp;
|
||||||
|
dateOutputFormatter = DateTimeFormatter.ofPattern(SolarForecastBindingConstants.PATTERN_FORMAT)
|
||||||
|
.withZone(tzp.getTimeZone());
|
||||||
|
add(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void join(String content) {
|
||||||
|
add(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void add(String content) {
|
||||||
|
if (!content.isEmpty()) {
|
||||||
|
JSONObject contentJson = new JSONObject(content);
|
||||||
|
JSONArray resultJsonArray;
|
||||||
|
|
||||||
|
// prepare data for raw channel
|
||||||
|
if (contentJson.has("forecasts")) {
|
||||||
|
resultJsonArray = contentJson.getJSONArray("forecasts");
|
||||||
|
addJSONArray(resultJsonArray);
|
||||||
|
rawData.get().put("forecasts", resultJsonArray);
|
||||||
|
}
|
||||||
|
if (contentJson.has("estimated_actuals")) {
|
||||||
|
resultJsonArray = contentJson.getJSONArray("estimated_actuals");
|
||||||
|
addJSONArray(resultJsonArray);
|
||||||
|
rawData.get().put("estimated_actuals", resultJsonArray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addJSONArray(JSONArray resultJsonArray) {
|
||||||
|
// sort data into TreeMaps
|
||||||
|
for (int i = 0; i < resultJsonArray.length(); i++) {
|
||||||
|
JSONObject jo = resultJsonArray.getJSONObject(i);
|
||||||
|
String periodEnd = jo.getString("period_end");
|
||||||
|
ZonedDateTime periodEndZdt = getZdtFromUTC(periodEnd);
|
||||||
|
if (periodEndZdt == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
estimationDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
|
||||||
|
|
||||||
|
// fill pessimistic values
|
||||||
|
if (jo.has("pv_estimate10")) {
|
||||||
|
pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate10"));
|
||||||
|
} else {
|
||||||
|
pessimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill optimistic values
|
||||||
|
if (jo.has("pv_estimate90")) {
|
||||||
|
optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate90"));
|
||||||
|
} else {
|
||||||
|
optimisticDataMap.put(periodEndZdt, jo.getDouble("pv_estimate"));
|
||||||
|
}
|
||||||
|
if (jo.has("period")) {
|
||||||
|
period = Duration.parse(jo.getString("period")).toMinutes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isExpired() {
|
||||||
|
return expirationDateTime.isBefore(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getActualEnergyValue(ZonedDateTime query, QueryMode mode) {
|
||||||
|
// calculate energy from day begin to latest entry BEFORE query
|
||||||
|
ZonedDateTime iterationDateTime = query.withHour(0).withMinute(0).withSecond(0);
|
||||||
|
TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
|
||||||
|
Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
|
||||||
|
if (nextEntry == null) {
|
||||||
|
throwOutOfRangeException(query.toInstant());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
double forecastValue = 0;
|
||||||
|
double previousEstimate = 0;
|
||||||
|
while (nextEntry.getKey().isBefore(query) || nextEntry.getKey().isEqual(query)) {
|
||||||
|
// value are reported in PT30M = 30 minutes interval with kw value
|
||||||
|
// for kw/h it's half the value
|
||||||
|
Double endValue = nextEntry.getValue();
|
||||||
|
// production during period is half of previous and next value
|
||||||
|
double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
|
||||||
|
forecastValue += addedValue;
|
||||||
|
previousEstimate = endValue.doubleValue();
|
||||||
|
iterationDateTime = nextEntry.getKey();
|
||||||
|
nextEntry = dtm.higherEntry(iterationDateTime);
|
||||||
|
if (nextEntry == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// interpolate minutes AFTER query
|
||||||
|
Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
|
||||||
|
Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
|
||||||
|
if (f != null) {
|
||||||
|
if (c != null) {
|
||||||
|
long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
|
||||||
|
// floor == ceiling: no addon calculation needed
|
||||||
|
if (duration == 0) {
|
||||||
|
return forecastValue;
|
||||||
|
}
|
||||||
|
if (c.getValue() > 0) {
|
||||||
|
double interpolation = Duration.between(f.getKey(), query).toMinutes() / 60.0;
|
||||||
|
double interpolationProduction = getActualPowerValue(query, mode) * interpolation;
|
||||||
|
forecastValue += interpolationProduction;
|
||||||
|
return forecastValue;
|
||||||
|
} else {
|
||||||
|
// if ceiling value is 0 there's no further production in this period
|
||||||
|
return forecastValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if ceiling is null we're at the very end of the day
|
||||||
|
return forecastValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if floor is null we're at the very beginning of the day => 0
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeSeries getEnergyTimeSeries(QueryMode mode) {
|
||||||
|
TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
|
||||||
|
TimeSeries ts = new TimeSeries(Policy.REPLACE);
|
||||||
|
dtm.forEach((timestamp, energy) -> {
|
||||||
|
ts.add(timestamp.toInstant(), Utils.getEnergyState(getActualEnergyValue(timestamp, mode)));
|
||||||
|
});
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get power values
|
||||||
|
*/
|
||||||
|
public double getActualPowerValue(ZonedDateTime query, QueryMode mode) {
|
||||||
|
if (query.toInstant().isBefore(getForecastBegin()) || query.toInstant().isAfter(getForecastEnd())) {
|
||||||
|
throwOutOfRangeException(query.toInstant());
|
||||||
|
}
|
||||||
|
TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
|
||||||
|
double actualPowerValue = 0;
|
||||||
|
Entry<ZonedDateTime, Double> f = dtm.floorEntry(query);
|
||||||
|
Entry<ZonedDateTime, Double> c = dtm.ceilingEntry(query);
|
||||||
|
if (f != null) {
|
||||||
|
if (c != null) {
|
||||||
|
double powerCeiling = c.getValue();
|
||||||
|
long duration = Duration.between(f.getKey(), c.getKey()).toMinutes();
|
||||||
|
// floor == ceiling: return power from node, no interpolation needed
|
||||||
|
if (duration == 0) {
|
||||||
|
return powerCeiling;
|
||||||
|
}
|
||||||
|
if (powerCeiling > 0) {
|
||||||
|
double powerFloor = f.getValue();
|
||||||
|
// calculate in minutes from floor to now, e.g. 20 minutes from PT30M 30 minutes
|
||||||
|
// => take 1/3 of floor and 2/3 of ceiling
|
||||||
|
double interpolation = Duration.between(f.getKey(), query).toMinutes() / (double) period;
|
||||||
|
actualPowerValue = ((1 - interpolation) * powerFloor) + (interpolation * powerCeiling);
|
||||||
|
return actualPowerValue;
|
||||||
|
} else {
|
||||||
|
// if power ceiling == 0 there's no production in this period
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if ceiling is null we're at the very end of this day => 0
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if floor is null we're at the very beginning of this day => 0
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeSeries getPowerTimeSeries(QueryMode mode) {
|
||||||
|
TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
|
||||||
|
TimeSeries ts = new TimeSeries(Policy.REPLACE);
|
||||||
|
dtm.forEach((timestamp, power) -> {
|
||||||
|
ts.add(timestamp.toInstant(), Utils.getPowerState(power));
|
||||||
|
});
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily totals
|
||||||
|
*/
|
||||||
|
public double getDayTotal(LocalDate query, QueryMode mode) {
|
||||||
|
TreeMap<ZonedDateTime, Double> dtm = getDataMap(mode);
|
||||||
|
ZonedDateTime iterationDateTime = query.atStartOfDay(timeZoneProvider.getTimeZone());
|
||||||
|
Entry<ZonedDateTime, Double> nextEntry = dtm.higherEntry(iterationDateTime);
|
||||||
|
if (nextEntry == null) {
|
||||||
|
throw new SolarForecastException(this, "Day " + query + " not available in forecast. " + getTimeRange());
|
||||||
|
}
|
||||||
|
ZonedDateTime endDateTime = iterationDateTime.plusDays(1);
|
||||||
|
double forecastValue = 0;
|
||||||
|
double previousEstimate = 0;
|
||||||
|
while (nextEntry.getKey().isBefore(endDateTime)) {
|
||||||
|
// value are reported in PT30M = 30 minutes interval with kw value
|
||||||
|
// for kw/h it's half the value
|
||||||
|
Double endValue = nextEntry.getValue();
|
||||||
|
// production during period is half of previous and next value
|
||||||
|
double addedValue = ((endValue.doubleValue() + previousEstimate) / 2.0) * period / 60.0;
|
||||||
|
forecastValue += addedValue;
|
||||||
|
previousEstimate = endValue.doubleValue();
|
||||||
|
iterationDateTime = nextEntry.getKey();
|
||||||
|
nextEntry = dtm.higherEntry(iterationDateTime);
|
||||||
|
if (nextEntry == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return forecastValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getRemainingProduction(ZonedDateTime query, QueryMode mode) {
|
||||||
|
return getDayTotal(query.toLocalDate(), mode) - getActualEnergyValue(query, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Expiration: " + expirationDateTime + ", Data: " + estimationDataMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRaw() {
|
||||||
|
if (rawData.isPresent()) {
|
||||||
|
return rawData.get().toString();
|
||||||
|
}
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private TreeMap<ZonedDateTime, Double> getDataMap(QueryMode mode) {
|
||||||
|
TreeMap<ZonedDateTime, Double> returnMap = EMPTY_MAP;
|
||||||
|
switch (mode) {
|
||||||
|
case Average:
|
||||||
|
returnMap = estimationDataMap;
|
||||||
|
break;
|
||||||
|
case Optimistic:
|
||||||
|
returnMap = optimisticDataMap;
|
||||||
|
break;
|
||||||
|
case Pessimistic:
|
||||||
|
returnMap = pessimisticDataMap;
|
||||||
|
break;
|
||||||
|
case Error:
|
||||||
|
// nothing to do
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// nothing to do
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return returnMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable ZonedDateTime getZdtFromUTC(String utc) {
|
||||||
|
try {
|
||||||
|
Instant timestamp = Instant.parse(utc);
|
||||||
|
return timestamp.atZone(timeZoneProvider.getTimeZone());
|
||||||
|
} catch (DateTimeParseException dtpe) {
|
||||||
|
logger.warn("Exception parsing time {} Reason: {}", utc, dtpe.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SolarForecast Interface
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public QuantityType<Energy> getDay(LocalDate date, String... args) throws IllegalArgumentException {
|
||||||
|
QueryMode mode = evalArguments(args);
|
||||||
|
if (mode.equals(QueryMode.Error)) {
|
||||||
|
if (args.length > 1) {
|
||||||
|
throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
|
||||||
|
}
|
||||||
|
} else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
|
||||||
|
if (date.isBefore(LocalDate.now())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Solcast argument " + mode.toString() + " only available for future values");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double measure = getDayTotal(date, mode);
|
||||||
|
return Utils.getEnergyState(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public QuantityType<Energy> getEnergy(Instant start, Instant end, String... args) throws IllegalArgumentException {
|
||||||
|
if (end.isBefore(start)) {
|
||||||
|
if (args.length > 1) {
|
||||||
|
throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QueryMode mode = evalArguments(args);
|
||||||
|
if (mode.equals(QueryMode.Error)) {
|
||||||
|
return Utils.getEnergyState(-1);
|
||||||
|
} else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
|
||||||
|
if (end.isBefore(Instant.now())) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Solcast argument " + mode.toString() + " only available for future values");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LocalDate beginDate = start.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
|
||||||
|
LocalDate endDate = end.atZone(timeZoneProvider.getTimeZone()).toLocalDate();
|
||||||
|
double measure = -1;
|
||||||
|
if (beginDate.isEqual(endDate)) {
|
||||||
|
measure = getDayTotal(beginDate, mode)
|
||||||
|
- getActualEnergyValue(start.atZone(timeZoneProvider.getTimeZone()), mode)
|
||||||
|
- getRemainingProduction(end.atZone(timeZoneProvider.getTimeZone()), mode);
|
||||||
|
} else {
|
||||||
|
measure = getRemainingProduction(start.atZone(timeZoneProvider.getTimeZone()), mode);
|
||||||
|
beginDate = beginDate.plusDays(1);
|
||||||
|
while (beginDate.isBefore(endDate) && measure >= 0) {
|
||||||
|
double day = getDayTotal(beginDate, mode);
|
||||||
|
if (day > 0) {
|
||||||
|
measure += day;
|
||||||
|
}
|
||||||
|
beginDate = beginDate.plusDays(1);
|
||||||
|
}
|
||||||
|
double lastDay = getActualEnergyValue(end.atZone(timeZoneProvider.getTimeZone()), mode);
|
||||||
|
if (lastDay >= 0) {
|
||||||
|
measure += lastDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Utils.getEnergyState(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public QuantityType<Power> getPower(Instant timestamp, String... args) throws IllegalArgumentException {
|
||||||
|
// eliminate error cases and return immediately
|
||||||
|
QueryMode mode = evalArguments(args);
|
||||||
|
if (mode.equals(QueryMode.Error)) {
|
||||||
|
if (args.length > 1) {
|
||||||
|
throw new IllegalArgumentException("Solcast doesn't support " + args.length + " arguments");
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("Solcast doesn't support argument " + args[0]);
|
||||||
|
}
|
||||||
|
} else if (mode.equals(QueryMode.Optimistic) || mode.equals(QueryMode.Pessimistic)) {
|
||||||
|
if (timestamp.isBefore(Instant.now().minus(1, ChronoUnit.MINUTES))) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Solcast argument " + mode.toString() + " only available for future values");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double measure = getActualPowerValue(ZonedDateTime.ofInstant(timestamp, timeZoneProvider.getTimeZone()), mode);
|
||||||
|
return Utils.getPowerState(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant getForecastBegin() {
|
||||||
|
if (!estimationDataMap.isEmpty()) {
|
||||||
|
return estimationDataMap.firstEntry().getKey().toInstant();
|
||||||
|
}
|
||||||
|
return Instant.MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant getForecastEnd() {
|
||||||
|
if (!estimationDataMap.isEmpty()) {
|
||||||
|
return estimationDataMap.lastEntry().getKey().toInstant();
|
||||||
|
}
|
||||||
|
return Instant.MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueryMode evalArguments(String[] args) {
|
||||||
|
if (args.length > 0) {
|
||||||
|
if (args.length > 1) {
|
||||||
|
logger.info("Too many arguments {}", Arrays.toString(args));
|
||||||
|
return QueryMode.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SolarForecast.OPTIMISTIC.equals(args[0])) {
|
||||||
|
return QueryMode.Optimistic;
|
||||||
|
} else if (SolarForecast.PESSIMISTIC.equals(args[0])) {
|
||||||
|
return QueryMode.Pessimistic;
|
||||||
|
} else if (SolarForecast.AVERAGE.equals(args[0])) {
|
||||||
|
return QueryMode.Average;
|
||||||
|
} else {
|
||||||
|
logger.info("Argument {} not supported", args[0]);
|
||||||
|
return QueryMode.Error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return QueryMode.Average;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getIdentifier() {
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void throwOutOfRangeException(Instant query) {
|
||||||
|
if (getForecastBegin().equals(Instant.MAX) || getForecastEnd().equals(Instant.MIN)) {
|
||||||
|
throw new SolarForecastException(this, "Forecast invalid time range");
|
||||||
|
}
|
||||||
|
if (query.isBefore(getForecastBegin())) {
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Query " + dateOutputFormatter.format(query) + " too early. " + getTimeRange());
|
||||||
|
} else if (query.isAfter(getForecastEnd())) {
|
||||||
|
throw new SolarForecastException(this,
|
||||||
|
"Query " + dateOutputFormatter.format(query) + " too late. " + getTimeRange());
|
||||||
|
} else {
|
||||||
|
logger.warn("Query {} is fine. {}", dateOutputFormatter.format(query), getTimeRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getTimeRange() {
|
||||||
|
return "Valid range: " + dateOutputFormatter.format(getForecastBegin()) + " - "
|
||||||
|
+ dateOutputFormatter.format(getForecastEnd());
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.solcast.handler;
|
||||||
|
|
||||||
|
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
|
||||||
|
|
||||||
|
import java.time.DateTimeException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.config.SolcastBridgeConfiguration;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.i18n.TimeZoneProvider;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
|
import org.openhab.core.thing.ChannelUID;
|
||||||
|
import org.openhab.core.thing.ThingStatus;
|
||||||
|
import org.openhab.core.thing.ThingStatusDetail;
|
||||||
|
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||||
|
import org.openhab.core.types.Command;
|
||||||
|
import org.openhab.core.types.RefreshType;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
import org.openhab.core.types.TimeSeries.Policy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolcastBridgeHandler} is a non active handler instance. It will be triggered by the bridge.
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class SolcastBridgeHandler extends BaseBridgeHandler implements SolarForecastProvider, TimeZoneProvider {
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(SolcastBridgeHandler.class);
|
||||||
|
|
||||||
|
private List<SolcastPlaneHandler> planes = new ArrayList<>();
|
||||||
|
private Optional<ScheduledFuture<?>> refreshJob = Optional.empty();
|
||||||
|
private SolcastBridgeConfiguration configuration = new SolcastBridgeConfiguration();
|
||||||
|
private ZoneId timeZone;
|
||||||
|
|
||||||
|
public SolcastBridgeHandler(Bridge bridge, TimeZoneProvider tzp) {
|
||||||
|
super(bridge);
|
||||||
|
timeZone = tzp.getTimeZone();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||||
|
return List.of(SolarForecastActions.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() {
|
||||||
|
configuration = getConfigAs(SolcastBridgeConfiguration.class);
|
||||||
|
if (!configuration.apiKey.isBlank()) {
|
||||||
|
if (!configuration.timeZone.isBlank()) {
|
||||||
|
try {
|
||||||
|
timeZone = ZoneId.of(configuration.timeZone);
|
||||||
|
} catch (DateTimeException e) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.site.status.timezone" + " [\"" + configuration.timeZone + "\"]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStatus(ThingStatus.UNKNOWN);
|
||||||
|
refreshJob = Optional
|
||||||
|
.of(scheduler.scheduleWithFixedDelay(this::getData, 0, REFRESH_ACTUAL_INTERVAL, TimeUnit.MINUTES));
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.site.status.api-key-missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||||
|
if (command instanceof RefreshType) {
|
||||||
|
String channel = channelUID.getIdWithoutGroup();
|
||||||
|
switch (channel) {
|
||||||
|
case CHANNEL_ENERGY_ACTUAL:
|
||||||
|
case CHANNEL_ENERGY_REMAIN:
|
||||||
|
case CHANNEL_ENERGY_TODAY:
|
||||||
|
case CHANNEL_POWER_ACTUAL:
|
||||||
|
getData();
|
||||||
|
break;
|
||||||
|
case CHANNEL_POWER_ESTIMATE:
|
||||||
|
case CHANNEL_ENERGY_ESTIMATE:
|
||||||
|
forecastUpdate();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose() {
|
||||||
|
refreshJob.ifPresent(job -> job.cancel(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data for all planes. Protect plane list from being modified during update
|
||||||
|
*/
|
||||||
|
public synchronized void getData() {
|
||||||
|
if (planes.isEmpty()) {
|
||||||
|
logger.debug("No PV plane defined yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(getTimeZone());
|
||||||
|
List<QueryMode> modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
|
||||||
|
modes.forEach(mode -> {
|
||||||
|
String group = switch (mode) {
|
||||||
|
case Average -> GROUP_AVERAGE;
|
||||||
|
case Optimistic -> GROUP_OPTIMISTIC;
|
||||||
|
case Pessimistic -> GROUP_PESSIMISTIC;
|
||||||
|
default -> GROUP_AVERAGE;
|
||||||
|
};
|
||||||
|
boolean update = true;
|
||||||
|
double energySum = 0;
|
||||||
|
double powerSum = 0;
|
||||||
|
double daySum = 0;
|
||||||
|
for (Iterator<SolcastPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||||
|
try {
|
||||||
|
SolcastPlaneHandler sfph = iterator.next();
|
||||||
|
SolcastObject fo = sfph.fetchData();
|
||||||
|
energySum += fo.getActualEnergyValue(now, mode);
|
||||||
|
powerSum += fo.getActualPowerValue(now, mode);
|
||||||
|
daySum += fo.getDayTotal(now.toLocalDate(), mode);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
|
||||||
|
"@text/solarforecast.site.status.exception [\"" + sfe.getMessage() + "\"]");
|
||||||
|
update = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (update) {
|
||||||
|
updateStatus(ThingStatus.ONLINE);
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ACTUAL,
|
||||||
|
Utils.getEnergyState(energySum));
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_REMAIN,
|
||||||
|
Utils.getEnergyState(daySum - energySum));
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_TODAY,
|
||||||
|
Utils.getEnergyState(daySum));
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ACTUAL,
|
||||||
|
Utils.getPowerState(powerSum));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void forecastUpdate() {
|
||||||
|
if (planes.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// get all available forecasts
|
||||||
|
List<SolarForecast> forecastObjects = new ArrayList<>();
|
||||||
|
for (Iterator<SolcastPlaneHandler> iterator = planes.iterator(); iterator.hasNext();) {
|
||||||
|
SolcastPlaneHandler sfph = iterator.next();
|
||||||
|
forecastObjects.addAll(sfph.getSolarForecasts());
|
||||||
|
}
|
||||||
|
// sort in Tree according to times for each scenario
|
||||||
|
List<QueryMode> modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
|
||||||
|
modes.forEach(mode -> {
|
||||||
|
TreeMap<Instant, QuantityType<?>> combinedPowerForecast = new TreeMap<>();
|
||||||
|
TreeMap<Instant, QuantityType<?>> combinedEnergyForecast = new TreeMap<>();
|
||||||
|
|
||||||
|
// bugfix: https://github.com/weymann/OH3-SolarForecast-Drops/issues/5
|
||||||
|
// find common start and end time which fits to all forecast objects to avoid ambiguous values
|
||||||
|
final Instant commonStart = Utils.getCommonStartTime(forecastObjects);
|
||||||
|
final Instant commonEnd = Utils.getCommonEndTime(forecastObjects);
|
||||||
|
forecastObjects.forEach(fc -> {
|
||||||
|
TimeSeries powerTS = fc.getPowerTimeSeries(mode);
|
||||||
|
powerTS.getStates().forEach(entry -> {
|
||||||
|
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||||
|
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||||
|
Utils.addState(combinedPowerForecast, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
TimeSeries energyTS = fc.getEnergyTimeSeries(mode);
|
||||||
|
energyTS.getStates().forEach(entry -> {
|
||||||
|
if (Utils.isAfterOrEqual(entry.timestamp(), commonStart)
|
||||||
|
&& Utils.isBeforeOrEqual(entry.timestamp(), commonEnd)) {
|
||||||
|
Utils.addState(combinedEnergyForecast, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// create TimeSeries and distribute
|
||||||
|
TimeSeries powerSeries = new TimeSeries(Policy.REPLACE);
|
||||||
|
combinedPowerForecast.forEach((timestamp, state) -> {
|
||||||
|
powerSeries.add(timestamp, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries energySeries = new TimeSeries(Policy.REPLACE);
|
||||||
|
combinedEnergyForecast.forEach((timestamp, state) -> {
|
||||||
|
energySeries.add(timestamp, state);
|
||||||
|
});
|
||||||
|
switch (mode) {
|
||||||
|
case Average:
|
||||||
|
sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
|
||||||
|
energySeries);
|
||||||
|
sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
|
||||||
|
powerSeries);
|
||||||
|
break;
|
||||||
|
case Optimistic:
|
||||||
|
sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
|
||||||
|
energySeries);
|
||||||
|
sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
|
||||||
|
powerSeries);
|
||||||
|
break;
|
||||||
|
case Pessimistic:
|
||||||
|
sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
|
||||||
|
energySeries);
|
||||||
|
sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
|
||||||
|
powerSeries);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void addPlane(SolcastPlaneHandler sph) {
|
||||||
|
planes.add(sph);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void removePlane(SolcastPlaneHandler sph) {
|
||||||
|
planes.remove(sph);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getApiKey() {
|
||||||
|
return configuration.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<SolarForecast> getSolarForecasts() {
|
||||||
|
List<SolarForecast> l = new ArrayList<>();
|
||||||
|
planes.forEach(entry -> {
|
||||||
|
l.addAll(entry.getSolarForecasts());
|
||||||
|
});
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ZoneId getTimeZone() {
|
||||||
|
return timeZone;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.solcast.handler;
|
||||||
|
|
||||||
|
import static org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants.*;
|
||||||
|
import static org.openhab.binding.solarforecast.internal.solcast.SolcastConstants.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jetty.client.HttpClient;
|
||||||
|
import org.eclipse.jetty.client.api.ContentResponse;
|
||||||
|
import org.eclipse.jetty.client.api.Request;
|
||||||
|
import org.eclipse.jetty.http.HttpHeader;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastProvider;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.config.SolcastPlaneConfiguration;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.library.types.StringType;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
|
import org.openhab.core.thing.ChannelUID;
|
||||||
|
import org.openhab.core.thing.Thing;
|
||||||
|
import org.openhab.core.thing.ThingStatus;
|
||||||
|
import org.openhab.core.thing.ThingStatusDetail;
|
||||||
|
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||||
|
import org.openhab.core.thing.binding.BridgeHandler;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||||
|
import org.openhab.core.types.Command;
|
||||||
|
import org.openhab.core.types.RefreshType;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolcastPlaneHandler} is a non active handler instance. It will be triggerer by the bridge.
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class SolcastPlaneHandler extends BaseThingHandler implements SolarForecastProvider {
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(SolcastPlaneHandler.class);
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private SolcastPlaneConfiguration configuration = new SolcastPlaneConfiguration();
|
||||||
|
private Optional<SolcastBridgeHandler> bridgeHandler = Optional.empty();
|
||||||
|
protected Optional<SolcastObject> forecast = Optional.empty();
|
||||||
|
|
||||||
|
public SolcastPlaneHandler(Thing thing, HttpClient hc) {
|
||||||
|
super(thing);
|
||||||
|
httpClient = hc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||||
|
return List.of(SolarForecastActions.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize() {
|
||||||
|
configuration = getConfigAs(SolcastPlaneConfiguration.class);
|
||||||
|
|
||||||
|
// connect Bridge & Status
|
||||||
|
Bridge bridge = getBridge();
|
||||||
|
if (bridge != null) {
|
||||||
|
BridgeHandler handler = bridge.getHandler();
|
||||||
|
if (handler != null) {
|
||||||
|
if (handler instanceof SolcastBridgeHandler sbh) {
|
||||||
|
bridgeHandler = Optional.of(sbh);
|
||||||
|
forecast = Optional.of(new SolcastObject(thing.getUID().getAsString(), sbh));
|
||||||
|
sbh.addPlane(this);
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.wrong-handler [\"" + handler + "\"]");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.bridge-handler-not-found");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.bridge-missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
bridgeHandler.ifPresent(bridge -> bridge.removePlane(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||||
|
if (command instanceof RefreshType) {
|
||||||
|
forecast.ifPresent(forecastObject -> {
|
||||||
|
String group = channelUID.getGroupId();
|
||||||
|
if (group == null) {
|
||||||
|
group = EMPTY;
|
||||||
|
}
|
||||||
|
String channel = channelUID.getIdWithoutGroup();
|
||||||
|
QueryMode mode = QueryMode.Average;
|
||||||
|
switch (group) {
|
||||||
|
case GROUP_AVERAGE:
|
||||||
|
mode = QueryMode.Average;
|
||||||
|
break;
|
||||||
|
case GROUP_OPTIMISTIC:
|
||||||
|
mode = QueryMode.Optimistic;
|
||||||
|
break;
|
||||||
|
case GROUP_PESSIMISTIC:
|
||||||
|
mode = QueryMode.Pessimistic;
|
||||||
|
break;
|
||||||
|
case GROUP_RAW:
|
||||||
|
forecast.ifPresent(f -> {
|
||||||
|
updateState(GROUP_RAW + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_JSON,
|
||||||
|
StringType.valueOf(f.getRaw()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
switch (channel) {
|
||||||
|
case CHANNEL_ENERGY_ESTIMATE:
|
||||||
|
sendTimeSeries(CHANNEL_ENERGY_ESTIMATE, forecastObject.getEnergyTimeSeries(mode));
|
||||||
|
break;
|
||||||
|
case CHANNEL_POWER_ESTIMATE:
|
||||||
|
sendTimeSeries(CHANNEL_POWER_ESTIMATE, forecastObject.getPowerTimeSeries(mode));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
updateChannels(forecastObject);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected synchronized SolcastObject fetchData() {
|
||||||
|
bridgeHandler.ifPresent(bridge -> {
|
||||||
|
forecast.ifPresent(forecastObject -> {
|
||||||
|
if (forecastObject.isExpired()) {
|
||||||
|
logger.trace("Get new forecast {}", forecastObject.toString());
|
||||||
|
String forecastUrl = String.format(FORECAST_URL, configuration.resourceId);
|
||||||
|
String currentEstimateUrl = String.format(CURRENT_ESTIMATE_URL, configuration.resourceId);
|
||||||
|
try {
|
||||||
|
// get actual estimate
|
||||||
|
Request estimateRequest = httpClient.newRequest(currentEstimateUrl);
|
||||||
|
estimateRequest.header(HttpHeader.AUTHORIZATION, BEARER + bridge.getApiKey());
|
||||||
|
ContentResponse crEstimate = estimateRequest.send();
|
||||||
|
if (crEstimate.getStatus() == 200) {
|
||||||
|
SolcastObject localForecast = new SolcastObject(thing.getUID().getAsString(),
|
||||||
|
crEstimate.getContentAsString(),
|
||||||
|
Instant.now().plus(configuration.refreshInterval, ChronoUnit.MINUTES), bridge);
|
||||||
|
|
||||||
|
// get forecast
|
||||||
|
Request forecastRequest = httpClient.newRequest(forecastUrl);
|
||||||
|
forecastRequest.header(HttpHeader.AUTHORIZATION, BEARER + bridge.getApiKey());
|
||||||
|
ContentResponse crForecast = forecastRequest.send();
|
||||||
|
|
||||||
|
if (crForecast.getStatus() == 200) {
|
||||||
|
localForecast.join(crForecast.getContentAsString());
|
||||||
|
setForecast(localForecast);
|
||||||
|
updateState(GROUP_RAW + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_JSON,
|
||||||
|
StringType.valueOf(forecast.get().getRaw()));
|
||||||
|
updateStatus(ThingStatus.ONLINE);
|
||||||
|
} else {
|
||||||
|
logger.debug("{} Call {} failed {}", thing.getLabel(), forecastUrl,
|
||||||
|
crForecast.getStatus());
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.http-status [\"" + crForecast.getStatus()
|
||||||
|
+ "\"]");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.debug("{} Call {} failed {}", thing.getLabel(), currentEstimateUrl,
|
||||||
|
crEstimate.getStatus());
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||||
|
"@text/solarforecast.plane.status.http-status [\"" + crEstimate.getStatus()
|
||||||
|
+ "\"]");
|
||||||
|
}
|
||||||
|
} catch (ExecutionException | TimeoutException e) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateChannels(forecastObject);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return forecast.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void updateChannels(SolcastObject f) {
|
||||||
|
if (bridgeHandler.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(bridgeHandler.get().getTimeZone());
|
||||||
|
List<QueryMode> modes = List.of(QueryMode.Average, QueryMode.Pessimistic, QueryMode.Optimistic);
|
||||||
|
modes.forEach(mode -> {
|
||||||
|
double energyDay = f.getDayTotal(now.toLocalDate(), mode);
|
||||||
|
double energyProduced = f.getActualEnergyValue(now, mode);
|
||||||
|
String group = switch (mode) {
|
||||||
|
case Average -> GROUP_AVERAGE;
|
||||||
|
case Optimistic -> GROUP_OPTIMISTIC;
|
||||||
|
case Pessimistic -> GROUP_PESSIMISTIC;
|
||||||
|
case Error -> throw new IllegalStateException("mode " + mode + " not expected");
|
||||||
|
};
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ACTUAL,
|
||||||
|
Utils.getEnergyState(energyProduced));
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_REMAIN,
|
||||||
|
Utils.getEnergyState(energyDay - energyProduced));
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_TODAY,
|
||||||
|
Utils.getEnergyState(energyDay));
|
||||||
|
updateState(group + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ACTUAL,
|
||||||
|
Utils.getPowerState(f.getActualPowerValue(now, QueryMode.Average)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected synchronized void setForecast(SolcastObject f) {
|
||||||
|
forecast = Optional.of(f);
|
||||||
|
sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
|
||||||
|
f.getPowerTimeSeries(QueryMode.Average));
|
||||||
|
sendTimeSeries(GROUP_AVERAGE + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
|
||||||
|
f.getEnergyTimeSeries(QueryMode.Average));
|
||||||
|
sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
|
||||||
|
f.getPowerTimeSeries(QueryMode.Optimistic));
|
||||||
|
sendTimeSeries(GROUP_OPTIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
|
||||||
|
f.getEnergyTimeSeries(QueryMode.Optimistic));
|
||||||
|
sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_POWER_ESTIMATE,
|
||||||
|
f.getPowerTimeSeries(QueryMode.Pessimistic));
|
||||||
|
sendTimeSeries(GROUP_PESSIMISTIC + ChannelUID.CHANNEL_GROUP_SEPARATOR + CHANNEL_ENERGY_ESTIMATE,
|
||||||
|
f.getEnergyTimeSeries(QueryMode.Pessimistic));
|
||||||
|
bridgeHandler.ifPresent(h -> {
|
||||||
|
h.forecastUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized List<SolarForecast> getSolarForecasts() {
|
||||||
|
return List.of(forecast.get());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast.internal.utils;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
import javax.measure.MetricPrefix;
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.library.unit.Units;
|
||||||
|
import org.openhab.core.types.TimeSeries.Entry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link Utils} Helpers for Solcast and ForecastSolar
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class Utils {
|
||||||
|
public static QuantityType<Energy> getEnergyState(double d) {
|
||||||
|
if (d < 0) {
|
||||||
|
return QuantityType.valueOf(-1, Units.KILOWATT_HOUR);
|
||||||
|
}
|
||||||
|
return QuantityType.valueOf(Math.round(d * 1000) / 1000.0, Units.KILOWATT_HOUR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static QuantityType<Power> getPowerState(double d) {
|
||||||
|
if (d < 0) {
|
||||||
|
return QuantityType.valueOf(-1, MetricPrefix.KILO(Units.WATT));
|
||||||
|
}
|
||||||
|
return QuantityType.valueOf(Math.round(d * 1000) / 1000.0, MetricPrefix.KILO(Units.WATT));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addState(TreeMap<Instant, QuantityType<?>> map, Entry entry) {
|
||||||
|
Instant timestamp = entry.timestamp();
|
||||||
|
QuantityType<?> qt1 = map.get(timestamp);
|
||||||
|
if (qt1 != null) {
|
||||||
|
QuantityType<?> qt2 = (QuantityType<?>) entry.state();
|
||||||
|
double combinedValue = qt1.doubleValue() + qt2.doubleValue();
|
||||||
|
map.put(timestamp, QuantityType.valueOf(combinedValue, qt2.getUnit()));
|
||||||
|
} else {
|
||||||
|
map.put(timestamp, (QuantityType<?>) entry.state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isBeforeOrEqual(Instant query, Instant reference) {
|
||||||
|
return !query.isAfter(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isAfterOrEqual(Instant query, Instant reference) {
|
||||||
|
return !query.isBefore(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Instant getCommonStartTime(List<SolarForecast> forecastObjects) {
|
||||||
|
if (forecastObjects.isEmpty()) {
|
||||||
|
return Instant.MAX;
|
||||||
|
}
|
||||||
|
Instant start = Instant.MIN;
|
||||||
|
for (Iterator<SolarForecast> iterator = forecastObjects.iterator(); iterator.hasNext();) {
|
||||||
|
SolarForecast sf = iterator.next();
|
||||||
|
// if start is maximum there's no forecast data available - return immediately
|
||||||
|
if (sf.getForecastBegin().equals(Instant.MAX)) {
|
||||||
|
return Instant.MAX;
|
||||||
|
} else if (sf.getForecastBegin().isAfter(start)) {
|
||||||
|
// take latest timestamp from all forecasts
|
||||||
|
start = sf.getForecastBegin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Instant getCommonEndTime(List<SolarForecast> forecastObjects) {
|
||||||
|
if (forecastObjects.isEmpty()) {
|
||||||
|
return Instant.MIN;
|
||||||
|
}
|
||||||
|
Instant end = Instant.MAX;
|
||||||
|
for (Iterator<SolarForecast> iterator = forecastObjects.iterator(); iterator.hasNext();) {
|
||||||
|
SolarForecast sf = iterator.next();
|
||||||
|
// if end is minimum there's no forecast data available - return immediately
|
||||||
|
if (sf.getForecastEnd().equals(Instant.MIN)) {
|
||||||
|
return Instant.MIN;
|
||||||
|
} else if (sf.getForecastEnd().isBefore(end)) {
|
||||||
|
// take earliest timestamp from all forecast
|
||||||
|
end = sf.getForecastEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<addon:addon id="solarforecast" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
|
||||||
|
|
||||||
|
<type>binding</type>
|
||||||
|
<name>SolarForecast Binding</name>
|
||||||
|
<description>Solar Forecast for your location</description>
|
||||||
|
<connection>cloud</connection>
|
||||||
|
</addon:addon>
|
@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<config-description:config-descriptions
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<config-description uri="thing-type:solarforecast:fs-plane">
|
||||||
|
<parameter name="refreshInterval" type="integer" min="1" unit="min" required="true">
|
||||||
|
<label>Forecast Refresh Interval</label>
|
||||||
|
<description>Data refresh rate of forecast data in minutes</description>
|
||||||
|
<default>30</default>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="declination" type="integer" min="0" max="90" required="true">
|
||||||
|
<label>Plane Declination</label>
|
||||||
|
<description>0 for horizontal till 90 for vertical declination</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="azimuth" type="integer" min="-180" max="180" required="true">
|
||||||
|
<label>Plane Azimuth</label>
|
||||||
|
<description>-180 = north, -90 = east, 0 = south, 90 = west, 180 = north</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="kwp" type="decimal" step="0.001" required="true">
|
||||||
|
<label>Installed Kilowatt Peak</label>
|
||||||
|
<description>Installed module power of this plane</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="dampAM" type="decimal" step="0.01" min="0" max="1">
|
||||||
|
<label>Morning Damping Factor</label>
|
||||||
|
<description>Damping factor of morning hours</description>
|
||||||
|
<default>0.25</default>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="dampPM" type="decimal" step="0.01" min="0" max="1">
|
||||||
|
<label>Evening Damping Factor</label>
|
||||||
|
<description>Damping factor of evening hours</description>
|
||||||
|
<default>0.25</default>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="horizon" type="text">
|
||||||
|
<label>Horizon</label>
|
||||||
|
<description>Horizon definition as comma-separated integer values</description>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
</config-description>
|
||||||
|
</config-description:config-descriptions>
|
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<config-description:config-descriptions
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<config-description uri="thing-type:solarforecast:fs-site">
|
||||||
|
<parameter name="location" type="text">
|
||||||
|
<context>location</context>
|
||||||
|
<label>PV Location</label>
|
||||||
|
<description>Location of photovoltaic system. Location from openHAB settings is used in case of empty value.</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="apiKey" type="text">
|
||||||
|
<label>API Key</label>
|
||||||
|
<description>If you have a paid subscription plan</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="inverterKwp" type="decimal" step="0.1">
|
||||||
|
<label>Inverter Kilowatt Peak</label>
|
||||||
|
<description>Inverter maximum kilowatt peak capability</description>
|
||||||
|
</parameter>
|
||||||
|
</config-description>
|
||||||
|
</config-description:config-descriptions>
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<config-description:config-descriptions
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<config-description uri="thing-type:solarforecast:sc-plane">
|
||||||
|
<parameter name="resourceId" type="text" required="true">
|
||||||
|
<label>Rooftop Resource Id</label>
|
||||||
|
<description>Resource Id of Solcast rooftop site</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="refreshInterval" type="integer" min="1" unit="min" required="true">
|
||||||
|
<label>Forecast Refresh Interval</label>
|
||||||
|
<description>Data refresh rate of forecast data in minutes</description>
|
||||||
|
<default>120</default>
|
||||||
|
</parameter>
|
||||||
|
</config-description>
|
||||||
|
</config-description:config-descriptions>
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<config-description:config-descriptions
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<config-description uri="thing-type:solarforecast:sc-site">
|
||||||
|
<parameter name="apiKey" type="text" required="true">
|
||||||
|
<label>API Key</label>
|
||||||
|
<description>API key from your subscription</description>
|
||||||
|
</parameter>
|
||||||
|
<parameter name="timeZone" type="text" required="false">
|
||||||
|
<label>Time Zone</label>
|
||||||
|
<description>Time zone of forecast location</description>
|
||||||
|
<advanced>true</advanced>
|
||||||
|
</parameter>
|
||||||
|
</config-description>
|
||||||
|
</config-description:config-descriptions>
|
@ -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
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
<channel-group-type id="average-values">
|
||||||
|
<label>Average Forecast Values</label>
|
||||||
|
<description>Forecast values showing average case data</description>
|
||||||
|
<channels>
|
||||||
|
<channel id="power-estimate" typeId="power-estimate"/>
|
||||||
|
<channel id="energy-estimate" typeId="energy-estimate"/>
|
||||||
|
<channel id="power-actual" typeId="power-actual"/>
|
||||||
|
<channel id="energy-actual" typeId="energy-actual"/>
|
||||||
|
<channel id="energy-remain" typeId="energy-remain"/>
|
||||||
|
<channel id="energy-today" typeId="energy-today"/>
|
||||||
|
</channels>
|
||||||
|
</channel-group-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<channel-type id="power-actual">
|
||||||
|
<item-type>Number:Power</item-type>
|
||||||
|
<label>Actual Power</label>
|
||||||
|
<description>Power prediction for this moment</description>
|
||||||
|
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||||
|
</channel-type>
|
||||||
|
<channel-type id="power-estimate">
|
||||||
|
<item-type>Number:Power</item-type>
|
||||||
|
<label>Power Forecast</label>
|
||||||
|
<description>Power forecast for next hours/days</description>
|
||||||
|
<state pattern="%.0f %unit%" readOnly="true"/>
|
||||||
|
</channel-type>
|
||||||
|
<channel-type id="energy-actual">
|
||||||
|
<item-type>Number:Energy</item-type>
|
||||||
|
<label>Actual Energy Forecast</label>
|
||||||
|
<description>Today's forecast till now</description>
|
||||||
|
<state pattern="%.3f %unit%" readOnly="true"/>
|
||||||
|
</channel-type>
|
||||||
|
<channel-type id="energy-remain">
|
||||||
|
<item-type>Number:Energy</item-type>
|
||||||
|
<label>Remaining Energy Forecast</label>
|
||||||
|
<description>Today's remaining forecast till sunset</description>
|
||||||
|
<state pattern="%.3f %unit%" readOnly="true"/>
|
||||||
|
</channel-type>
|
||||||
|
<channel-type id="energy-today">
|
||||||
|
<item-type>Number:Energy</item-type>
|
||||||
|
<label>Todays Energy Forecast</label>
|
||||||
|
<description>Today's total energy forecast</description>
|
||||||
|
<state pattern="%.3f %unit%" readOnly="true"/>
|
||||||
|
</channel-type>
|
||||||
|
<channel-type id="energy-estimate">
|
||||||
|
<item-type>Number:Energy</item-type>
|
||||||
|
<label>Energy Forecast</label>
|
||||||
|
<description>Energy forecast for next hours/days</description>
|
||||||
|
<state pattern="%.3f %unit%" readOnly="true"/>
|
||||||
|
</channel-type>
|
||||||
|
<channel-type id="json" advanced="true">
|
||||||
|
<item-type>String</item-type>
|
||||||
|
<label>Raw JSON Response</label>
|
||||||
|
<description>Plain JSON response without conversions</description>
|
||||||
|
</channel-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<thing-type id="fs-plane">
|
||||||
|
<supported-bridge-type-refs>
|
||||||
|
<bridge-type-ref id="fs-site"/>
|
||||||
|
</supported-bridge-type-refs>
|
||||||
|
|
||||||
|
<label>ForecastSolar PV Plane</label>
|
||||||
|
<description>One PV Plane of Multi Plane Bridge</description>
|
||||||
|
|
||||||
|
<channels>
|
||||||
|
<channel id="power-estimate" typeId="power-estimate"/>
|
||||||
|
<channel id="energy-estimate" typeId="energy-estimate"/>
|
||||||
|
<channel id="power-actual" typeId="power-actual"/>
|
||||||
|
<channel id="energy-actual" typeId="energy-actual"/>
|
||||||
|
<channel id="energy-remain" typeId="energy-remain"/>
|
||||||
|
<channel id="energy-today" typeId="energy-today"/>
|
||||||
|
<channel id="json" typeId="json"/>
|
||||||
|
</channels>
|
||||||
|
|
||||||
|
<config-description-ref uri="thing-type:solarforecast:fs-plane"/>
|
||||||
|
</thing-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<bridge-type id="fs-site">
|
||||||
|
<label>ForecastSolar Site</label>
|
||||||
|
<description>Site location for Forecast Solar</description>
|
||||||
|
|
||||||
|
<channels>
|
||||||
|
<channel id="power-estimate" typeId="power-estimate"/>
|
||||||
|
<channel id="energy-estimate" typeId="energy-estimate"/>
|
||||||
|
<channel id="power-actual" typeId="power-actual"/>
|
||||||
|
<channel id="energy-actual" typeId="energy-actual"/>
|
||||||
|
<channel id="energy-remain" typeId="energy-remain"/>
|
||||||
|
<channel id="energy-today" typeId="energy-today"/>
|
||||||
|
</channels>
|
||||||
|
|
||||||
|
<config-description-ref uri="thing-type:solarforecast:fs-site"/>
|
||||||
|
</bridge-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
<channel-group-type id="optimistic-values">
|
||||||
|
<label>Optimistic Forecast Values</label>
|
||||||
|
<description>Forecast values showing 90th percentile case data</description>
|
||||||
|
<channels>
|
||||||
|
<channel id="power-estimate" typeId="power-estimate"/>
|
||||||
|
<channel id="energy-estimate" typeId="energy-estimate"/>
|
||||||
|
<channel id="power-actual" typeId="power-actual"/>
|
||||||
|
<channel id="energy-actual" typeId="energy-actual"/>
|
||||||
|
<channel id="energy-remain" typeId="energy-remain"/>
|
||||||
|
<channel id="energy-today" typeId="energy-today"/>
|
||||||
|
</channels>
|
||||||
|
</channel-group-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
<channel-group-type id="pessimistic-values">
|
||||||
|
<label>Pessimistic Forecast Values</label>
|
||||||
|
<description>Forecast values showing 10th percentile case data</description>
|
||||||
|
<channels>
|
||||||
|
<channel id="power-estimate" typeId="power-estimate"/>
|
||||||
|
<channel id="energy-estimate" typeId="energy-estimate"/>
|
||||||
|
<channel id="power-actual" typeId="power-actual"/>
|
||||||
|
<channel id="energy-actual" typeId="energy-actual"/>
|
||||||
|
<channel id="energy-remain" typeId="energy-remain"/>
|
||||||
|
<channel id="energy-today" typeId="energy-today"/>
|
||||||
|
</channels>
|
||||||
|
</channel-group-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
<channel-group-type id="raw-values">
|
||||||
|
<label>Raw Forecast Values</label>
|
||||||
|
<description>Raw response from service provider</description>
|
||||||
|
<channels>
|
||||||
|
<channel id="json" typeId="json"/>
|
||||||
|
</channels>
|
||||||
|
</channel-group-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<thing-type id="sc-plane">
|
||||||
|
<supported-bridge-type-refs>
|
||||||
|
<bridge-type-ref id="sc-site"/>
|
||||||
|
</supported-bridge-type-refs>
|
||||||
|
|
||||||
|
<label>Solcast PV Plane</label>
|
||||||
|
<description>One PV Plane of Multi Plane Bridge</description>
|
||||||
|
|
||||||
|
<channel-groups>
|
||||||
|
<channel-group id="average" typeId="average-values"/>
|
||||||
|
<channel-group id="optimistic" typeId="optimistic-values"/>
|
||||||
|
<channel-group id="pessimistic" typeId="pessimistic-values"/>
|
||||||
|
<channel-group id="raw" typeId="raw-values"/>
|
||||||
|
</channel-groups>
|
||||||
|
|
||||||
|
<config-description-ref uri="thing-type:solarforecast:sc-plane"/>
|
||||||
|
</thing-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<thing:thing-descriptions bindingId="solarforecast"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||||
|
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||||
|
|
||||||
|
<bridge-type id="sc-site">
|
||||||
|
<label>Solcast Site</label>
|
||||||
|
<description>Solcast service site definition</description>
|
||||||
|
|
||||||
|
<channel-groups>
|
||||||
|
<channel-group id="average" typeId="average-values"/>
|
||||||
|
<channel-group id="optimistic" typeId="optimistic-values"/>
|
||||||
|
<channel-group id="pessimistic" typeId="pessimistic-values"/>
|
||||||
|
</channel-groups>
|
||||||
|
<config-description-ref uri="thing-type:solarforecast:sc-site"/>
|
||||||
|
</bridge-type>
|
||||||
|
</thing:thing-descriptions>
|
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
|
import org.openhab.core.config.core.ConfigDescription;
|
||||||
|
import org.openhab.core.config.core.Configuration;
|
||||||
|
import org.openhab.core.thing.Bridge;
|
||||||
|
import org.openhab.core.thing.Channel;
|
||||||
|
import org.openhab.core.thing.ChannelGroupUID;
|
||||||
|
import org.openhab.core.thing.ChannelUID;
|
||||||
|
import org.openhab.core.thing.Thing;
|
||||||
|
import org.openhab.core.thing.ThingStatusInfo;
|
||||||
|
import org.openhab.core.thing.ThingTypeUID;
|
||||||
|
import org.openhab.core.thing.ThingUID;
|
||||||
|
import org.openhab.core.thing.binding.ThingHandlerCallback;
|
||||||
|
import org.openhab.core.thing.binding.builder.ChannelBuilder;
|
||||||
|
import org.openhab.core.thing.type.ChannelGroupTypeUID;
|
||||||
|
import org.openhab.core.thing.type.ChannelTypeUID;
|
||||||
|
import org.openhab.core.types.Command;
|
||||||
|
import org.openhab.core.types.State;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
import org.openhab.core.types.TimeSeries.Policy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link CallbackMock} is a helper for unit tests to receive callbacks
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
public class CallbackMock implements ThingHandlerCallback {
|
||||||
|
|
||||||
|
Map<String, TimeSeries> seriesMap = new HashMap<String, TimeSeries>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stateUpdated(ChannelUID channelUID, State state) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postCommand(ChannelUID channelUID, Command command) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendTimeSeries(ChannelUID channelUID, TimeSeries timeSeries) {
|
||||||
|
seriesMap.put(channelUID.getAsString(), timeSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSeries getTimeSeries(String cuid) {
|
||||||
|
TimeSeries ts = seriesMap.get(cuid);
|
||||||
|
if (ts == null) {
|
||||||
|
ts = new TimeSeries(Policy.REPLACE);
|
||||||
|
}
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void statusUpdated(Thing thing, ThingStatusInfo thingStatus) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void thingUpdated(Thing thing) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validateConfigurationParameters(Thing thing, Map<String, Object> configurationParameters) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void validateConfigurationParameters(Channel channel, Map<String, Object> configurationParameters) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable ConfigDescription getConfigDescription(ChannelTypeUID channelTypeUID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable ConfigDescription getConfigDescription(ThingTypeUID thingTypeUID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configurationUpdated(Thing thing) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void migrateThingType(Thing thing, ThingTypeUID thingTypeUID, Configuration configuration) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void channelTriggered(Thing thing, ChannelUID channelUID, String event) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChannelBuilder createChannelBuilder(ChannelUID channelUID, ChannelTypeUID channelTypeUID) {
|
||||||
|
return ChannelBuilder.create(channelUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChannelBuilder editChannel(Thing thing, ChannelUID channelUID) {
|
||||||
|
return ChannelBuilder.create(channelUID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ChannelBuilder> createChannelBuilders(ChannelGroupUID channelGroupUID,
|
||||||
|
ChannelGroupTypeUID channelGroupTypeUID) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isChannelLinked(ChannelUID channelUID) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable Bridge getBridge(ThingUID bridgeUID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
import javax.measure.quantity.Power;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecastActions;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.ForecastSolarObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarBridgeHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.forecastsolar.handler.ForecastSolarPlaneMock;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.utils.Utils;
|
||||||
|
import org.openhab.core.library.types.PointType;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.library.unit.Units;
|
||||||
|
import org.openhab.core.thing.internal.BridgeImpl;
|
||||||
|
import org.openhab.core.types.State;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link ForecastSolarTest} tests responses from forecast solar object
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
class ForecastSolarTest {
|
||||||
|
private static final double TOLERANCE = 0.001;
|
||||||
|
public static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
|
||||||
|
public static final QuantityType<Power> POWER_UNDEF = Utils.getPowerState(-1);
|
||||||
|
public static final QuantityType<Energy> ENERGY_UNDEF = Utils.getEnergyState(-1);
|
||||||
|
|
||||||
|
public static final String TOO_EARLY_INDICATOR = "too early";
|
||||||
|
public static final String TOO_LATE_INDICATOR = "too late";
|
||||||
|
public static final String INVALID_RANGE_INDICATOR = "invalid time range";
|
||||||
|
public static final String NO_GORECAST_INDICATOR = "No forecast data";
|
||||||
|
public static final String DAY_MISSING_INDICATOR = "not available in forecast";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testForecastObject() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 17, 00).atZone(TEST_ZONE);
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
|
||||||
|
queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
|
||||||
|
// "2022-07-17 21:32:00": 63583,
|
||||||
|
assertEquals(63.583, fo.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
|
||||||
|
// "2022-07-17 17:00:00": 52896,
|
||||||
|
assertEquals(52.896, fo.getActualEnergyValue(queryDateTime), TOLERANCE, "Current Production");
|
||||||
|
// 63583 - 52896 = 10687
|
||||||
|
assertEquals(10.687, fo.getRemainingProduction(queryDateTime), TOLERANCE, "Current Production");
|
||||||
|
// sum cross check
|
||||||
|
assertEquals(fo.getDayTotal(queryDateTime.toLocalDate()),
|
||||||
|
fo.getActualEnergyValue(queryDateTime) + fo.getRemainingProduction(queryDateTime), TOLERANCE,
|
||||||
|
"actual + remain = total");
|
||||||
|
|
||||||
|
queryDateTime = LocalDateTime.of(2022, 7, 18, 19, 00).atZone(TEST_ZONE);
|
||||||
|
// "2022-07-18 19:00:00": 63067,
|
||||||
|
assertEquals(63.067, fo.getActualEnergyValue(queryDateTime), TOLERANCE, "Actual production");
|
||||||
|
// "2022-07-18 21:31:00": 65554
|
||||||
|
assertEquals(65.554, fo.getDayTotal(queryDateTime.toLocalDate()), TOLERANCE, "Total production");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testActualPower() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 10, 00).atZone(TEST_ZONE);
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
|
||||||
|
queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
|
||||||
|
// "2022-07-17 10:00:00": 4874,
|
||||||
|
assertEquals(4.874, fo.getActualPowerValue(queryDateTime), TOLERANCE, "Actual estimation");
|
||||||
|
|
||||||
|
queryDateTime = LocalDateTime.of(2022, 7, 18, 14, 00).atZone(TEST_ZONE);
|
||||||
|
// "2022-07-18 14:00:00": 7054,
|
||||||
|
assertEquals(7.054, fo.getActualPowerValue(queryDateTime), TOLERANCE, "Actual estimation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testInterpolation() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 0).atZone(TEST_ZONE);
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
|
||||||
|
queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
|
||||||
|
|
||||||
|
// test steady value increase
|
||||||
|
double previousValue = 0;
|
||||||
|
for (int i = 0; i < 60; i++) {
|
||||||
|
queryDateTime = queryDateTime.plusMinutes(1);
|
||||||
|
assertTrue(previousValue < fo.getActualEnergyValue(queryDateTime));
|
||||||
|
previousValue = fo.getActualEnergyValue(queryDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryDateTime = LocalDateTime.of(2022, 7, 18, 6, 23).atZone(TEST_ZONE);
|
||||||
|
// "2022-07-18 06:00:00": 132,
|
||||||
|
// "2022-07-18 07:00:00": 1188,
|
||||||
|
// 1188 - 132 = 1056 | 1056 * 23 / 60 = 404 | 404 + 131 = 535
|
||||||
|
assertEquals(0.535, fo.getActualEnergyValue(queryDateTime), 0.002, "Actual estimation");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testForecastSum() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content,
|
||||||
|
queryDateTime.toInstant().plus(1, ChronoUnit.DAYS));
|
||||||
|
QuantityType<Energy> actual = QuantityType.valueOf(0, Units.KILOWATT_HOUR);
|
||||||
|
QuantityType<Energy> st = Utils.getEnergyState(fo.getActualEnergyValue(queryDateTime));
|
||||||
|
assertTrue(st instanceof QuantityType);
|
||||||
|
actual = actual.add(st);
|
||||||
|
assertEquals(49.431, actual.floatValue(), TOLERANCE, "Current Production");
|
||||||
|
actual = actual.add(st);
|
||||||
|
assertEquals(98.862, actual.floatValue(), TOLERANCE, "Doubled Current Production");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCornerCases() {
|
||||||
|
// invalid object
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test");
|
||||||
|
ZonedDateTime query = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
|
||||||
|
try {
|
||||||
|
double d = fo.getActualEnergyValue(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(INVALID_RANGE_INDICATOR),
|
||||||
|
"Expected: " + INVALID_RANGE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getRemainingProduction(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(NO_GORECAST_INDICATOR),
|
||||||
|
"Expected: " + NO_GORECAST_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getDayTotal(query.toLocalDate());
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(NO_GORECAST_INDICATOR),
|
||||||
|
"Expected: " + NO_GORECAST_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getDayTotal(query.plusDays(1).toLocalDate());
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(NO_GORECAST_INDICATOR),
|
||||||
|
"Expected: " + NO_GORECAST_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid object - query date one day too early
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
query = LocalDateTime.of(2022, 7, 16, 23, 59).atZone(TEST_ZONE);
|
||||||
|
fo = new ForecastSolarObject("fs-test", content, query.toInstant());
|
||||||
|
try {
|
||||||
|
double d = fo.getActualEnergyValue(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_EARLY_INDICATOR),
|
||||||
|
"Expected: " + TOO_EARLY_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getRemainingProduction(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(DAY_MISSING_INDICATOR),
|
||||||
|
"Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getActualPowerValue(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_EARLY_INDICATOR),
|
||||||
|
"Expected: " + TOO_EARLY_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getDayTotal(query.toLocalDate());
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(DAY_MISSING_INDICATOR),
|
||||||
|
"Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// one minute later we reach a valid date
|
||||||
|
query = query.plusMinutes(1);
|
||||||
|
assertEquals(63.583, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(0.0, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(63.583, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
|
||||||
|
assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
|
||||||
|
|
||||||
|
// valid object - query date one day too late
|
||||||
|
query = LocalDateTime.of(2022, 7, 19, 0, 0).atZone(TEST_ZONE);
|
||||||
|
try {
|
||||||
|
double d = fo.getActualEnergyValue(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getRemainingProduction(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(DAY_MISSING_INDICATOR),
|
||||||
|
"Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getActualPowerValue(query);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = fo.getDayTotal(query.toLocalDate());
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(DAY_MISSING_INDICATOR),
|
||||||
|
"Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// one minute earlier we reach a valid date
|
||||||
|
query = query.minusMinutes(1);
|
||||||
|
assertEquals(65.554, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(65.554, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(0.0, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
|
||||||
|
assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
|
||||||
|
|
||||||
|
// test times between 2 dates
|
||||||
|
query = LocalDateTime.of(2022, 7, 17, 23, 59).atZone(TEST_ZONE);
|
||||||
|
assertEquals(63.583, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(63.583, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(0.0, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
|
||||||
|
assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
|
||||||
|
query = query.plusMinutes(1);
|
||||||
|
assertEquals(65.554, fo.getDayTotal(query.toLocalDate()), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(0.0, fo.getActualEnergyValue(query), TOLERANCE, "Actual out of scope");
|
||||||
|
assertEquals(65.554, fo.getRemainingProduction(query), TOLERANCE, "Remain out of scope");
|
||||||
|
assertEquals(0.0, fo.getActualPowerValue(query), TOLERANCE, "Remain out of scope");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testExceptions() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content, queryDateTime.toInstant());
|
||||||
|
assertEquals("2022-07-17T05:31:00",
|
||||||
|
fo.getForecastBegin().atZone(TEST_ZONE).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||||
|
"Forecast begin");
|
||||||
|
assertEquals("2022-07-18T21:31:00",
|
||||||
|
fo.getForecastEnd().atZone(TEST_ZONE).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), "Forecast end");
|
||||||
|
assertEquals(QuantityType.valueOf(63.583, Units.KILOWATT_HOUR).toString(),
|
||||||
|
fo.getDay(queryDateTime.toLocalDate()).toFullString(), "Actual out of scope");
|
||||||
|
|
||||||
|
queryDateTime = LocalDateTime.of(2022, 7, 10, 0, 0).atZone(TEST_ZONE);
|
||||||
|
// "watt_hours_day": {
|
||||||
|
// "2022-07-17": 63583,
|
||||||
|
// "2022-07-18": 65554
|
||||||
|
// }
|
||||||
|
try {
|
||||||
|
fo.getEnergy(queryDateTime.toInstant(), queryDateTime.plusDays(2).toInstant());
|
||||||
|
fail("Too early exception missing");
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains("not available"), "not available expected: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fo.getDay(queryDateTime.toLocalDate(), "optimistic");
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("ForecastSolar doesn't accept arguments", e.getMessage(), "optimistic");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fo.getDay(queryDateTime.toLocalDate(), "pessimistic");
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("ForecastSolar doesn't accept arguments", e.getMessage(), "pessimistic");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fo.getDay(queryDateTime.toLocalDate(), "total", "rubbish");
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("ForecastSolar doesn't accept arguments", e.getMessage(), "rubbish");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTimeSeries() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ZonedDateTime queryDateTime = LocalDateTime.of(2022, 7, 17, 16, 23).atZone(TEST_ZONE);
|
||||||
|
ForecastSolarObject fo = new ForecastSolarObject("fs-test", content, queryDateTime.toInstant());
|
||||||
|
|
||||||
|
TimeSeries powerSeries = fo.getPowerTimeSeries(QueryMode.Average);
|
||||||
|
assertEquals(36, powerSeries.size()); // 18 values each day for 2 days
|
||||||
|
powerSeries.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries energySeries = fo.getEnergyTimeSeries(QueryMode.Average);
|
||||||
|
assertEquals(36, energySeries.size());
|
||||||
|
energySeries.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPowerTimeSeries() {
|
||||||
|
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||||
|
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||||
|
Optional.of(PointType.valueOf("1,2")));
|
||||||
|
CallbackMock cm = new CallbackMock();
|
||||||
|
fsbh.setCallback(cm);
|
||||||
|
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
|
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
|
||||||
|
fsbh.addPlane(fsph1);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
TimeSeries ts1 = cm.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||||
|
|
||||||
|
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1);
|
||||||
|
fsbh.addPlane(fsph2);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
TimeSeries ts2 = cm.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||||
|
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||||
|
Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
|
||||||
|
while (iter1.hasNext()) {
|
||||||
|
TimeSeries.Entry e1 = iter1.next();
|
||||||
|
TimeSeries.Entry e2 = iter2.next();
|
||||||
|
assertEquals("kW", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals("kW", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() / 2,
|
||||||
|
0.1, "Power Value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCommonForecastStartEnd() {
|
||||||
|
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||||
|
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||||
|
Optional.of(PointType.valueOf("1,2")));
|
||||||
|
CallbackMock cmSite = new CallbackMock();
|
||||||
|
fsbh.setCallback(cmSite);
|
||||||
|
String contentOne = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ForecastSolarObject fso1One = new ForecastSolarObject("fs-test", contentOne,
|
||||||
|
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
|
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1One);
|
||||||
|
fsbh.addPlane(fsph1);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
|
||||||
|
String contentTwo = FileReader.readFileInString("src/test/resources/forecastsolar/resultNextDay.json");
|
||||||
|
ForecastSolarObject fso1Two = new ForecastSolarObject("fs-plane", contentTwo,
|
||||||
|
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
|
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1Two);
|
||||||
|
CallbackMock cmPlane = new CallbackMock();
|
||||||
|
fsph2.setCallback(cmPlane);
|
||||||
|
((ForecastSolarPlaneMock) fsph2).updateForecast(fso1Two);
|
||||||
|
fsbh.addPlane(fsph2);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
|
||||||
|
TimeSeries tsPlaneOne = cmPlane.getTimeSeries("test::plane:power-estimate");
|
||||||
|
TimeSeries tsSite = cmSite.getTimeSeries("solarforecast:fs-site:bridge:power-estimate");
|
||||||
|
Iterator<TimeSeries.Entry> planeIter = tsPlaneOne.getStates().iterator();
|
||||||
|
Iterator<TimeSeries.Entry> siteIter = tsSite.getStates().iterator();
|
||||||
|
while (siteIter.hasNext()) {
|
||||||
|
TimeSeries.Entry planeEntry = planeIter.next();
|
||||||
|
TimeSeries.Entry siteEntry = siteIter.next();
|
||||||
|
assertEquals("kW", ((QuantityType<?>) planeEntry.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals("kW", ((QuantityType<?>) siteEntry.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals(((QuantityType<?>) planeEntry.state()).doubleValue(),
|
||||||
|
((QuantityType<?>) siteEntry.state()).doubleValue() / 2, 0.1, "Power Value");
|
||||||
|
}
|
||||||
|
// only one day shall be reported which is available in both planes
|
||||||
|
LocalDate ld = LocalDate.of(2022, 7, 18);
|
||||||
|
assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), tsSite.getBegin().truncatedTo(ChronoUnit.DAYS),
|
||||||
|
"TimeSeries start");
|
||||||
|
assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), tsSite.getEnd().truncatedTo(ChronoUnit.DAYS),
|
||||||
|
"TimeSeries end");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testActions() {
|
||||||
|
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||||
|
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||||
|
Optional.of(PointType.valueOf("1,2")));
|
||||||
|
CallbackMock cmSite = new CallbackMock();
|
||||||
|
fsbh.setCallback(cmSite);
|
||||||
|
String contentOne = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ForecastSolarObject fso1One = new ForecastSolarObject("fs-test", contentOne,
|
||||||
|
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
|
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1One);
|
||||||
|
fsbh.addPlane(fsph1);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
|
||||||
|
String contentTwo = FileReader.readFileInString("src/test/resources/forecastsolar/resultNextDay.json");
|
||||||
|
ForecastSolarObject fso1Two = new ForecastSolarObject("fs-plane", contentTwo,
|
||||||
|
Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
|
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1Two);
|
||||||
|
CallbackMock cmPlane = new CallbackMock();
|
||||||
|
fsph2.setCallback(cmPlane);
|
||||||
|
((ForecastSolarPlaneMock) fsph2).updateForecast(fso1Two);
|
||||||
|
fsbh.addPlane(fsph2);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
|
||||||
|
SolarForecastActions sfa = new SolarForecastActions();
|
||||||
|
sfa.setThingHandler(fsbh);
|
||||||
|
// only one day shall be reported which is available in both planes
|
||||||
|
LocalDate ld = LocalDate.of(2022, 7, 18);
|
||||||
|
assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), sfa.getForecastBegin().truncatedTo(ChronoUnit.DAYS),
|
||||||
|
"TimeSeries start");
|
||||||
|
assertEquals(ld.atStartOfDay(ZoneId.of("UTC")).toInstant(), sfa.getForecastEnd().truncatedTo(ChronoUnit.DAYS),
|
||||||
|
"TimeSeries end");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testEnergyTimeSeries() {
|
||||||
|
ForecastSolarBridgeHandler fsbh = new ForecastSolarBridgeHandler(
|
||||||
|
new BridgeImpl(SolarForecastBindingConstants.FORECAST_SOLAR_SITE, "bridge"),
|
||||||
|
Optional.of(PointType.valueOf("1,2")));
|
||||||
|
CallbackMock cm = new CallbackMock();
|
||||||
|
fsbh.setCallback(cm);
|
||||||
|
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/forecastsolar/result.json");
|
||||||
|
ForecastSolarObject fso1 = new ForecastSolarObject("fs-test", content, Instant.now().plus(1, ChronoUnit.DAYS));
|
||||||
|
ForecastSolarPlaneHandler fsph1 = new ForecastSolarPlaneMock(fso1);
|
||||||
|
fsbh.addPlane(fsph1);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
TimeSeries ts1 = cm.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
|
||||||
|
|
||||||
|
ForecastSolarPlaneHandler fsph2 = new ForecastSolarPlaneMock(fso1);
|
||||||
|
fsbh.addPlane(fsph2);
|
||||||
|
fsbh.forecastUpdate();
|
||||||
|
TimeSeries ts2 = cm.getTimeSeries("solarforecast:fs-site:bridge:energy-estimate");
|
||||||
|
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||||
|
Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
|
||||||
|
while (iter1.hasNext()) {
|
||||||
|
TimeSeries.Entry e1 = iter1.next();
|
||||||
|
TimeSeries.Entry e2 = iter2.next();
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() / 2,
|
||||||
|
0.1, "Power Value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,717 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2010-2024 Contributors to the openHAB project
|
||||||
|
*
|
||||||
|
* See the NOTICE file(s) distributed with this work for additional
|
||||||
|
* information.
|
||||||
|
*
|
||||||
|
* This program and the accompanying materials are made available under the
|
||||||
|
* terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
* http://www.eclipse.org/legal/epl-2.0
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: EPL-2.0
|
||||||
|
*/
|
||||||
|
package org.openhab.binding.solarforecast;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.measure.quantity.Energy;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastBindingConstants;
|
||||||
|
import org.openhab.binding.solarforecast.internal.SolarForecastException;
|
||||||
|
import org.openhab.binding.solarforecast.internal.actions.SolarForecast;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastConstants;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.SolcastObject.QueryMode;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastBridgeHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneHandler;
|
||||||
|
import org.openhab.binding.solarforecast.internal.solcast.handler.SolcastPlaneMock;
|
||||||
|
import org.openhab.core.library.types.QuantityType;
|
||||||
|
import org.openhab.core.library.unit.Units;
|
||||||
|
import org.openhab.core.thing.internal.BridgeImpl;
|
||||||
|
import org.openhab.core.types.State;
|
||||||
|
import org.openhab.core.types.TimeSeries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@link SolcastTest} tests responses from forecast solar website
|
||||||
|
*
|
||||||
|
* @author Bernd Weymann - Initial contribution
|
||||||
|
*/
|
||||||
|
@NonNullByDefault
|
||||||
|
class SolcastTest {
|
||||||
|
public static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
|
||||||
|
private static final TimeZP TIMEZONEPROVIDER = new TimeZP();
|
||||||
|
// double comparison tolerance = 1 Watt
|
||||||
|
private static final double TOLERANCE = 0.001;
|
||||||
|
|
||||||
|
public static final String TOO_LATE_INDICATOR = "too late";
|
||||||
|
public static final String DAY_MISSING_INDICATOR = "not available in forecast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "2022-07-18T00:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T00:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T01:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T01:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T02:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T02:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T03:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T03:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T04:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T04:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T05:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T05:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T06:00+02:00[Europe/Berlin]": 0.0205,
|
||||||
|
* "2022-07-18T06:30+02:00[Europe/Berlin]": 0.1416,
|
||||||
|
* "2022-07-18T07:00+02:00[Europe/Berlin]": 0.4478,
|
||||||
|
* "2022-07-18T07:30+02:00[Europe/Berlin]": 0.763,
|
||||||
|
* "2022-07-18T08:00+02:00[Europe/Berlin]": 1.1367,
|
||||||
|
* "2022-07-18T08:30+02:00[Europe/Berlin]": 1.4044,
|
||||||
|
* "2022-07-18T09:00+02:00[Europe/Berlin]": 1.6632,
|
||||||
|
* "2022-07-18T09:30+02:00[Europe/Berlin]": 1.8667,
|
||||||
|
* "2022-07-18T10:00+02:00[Europe/Berlin]": 2.0729,
|
||||||
|
* "2022-07-18T10:30+02:00[Europe/Berlin]": 2.2377,
|
||||||
|
* "2022-07-18T11:00+02:00[Europe/Berlin]": 2.3516,
|
||||||
|
* "2022-07-18T11:30+02:00[Europe/Berlin]": 2.4295,
|
||||||
|
* "2022-07-18T12:00+02:00[Europe/Berlin]": 2.5136,
|
||||||
|
* "2022-07-18T12:30+02:00[Europe/Berlin]": 2.5295,
|
||||||
|
* "2022-07-18T13:00+02:00[Europe/Berlin]": 2.526,
|
||||||
|
* "2022-07-18T13:30+02:00[Europe/Berlin]": 2.4879,
|
||||||
|
* "2022-07-18T14:00+02:00[Europe/Berlin]": 2.4092,
|
||||||
|
* "2022-07-18T14:30+02:00[Europe/Berlin]": 2.3309,
|
||||||
|
* "2022-07-18T15:00+02:00[Europe/Berlin]": 2.1984,
|
||||||
|
* "2022-07-18T15:30+02:00[Europe/Berlin]": 2.0416,
|
||||||
|
* "2022-07-18T16:00+02:00[Europe/Berlin]": 1.9076,
|
||||||
|
* "2022-07-18T16:30+02:00[Europe/Berlin]": 1.7416,
|
||||||
|
* "2022-07-18T17:00+02:00[Europe/Berlin]": 1.5414,
|
||||||
|
* "2022-07-18T17:30+02:00[Europe/Berlin]": 1.3683,
|
||||||
|
* "2022-07-18T18:00+02:00[Europe/Berlin]": 1.1603,
|
||||||
|
* "2022-07-18T18:30+02:00[Europe/Berlin]": 0.9527,
|
||||||
|
* "2022-07-18T19:00+02:00[Europe/Berlin]": 0.7705,
|
||||||
|
* "2022-07-18T19:30+02:00[Europe/Berlin]": 0.5673,
|
||||||
|
* "2022-07-18T20:00+02:00[Europe/Berlin]": 0.3588,
|
||||||
|
* "2022-07-18T20:30+02:00[Europe/Berlin]": 0.1948,
|
||||||
|
* "2022-07-18T21:00+02:00[Europe/Berlin]": 0.0654,
|
||||||
|
* "2022-07-18T21:30+02:00[Europe/Berlin]": 0.0118,
|
||||||
|
* "2022-07-18T22:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T22:30+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T23:00+02:00[Europe/Berlin]": 0,
|
||||||
|
* "2022-07-18T23:30+02:00[Europe/Berlin]": 0
|
||||||
|
**/
|
||||||
|
@Test
|
||||||
|
void testForecastObject() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
scfo.join(content);
|
||||||
|
// test one day, step ahead in time and cross check channel values
|
||||||
|
double dayTotal = scfo.getDayTotal(now.toLocalDate(), QueryMode.Average);
|
||||||
|
double actual = scfo.getActualEnergyValue(now, QueryMode.Average);
|
||||||
|
double remain = scfo.getRemainingProduction(now, QueryMode.Average);
|
||||||
|
assertEquals(0.0, actual, TOLERANCE, "Begin of day actual");
|
||||||
|
assertEquals(23.107, remain, TOLERANCE, "Begin of day remaining");
|
||||||
|
assertEquals(23.107, dayTotal, TOLERANCE, "Day total");
|
||||||
|
assertEquals(0.0, scfo.getActualPowerValue(now, QueryMode.Average), TOLERANCE, "Begin of day power");
|
||||||
|
double previousPower = 0;
|
||||||
|
for (int i = 0; i < 47; i++) {
|
||||||
|
now = now.plusMinutes(30);
|
||||||
|
double power = scfo.getActualPowerValue(now, QueryMode.Average) / 2.0;
|
||||||
|
double powerAddOn = ((power + previousPower) / 2.0);
|
||||||
|
actual += powerAddOn;
|
||||||
|
assertEquals(actual, scfo.getActualEnergyValue(now, QueryMode.Average), TOLERANCE, "Actual at " + now);
|
||||||
|
remain -= powerAddOn;
|
||||||
|
assertEquals(remain, scfo.getRemainingProduction(now, QueryMode.Average), TOLERANCE, "Remain at " + now);
|
||||||
|
assertEquals(dayTotal, actual + remain, TOLERANCE, "Total sum at " + now);
|
||||||
|
previousPower = power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPower() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 23, 16, 00).atZone(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
scfo.join(content);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {
|
||||||
|
* "pv_estimate": 1.9176,
|
||||||
|
* "pv_estimate10": 0.8644,
|
||||||
|
* "pv_estimate90": 2.0456,
|
||||||
|
* "period_end": "2022-07-23T14:00:00.0000000Z",
|
||||||
|
* "period": "PT30M"
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* "pv_estimate": 1.7544,
|
||||||
|
* "pv_estimate10": 0.7708,
|
||||||
|
* "pv_estimate90": 1.864,
|
||||||
|
* "period_end": "2022-07-23T14:30:00.0000000Z",
|
||||||
|
* "period": "PT30M"
|
||||||
|
*/
|
||||||
|
assertEquals(1.9176, scfo.getActualPowerValue(now, QueryMode.Average), TOLERANCE, "Estimate power " + now);
|
||||||
|
assertEquals(1.9176, scfo.getPower(now.toInstant(), "average").doubleValue(), TOLERANCE,
|
||||||
|
"Estimate power " + now);
|
||||||
|
assertEquals(1.754, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Average), TOLERANCE,
|
||||||
|
"Estimate power " + now.plusMinutes(30));
|
||||||
|
|
||||||
|
assertEquals(2.046, scfo.getActualPowerValue(now, QueryMode.Optimistic), TOLERANCE, "Optimistic power " + now);
|
||||||
|
assertEquals(1.864, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Optimistic), TOLERANCE,
|
||||||
|
"Optimistic power " + now.plusMinutes(30));
|
||||||
|
|
||||||
|
assertEquals(0.864, scfo.getActualPowerValue(now, QueryMode.Pessimistic), TOLERANCE,
|
||||||
|
"Pessimistic power " + now);
|
||||||
|
assertEquals(0.771, scfo.getActualPowerValue(now.plusMinutes(30), QueryMode.Pessimistic), TOLERANCE,
|
||||||
|
"Pessimistic power " + now.plusMinutes(30));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {
|
||||||
|
* "pv_estimate": 1.9318,
|
||||||
|
* "period_end": "2022-07-17T14:30:00.0000000Z",
|
||||||
|
* "period": "PT30M"
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* "pv_estimate": 1.724,
|
||||||
|
* "period_end": "2022-07-17T15:00:00.0000000Z",
|
||||||
|
* "period": "PT30M"
|
||||||
|
* },
|
||||||
|
**/
|
||||||
|
// get same values for optimistic / pessimistic and estimate in the past
|
||||||
|
ZonedDateTime past = LocalDateTime.of(2022, 7, 17, 16, 30).atZone(TEST_ZONE);
|
||||||
|
assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Average), TOLERANCE, "Estimate power " + past);
|
||||||
|
assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Average), TOLERANCE,
|
||||||
|
"Estimate power " + now.plusMinutes(30));
|
||||||
|
|
||||||
|
assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Optimistic), TOLERANCE,
|
||||||
|
"Optimistic power " + past);
|
||||||
|
assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Optimistic), TOLERANCE,
|
||||||
|
"Optimistic power " + past.plusMinutes(30));
|
||||||
|
|
||||||
|
assertEquals(1.932, scfo.getActualPowerValue(past, QueryMode.Pessimistic), TOLERANCE,
|
||||||
|
"Pessimistic power " + past);
|
||||||
|
assertEquals(1.724, scfo.getActualPowerValue(past.plusMinutes(30), QueryMode.Pessimistic), TOLERANCE,
|
||||||
|
"Pessimistic power " + past.plusMinutes(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data from TreeMap for manual validation
|
||||||
|
* 2022-07-17T04:30+02:00[Europe/Berlin]=0.0,
|
||||||
|
* 2022-07-17T05:00+02:00[Europe/Berlin]=0.0,
|
||||||
|
* 2022-07-17T05:30+02:00[Europe/Berlin]=0.0,
|
||||||
|
* 2022-07-17T06:00+02:00[Europe/Berlin]=0.0262,
|
||||||
|
* 2022-07-17T06:30+02:00[Europe/Berlin]=0.4252,
|
||||||
|
* 2022-07-17T07:00+02:00[Europe/Berlin]=0.7772, <<<
|
||||||
|
* 2022-07-17T07:30+02:00[Europe/Berlin]=1.0663,
|
||||||
|
* 2022-07-17T08:00+02:00[Europe/Berlin]=1.3848,
|
||||||
|
* 2022-07-17T08:30+02:00[Europe/Berlin]=1.6401,
|
||||||
|
* 2022-07-17T09:00+02:00[Europe/Berlin]=1.8614,
|
||||||
|
* 2022-07-17T09:30+02:00[Europe/Berlin]=2.0613,
|
||||||
|
* 2022-07-17T10:00+02:00[Europe/Berlin]=2.2365,
|
||||||
|
* 2022-07-17T10:30+02:00[Europe/Berlin]=2.3766,
|
||||||
|
* 2022-07-17T11:00+02:00[Europe/Berlin]=2.4719,
|
||||||
|
* 2022-07-17T11:30+02:00[Europe/Berlin]=2.5438,
|
||||||
|
* 2022-07-17T12:00+02:00[Europe/Berlin]=2.602,
|
||||||
|
* 2022-07-17T12:30+02:00[Europe/Berlin]=2.6213,
|
||||||
|
* 2022-07-17T13:00+02:00[Europe/Berlin]=2.6061,
|
||||||
|
* 2022-07-17T13:30+02:00[Europe/Berlin]=2.6181,
|
||||||
|
* 2022-07-17T14:00+02:00[Europe/Berlin]=2.5378,
|
||||||
|
* 2022-07-17T14:30+02:00[Europe/Berlin]=2.4651,
|
||||||
|
* 2022-07-17T15:00+02:00[Europe/Berlin]=2.3656,
|
||||||
|
* 2022-07-17T15:30+02:00[Europe/Berlin]=2.2374,
|
||||||
|
* 2022-07-17T16:00+02:00[Europe/Berlin]=2.1015,
|
||||||
|
* 2022-07-17T16:30+02:00[Europe/Berlin]=1.9318,
|
||||||
|
* 2022-07-17T17:00+02:00[Europe/Berlin]=1.724,
|
||||||
|
* 2022-07-17T17:30+02:00[Europe/Berlin]=1.5031,
|
||||||
|
* 2022-07-17T18:00+02:00[Europe/Berlin]=1.2834,
|
||||||
|
* 2022-07-17T18:30+02:00[Europe/Berlin]=1.0839,
|
||||||
|
* 2022-07-17T19:00+02:00[Europe/Berlin]=0.8581,
|
||||||
|
* 2022-07-17T19:30+02:00[Europe/Berlin]=0.6164,
|
||||||
|
* 2022-07-17T20:00+02:00[Europe/Berlin]=0.4465,
|
||||||
|
* 2022-07-17T20:30+02:00[Europe/Berlin]=0.2543,
|
||||||
|
* 2022-07-17T21:00+02:00[Europe/Berlin]=0.0848,
|
||||||
|
* 2022-07-17T21:30+02:00[Europe/Berlin]=0.0132,
|
||||||
|
* 2022-07-17T22:00+02:00[Europe/Berlin]=0.0,
|
||||||
|
* 2022-07-17T22:30+02:00[Europe/Berlin]=0.0
|
||||||
|
*
|
||||||
|
* <<< = 0.0262 + 0.4252 + 0.7772 = 1.2286 / 2 = 0.6143
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void testForecastTreeMap() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 17, 7, 0).atZone(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
assertEquals(0.42, scfo.getActualEnergyValue(now, QueryMode.Average), TOLERANCE, "Actual estimation");
|
||||||
|
assertEquals(25.413, scfo.getDayTotal(now.toLocalDate(), QueryMode.Average), TOLERANCE, "Day total");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testJoin() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
try {
|
||||||
|
double d = scfo.getActualEnergyValue(now, QueryMode.Average);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
scfo.join(content);
|
||||||
|
assertEquals(18.946, scfo.getActualEnergyValue(now, QueryMode.Average), 0.01, "Actual data");
|
||||||
|
assertEquals(23.107, scfo.getDayTotal(now.toLocalDate(), QueryMode.Average), 0.01, "Today data");
|
||||||
|
JSONObject rawJson = new JSONObject(scfo.getRaw());
|
||||||
|
assertTrue(rawJson.has("forecasts"));
|
||||||
|
assertTrue(rawJson.has("estimated_actuals"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testActions() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
try {
|
||||||
|
double d = scfo.getActualEnergyValue(now, QueryMode.Average);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
scfo.join(content);
|
||||||
|
|
||||||
|
assertEquals("2022-07-10T23:30+02:00[Europe/Berlin]", scfo.getForecastBegin().atZone(TEST_ZONE).toString(),
|
||||||
|
"Forecast begin");
|
||||||
|
assertEquals("2022-07-24T23:00+02:00[Europe/Berlin]", scfo.getForecastEnd().atZone(TEST_ZONE).toString(),
|
||||||
|
"Forecast end");
|
||||||
|
// test daily forecasts + cumulated getEnergy
|
||||||
|
double totalEnergy = 0;
|
||||||
|
ZonedDateTime start = LocalDateTime.of(2022, 7, 18, 0, 0).atZone(TEST_ZONE);
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
QuantityType<Energy> qt = scfo.getDay(start.toLocalDate().plusDays(i));
|
||||||
|
QuantityType<Energy> eqt = scfo.getEnergy(start.plusDays(i).toInstant(), start.plusDays(i + 1).toInstant());
|
||||||
|
|
||||||
|
// check if energy calculation fits to daily query
|
||||||
|
assertEquals(qt.doubleValue(), eqt.doubleValue(), TOLERANCE, "Total " + i + " days forecast");
|
||||||
|
totalEnergy += qt.doubleValue();
|
||||||
|
|
||||||
|
// check if sum is fitting to total energy query
|
||||||
|
qt = scfo.getEnergy(start.toInstant(), start.plusDays(i + 1).toInstant());
|
||||||
|
assertEquals(totalEnergy, qt.doubleValue(), TOLERANCE * 2, "Total " + i + " days forecast");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testOptimisticPessimistic() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
scfo.join(content);
|
||||||
|
assertEquals(19.389, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Average), TOLERANCE,
|
||||||
|
"Estimation");
|
||||||
|
assertEquals(7.358, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Pessimistic), TOLERANCE,
|
||||||
|
"Estimation");
|
||||||
|
assertEquals(22.283, scfo.getDayTotal(now.toLocalDate().plusDays(2), QueryMode.Optimistic), TOLERANCE,
|
||||||
|
"Estimation");
|
||||||
|
assertEquals(23.316, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Average), TOLERANCE,
|
||||||
|
"Estimation");
|
||||||
|
assertEquals(9.8, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Pessimistic), TOLERANCE,
|
||||||
|
"Estimation");
|
||||||
|
assertEquals(23.949, scfo.getDayTotal(now.toLocalDate().plusDays(6), QueryMode.Optimistic), TOLERANCE,
|
||||||
|
"Estimation");
|
||||||
|
|
||||||
|
// access in past shall be rejected
|
||||||
|
Instant past = Instant.now().minus(5, ChronoUnit.MINUTES);
|
||||||
|
try {
|
||||||
|
scfo.getPower(past, SolarForecast.OPTIMISTIC);
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("Solcast argument optimistic only available for future values", e.getMessage(),
|
||||||
|
"Optimistic Power");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
scfo.getPower(past, SolarForecast.PESSIMISTIC);
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("Solcast argument pessimistic only available for future values", e.getMessage(),
|
||||||
|
"Pessimistic Power");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
scfo.getPower(past, "total", "rubbish");
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("Solcast doesn't support 2 arguments", e.getMessage(), "Too many qrguments");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
scfo.getPower(past.plus(2, ChronoUnit.HOURS), "rubbish");
|
||||||
|
fail();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
assertEquals("Solcast doesn't support argument rubbish", e.getMessage(), "Rubbish argument");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
scfo.getPower(past);
|
||||||
|
fail("Exception expected");
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testInavlid() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(TEST_ZONE);
|
||||||
|
SolcastObject scfo = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
try {
|
||||||
|
double d = scfo.getActualEnergyValue(now, QueryMode.Average);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
scfo.join(content);
|
||||||
|
try {
|
||||||
|
double d = scfo.getActualEnergyValue(now, QueryMode.Average);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(TOO_LATE_INDICATOR),
|
||||||
|
"Expected: " + TOO_LATE_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
double d = scfo.getDayTotal(now.toLocalDate(), QueryMode.Average);
|
||||||
|
fail("Exception expected instead of " + d);
|
||||||
|
} catch (SolarForecastException sfe) {
|
||||||
|
String message = sfe.getMessage();
|
||||||
|
assertNotNull(message);
|
||||||
|
assertTrue(message.contains(DAY_MISSING_INDICATOR),
|
||||||
|
"Expected: " + DAY_MISSING_INDICATOR + " Received: " + sfe.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPowerInterpolation() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 15, 0).atZone(TEST_ZONE);
|
||||||
|
SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
sco.join(content);
|
||||||
|
|
||||||
|
double startValue = sco.getActualPowerValue(now, QueryMode.Average);
|
||||||
|
double endValue = sco.getActualPowerValue(now.plusMinutes(30), QueryMode.Average);
|
||||||
|
for (int i = 0; i < 31; i++) {
|
||||||
|
double interpolation = i / 30.0;
|
||||||
|
double expected = ((1 - interpolation) * startValue) + (interpolation * endValue);
|
||||||
|
assertEquals(expected, sco.getActualPowerValue(now.plusMinutes(i), QueryMode.Average), TOLERANCE,
|
||||||
|
"Step " + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testEnergyInterpolation() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 5, 30).atZone(TEST_ZONE);
|
||||||
|
SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
sco.join(content);
|
||||||
|
|
||||||
|
double maxDiff = 0;
|
||||||
|
double productionExpected = 0;
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
double forecast = sco.getActualEnergyValue(now.plusMinutes(i), QueryMode.Average);
|
||||||
|
double addOnExpected = sco.getActualPowerValue(now.plusMinutes(i), QueryMode.Average) / 60.0;
|
||||||
|
productionExpected += addOnExpected;
|
||||||
|
double diff = forecast - productionExpected;
|
||||||
|
maxDiff = Math.max(diff, maxDiff);
|
||||||
|
assertEquals(productionExpected, sco.getActualEnergyValue(now.plusMinutes(i), QueryMode.Average),
|
||||||
|
100 * TOLERANCE, "Step " + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRawChannel() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
sco.join(content);
|
||||||
|
JSONObject joined = new JSONObject(sco.getRaw());
|
||||||
|
assertTrue(joined.has("forecasts"), "Forecasts available");
|
||||||
|
assertTrue(joined.has("estimated_actuals"), "Actual data available");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdates() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
sco.join(content);
|
||||||
|
JSONObject joined = new JSONObject(sco.getRaw());
|
||||||
|
assertTrue(joined.has("forecasts"), "Forecasts available");
|
||||||
|
assertTrue(joined.has("estimated_actuals"), "Actual data available");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUnitDetection() {
|
||||||
|
assertEquals("kW", SolcastConstants.KILOWATT_UNIT.toString(), "Kilowatt");
|
||||||
|
assertEquals("W", Units.WATT.toString(), "Watt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTimes() {
|
||||||
|
String utcTimeString = "2022-07-17T19:30:00.0000000Z";
|
||||||
|
SolcastObject so = new SolcastObject("sc-test", TIMEZONEPROVIDER);
|
||||||
|
ZonedDateTime zdt = so.getZdtFromUTC(utcTimeString);
|
||||||
|
assertNotNull(zdt);
|
||||||
|
assertEquals("2022-07-17T21:30+02:00[Europe/Berlin]", zdt.toString(), "ZonedDateTime");
|
||||||
|
LocalDateTime ldt = zdt.toLocalDateTime();
|
||||||
|
assertEquals("2022-07-17T21:30", ldt.toString(), "LocalDateTime");
|
||||||
|
LocalTime lt = zdt.toLocalTime();
|
||||||
|
assertEquals("21:30", lt.toString(), "LocalTime");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testPowerTimeSeries() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
sco.join(content);
|
||||||
|
|
||||||
|
TimeSeries powerSeries = sco.getPowerTimeSeries(QueryMode.Average);
|
||||||
|
List<QuantityType<?>> estimateL = new ArrayList<>();
|
||||||
|
assertEquals(672, powerSeries.size());
|
||||||
|
powerSeries.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
if (s instanceof QuantityType<?> qt) {
|
||||||
|
estimateL.add(qt);
|
||||||
|
} else {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries powerSeries10 = sco.getPowerTimeSeries(QueryMode.Pessimistic);
|
||||||
|
List<QuantityType<?>> estimate10 = new ArrayList<>();
|
||||||
|
assertEquals(672, powerSeries10.size());
|
||||||
|
powerSeries10.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
if (s instanceof QuantityType<?> qt) {
|
||||||
|
estimate10.add(qt);
|
||||||
|
} else {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries powerSeries90 = sco.getPowerTimeSeries(QueryMode.Optimistic);
|
||||||
|
List<QuantityType<?>> estimate90 = new ArrayList<>();
|
||||||
|
assertEquals(672, powerSeries90.size());
|
||||||
|
powerSeries90.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kW", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
if (s instanceof QuantityType<?> qt) {
|
||||||
|
estimate90.add(qt);
|
||||||
|
} else {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int i = 0; i < estimateL.size(); i++) {
|
||||||
|
double lowValue = estimate10.get(i).doubleValue();
|
||||||
|
double estValue = estimateL.get(i).doubleValue();
|
||||||
|
double highValue = estimate90.get(i).doubleValue();
|
||||||
|
assertTrue(lowValue <= estValue && estValue <= highValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testEnergyTimeSeries() {
|
||||||
|
String content = FileReader.readFileInString("src/test/resources/solcast/estimated-actuals.json");
|
||||||
|
ZonedDateTime now = LocalDateTime.of(2022, 7, 18, 16, 23).atZone(TEST_ZONE);
|
||||||
|
SolcastObject sco = new SolcastObject("sc-test", content, now.toInstant(), TIMEZONEPROVIDER);
|
||||||
|
content = FileReader.readFileInString("src/test/resources/solcast/forecasts.json");
|
||||||
|
sco.join(content);
|
||||||
|
|
||||||
|
TimeSeries energySeries = sco.getEnergyTimeSeries(QueryMode.Average);
|
||||||
|
List<QuantityType<?>> estimateL = new ArrayList<>();
|
||||||
|
assertEquals(672, energySeries.size()); // 18 values each day for 2 days
|
||||||
|
energySeries.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
if (s instanceof QuantityType<?> qt) {
|
||||||
|
estimateL.add(qt);
|
||||||
|
} else {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries energySeries10 = sco.getEnergyTimeSeries(QueryMode.Pessimistic);
|
||||||
|
List<QuantityType<?>> estimate10 = new ArrayList<>();
|
||||||
|
assertEquals(672, energySeries10.size()); // 18 values each day for 2 days
|
||||||
|
energySeries10.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
if (s instanceof QuantityType<?> qt) {
|
||||||
|
estimate10.add(qt);
|
||||||
|
} else {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
TimeSeries energySeries90 = sco.getEnergyTimeSeries(QueryMode.Optimistic);
|
||||||
|
List<QuantityType<?>> estimate90 = new ArrayList<>();
|
||||||
|
assertEquals(672, energySeries90.size()); // 18 values each day for 2 days
|
||||||
|
energySeries90.getStates().forEachOrdered(entry -> {
|
||||||
|
State s = entry.state();
|
||||||
|
assertTrue(s instanceof QuantityType<?>);
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) s).getUnit().toString());
|
||||||
|
if (s instanceof QuantityType<?> qt) {
|
||||||
|
estimate90.add(qt);
|
||||||
|
} else {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int i = 0; i < estimateL.size(); i++) {
|
||||||
|
double lowValue = estimate10.get(i).doubleValue();
|
||||||
|
double estValue = estimateL.get(i).doubleValue();
|
||||||
|
double highValue = estimate90.get(i).doubleValue();
|
||||||
|
assertTrue(lowValue <= estValue && estValue <= highValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCombinedPowerTimeSeries() {
|
||||||
|
BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
|
||||||
|
SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
|
||||||
|
bi.setHandler(scbh);
|
||||||
|
CallbackMock cm = new CallbackMock();
|
||||||
|
scbh.setCallback(cm);
|
||||||
|
SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
|
||||||
|
CallbackMock cm1 = new CallbackMock();
|
||||||
|
scph1.initialize();
|
||||||
|
scph1.setCallback(cm1);
|
||||||
|
scbh.getData();
|
||||||
|
|
||||||
|
SolcastPlaneHandler scph2 = new SolcastPlaneMock(bi);
|
||||||
|
CallbackMock cm2 = new CallbackMock();
|
||||||
|
scph2.initialize();
|
||||||
|
scph2.setCallback(cm2);
|
||||||
|
scbh.getData();
|
||||||
|
|
||||||
|
TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#power-estimate");
|
||||||
|
TimeSeries ts2 = cm2.getTimeSeries("solarforecast:sc-plane:thing:average#power-estimate");
|
||||||
|
assertEquals(336, ts1.size(), "TimeSeries size");
|
||||||
|
assertEquals(336, ts2.size(), "TimeSeries size");
|
||||||
|
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||||
|
Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
|
||||||
|
while (iter1.hasNext()) {
|
||||||
|
TimeSeries.Entry e1 = iter1.next();
|
||||||
|
TimeSeries.Entry e2 = iter2.next();
|
||||||
|
assertEquals("kW", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals("kW", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() * 2,
|
||||||
|
0.01, "Power Value");
|
||||||
|
}
|
||||||
|
scbh.dispose();
|
||||||
|
scph1.dispose();
|
||||||
|
scph2.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCombinedEnergyTimeSeries() {
|
||||||
|
BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
|
||||||
|
SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
|
||||||
|
bi.setHandler(scbh);
|
||||||
|
CallbackMock cm = new CallbackMock();
|
||||||
|
scbh.setCallback(cm);
|
||||||
|
|
||||||
|
SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
|
||||||
|
CallbackMock cm1 = new CallbackMock();
|
||||||
|
scph1.initialize();
|
||||||
|
scph1.setCallback(cm1);
|
||||||
|
|
||||||
|
SolcastPlaneHandler scph2 = new SolcastPlaneMock(bi);
|
||||||
|
CallbackMock cm2 = new CallbackMock();
|
||||||
|
scph2.initialize();
|
||||||
|
scph2.setCallback(cm2);
|
||||||
|
|
||||||
|
// simulate trigger of refresh job
|
||||||
|
scbh.getData();
|
||||||
|
|
||||||
|
TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#energy-estimate");
|
||||||
|
TimeSeries ts2 = cm2.getTimeSeries("solarforecast:sc-plane:thing:average#energy-estimate");
|
||||||
|
assertEquals(336, ts1.size(), "TimeSeries size");
|
||||||
|
assertEquals(336, ts2.size(), "TimeSeries size");
|
||||||
|
|
||||||
|
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||||
|
Iterator<TimeSeries.Entry> iter2 = ts2.getStates().iterator();
|
||||||
|
while (iter1.hasNext()) {
|
||||||
|
TimeSeries.Entry e1 = iter1.next();
|
||||||
|
TimeSeries.Entry e2 = iter2.next();
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) e2.state()).getUnit().toString(), "Power Unit");
|
||||||
|
assertEquals(((QuantityType<?>) e1.state()).doubleValue(), ((QuantityType<?>) e2.state()).doubleValue() * 2,
|
||||||
|
0.1, "Power Value");
|
||||||
|
}
|
||||||
|
scbh.dispose();
|
||||||
|
scph1.dispose();
|
||||||
|
scph2.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testSingleEnergyTimeSeries() {
|
||||||
|
BridgeImpl bi = new BridgeImpl(SolarForecastBindingConstants.SOLCAST_SITE, "bridge");
|
||||||
|
SolcastBridgeHandler scbh = new SolcastBridgeHandler(bi, new TimeZP());
|
||||||
|
bi.setHandler(scbh);
|
||||||
|
CallbackMock cm = new CallbackMock();
|
||||||
|
scbh.setCallback(cm);
|
||||||
|
|
||||||
|
SolcastPlaneHandler scph1 = new SolcastPlaneMock(bi);
|
||||||
|
CallbackMock cm1 = new CallbackMock();
|
||||||
|
scph1.initialize();
|
||||||
|
scph1.setCallback(cm1);
|
||||||
|
|
||||||
|
// simulate trigger of refresh job
|
||||||
|
scbh.getData();
|
||||||
|
|
||||||
|
TimeSeries ts1 = cm.getTimeSeries("solarforecast:sc-site:bridge:average#energy-estimate");
|
||||||
|
assertEquals(336, ts1.size(), "TimeSeries size");
|
||||||
|
Iterator<TimeSeries.Entry> iter1 = ts1.getStates().iterator();
|
||||||
|
while (iter1.hasNext()) {
|
||||||
|
TimeSeries.Entry e1 = iter1.next();
|
||||||
|
assertEquals("kWh", ((QuantityType<?>) e1.state()).getUnit().toString(), "Power Unit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -363,6 +363,7 @@
|
|||||||
<module>org.openhab.binding.sncf</module>
|
<module>org.openhab.binding.sncf</module>
|
||||||
<module>org.openhab.binding.snmp</module>
|
<module>org.openhab.binding.snmp</module>
|
||||||
<module>org.openhab.binding.solaredge</module>
|
<module>org.openhab.binding.solaredge</module>
|
||||||
|
<module>org.openhab.binding.solarforecast</module>
|
||||||
<module>org.openhab.binding.solarlog</module>
|
<module>org.openhab.binding.solarlog</module>
|
||||||
<module>org.openhab.binding.solarmax</module>
|
<module>org.openhab.binding.solarmax</module>
|
||||||
<module>org.openhab.binding.solarwatt</module>
|
<module>org.openhab.binding.solarwatt</module>
|
||||||
|
Loading…
Reference in New Issue
Block a user