mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-10-30 22:36:21 +00:00 
			
		
		
		
	feat(VRM forecast): add load and pv forecast by VRM API (#611)
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				docker-build / platform-excludes (push) Has been cancelled
				
			
		
			
				
	
				docker-build / build (push) Has been cancelled
				
			
		
			
				
	
				docker-build / merge (push) Has been cancelled
				
			
		
			
				
	
				pre-commit / pre-commit (push) Has been cancelled
				
			
		
			
				
	
				Run Pytest on Pull Request / test (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	docker-build / platform-excludes (push) Has been cancelled
				
			docker-build / build (push) Has been cancelled
				
			docker-build / merge (push) Has been cancelled
				
			pre-commit / pre-commit (push) Has been cancelled
				
			Run Pytest on Pull Request / test (push) Has been cancelled
				
			Add support for fetching forecasts from the VRM API by Victron Energy. Retrieve forecasts for PV generation and total load of a Victron system from the internet. Tests for the new modules have been added, and the documentation has been updated accordingly. Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
This commit is contained in:
		| @@ -477,7 +477,7 @@ Validators: | ||||
| | Name | Environment Variable | Type | Read-Only | Default | Description | | ||||
| | ---- | -------------------- | ---- | --------- | ------- | ----------- | | ||||
| | provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. | | ||||
| | provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings | | ||||
| | provider_settings | `EOS_LOAD__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorCommonSettings, akkudoktoreos.prediction.loadvrm.LoadVrmCommonSettings, akkudoktoreos.prediction.loadimport.LoadImportCommonSettings, NoneType]` | `rw` | `None` | Provider settings | | ||||
| ::: | ||||
|  | ||||
| ### Example Input/Output | ||||
| @@ -520,6 +520,33 @@ Validators: | ||||
|    } | ||||
| ``` | ||||
|  | ||||
| ### Common settings for VRM API | ||||
|  | ||||
| :::{table} load::provider_settings | ||||
| :widths: 10 10 5 5 30 | ||||
| :align: left | ||||
|  | ||||
| | Name | Type | Read-Only | Default | Description | | ||||
| | ---- | ---- | --------- | ------- | ----------- | | ||||
| | load_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API | | ||||
| | load_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID | | ||||
| ::: | ||||
|  | ||||
| #### Example Input/Output | ||||
|  | ||||
| ```{eval-rst} | ||||
| .. code-block:: json | ||||
|  | ||||
|    { | ||||
|        "load": { | ||||
|            "provider_settings": { | ||||
|                "load_vrm_token": "your-token", | ||||
|                "load_vrm_idsite": 12345 | ||||
|            } | ||||
|        } | ||||
|    } | ||||
| ``` | ||||
|  | ||||
| ### Common settings for load data import from file | ||||
|  | ||||
| :::{table} load::provider_settings | ||||
| @@ -554,7 +581,7 @@ Validators: | ||||
| | Name | Environment Variable | Type | Read-Only | Default | Description | | ||||
| | ---- | -------------------- | ---- | --------- | ------- | ----------- | | ||||
| | provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. | | ||||
| | provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | Provider settings | | ||||
| | provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `Union[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings, akkudoktoreos.prediction.pvforecastvrm.PVforecastVrmCommonSettings, NoneType]` | `rw` | `None` | Provider settings | | ||||
| | planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. | | ||||
| | max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set | | ||||
| | planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. | | ||||
| @@ -795,6 +822,33 @@ Validators: | ||||
|    } | ||||
| ``` | ||||
|  | ||||
| ### Common settings for VRM API | ||||
|  | ||||
| :::{table} pvforecast::provider_settings | ||||
| :widths: 10 10 5 5 30 | ||||
| :align: left | ||||
|  | ||||
| | Name | Type | Read-Only | Default | Description | | ||||
| | ---- | ---- | --------- | ------- | ----------- | | ||||
| | pvforecast_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API | | ||||
| | pvforecast_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID | | ||||
| ::: | ||||
|  | ||||
| #### Example Input/Output | ||||
|  | ||||
| ```{eval-rst} | ||||
| .. code-block:: json | ||||
|  | ||||
|    { | ||||
|        "pvforecast": { | ||||
|            "provider_settings": { | ||||
|                "pvforecast_vrm_token": "your-token", | ||||
|                "pvforecast_vrm_idsite": 12345 | ||||
|            } | ||||
|        } | ||||
|    } | ||||
| ``` | ||||
|  | ||||
| ### Common settings for pvforecast data import from file or JSON string | ||||
|  | ||||
| :::{table} pvforecast::provider_settings | ||||
|   | ||||
| @@ -184,6 +184,7 @@ Configuration options: | ||||
|   - `provider`: Load provider id of provider to be used. | ||||
|  | ||||
|     - `LoadAkkudoktor`: Retrieves from local database. | ||||
|     - `LoadVrm`: Retrieves data from the VRM API by Victron Energy. | ||||
|     - `LoadImport`: Imports from a file or JSON string. | ||||
|  | ||||
|   - `provider_settings.loadakkudoktor_year_energy`: Yearly energy consumption (kWh). | ||||
| @@ -196,6 +197,27 @@ The `LoadAkkudoktor` provider retrieves generic load data from a local database | ||||
| align with the annual energy consumption specified in the `loadakkudoktor_year_energy` configuration | ||||
| option. | ||||
|  | ||||
| ### LoadVrm Provider | ||||
|  | ||||
| The `LoadVrm` provider retrieves load forecast data from the VRM API by Victron Energy. | ||||
| To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal. | ||||
| To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences. | ||||
| This token must be stored in the EOS configuration along with the VRM-Installations-ID. | ||||
|  | ||||
| ```python | ||||
|     { | ||||
|         "load": { | ||||
|             "provider": "LoadVrm", | ||||
|             "provider_settings": { | ||||
|                 "load_vrm_token": "dummy-token", | ||||
|                 "load_vrm_idsite": 12345 | ||||
|     } | ||||
| ``` | ||||
|  | ||||
| The prediction keys for the load forecast data are: | ||||
|  | ||||
| - `load_mean`: Predicted load mean value (W). | ||||
|  | ||||
| ### LoadImport Provider | ||||
|  | ||||
| The `LoadImport` provider is designed to import load forecast data from a file or a JSON | ||||
| @@ -234,6 +256,7 @@ Configuration options: | ||||
|   - `provider`: PVForecast provider id of provider to be used. | ||||
|  | ||||
|     - `PVForecastAkkudoktor`: Retrieves from Akkudoktor.net. | ||||
|     - `PVForecastVrm`: Retrieves data from the VRM API by Victron Energy. | ||||
|     - `PVForecastImport`: Imports from a file or JSON string. | ||||
|  | ||||
|   - `planes[].surface_tilt`: Tilt angle from horizontal plane. Ignored for two-axis tracking. | ||||
| @@ -422,6 +445,28 @@ Example: | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### PVForecastVrm Provider | ||||
|  | ||||
| The `PVForecastVrm` provider retrieves pv power forecast data from the VRM API by Victron Energy. | ||||
| To receive forecasts, the system data must be configured under Dynamic ESS in the VRM portal. | ||||
| To query the forecasts, an API token is required, which can also be created in the VRM portal under Preferences. | ||||
| This token must be stored in the EOS configuration along with the VRM-Installations-ID. | ||||
|  | ||||
| ```python | ||||
|     { | ||||
|         "pvforecast": { | ||||
|             "provider": "PVForecastVrm", | ||||
|             "provider_settings": { | ||||
|                 "pvforecast_vrm_token": "dummy-token", | ||||
|                 "pvforecast_vrm_idsite": 12345 | ||||
|             } | ||||
|     } | ||||
| ``` | ||||
|  | ||||
| The prediction keys for the PV forecast data are: | ||||
|  | ||||
| - `pvforecast_dc_power`: Total DC power (W). | ||||
|  | ||||
| ### PVForecastImport Provider | ||||
|  | ||||
| The `PVForecastImport` provider is designed to import PV forecast data from a file or a JSON | ||||
| @@ -430,8 +475,8 @@ becomes available. | ||||
|  | ||||
| The prediction keys for the PV forecast data are: | ||||
|  | ||||
| - `pvforecast_ac_power`: Total DC power (W). | ||||
| - `pvforecast_dc_power`: Total AC power (W). | ||||
| - `pvforecast_ac_power`: Total AC power (W). | ||||
| - `pvforecast_dc_power`: Total DC power (W). | ||||
|  | ||||
| The PV forecast data must be provided in one of the formats described in | ||||
| <project:#prediction-import-providers>. The data source can be given in the | ||||
|   | ||||
							
								
								
									
										9685
									
								
								openapi.json
									
									
									
									
									
								
							
							
						
						
									
										9685
									
								
								openapi.json
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -8,6 +8,7 @@ from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.prediction.loadabc import LoadProvider | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings | ||||
| from akkudoktoreos.prediction.loadimport import LoadImportCommonSettings | ||||
| from akkudoktoreos.prediction.loadvrm import LoadVrmCommonSettings | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
|  | ||||
| prediction_eos = get_prediction() | ||||
| @@ -29,9 +30,9 @@ class LoadCommonSettings(SettingsBaseModel): | ||||
|         examples=["LoadAkkudoktor"], | ||||
|     ) | ||||
|  | ||||
|     provider_settings: Optional[Union[LoadAkkudoktorCommonSettings, LoadImportCommonSettings]] = ( | ||||
|         Field(default=None, description="Provider settings", examples=[None]) | ||||
|     ) | ||||
|     provider_settings: Optional[ | ||||
|         Union[LoadAkkudoktorCommonSettings, LoadVrmCommonSettings, LoadImportCommonSettings] | ||||
|     ] = Field(default=None, description="Provider settings", examples=[None]) | ||||
|  | ||||
|     # Validators | ||||
|     @field_validator("provider", mode="after") | ||||
|   | ||||
							
								
								
									
										109
									
								
								src/akkudoktoreos/prediction/loadvrm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/akkudoktoreos/prediction/loadvrm.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| """Retrieves load forecast data from VRM API.""" | ||||
|  | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| import requests | ||||
| from loguru import logger | ||||
| from pendulum import DateTime | ||||
| from pydantic import Field, ValidationError | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.pydantic import PydanticBaseModel | ||||
| from akkudoktoreos.prediction.loadabc import LoadProvider | ||||
| from akkudoktoreos.utils.datetimeutil import to_datetime | ||||
|  | ||||
|  | ||||
| class VrmForecastRecords(PydanticBaseModel): | ||||
|     vrm_consumption_fc: list[tuple[int, float]] | ||||
|     solar_yield_forecast: list[tuple[int, float]] | ||||
|  | ||||
|  | ||||
| class VrmForecastResponse(PydanticBaseModel): | ||||
|     success: bool | ||||
|     records: VrmForecastRecords | ||||
|     totals: dict | ||||
|  | ||||
|  | ||||
| class LoadVrmCommonSettings(SettingsBaseModel): | ||||
|     """Common settings for VRM API.""" | ||||
|  | ||||
|     load_vrm_token: str = Field( | ||||
|         default="your-token", description="Token for Connecting VRM API", examples=["your-token"] | ||||
|     ) | ||||
|     load_vrm_idsite: int = Field(default=12345, description="VRM-Installation-ID", examples=[12345]) | ||||
|  | ||||
|  | ||||
| class LoadVrm(LoadProvider): | ||||
|     """Fetch Load forecast data from VRM API.""" | ||||
|  | ||||
|     @classmethod | ||||
|     def provider_id(cls) -> str: | ||||
|         return "LoadVrm" | ||||
|  | ||||
|     @classmethod | ||||
|     def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse: | ||||
|         """Validate the VRM API load forecast response.""" | ||||
|         try: | ||||
|             return VrmForecastResponse.model_validate_json(json_str) | ||||
|         except ValidationError as e: | ||||
|             error_msg = "\n".join( | ||||
|                 f"Field: {' -> '.join(str(x) for x in err['loc'])}\n" | ||||
|                 f"Error: {err['msg']}\nType: {err['type']}" | ||||
|                 for err in e.errors() | ||||
|             ) | ||||
|             logger.error(f"VRM-API schema validation failed:\n{error_msg}") | ||||
|             raise ValueError(error_msg) | ||||
|  | ||||
|     def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse: | ||||
|         """Fetch forecast data from Victron VRM API.""" | ||||
|         base_url = "https://vrmapi.victronenergy.com/v2/installations" | ||||
|         installation_id = self.config.load.provider_settings.load_vrm_idsite | ||||
|         api_token = self.config.load.provider_settings.load_vrm_token | ||||
|  | ||||
|         url = f"{base_url}/{installation_id}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours" | ||||
|         headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"} | ||||
|  | ||||
|         logger.debug(f"Requesting VRM load forecast: {url}") | ||||
|         try: | ||||
|             response = requests.get(url, headers=headers, timeout=30) | ||||
|             response.raise_for_status() | ||||
|         except requests.RequestException as e: | ||||
|             logger.error(f"Error during VRM API request: {e}") | ||||
|             raise RuntimeError("Failed to fetch load forecast from VRM API") from e | ||||
|  | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) | ||||
|         return self._validate_data(response.content) | ||||
|  | ||||
|     def _ts_to_datetime(self, timestamp: int) -> DateTime: | ||||
|         """Convert UNIX ms timestamp to timezone-aware datetime.""" | ||||
|         return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone) | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
|         """Fetch and store VRM load forecast as load_mean and related values.""" | ||||
|         start_date = self.start_datetime.start_of("day") | ||||
|         end_date = self.start_datetime.add(hours=self.config.prediction.hours) | ||||
|         start_ts = int(start_date.timestamp()) | ||||
|         end_ts = int(end_date.timestamp()) | ||||
|  | ||||
|         logger.info(f"Updating Load forecast from VRM: {start_date} to {end_date}") | ||||
|         vrm_forecast_data = self._request_forecast(start_ts, end_ts) | ||||
|  | ||||
|         load_mean_data = [] | ||||
|         for timestamp, value in vrm_forecast_data.records.vrm_consumption_fc: | ||||
|             date = self._ts_to_datetime(timestamp) | ||||
|             rounded_value = round(value, 2) | ||||
|  | ||||
|             self.update_value( | ||||
|                 date, | ||||
|                 {"load_mean": rounded_value, "load_std": 0.0, "load_mean_adjusted": rounded_value}, | ||||
|             ) | ||||
|  | ||||
|             load_mean_data.append((date, rounded_value)) | ||||
|  | ||||
|         logger.debug(f"Updated load_mean with {len(load_mean_data)} entries.") | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     lv = LoadVrm() | ||||
|     lv._update_data() | ||||
| @@ -36,9 +36,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts | ||||
| from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor | ||||
| from akkudoktoreos.prediction.loadimport import LoadImport | ||||
| from akkudoktoreos.prediction.loadvrm import LoadVrm | ||||
| from akkudoktoreos.prediction.predictionabc import PredictionContainer | ||||
| from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor | ||||
| from akkudoktoreos.prediction.pvforecastimport import PVForecastImport | ||||
| from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm | ||||
| from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky | ||||
| from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside | ||||
| from akkudoktoreos.prediction.weatherimport import WeatherImport | ||||
| @@ -87,8 +89,10 @@ class Prediction(PredictionContainer): | ||||
|             ElecPriceEnergyCharts, | ||||
|             ElecPriceImport, | ||||
|             LoadAkkudoktor, | ||||
|             LoadVrm, | ||||
|             LoadImport, | ||||
|             PVForecastAkkudoktor, | ||||
|             PVForecastVrm, | ||||
|             PVForecastImport, | ||||
|             WeatherBrightSky, | ||||
|             WeatherClearOutside, | ||||
| @@ -102,8 +106,10 @@ elecprice_akkudoktor = ElecPriceAkkudoktor() | ||||
| elecprice_energy_charts = ElecPriceEnergyCharts() | ||||
| elecprice_import = ElecPriceImport() | ||||
| load_akkudoktor = LoadAkkudoktor() | ||||
| load_vrm = LoadVrm() | ||||
| load_import = LoadImport() | ||||
| pvforecast_akkudoktor = PVForecastAkkudoktor() | ||||
| pvforecast_vrm = PVForecastVrm() | ||||
| pvforecast_import = PVForecastImport() | ||||
| weather_brightsky = WeatherBrightSky() | ||||
| weather_clearoutside = WeatherClearOutside() | ||||
| @@ -120,8 +126,10 @@ def get_prediction() -> Prediction: | ||||
|             elecprice_energy_charts, | ||||
|             elecprice_import, | ||||
|             load_akkudoktor, | ||||
|             load_vrm, | ||||
|             load_import, | ||||
|             pvforecast_akkudoktor, | ||||
|             pvforecast_vrm, | ||||
|             pvforecast_import, | ||||
|             weather_brightsky, | ||||
|             weather_clearoutside, | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| """PV forecast module for PV power predictions.""" | ||||
|  | ||||
| from typing import Any, List, Optional, Self | ||||
| from typing import Any, List, Optional, Self, Union | ||||
|  | ||||
| from pydantic import Field, computed_field, field_validator, model_validator | ||||
|  | ||||
| @@ -8,6 +8,7 @@ from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.prediction.prediction import get_prediction | ||||
| from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider | ||||
| from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings | ||||
| from akkudoktoreos.prediction.pvforecastvrm import PVforecastVrmCommonSettings | ||||
| from akkudoktoreos.utils.docs import get_model_structure_from_examples | ||||
|  | ||||
| prediction_eos = get_prediction() | ||||
| @@ -134,9 +135,9 @@ class PVForecastCommonSettings(SettingsBaseModel): | ||||
|         examples=["PVForecastAkkudoktor"], | ||||
|     ) | ||||
|  | ||||
|     provider_settings: Optional[PVForecastImportCommonSettings] = Field( | ||||
|         default=None, description="Provider settings", examples=[None] | ||||
|     ) | ||||
|     provider_settings: Optional[ | ||||
|         Union[PVForecastImportCommonSettings, PVforecastVrmCommonSettings] | ||||
|     ] = Field(default=None, description="Provider settings", examples=[None]) | ||||
|  | ||||
|     planes: Optional[list[PVForecastPlaneSetting]] = Field( | ||||
|         default=None, | ||||
|   | ||||
							
								
								
									
										110
									
								
								src/akkudoktoreos/prediction/pvforecastvrm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								src/akkudoktoreos/prediction/pvforecastvrm.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| """Retrieves pvforecast data from VRM API.""" | ||||
|  | ||||
| from typing import Any, Optional, Union | ||||
|  | ||||
| import requests | ||||
| from loguru import logger | ||||
| from pendulum import DateTime | ||||
| from pydantic import Field, ValidationError | ||||
|  | ||||
| from akkudoktoreos.config.configabc import SettingsBaseModel | ||||
| from akkudoktoreos.core.pydantic import PydanticBaseModel | ||||
| from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider | ||||
| from akkudoktoreos.utils.datetimeutil import to_datetime | ||||
|  | ||||
|  | ||||
| class VrmForecastRecords(PydanticBaseModel): | ||||
|     vrm_consumption_fc: list[tuple[int, float]] | ||||
|     solar_yield_forecast: list[tuple[int, float]] | ||||
|  | ||||
|  | ||||
| class VrmForecastResponse(PydanticBaseModel): | ||||
|     success: bool | ||||
|     records: VrmForecastRecords | ||||
|     totals: dict | ||||
|  | ||||
|  | ||||
| class PVforecastVrmCommonSettings(SettingsBaseModel): | ||||
|     """Common settings for VRM API.""" | ||||
|  | ||||
|     pvforecast_vrm_token: str = Field( | ||||
|         default="your-token", description="Token for Connecting VRM API", examples=["your-token"] | ||||
|     ) | ||||
|     pvforecast_vrm_idsite: int = Field( | ||||
|         default=12345, description="VRM-Installation-ID", examples=[12345] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class PVForecastVrm(PVForecastProvider): | ||||
|     """Fetch and process PV forecast data from VRM API.""" | ||||
|  | ||||
|     @classmethod | ||||
|     def provider_id(cls) -> str: | ||||
|         """Return the unique identifier for the PV-Forecast-Provider.""" | ||||
|         return "PVForecastVrm" | ||||
|  | ||||
|     @classmethod | ||||
|     def _validate_data(cls, json_str: Union[bytes, Any]) -> VrmForecastResponse: | ||||
|         """Validate the VRM forecast response data against the expected schema.""" | ||||
|         try: | ||||
|             return VrmForecastResponse.model_validate_json(json_str) | ||||
|         except ValidationError as e: | ||||
|             error_msg = "\n".join( | ||||
|                 f"Field: {' -> '.join(str(x) for x in err['loc'])}\n" | ||||
|                 f"Error: {err['msg']}\nType: {err['type']}" | ||||
|                 for err in e.errors() | ||||
|             ) | ||||
|             logger.error(f"VRM-API schema change:\n{error_msg}") | ||||
|             raise ValueError(error_msg) | ||||
|  | ||||
|     def _request_forecast(self, start_ts: int, end_ts: int) -> VrmForecastResponse: | ||||
|         """Fetch forecast data from Victron VRM API.""" | ||||
|         source = "https://vrmapi.victronenergy.com/v2/installations" | ||||
|         id_site = self.config.pvforecast.provider_settings.pvforecast_vrm_idsite | ||||
|         api_token = self.config.pvforecast.provider_settings.pvforecast_vrm_token | ||||
|         headers = {"X-Authorization": f"Token {api_token}", "Content-Type": "application/json"} | ||||
|         url = f"{source}/{id_site}/stats?type=forecast&start={start_ts}&end={end_ts}&interval=hours" | ||||
|         logger.debug(f"Requesting VRM forecast: {url}") | ||||
|  | ||||
|         try: | ||||
|             response = requests.get(url, headers=headers, timeout=30) | ||||
|             response.raise_for_status() | ||||
|         except requests.RequestException as e: | ||||
|             logger.error(f"Failed to fetch pvforecast: {e}") | ||||
|             raise RuntimeError("Failed to fetch pvforecast from VRM API") from e | ||||
|  | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) | ||||
|         return self._validate_data(response.content) | ||||
|  | ||||
|     def _ts_to_datetime(self, timestamp: int) -> DateTime: | ||||
|         """Convert UNIX ms timestamp to timezone-aware datetime.""" | ||||
|         return to_datetime(timestamp / 1000, in_timezone=self.config.general.timezone) | ||||
|  | ||||
|     def _update_data(self, force_update: Optional[bool] = False) -> None: | ||||
|         """Update forecast data in the PVForecastDataRecord format.""" | ||||
|         start_date = self.start_datetime.start_of("day") | ||||
|         end_date = self.start_datetime.add(hours=self.config.prediction.hours) | ||||
|         start_ts = int(start_date.timestamp()) | ||||
|         end_ts = int(end_date.timestamp()) | ||||
|  | ||||
|         logger.info(f"Updating PV forecast from VRM: {start_date} to {end_date}") | ||||
|         vrm_forecast_data = self._request_forecast(start_ts, end_ts) | ||||
|  | ||||
|         pv_forecast = [] | ||||
|         for timestamp, value in vrm_forecast_data.records.solar_yield_forecast: | ||||
|             date = self._ts_to_datetime(timestamp) | ||||
|             dc_power = round(value, 2) | ||||
|             ac_power = round(dc_power * 0.96, 2) | ||||
|             self.update_value( | ||||
|                 date, {"pvforecast_dc_power": dc_power, "pvforecast_ac_power": ac_power} | ||||
|             ) | ||||
|             pv_forecast.append((date, dc_power)) | ||||
|  | ||||
|         logger.debug(f"Updated pvforecast_dc_power with {len(pv_forecast)} entries.") | ||||
|         self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) | ||||
|  | ||||
|  | ||||
| # Example usage | ||||
| if __name__ == "__main__": | ||||
|     pv = PVForecastVrm() | ||||
|     pv._update_data() | ||||
							
								
								
									
										116
									
								
								tests/test_loadvrm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								tests/test_loadvrm.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| import json | ||||
| from unittest.mock import call, patch | ||||
|  | ||||
| import pendulum | ||||
| import pytest | ||||
| import requests | ||||
|  | ||||
| from akkudoktoreos.prediction.loadvrm import ( | ||||
|     LoadVrm, | ||||
|     VrmForecastRecords, | ||||
|     VrmForecastResponse, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def load_vrm_instance(config_eos): | ||||
|     # Settings für LoadVrm | ||||
|     settings = { | ||||
|         "load": { | ||||
|             "provider": "LoadVrm", | ||||
|             "provider_settings": { | ||||
|                 "load_vrm_token": "dummy-token", | ||||
|                 "load_vrm_idsite": 12345 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     config_eos.merge_settings_from_dict(settings) | ||||
|     # start_datetime initialize | ||||
|     start_dt = pendulum.datetime(2025, 1, 1, tz='Europe/Berlin') | ||||
|  | ||||
|     # create LoadVrm-instance with config and start_datetime | ||||
|     lv = LoadVrm(config=config_eos.load, start_datetime=start_dt) | ||||
|  | ||||
|     return lv | ||||
|  | ||||
|  | ||||
| def mock_forecast_response(): | ||||
|     """Return a fake VrmForecastResponse with sample data.""" | ||||
|     return VrmForecastResponse( | ||||
|         success=True, | ||||
|         records=VrmForecastRecords( | ||||
|             vrm_consumption_fc=[ | ||||
|                 (pendulum.datetime(2025, 1, 1, 0, 0, tz='Europe/Berlin').int_timestamp * 1000, 100.5), | ||||
|                 (pendulum.datetime(2025, 1, 1, 1, 0, tz='Europe/Berlin').int_timestamp * 1000, 101.2) | ||||
|             ], | ||||
|             solar_yield_forecast=[] | ||||
|         ), | ||||
|         totals={} | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_update_data_calls_update_value(load_vrm_instance): | ||||
|     with patch.object(load_vrm_instance, "_request_forecast", return_value=mock_forecast_response()), \ | ||||
|          patch.object(LoadVrm, "update_value") as mock_update: | ||||
|  | ||||
|         load_vrm_instance._update_data() | ||||
|  | ||||
|         assert mock_update.call_count == 2 | ||||
|  | ||||
|         expected_calls = [ | ||||
|             call( | ||||
|                 pendulum.datetime(2025, 1, 1, 0, 0, 0, tz='Europe/Berlin'), | ||||
|                 {"load_mean": 100.5, "load_std": 0.0, "load_mean_adjusted": 100.5} | ||||
|             ), | ||||
|             call( | ||||
|                 pendulum.datetime(2025, 1, 1, 1, 0, 0, tz='Europe/Berlin'), | ||||
|                 {"load_mean": 101.2, "load_std": 0.0, "load_mean_adjusted": 101.2} | ||||
|             ), | ||||
|         ] | ||||
|  | ||||
|         mock_update.assert_has_calls(expected_calls, any_order=False) | ||||
|  | ||||
|  | ||||
| def test_validate_data_accepts_valid_json(): | ||||
|     """Test that _validate_data doesn't raise with valid input.""" | ||||
|     response = mock_forecast_response() | ||||
|     json_data = response.model_dump_json() | ||||
|  | ||||
|     validated = LoadVrm._validate_data(json_data) | ||||
|     assert validated.success | ||||
|     assert len(validated.records.vrm_consumption_fc) == 2 | ||||
|  | ||||
|  | ||||
| def test_validate_data_raises_on_invalid_json(): | ||||
|     """_validate_data should raise ValueError on schema mismatch.""" | ||||
|     invalid_json = json.dumps({"success": True})  # missing 'records' | ||||
|  | ||||
|     with pytest.raises(ValueError) as exc_info: | ||||
|         LoadVrm._validate_data(invalid_json) | ||||
|  | ||||
|     assert "Field:" in str(exc_info.value) | ||||
|     assert "records" in str(exc_info.value) | ||||
|  | ||||
|  | ||||
| def test_request_forecast_raises_on_http_error(load_vrm_instance): | ||||
|     with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get: | ||||
|         with pytest.raises(RuntimeError) as exc_info: | ||||
|             load_vrm_instance._request_forecast(0, 1) | ||||
|  | ||||
|         assert "Failed to fetch load forecast" in str(exc_info.value) | ||||
|         mock_get.assert_called_once() | ||||
|  | ||||
|  | ||||
| def test_update_data_does_nothing_on_empty_forecast(load_vrm_instance): | ||||
|     empty_response = VrmForecastResponse( | ||||
|         success=True, | ||||
|         records=VrmForecastRecords(vrm_consumption_fc=[], solar_yield_forecast=[]), | ||||
|         totals={} | ||||
|     ) | ||||
|  | ||||
|     with patch.object(load_vrm_instance, "_request_forecast", return_value=empty_response), \ | ||||
|          patch.object(LoadVrm, "update_value") as mock_update: | ||||
|  | ||||
|         load_vrm_instance._update_data() | ||||
|  | ||||
|         mock_update.assert_not_called() | ||||
| @@ -6,6 +6,7 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts | ||||
| from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport | ||||
| from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor | ||||
| from akkudoktoreos.prediction.loadimport import LoadImport | ||||
| from akkudoktoreos.prediction.loadvrm import LoadVrm | ||||
| from akkudoktoreos.prediction.prediction import ( | ||||
|     Prediction, | ||||
|     PredictionCommonSettings, | ||||
| @@ -13,6 +14,7 @@ from akkudoktoreos.prediction.prediction import ( | ||||
| ) | ||||
| from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor | ||||
| from akkudoktoreos.prediction.pvforecastimport import PVForecastImport | ||||
| from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm | ||||
| from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky | ||||
| from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside | ||||
| from akkudoktoreos.prediction.weatherimport import WeatherImport | ||||
| @@ -32,8 +34,10 @@ def forecast_providers(): | ||||
|         ElecPriceEnergyCharts(), | ||||
|         ElecPriceImport(), | ||||
|         LoadAkkudoktor(), | ||||
|         LoadVrm(), | ||||
|         LoadImport(), | ||||
|         PVForecastAkkudoktor(), | ||||
|         PVForecastVrm(), | ||||
|         PVForecastImport(), | ||||
|         WeatherBrightSky(), | ||||
|         WeatherClearOutside(), | ||||
| @@ -73,12 +77,14 @@ def test_provider_sequence(prediction): | ||||
|     assert isinstance(prediction.providers[1], ElecPriceEnergyCharts) | ||||
|     assert isinstance(prediction.providers[2], ElecPriceImport) | ||||
|     assert isinstance(prediction.providers[3], LoadAkkudoktor) | ||||
|     assert isinstance(prediction.providers[4], LoadImport) | ||||
|     assert isinstance(prediction.providers[5], PVForecastAkkudoktor) | ||||
|     assert isinstance(prediction.providers[6], PVForecastImport) | ||||
|     assert isinstance(prediction.providers[7], WeatherBrightSky) | ||||
|     assert isinstance(prediction.providers[8], WeatherClearOutside) | ||||
|     assert isinstance(prediction.providers[9], WeatherImport) | ||||
|     assert isinstance(prediction.providers[4], LoadVrm) | ||||
|     assert isinstance(prediction.providers[5], LoadImport) | ||||
|     assert isinstance(prediction.providers[6], PVForecastAkkudoktor) | ||||
|     assert isinstance(prediction.providers[7], PVForecastVrm) | ||||
|     assert isinstance(prediction.providers[8], PVForecastImport) | ||||
|     assert isinstance(prediction.providers[9], WeatherBrightSky) | ||||
|     assert isinstance(prediction.providers[10], WeatherClearOutside) | ||||
|     assert isinstance(prediction.providers[11], WeatherImport) | ||||
|  | ||||
|  | ||||
| def test_provider_by_id(prediction, forecast_providers): | ||||
| @@ -95,8 +101,10 @@ def test_prediction_repr(prediction): | ||||
|     assert "ElecPriceEnergyCharts" in result | ||||
|     assert "ElecPriceImport" in result | ||||
|     assert "LoadAkkudoktor" in result | ||||
|     assert "LoadVrm" in result | ||||
|     assert "LoadImport" in result | ||||
|     assert "PVForecastAkkudoktor" in result | ||||
|     assert "PVForecastVrm" in result | ||||
|     assert "PVForecastImport" in result | ||||
|     assert "WeatherBrightSky" in result | ||||
|     assert "WeatherClearOutside" in result | ||||
|   | ||||
							
								
								
									
										116
									
								
								tests/test_pvforecastvrm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								tests/test_pvforecastvrm.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,116 @@ | ||||
| import json | ||||
| from unittest.mock import call, patch | ||||
|  | ||||
| import pendulum | ||||
| import pytest | ||||
| import requests | ||||
|  | ||||
| from akkudoktoreos.prediction.pvforecastvrm import ( | ||||
|     PVForecastVrm, | ||||
|     VrmForecastRecords, | ||||
|     VrmForecastResponse, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def pvforecast_instance(config_eos): | ||||
|     # Settings for PVForecastVrm | ||||
|     settings = { | ||||
|         "pvforecast": { | ||||
|             "provider": "PVForecastVrm", | ||||
|             "provider_settings": { | ||||
|                 "pvforecast_vrm_token": "dummy-token", | ||||
|                 "pvforecast_vrm_idsite": 12345 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     config_eos.merge_settings_from_dict(settings) | ||||
|     # start_datetime initialize | ||||
|     start_dt = pendulum.datetime(2025, 1, 1, tz='Europe/Berlin') | ||||
|  | ||||
|     # create PVForecastVrm-instance with config and start_datetime | ||||
|     pv = PVForecastVrm(config=config_eos.load, start_datetime=start_dt) | ||||
|  | ||||
|     return pv | ||||
|  | ||||
|  | ||||
| def mock_forecast_response(): | ||||
|     """Return a fake VrmForecastResponse with sample data.""" | ||||
|     return VrmForecastResponse( | ||||
|         success=True, | ||||
|         records=VrmForecastRecords( | ||||
|             vrm_consumption_fc=[], | ||||
|             solar_yield_forecast=[ | ||||
|                 (pendulum.datetime(2025, 1, 1, 0, 0, tz='Europe/Berlin').int_timestamp * 1000, 120.0), | ||||
|                 (pendulum.datetime(2025, 1, 1, 1, 0, tz='Europe/Berlin').int_timestamp * 1000, 130.0) | ||||
|             ] | ||||
|         ), | ||||
|         totals={} | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_update_data_updates_dc_and_ac_power(pvforecast_instance): | ||||
|     with patch.object(pvforecast_instance, "_request_forecast", return_value=mock_forecast_response()), \ | ||||
|          patch.object(PVForecastVrm, "update_value") as mock_update: | ||||
|  | ||||
|         pvforecast_instance._update_data() | ||||
|  | ||||
|         # Check that update_value was called correctly | ||||
|         assert mock_update.call_count == 2 | ||||
|  | ||||
|         expected_calls = [ | ||||
|             call( | ||||
|                 pendulum.datetime(2025, 1, 1, 0, 0, tz='Europe/Berlin'), | ||||
|                 {"pvforecast_dc_power": 120.0, "pvforecast_ac_power": 115.2} | ||||
|             ), | ||||
|             call( | ||||
|                 pendulum.datetime(2025, 1, 1, 1, 0, tz='Europe/Berlin'), | ||||
|                 {"pvforecast_dc_power": 130.0, "pvforecast_ac_power": 124.8} | ||||
|             ), | ||||
|         ] | ||||
|  | ||||
|         mock_update.assert_has_calls(expected_calls, any_order=False) | ||||
|  | ||||
|  | ||||
| def test_validate_data_accepts_valid_json(): | ||||
|     """Test that _validate_data doesn't raise with valid input.""" | ||||
|     response = mock_forecast_response() | ||||
|     json_data = response.model_dump_json() | ||||
|  | ||||
|     validated = PVForecastVrm._validate_data(json_data) | ||||
|     assert validated.success | ||||
|     assert len(validated.records.solar_yield_forecast) == 2 | ||||
|  | ||||
|  | ||||
| def test_validate_data_invalid_json_raises(): | ||||
|     """Test that _validate_data raises with invalid input.""" | ||||
|     invalid_json = json.dumps({"success": True})  # missing 'records' | ||||
|     with pytest.raises(ValueError) as exc_info: | ||||
|         PVForecastVrm._validate_data(invalid_json) | ||||
|     assert "Field:" in str(exc_info.value) | ||||
|     assert "records" in str(exc_info.value) | ||||
|  | ||||
|  | ||||
| def test_request_forecast_raises_on_http_error(pvforecast_instance): | ||||
|     """Ensure _request_forecast raises RuntimeError on HTTP failure.""" | ||||
|     with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get: | ||||
|         with pytest.raises(RuntimeError) as exc_info: | ||||
|             pvforecast_instance._request_forecast(0, 1) | ||||
|  | ||||
|         assert "Failed to fetch pvforecast" in str(exc_info.value) | ||||
|         mock_get.assert_called_once() | ||||
|  | ||||
|  | ||||
| def test_update_data_skips_on_empty_forecast(pvforecast_instance): | ||||
|     """Ensure no update_value calls are made if no forecast data is present.""" | ||||
|     empty_response = VrmForecastResponse( | ||||
|         success=True, | ||||
|         records=VrmForecastRecords(vrm_consumption_fc=[], solar_yield_forecast=[]), | ||||
|         totals={} | ||||
|     ) | ||||
|  | ||||
|     with patch.object(pvforecast_instance, "_request_forecast", return_value=empty_response), \ | ||||
|          patch.object(PVForecastVrm, "update_value") as mock_update: | ||||
|  | ||||
|         pvforecast_instance._update_data() | ||||
|         mock_update.assert_not_called() | ||||
		Reference in New Issue
	
	Block a user