[solarforecast] Initial contribution (#13308)

Signed-off-by: Bernd Weymann <bernd.weymann@gmail.com>
This commit is contained in:
Bernd Weymann 2024-05-02 20:26:09 +02:00 committed by GitHub
parent 4d7864ba1f
commit 71d335df9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 9205 additions and 0 deletions

View File

@ -1641,6 +1641,11 @@
<artifactId>org.openhab.binding.solaredge</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarforecast</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.solarlog</artifactId>

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View 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>

View File

@ -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>

View File

@ -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";
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -363,6 +363,7 @@
<module>org.openhab.binding.sncf</module>
<module>org.openhab.binding.snmp</module>
<module>org.openhab.binding.solaredge</module>
<module>org.openhab.binding.solarforecast</module>
<module>org.openhab.binding.solarlog</module>
<module>org.openhab.binding.solarmax</module>
<module>org.openhab.binding.solarwatt</module>