mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-11-04 08:46:20 +00:00 
			
		
		
		
	Nested config, devices registry
* All config now nested.
    - Use default config from model field default values. If providers
      should be enabled by default, non-empty default config file could
      be provided again.
    - Environment variable support with EOS_ prefix and __ between levels,
      e.g. EOS_SERVER__EOS_SERVER_PORT=8503 where all values are case
      insensitive.
      For more information see:
      https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
    - Use devices as registry for configured devices. DeviceBase as base
      class with for now just initializion support (in the future expand
      to operations during optimization).
    - Strip down ConfigEOS to the only configuration instance. Reload
      from file or reset to defaults is possible.
 * Fix multi-initialization of derived SingletonMixin classes.
			
			
This commit is contained in:
		@@ -3,6 +3,7 @@ from typing import Optional
 | 
			
		||||
from pydantic import Field
 | 
			
		||||
 | 
			
		||||
from akkudoktoreos.config.configabc import SettingsBaseModel
 | 
			
		||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ElecPriceCommonSettings(SettingsBaseModel):
 | 
			
		||||
@@ -12,3 +13,5 @@ class ElecPriceCommonSettings(SettingsBaseModel):
 | 
			
		||||
    elecprice_charges_kwh: Optional[float] = Field(
 | 
			
		||||
        default=None, ge=0, description="Electricity price charges (€/kWh)."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    provider_settings: Optional[ElecPriceImportCommonSettings] = None
 | 
			
		||||
 
 | 
			
		||||
@@ -71,4 +71,4 @@ class ElecPriceProvider(PredictionProvider):
 | 
			
		||||
        return "ElecPriceProvider"
 | 
			
		||||
 | 
			
		||||
    def enabled(self) -> bool:
 | 
			
		||||
        return self.provider_id() == self.config.elecprice_provider
 | 
			
		||||
        return self.provider_id() == self.config.elecprice.elecprice_provider
 | 
			
		||||
 
 | 
			
		||||
@@ -108,13 +108,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
 | 
			
		||||
        # Try to take data from 5 weeks back for prediction
 | 
			
		||||
        date = to_datetime(self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD")
 | 
			
		||||
        last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
 | 
			
		||||
        url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.timezone}"
 | 
			
		||||
        url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.prediction.timezone}"
 | 
			
		||||
        response = requests.get(url)
 | 
			
		||||
        logger.debug(f"Response from {url}: {response}")
 | 
			
		||||
        response.raise_for_status()  # Raise an error for bad responses
 | 
			
		||||
        akkudoktor_data = self._validate_data(response.content)
 | 
			
		||||
        # We are working on fresh data (no cache), report update time
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.timezone)
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
 | 
			
		||||
        return akkudoktor_data
 | 
			
		||||
 | 
			
		||||
    def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
 | 
			
		||||
@@ -156,13 +156,13 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
 | 
			
		||||
        # in ascending order and have the same timestamps.
 | 
			
		||||
 | 
			
		||||
        # Get elecprice_charges_kwh in wh
 | 
			
		||||
        charges_wh = (self.config.elecprice_charges_kwh or 0) / 1000
 | 
			
		||||
        charges_wh = (self.config.elecprice.elecprice_charges_kwh or 0) / 1000
 | 
			
		||||
 | 
			
		||||
        highest_orig_datetime = None  # newest datetime from the api after that we want to update.
 | 
			
		||||
        series_data = pd.Series(dtype=float)  # Initialize an empty series
 | 
			
		||||
 | 
			
		||||
        for value in akkudoktor_data.values:
 | 
			
		||||
            orig_datetime = to_datetime(value.start, in_timezone=self.config.timezone)
 | 
			
		||||
            orig_datetime = to_datetime(value.start, in_timezone=self.config.prediction.timezone)
 | 
			
		||||
            if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
 | 
			
		||||
                highest_orig_datetime = orig_datetime
 | 
			
		||||
 | 
			
		||||
@@ -184,14 +184,14 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
 | 
			
		||||
 | 
			
		||||
        # some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
 | 
			
		||||
        needed_prediction_hours = int(
 | 
			
		||||
            self.config.prediction_hours
 | 
			
		||||
            self.config.prediction.prediction_hours
 | 
			
		||||
            - ((highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        if needed_prediction_hours <= 0:
 | 
			
		||||
            logger.warning(
 | 
			
		||||
                f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
 | 
			
		||||
            )  # this might keep data longer than self.start_datetime + self.config.prediction_hours in the records
 | 
			
		||||
                f"No prediction needed. needed_prediction_hours={needed_prediction_hours}, prediction_hours={self.config.prediction.prediction_hours},highest_orig_datetime {highest_orig_datetime}, start_datetime {self.start_datetime}"
 | 
			
		||||
            )  # this might keep data longer than self.start_datetime + self.config.prediction.prediction_hours in the records
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if amount_datasets > 800:  # we do the full ets with seasons of 1 week
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,12 @@ class ElecPriceImport(ElecPriceProvider, PredictionImportProvider):
 | 
			
		||||
        return "ElecPriceImport"
 | 
			
		||||
 | 
			
		||||
    def _update_data(self, force_update: Optional[bool] = False) -> None:
 | 
			
		||||
        if self.config.elecpriceimport_file_path is not None:
 | 
			
		||||
            self.import_from_file(self.config.elecpriceimport_file_path, key_prefix="elecprice")
 | 
			
		||||
        if self.config.elecpriceimport_json is not None:
 | 
			
		||||
            self.import_from_json(self.config.elecpriceimport_json, key_prefix="elecprice")
 | 
			
		||||
        if self.config.elecprice.provider_settings.elecpriceimport_file_path is not None:
 | 
			
		||||
            self.import_from_file(
 | 
			
		||||
                self.config.elecprice.provider_settings.elecpriceimport_file_path,
 | 
			
		||||
                key_prefix="elecprice",
 | 
			
		||||
            )
 | 
			
		||||
        if self.config.elecprice.provider_settings.elecpriceimport_json is not None:
 | 
			
		||||
            self.import_from_json(
 | 
			
		||||
                self.config.elecprice.provider_settings.elecpriceimport_json, key_prefix="elecprice"
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,8 @@ from pathlib import Path
 | 
			
		||||
import numpy as np
 | 
			
		||||
from scipy.interpolate import RegularGridInterpolator
 | 
			
		||||
 | 
			
		||||
from akkudoktoreos.core.coreabc import SingletonMixin
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SelfConsumptionProbabilityInterpolator:
 | 
			
		||||
    def __init__(self, filepath: str | Path):
 | 
			
		||||
@@ -67,5 +69,17 @@ class SelfConsumptionProbabilityInterpolator:
 | 
			
		||||
    #     return self_consumption_rate
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Test the function
 | 
			
		||||
# print(calculate_self_consumption(1000, 1200))
 | 
			
		||||
class EOSLoadInterpolator(SelfConsumptionProbabilityInterpolator, SingletonMixin):
 | 
			
		||||
    def __init__(self) -> None:
 | 
			
		||||
        if hasattr(self, "_initialized"):
 | 
			
		||||
            return
 | 
			
		||||
        filename = Path(__file__).parent.resolve() / ".." / "data" / "regular_grid_interpolator.pkl"
 | 
			
		||||
        super().__init__(filename)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Initialize the Energy Management System, it is a singleton.
 | 
			
		||||
eos_load_interpolator = EOSLoadInterpolator()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_eos_load_interpolator() -> EOSLoadInterpolator:
 | 
			
		||||
    return eos_load_interpolator
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,13 @@
 | 
			
		||||
"""Load forecast module for load predictions."""
 | 
			
		||||
 | 
			
		||||
from typing import Optional
 | 
			
		||||
from typing import Optional, Union
 | 
			
		||||
 | 
			
		||||
from pydantic import Field
 | 
			
		||||
 | 
			
		||||
from akkudoktoreos.config.configabc import SettingsBaseModel
 | 
			
		||||
from akkudoktoreos.core.logging import get_logger
 | 
			
		||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
 | 
			
		||||
from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -16,3 +18,7 @@ class LoadCommonSettings(SettingsBaseModel):
 | 
			
		||||
    load_provider: Optional[str] = Field(
 | 
			
		||||
        default=None, description="Load provider id of provider to be used."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = (
 | 
			
		||||
        None
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -58,4 +58,4 @@ class LoadProvider(PredictionProvider):
 | 
			
		||||
        return "LoadProvider"
 | 
			
		||||
 | 
			
		||||
    def enabled(self) -> bool:
 | 
			
		||||
        return self.provider_id() == self.config.load_provider
 | 
			
		||||
        return self.provider_id() == self.config.load.load_provider
 | 
			
		||||
 
 | 
			
		||||
@@ -91,7 +91,9 @@ class LoadAkkudoktor(LoadProvider):
 | 
			
		||||
                list(zip(file_data["yearly_profiles"], file_data["yearly_profiles_std"]))
 | 
			
		||||
            )
 | 
			
		||||
            # Calculate values in W by relative profile data and yearly consumption given in kWh
 | 
			
		||||
            data_year_energy = profile_data * self.config.loadakkudoktor_year_energy * 1000
 | 
			
		||||
            data_year_energy = (
 | 
			
		||||
                profile_data * self.config.load.provider_settings.loadakkudoktor_year_energy * 1000
 | 
			
		||||
            )
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            error_msg = f"Error: File {load_file} not found."
 | 
			
		||||
            logger.error(error_msg)
 | 
			
		||||
@@ -109,7 +111,7 @@ class LoadAkkudoktor(LoadProvider):
 | 
			
		||||
        # We provide prediction starting at start of day, to be compatible to old system.
 | 
			
		||||
        # End date for prediction is prediction hours from now.
 | 
			
		||||
        date = self.start_datetime.start_of("day")
 | 
			
		||||
        end_date = self.start_datetime.add(hours=self.config.prediction_hours)
 | 
			
		||||
        end_date = self.start_datetime.add(hours=self.config.prediction.prediction_hours)
 | 
			
		||||
        while compare_datetimes(date, end_date).lt:
 | 
			
		||||
            # Extract mean (index 0) and standard deviation (index 1) for the given day and hour
 | 
			
		||||
            # Day indexing starts at 0, -1 because of that
 | 
			
		||||
@@ -127,4 +129,4 @@ class LoadAkkudoktor(LoadProvider):
 | 
			
		||||
            self.update_value(date, values)
 | 
			
		||||
            date += to_duration("1 hour")
 | 
			
		||||
        # We are working on fresh data (no cache), report update time
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.timezone)
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
 | 
			
		||||
 
 | 
			
		||||
@@ -58,7 +58,11 @@ class LoadImport(LoadProvider, PredictionImportProvider):
 | 
			
		||||
        return "LoadImport"
 | 
			
		||||
 | 
			
		||||
    def _update_data(self, force_update: Optional[bool] = False) -> None:
 | 
			
		||||
        if self.config.load_import_file_path is not None:
 | 
			
		||||
            self.import_from_file(self.config.load_import_file_path, key_prefix="load")
 | 
			
		||||
        if self.config.load_import_json is not None:
 | 
			
		||||
            self.import_from_json(self.config.load_import_json, key_prefix="load")
 | 
			
		||||
        if self.config.load.provider_settings.load_import_file_path is not None:
 | 
			
		||||
            self.import_from_file(
 | 
			
		||||
                self.config.provider_settings.load_import_file_path, key_prefix="load"
 | 
			
		||||
            )
 | 
			
		||||
        if self.config.load.provider_settings.load_import_json is not None:
 | 
			
		||||
            self.import_from_json(
 | 
			
		||||
                self.config.load.provider_settings.load_import_json, key_prefix="load"
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
@@ -80,13 +80,13 @@ class PredictionCommonSettings(SettingsBaseModel):
 | 
			
		||||
        description="Number of hours into the past for historical predictions data",
 | 
			
		||||
    )
 | 
			
		||||
    latitude: Optional[float] = Field(
 | 
			
		||||
        default=None,
 | 
			
		||||
        default=52.52,
 | 
			
		||||
        ge=-90.0,
 | 
			
		||||
        le=90.0,
 | 
			
		||||
        description="Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)",
 | 
			
		||||
    )
 | 
			
		||||
    longitude: Optional[float] = Field(
 | 
			
		||||
        default=None,
 | 
			
		||||
        default=13.405,
 | 
			
		||||
        ge=-180.0,
 | 
			
		||||
        le=180.0,
 | 
			
		||||
        description="Longitude in decimal degrees, within -180 to 180 (°)",
 | 
			
		||||
 
 | 
			
		||||
@@ -121,9 +121,9 @@ class PredictionStartEndKeepMixin(PredictionBase):
 | 
			
		||||
        Returns:
 | 
			
		||||
            Optional[DateTime]: The calculated end datetime, or `None` if inputs are missing.
 | 
			
		||||
        """
 | 
			
		||||
        if self.start_datetime and self.config.prediction_hours:
 | 
			
		||||
        if self.start_datetime and self.config.prediction.prediction_hours:
 | 
			
		||||
            end_datetime = self.start_datetime + to_duration(
 | 
			
		||||
                f"{self.config.prediction_hours} hours"
 | 
			
		||||
                f"{self.config.prediction.prediction_hours} hours"
 | 
			
		||||
            )
 | 
			
		||||
            dst_change = end_datetime.offset_hours - self.start_datetime.offset_hours
 | 
			
		||||
            logger.debug(f"Pre: {self.start_datetime}..{end_datetime}: DST change: {dst_change}")
 | 
			
		||||
@@ -147,10 +147,10 @@ class PredictionStartEndKeepMixin(PredictionBase):
 | 
			
		||||
            return None
 | 
			
		||||
        historic_hours = self.historic_hours_min()
 | 
			
		||||
        if (
 | 
			
		||||
            self.config.prediction_historic_hours
 | 
			
		||||
            and self.config.prediction_historic_hours > historic_hours
 | 
			
		||||
            self.config.prediction.prediction_historic_hours
 | 
			
		||||
            and self.config.prediction.prediction_historic_hours > historic_hours
 | 
			
		||||
        ):
 | 
			
		||||
            historic_hours = int(self.config.prediction_historic_hours)
 | 
			
		||||
            historic_hours = int(self.config.prediction.prediction_historic_hours)
 | 
			
		||||
        return self.start_datetime - to_duration(f"{historic_hours} hours")
 | 
			
		||||
 | 
			
		||||
    @computed_field  # type: ignore[prop-decorator]
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ from pydantic import Field, computed_field
 | 
			
		||||
 | 
			
		||||
from akkudoktoreos.config.configabc import SettingsBaseModel
 | 
			
		||||
from akkudoktoreos.core.logging import get_logger
 | 
			
		||||
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -260,7 +261,7 @@ class PVForecastCommonSettings(SettingsBaseModel):
 | 
			
		||||
        default=None, description="Nominal power of PV system in kW."
 | 
			
		||||
    )
 | 
			
		||||
    pvforecast4_pvtechchoice: Optional[str] = Field(
 | 
			
		||||
        "crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
 | 
			
		||||
        default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
 | 
			
		||||
    )
 | 
			
		||||
    pvforecast4_mountingplace: Optional[str] = Field(
 | 
			
		||||
        default="free",
 | 
			
		||||
@@ -316,7 +317,7 @@ class PVForecastCommonSettings(SettingsBaseModel):
 | 
			
		||||
        default=None, description="Nominal power of PV system in kW."
 | 
			
		||||
    )
 | 
			
		||||
    pvforecast5_pvtechchoice: Optional[str] = Field(
 | 
			
		||||
        "crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
 | 
			
		||||
        default="crystSi", description="PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'."
 | 
			
		||||
    )
 | 
			
		||||
    pvforecast5_mountingplace: Optional[str] = Field(
 | 
			
		||||
        default="free",
 | 
			
		||||
@@ -359,6 +360,8 @@ class PVForecastCommonSettings(SettingsBaseModel):
 | 
			
		||||
 | 
			
		||||
    pvforecast_max_planes: ClassVar[int] = 6  # Maximum number of planes that can be set
 | 
			
		||||
 | 
			
		||||
    provider_settings: Optional[PVForecastImportCommonSettings] = None
 | 
			
		||||
 | 
			
		||||
    # Computed fields
 | 
			
		||||
    @computed_field  # type: ignore[prop-decorator]
 | 
			
		||||
    @property
 | 
			
		||||
 
 | 
			
		||||
@@ -54,6 +54,6 @@ class PVForecastProvider(PredictionProvider):
 | 
			
		||||
 | 
			
		||||
    def enabled(self) -> bool:
 | 
			
		||||
        logger.debug(
 | 
			
		||||
            f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast_provider}"
 | 
			
		||||
            f"PVForecastProvider ID {self.provider_id()} vs. config {self.config.pvforecast.pvforecast_provider}"
 | 
			
		||||
        )
 | 
			
		||||
        return self.provider_id() == self.config.pvforecast_provider
 | 
			
		||||
        return self.provider_id() == self.config.pvforecast.pvforecast_provider
 | 
			
		||||
 
 | 
			
		||||
@@ -203,19 +203,23 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
        """Build akkudoktor.net API request URL."""
 | 
			
		||||
        base_url = "https://api.akkudoktor.net/forecast"
 | 
			
		||||
        query_params = [
 | 
			
		||||
            f"lat={self.config.latitude}",
 | 
			
		||||
            f"lon={self.config.longitude}",
 | 
			
		||||
            f"lat={self.config.prediction.latitude}",
 | 
			
		||||
            f"lon={self.config.prediction.longitude}",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for i in range(len(self.config.pvforecast_planes)):
 | 
			
		||||
            query_params.append(f"power={int(self.config.pvforecast_planes_peakpower[i] * 1000)}")
 | 
			
		||||
            query_params.append(f"azimuth={int(self.config.pvforecast_planes_azimuth[i])}")
 | 
			
		||||
            query_params.append(f"tilt={int(self.config.pvforecast_planes_tilt[i])}")
 | 
			
		||||
        for i in range(len(self.config.pvforecast.pvforecast_planes)):
 | 
			
		||||
            query_params.append(
 | 
			
		||||
                f"powerInverter={int(self.config.pvforecast_planes_inverter_paco[i])}"
 | 
			
		||||
                f"power={int(self.config.pvforecast.pvforecast_planes_peakpower[i] * 1000)}"
 | 
			
		||||
            )
 | 
			
		||||
            query_params.append(
 | 
			
		||||
                f"azimuth={int(self.config.pvforecast.pvforecast_planes_azimuth[i])}"
 | 
			
		||||
            )
 | 
			
		||||
            query_params.append(f"tilt={int(self.config.pvforecast.pvforecast_planes_tilt[i])}")
 | 
			
		||||
            query_params.append(
 | 
			
		||||
                f"powerInverter={int(self.config.pvforecast.pvforecast_planes_inverter_paco[i])}"
 | 
			
		||||
            )
 | 
			
		||||
            horizon_values = ",".join(
 | 
			
		||||
                str(int(h)) for h in self.config.pvforecast_planes_userhorizon[i]
 | 
			
		||||
                str(int(h)) for h in self.config.pvforecast.pvforecast_planes_userhorizon[i]
 | 
			
		||||
            )
 | 
			
		||||
            query_params.append(f"horizont={horizon_values}")
 | 
			
		||||
 | 
			
		||||
@@ -226,7 +230,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
                "cellCoEff=-0.36",
 | 
			
		||||
                "inverterEfficiency=0.8",
 | 
			
		||||
                "albedo=0.25",
 | 
			
		||||
                f"timezone={self.config.timezone}",
 | 
			
		||||
                f"timezone={self.config.prediction.timezone}",
 | 
			
		||||
                "hourly=relativehumidity_2m%2Cwindspeed_10m",
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
@@ -255,7 +259,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
        logger.debug(f"Response from {self._url()}: {response}")
 | 
			
		||||
        akkudoktor_data = self._validate_data(response.content)
 | 
			
		||||
        # We are working on fresh data (no cache), report update time
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.timezone)
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
 | 
			
		||||
        return akkudoktor_data
 | 
			
		||||
 | 
			
		||||
    def _update_data(self, force_update: Optional[bool] = False) -> None:
 | 
			
		||||
@@ -265,7 +269,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
        `PVForecastAkkudoktorDataRecord`.
 | 
			
		||||
        """
 | 
			
		||||
        # Assure we have something to request PV power for.
 | 
			
		||||
        if not self.config.pvforecast_planes:
 | 
			
		||||
        if not self.config.pvforecast.pvforecast_planes:
 | 
			
		||||
            # No planes for PV
 | 
			
		||||
            error_msg = "Requested PV forecast, but no planes configured."
 | 
			
		||||
            logger.error(f"Configuration error: {error_msg}")
 | 
			
		||||
@@ -275,17 +279,17 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
        akkudoktor_data = self._request_forecast(force_update=force_update)  # type: ignore
 | 
			
		||||
 | 
			
		||||
        # Timezone of the PV system
 | 
			
		||||
        if self.config.timezone != akkudoktor_data.meta.timezone:
 | 
			
		||||
            error_msg = f"Configured timezone '{self.config.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
 | 
			
		||||
        if self.config.prediction.timezone != akkudoktor_data.meta.timezone:
 | 
			
		||||
            error_msg = f"Configured timezone '{self.config.prediction.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'."
 | 
			
		||||
            logger.error(f"Akkudoktor schema change: {error_msg}")
 | 
			
		||||
            raise ValueError(error_msg)
 | 
			
		||||
 | 
			
		||||
        # Assumption that all lists are the same length and are ordered chronologically
 | 
			
		||||
        # in ascending order and have the same timestamps.
 | 
			
		||||
        if len(akkudoktor_data.values[0]) < self.config.prediction_hours:
 | 
			
		||||
        if len(akkudoktor_data.values[0]) < self.config.prediction.prediction_hours:
 | 
			
		||||
            # Expect one value set per prediction hour
 | 
			
		||||
            error_msg = (
 | 
			
		||||
                f"The forecast must cover at least {self.config.prediction_hours} hours, "
 | 
			
		||||
                f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, "
 | 
			
		||||
                f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
 | 
			
		||||
            )
 | 
			
		||||
            logger.error(f"Akkudoktor schema change: {error_msg}")
 | 
			
		||||
@@ -296,7 +300,7 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
        # Iterate over forecast data points
 | 
			
		||||
        for forecast_values in zip(*akkudoktor_data.values):
 | 
			
		||||
            original_datetime = forecast_values[0].datetime
 | 
			
		||||
            dt = to_datetime(original_datetime, in_timezone=self.config.timezone)
 | 
			
		||||
            dt = to_datetime(original_datetime, in_timezone=self.config.prediction.timezone)
 | 
			
		||||
 | 
			
		||||
            # Skip outdated forecast data
 | 
			
		||||
            if compare_datetimes(dt, self.start_datetime.start_of("day")).lt:
 | 
			
		||||
@@ -314,9 +318,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
 | 
			
		||||
 | 
			
		||||
            self.update_value(dt, data)
 | 
			
		||||
 | 
			
		||||
        if len(self) < self.config.prediction_hours:
 | 
			
		||||
        if len(self) < self.config.prediction.prediction_hours:
 | 
			
		||||
            raise ValueError(
 | 
			
		||||
                f"The forecast must cover at least {self.config.prediction_hours} hours, "
 | 
			
		||||
                f"The forecast must cover at least {self.config.prediction.prediction_hours} hours, "
 | 
			
		||||
                f"but only {len(self)} hours starting from {self.start_datetime} "
 | 
			
		||||
                f"were predicted."
 | 
			
		||||
            )
 | 
			
		||||
@@ -365,31 +369,35 @@ if __name__ == "__main__":
 | 
			
		||||
    """
 | 
			
		||||
    # Set up the configuration with necessary fields for URL generation
 | 
			
		||||
    settings_data = {
 | 
			
		||||
        "prediction_hours": 48,
 | 
			
		||||
        "prediction_historic_hours": 24,
 | 
			
		||||
        "latitude": 52.52,
 | 
			
		||||
        "longitude": 13.405,
 | 
			
		||||
        "pvforecast_provider": "PVForecastAkkudoktor",
 | 
			
		||||
        "pvforecast0_peakpower": 5.0,
 | 
			
		||||
        "pvforecast0_surface_azimuth": -10,
 | 
			
		||||
        "pvforecast0_surface_tilt": 7,
 | 
			
		||||
        "pvforecast0_userhorizon": [20, 27, 22, 20],
 | 
			
		||||
        "pvforecast0_inverter_paco": 10000,
 | 
			
		||||
        "pvforecast1_peakpower": 4.8,
 | 
			
		||||
        "pvforecast1_surface_azimuth": -90,
 | 
			
		||||
        "pvforecast1_surface_tilt": 7,
 | 
			
		||||
        "pvforecast1_userhorizon": [30, 30, 30, 50],
 | 
			
		||||
        "pvforecast1_inverter_paco": 10000,
 | 
			
		||||
        "pvforecast2_peakpower": 1.4,
 | 
			
		||||
        "pvforecast2_surface_azimuth": -40,
 | 
			
		||||
        "pvforecast2_surface_tilt": 60,
 | 
			
		||||
        "pvforecast2_userhorizon": [60, 30, 0, 30],
 | 
			
		||||
        "pvforecast2_inverter_paco": 2000,
 | 
			
		||||
        "pvforecast3_peakpower": 1.6,
 | 
			
		||||
        "pvforecast3_surface_azimuth": 5,
 | 
			
		||||
        "pvforecast3_surface_tilt": 45,
 | 
			
		||||
        "pvforecast3_userhorizon": [45, 25, 30, 60],
 | 
			
		||||
        "pvforecast3_inverter_paco": 1400,
 | 
			
		||||
        "prediction": {
 | 
			
		||||
            "prediction_hours": 48,
 | 
			
		||||
            "prediction_historic_hours": 24,
 | 
			
		||||
            "latitude": 52.52,
 | 
			
		||||
            "longitude": 13.405,
 | 
			
		||||
        },
 | 
			
		||||
        "pvforecast": {
 | 
			
		||||
            "pvforecast_provider": "PVForecastAkkudoktor",
 | 
			
		||||
            "pvforecast0_peakpower": 5.0,
 | 
			
		||||
            "pvforecast0_surface_azimuth": -10,
 | 
			
		||||
            "pvforecast0_surface_tilt": 7,
 | 
			
		||||
            "pvforecast0_userhorizon": [20, 27, 22, 20],
 | 
			
		||||
            "pvforecast0_inverter_paco": 10000,
 | 
			
		||||
            "pvforecast1_peakpower": 4.8,
 | 
			
		||||
            "pvforecast1_surface_azimuth": -90,
 | 
			
		||||
            "pvforecast1_surface_tilt": 7,
 | 
			
		||||
            "pvforecast1_userhorizon": [30, 30, 30, 50],
 | 
			
		||||
            "pvforecast1_inverter_paco": 10000,
 | 
			
		||||
            "pvforecast2_peakpower": 1.4,
 | 
			
		||||
            "pvforecast2_surface_azimuth": -40,
 | 
			
		||||
            "pvforecast2_surface_tilt": 60,
 | 
			
		||||
            "pvforecast2_userhorizon": [60, 30, 0, 30],
 | 
			
		||||
            "pvforecast2_inverter_paco": 2000,
 | 
			
		||||
            "pvforecast3_peakpower": 1.6,
 | 
			
		||||
            "pvforecast3_surface_azimuth": 5,
 | 
			
		||||
            "pvforecast3_surface_tilt": 45,
 | 
			
		||||
            "pvforecast3_userhorizon": [45, 25, 30, 60],
 | 
			
		||||
            "pvforecast3_inverter_paco": 1400,
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Initialize the forecast object with the generated configuration
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,13 @@ class PVForecastImport(PVForecastProvider, PredictionImportProvider):
 | 
			
		||||
        return "PVForecastImport"
 | 
			
		||||
 | 
			
		||||
    def _update_data(self, force_update: Optional[bool] = False) -> None:
 | 
			
		||||
        if self.config.pvforecastimport_file_path is not None:
 | 
			
		||||
            self.import_from_file(self.config.pvforecastimport_file_path, key_prefix="pvforecast")
 | 
			
		||||
        if self.config.pvforecastimport_json is not None:
 | 
			
		||||
            self.import_from_json(self.config.pvforecastimport_json, key_prefix="pvforecast")
 | 
			
		||||
        if self.config.pvforecast.provider_settings.pvforecastimport_file_path is not None:
 | 
			
		||||
            self.import_from_file(
 | 
			
		||||
                self.config.pvforecast.provider_settings.pvforecastimport_file_path,
 | 
			
		||||
                key_prefix="pvforecast",
 | 
			
		||||
            )
 | 
			
		||||
        if self.config.pvforecast.provider_settings.pvforecastimport_json is not None:
 | 
			
		||||
            self.import_from_json(
 | 
			
		||||
                self.config.pvforecast.provider_settings.pvforecastimport_json,
 | 
			
		||||
                key_prefix="pvforecast",
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
@@ -5,9 +5,12 @@ from typing import Optional
 | 
			
		||||
from pydantic import Field
 | 
			
		||||
 | 
			
		||||
from akkudoktoreos.config.configabc import SettingsBaseModel
 | 
			
		||||
from akkudoktoreos.prediction.weatherimport import WeatherImportCommonSettings
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WeatherCommonSettings(SettingsBaseModel):
 | 
			
		||||
    weather_provider: Optional[str] = Field(
 | 
			
		||||
        default=None, description="Weather provider id of provider to be used."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    provider_settings: Optional[WeatherImportCommonSettings] = None
 | 
			
		||||
 
 | 
			
		||||
@@ -126,7 +126,7 @@ class WeatherProvider(PredictionProvider):
 | 
			
		||||
        return "WeatherProvider"
 | 
			
		||||
 | 
			
		||||
    def enabled(self) -> bool:
 | 
			
		||||
        return self.provider_id() == self.config.weather_provider
 | 
			
		||||
        return self.provider_id() == self.config.weather.weather_provider
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def estimate_irradiance_from_cloud_cover(
 | 
			
		||||
 
 | 
			
		||||
@@ -99,7 +99,7 @@ class WeatherBrightSky(WeatherProvider):
 | 
			
		||||
        date = to_datetime(self.start_datetime, as_string="YYYY-MM-DD")
 | 
			
		||||
        last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
 | 
			
		||||
        response = requests.get(
 | 
			
		||||
            f"{source}/weather?lat={self.config.latitude}&lon={self.config.longitude}&date={date}&last_date={last_date}&tz={self.config.timezone}"
 | 
			
		||||
            f"{source}/weather?lat={self.config.prediction.latitude}&lon={self.config.prediction.longitude}&date={date}&last_date={last_date}&tz={self.config.prediction.timezone}"
 | 
			
		||||
        )
 | 
			
		||||
        response.raise_for_status()  # Raise an error for bad responses
 | 
			
		||||
        logger.debug(f"Response from {source}: {response}")
 | 
			
		||||
@@ -109,7 +109,7 @@ class WeatherBrightSky(WeatherProvider):
 | 
			
		||||
            logger.error(error_msg)
 | 
			
		||||
            raise ValueError(error_msg)
 | 
			
		||||
        # We are working on fresh data (no cache), report update time
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.timezone)
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
 | 
			
		||||
        return brightsky_data
 | 
			
		||||
 | 
			
		||||
    def _description_to_series(self, description: str) -> pd.Series:
 | 
			
		||||
@@ -200,7 +200,7 @@ class WeatherBrightSky(WeatherProvider):
 | 
			
		||||
        description = "Total Clouds (% Sky Obscured)"
 | 
			
		||||
        cloud_cover = self._description_to_series(description)
 | 
			
		||||
        ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
 | 
			
		||||
            self.config.latitude, self.config.longitude, cloud_cover
 | 
			
		||||
            self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        description = "Global Horizontal Irradiance (W/m2)"
 | 
			
		||||
 
 | 
			
		||||
@@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider):
 | 
			
		||||
            response: Weather forecast request reponse from ClearOutside.
 | 
			
		||||
        """
 | 
			
		||||
        source = "https://clearoutside.com/forecast"
 | 
			
		||||
        latitude = round(self.config.latitude, 2)
 | 
			
		||||
        longitude = round(self.config.longitude, 2)
 | 
			
		||||
        latitude = round(self.config.prediction.latitude, 2)
 | 
			
		||||
        longitude = round(self.config.prediction.longitude, 2)
 | 
			
		||||
        response = requests.get(f"{source}/{latitude}/{longitude}?desktop=true")
 | 
			
		||||
        response.raise_for_status()  # Raise an error for bad responses
 | 
			
		||||
        logger.debug(f"Response from {source}: {response}")
 | 
			
		||||
        # We are working on fresh data (no cache), report update time
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.timezone)
 | 
			
		||||
        self.update_datetime = to_datetime(in_timezone=self.config.prediction.timezone)
 | 
			
		||||
        return response
 | 
			
		||||
 | 
			
		||||
    def _update_data(self, force_update: Optional[bool] = None) -> None:
 | 
			
		||||
@@ -307,7 +307,7 @@ class WeatherClearOutside(WeatherProvider):
 | 
			
		||||
                data=clearout_data["Total Clouds (% Sky Obscured)"], index=clearout_data["DateTime"]
 | 
			
		||||
            )
 | 
			
		||||
            ghi, dni, dhi = self.estimate_irradiance_from_cloud_cover(
 | 
			
		||||
                self.config.latitude, self.config.longitude, cloud_cover
 | 
			
		||||
                self.config.prediction.latitude, self.config.prediction.longitude, cloud_cover
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            # Add GHI, DNI, DHI to clearout data
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,11 @@ class WeatherImport(WeatherProvider, PredictionImportProvider):
 | 
			
		||||
        return "WeatherImport"
 | 
			
		||||
 | 
			
		||||
    def _update_data(self, force_update: Optional[bool] = False) -> None:
 | 
			
		||||
        if self.config.weatherimport_file_path is not None:
 | 
			
		||||
            self.import_from_file(self.config.weatherimport_file_path, key_prefix="weather")
 | 
			
		||||
        if self.config.weatherimport_json is not None:
 | 
			
		||||
            self.import_from_json(self.config.weatherimport_json, key_prefix="weather")
 | 
			
		||||
        if self.config.weather.provider_settings.weatherimport_file_path is not None:
 | 
			
		||||
            self.import_from_file(
 | 
			
		||||
                self.config.weather.provider_settings.weatherimport_file_path, key_prefix="weather"
 | 
			
		||||
            )
 | 
			
		||||
        if self.config.weather.provider_settings.weatherimport_json is not None:
 | 
			
		||||
            self.import_from_json(
 | 
			
		||||
                self.config.weather.provider_settings.weatherimport_json, key_prefix="weather"
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user