Source code for eta_utility.connectors.forecast_solar

"""
This module implements a REST API connector to the forecast.solar API.

Documentation:

Get solar production estimate for specific location (defined by latitude and longitude)
and a specific plane orientation (defined by declination and azimuth) for an installed module power.

Historic solar production
historic means here that the average of all years of the available weather data on that day is considered,
the current weather is thus not included in the calculation.

Clear sky solar production
If there were no clouds it would be a clear sky. clearsky thus calculates the theoretically possible production.

"""

# Consider Caching for at least 15 minutes and don't exceed rate limit
from __future__ import annotations

import concurrent.futures
from collections.abc import Mapping
from datetime import datetime, timedelta
from typing import TYPE_CHECKING

import pandas as pd
import requests
import requests_cache

from eta_utility import get_logger
from eta_utility.connectors.node import NodeForecastSolar
from eta_utility.timeseries import df_resample
from eta_utility.util import round_timestamp

from .base_classes import BaseSeriesConnection, SubscriptionHandler

if TYPE_CHECKING:
    from typing import Any, ClassVar

    from eta_utility.type_hints import AnyNode, Nodes, TimeStep


log = get_logger("connectors.forecast_solar")

requests_cache.install_cache("forecast_solar_cache", expire_after=900)  # 15 minutes


[docs] class ForecastSolarConnection(BaseSeriesConnection, protocol="forecast_solar"): """ ForecastSolarConnection is a class to download and upload multiple features from and to the Forecast.Solar database as timeseries. :param usr: Not needed for Forecast.Solar. :param pwd: Not needed for Forecast.Solar. :param api_key: Token for API authentication. :param nodes: Nodes to select in connection. """ baseurl: ClassVar[str] = "https://api.forecast.solar" time_format: ClassVar[str] = "%Y-%m-%dT%H:%M:%SZ" headers: ClassVar[dict[str, str]] = {"Content-Type": "application/json"} def __init__( self, url: str = baseurl, *, api_key: str = "None", url_params: dict[str, Any] | None = None, query_params: dict[str, Any] | None = None, nodes: Nodes | None = None, ) -> None: super().__init__(url, None, None, nodes=nodes) #: Url params for the forecast.Solar api self.url_params: dict[str, Any] | None = url_params #: Query params for the forecast.Solar api self.query_params: dict[str, Any] | None = query_params #: Key to use the Forecast.Solar api. If API key is none, only the public functions are usable. self._api_key: str = api_key self.cache = requests_cache.get_cache() @classmethod def _from_node(cls, node: AnyNode, **kwargs: Any) -> ForecastSolarConnection: """Initialize the connection object from an Forecast.Solar protocol node object :param node: Node to initialize from. :param kwargs: Keyword arguments for API authentication, where "api_key" is required :return: ForecastSolarConnection object. """ api_key = kwargs.get("api_key", node.api_key) # type: ignore if node.protocol == "forecast_solar" and isinstance(node, NodeForecastSolar): return cls(node.url, api_key=api_key, nodes=[node]) else: raise ValueError( f"Tried to initialize ForecastSolarConnection from a node that does not specify forecast_solar as its" f"protocol: {node.name}." )
[docs] def read_node(self, node: NodeForecastSolar) -> pd.DataFrame: """Download data from the Forecast.Solar Database. :param node: Node to read values from. :return: pandas.DataFrame containing the data read from the connection. """ url, query_params = node.url, node._query_params raw_response = self._raw_request("GET", url, params=query_params, headers=self.headers) response = raw_response.json() timestamps = pd.to_datetime(list(response["result"].keys())) watts = response["result"].values() data = pd.DataFrame( data=watts, index=timestamps.tz_convert(self._local_tz), dtype="float64", ) data.index.name = "Time (with timezone)" return data
[docs] def select_data( self, results: pd.DataFrame, from_time: pd.Timestamp | None = None, to_time: pd.Timestamp | None = None ) -> tuple[pd.DataFrame, pd.Timestamp]: """Forecast.solar api returns the data for the whole day. Select data only for the time interval. :param nodes: pandas.DataFrame containing the raw data read from the connection. :param from_time: Starting time to begin reading (included in output). :param to_time: Time to stop reading at (included in output). :return: pandas.DataFrame containing the selected data read from the connection and the current timestamp. """ now = pd.Timestamp.now().tz_localize(self._local_tz) if isinstance(from_time, pd.Timestamp): previous_time = from_time.floor("15T") if self._api_key != "None" else from_time.floor("h") else: # When from_time is None, no series is selected previous_time = now.floor("15T") if self._api_key != "None" else now.floor("h") if isinstance(to_time, pd.Timestamp): next_time = ( to_time.floor("15T") + timedelta(minutes=15) if self._api_key != "None" else to_time.floor("h") + timedelta(minutes=60) ) else: # When to_time is None, no series is selected next_time = ( previous_time + timedelta(minutes=15) if self._api_key != "None" else previous_time + timedelta(minutes=60) ) if previous_time not in results: results.loc[previous_time] = 0 if next_time not in results: results.loc[next_time] = 0 results.sort_index(inplace=True) return results.loc[previous_time:next_time], now
[docs] def read(self, nodes: Nodes | None = None) -> pd.DataFrame: """Return current value from the Forecast.Solar Database. :param nodes: List of nodes to read values from. :return: pandas.DataFrame containing the data read from the connection. """ nodes = self._validate_nodes(nodes) with concurrent.futures.ThreadPoolExecutor() as executor: results = executor.map(self.read_node, nodes) values = pd.concat(results, axis=1, keys=[n.name for n in nodes], sort=False) values, now = self.select_data(values) # Insert the current timestamp _now and sort the index column to finish with the linear interpolation method values.loc[now] = pd.NA values.sort_index(inplace=True) return pd.DataFrame(values.interpolate(method="linear").loc[now])
[docs] def write(self, values: Mapping[AnyNode, Any]) -> None: raise NotImplementedError("Write is not implemented for Forecast.Solar.")
[docs] def subscribe(self, handler: SubscriptionHandler, nodes: Nodes | None = None, interval: TimeStep = 1) -> None: raise NotImplementedError("Subscribe is not implemented for Forecast.Solar.")
[docs] def read_series( self, from_time: datetime, to_time: datetime, nodes: Nodes | None = None, interval: TimeStep = 1, **kwargs: Any ) -> pd.DataFrame: """Download timeseries data from the Forecast.Solar Database :param nodes: List of nodes to read values from. :param from_time: Starting time to begin reading (included in output). :param to_time: Time to stop reading at (not included in output). :param interval: Interval between time steps. It is interpreted as seconds if given as integer. :param kwargs: Other parameters (ignored by this connector). :return: Pandas DataFrame containing the data read from the connection. """ _interval = interval if isinstance(interval, timedelta) else timedelta(seconds=interval) nodes = self._validate_nodes(nodes) from_time = pd.Timestamp(round_timestamp(from_time, _interval.total_seconds())).tz_convert(self._local_tz) to_time = pd.Timestamp(round_timestamp(to_time, _interval.total_seconds())).tz_convert(self._local_tz) with concurrent.futures.ThreadPoolExecutor() as executor: results = executor.map(self.read_node, nodes) values = pd.concat(results, axis=1, keys=[n.name for n in nodes], sort=False) values, _ = self.select_data(values, from_time, to_time) values = df_resample(values, _interval, missing_data="interpolate") return values.loc[from_time:to_time] # type: ignore
[docs] def subscribe_series( self, handler: SubscriptionHandler, req_interval: TimeStep, offset: TimeStep | None = None, nodes: Nodes | None = None, interval: TimeStep = 1, data_interval: TimeStep = 1, **kwargs: Any, ) -> None: raise NotImplementedError("Subscribe series is not implemented for Forecast.Solar.")
[docs] def close_sub(self) -> None: raise NotImplementedError("Close subscription is not implemented for Forecast.Solar.")
async def _subscription_loop( self, handler: SubscriptionHandler, interval: TimeStep, req_interval: TimeStep, offset: TimeStep, data_interval: TimeStep, ) -> None: raise NotImplementedError("Subscription loop is not implemented for Forecast.Solar.")
[docs] def timestr_from_datetime(self, dt: datetime) -> str: """Create an Forecast.Solar compatible time string. :param dt: Datetime object to convert to string. :return: Forecast.Solar compatible time string. """ return dt.isoformat(sep="T", timespec="seconds").replace(":", "%3A").replace("+", "%2B")
def _raw_request(self, method: str, url: str, **kwargs: Any) -> requests.Response: """Perform Forecast.Solar request and handle possibly resulting errors. :param method: HTTP request method. :param endpoint: Endpoint for the request (server URI is added automatically). :param kwargs: Additional arguments for the request. """ if self._api_key != "None": log.info("The api_key is None and only the public functions are available of the forecastsolar.api.") response = requests.request(method, url, **kwargs) # Check for request errors response.raise_for_status() return response def _validate_nodes(self, nodes: Nodes | None) -> set[NodeForecastSolar]: # type: ignore vnodes = super()._validate_nodes(nodes) _nodes = set() for node in vnodes: if isinstance(node, NodeForecastSolar): _nodes.add(node) return _nodes
[docs] @classmethod def route_valid(cls, nodes: Nodes) -> bool: """Check if node routes make up a valid route, by using the Forecast.Solar API's check endpoint. :param nodes: List of nodes to check. :return: Boolean if the nodes are on the same route. """ conn = ForecastSolarConnection() nodes = conn._validate_nodes(nodes) for node in nodes: _check_node = node.evolve(api_key=None, endpoint="check", data=None) try: conn._raw_request("GET", _check_node.url, headers=conn.headers) except requests.exceptions.HTTPError as e: log.error(f"\nRoute of node: {node.name} could not be verified: \n{e}") return False # If no request error occurred, the routes are valid return True
[docs] @classmethod def calculate_watt_hours_period(cls, df: pd.DataFrame, watts_column: str) -> pd.DataFrame: """ Calculates watt hours for each period based on the average watts provided. Assumes the DataFrame is indexed by timestamps. """ # Calculate the duration of each period in hours df["period_hours"] = df.index.to_series().diff().dt.total_seconds() / 3600 df["period_hours"].iloc[0] = df["period_hours"].mean() # Handle the first NaN # Calculate watt_hours for each period df["watt_hours_period"] = df[watts_column] * df["period_hours"] return df.drop(columns=["period_hours"])
[docs] @classmethod def summarize_watt_hours_over_day(cls, df: pd.DataFrame) -> pd.DataFrame: """ Sums up watt hours over each day. """ df["date"] = df.index.date daily_energy = df.groupby("date")["watt_hours_period"].sum().reset_index() daily_energy.columns = ["date", "watt_hours"] return daily_energy
[docs] @classmethod def get_dataframe_of_values( cls, df: pd.DataFrame, watts_column: str = "watts" ) -> tuple[pd.DataFrame, pd.DataFrame]: """ Process the original DataFrame to return a DataFrame with watt_hours_period, watt_hours (summarized over the day), and watt_hours_day. """ df = cls.calculate_watt_hours_period(df, watts_column) daily_sum = cls.summarize_watt_hours_over_day(df) return df, daily_sum