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:
		| @@ -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() | ||||
		Reference in New Issue
	
	Block a user