From c1dd31528be6a1f1a9d6040ff4106933045ec68b Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Mon, 20 Jan 2025 22:58:59 +0100 Subject: [PATCH] Config: Move lat/long/timezone from prediction to general --- docs/_generated/config.md | 65 +++--- docs/akkudoktoreos/prediction.md | 6 +- openapi.json | 207 +++++++----------- single_test_optimization.py | 6 +- single_test_prediction.py | 26 ++- src/akkudoktoreos/config/config.py | 39 +++- .../prediction/elecpriceakkudoktor.py | 6 +- .../prediction/loadakkudoktor.py | 2 +- src/akkudoktoreos/prediction/prediction.py | 37 +--- .../prediction/pvforecastakkudoktor.py | 26 ++- .../prediction/weatherbrightsky.py | 6 +- .../prediction/weatherclearoutside.py | 8 +- src/akkudoktoreos/utils/visualize.py | 8 +- tests/test_config.py | 67 +++++- tests/test_prediction.py | 58 +---- tests/test_predictionabc.py | 26 +-- tests/test_pvforecastakkudoktor.py | 12 +- tests/test_weatherbrightsky.py | 4 +- tests/test_weatherclearoutside.py | 4 +- 19 files changed, 297 insertions(+), 316 deletions(-) diff --git a/docs/_generated/config.md b/docs/_generated/config.md index 5742982..bef3710 100644 --- a/docs/_generated/config.md +++ b/docs/_generated/config.md @@ -2,7 +2,22 @@ ## Settings for common configuration -General configuration to set directories of cache and output files. +General configuration to set directories of cache and output files and system location (latitude +and longitude). +Validators ensure each parameter is within a specified range. A computed property, `timezone`, +determines the time zone based on latitude and longitude. + +Attributes: + latitude (Optional[float]): Latitude in degrees, must be between -90 and 90. + longitude (Optional[float]): Longitude in degrees, must be between -180 and 180. + +Properties: + timezone (Optional[str]): Computed time zone string based on the specified latitude + and longitude. + +Validators: + validate_latitude (float): Ensures `latitude` is within the range -90 to 90. + validate_longitude (float): Ensures `longitude` is within the range -180 to 180. :::{table} general :widths: 10 20 10 5 5 30 @@ -13,6 +28,9 @@ General configuration to set directories of cache and output files. | data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Optional[pathlib.Path]` | `rw` | `None` | Path to EOS data directory. | | data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `output` | Sub-path for the EOS output data directory. | | data_cache_subpath | `EOS_GENERAL__DATA_CACHE_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `cache` | Sub-path for the EOS cache data directory. | +| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°) | +| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees, within -180 to 180 (°) | +| timezone | | `Optional[str]` | `ro` | `N/A` | Compute timezone based on latitude and longitude. | | data_output_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Compute data_output_path based on data_folder_path. | | data_cache_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Compute data_cache_path based on data_folder_path. | | config_folder_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration directory. | @@ -28,7 +46,9 @@ General configuration to set directories of cache and output files. "general": { "data_folder_path": null, "data_output_subpath": "output", - "data_cache_subpath": "cache" + "data_cache_subpath": "cache", + "latitude": 52.52, + "longitude": 13.405 } } ``` @@ -43,6 +63,9 @@ General configuration to set directories of cache and output files. "data_folder_path": null, "data_output_subpath": "output", "data_cache_subpath": "cache", + "latitude": 52.52, + "longitude": 13.405, + "timezone": "Europe/Berlin", "data_output_path": null, "data_cache_path": null, "config_folder_path": "/home/user/.config/net.akkudoktoreos.net", @@ -308,27 +331,18 @@ Attributes: ## General Prediction Configuration This class provides configuration for prediction settings, allowing users to specify -parameters such as the forecast duration (in hours) and location (latitude and longitude). -Validators ensure each parameter is within a specified range. A computed property, `timezone`, -determines the time zone based on latitude and longitude. +parameters such as the forecast duration (in hours). +Validators ensure each parameter is within a specified range. Attributes: hours (Optional[int]): Number of hours into the future for predictions. Must be non-negative. historic_hours (Optional[int]): Number of hours into the past for historical data. Must be non-negative. - latitude (Optional[float]): Latitude in degrees, must be between -90 and 90. - longitude (Optional[float]): Longitude in degrees, must be between -180 and 180. - -Properties: - timezone (Optional[str]): Computed time zone string based on the specified latitude - and longitude. Validators: validate_hours (int): Ensures `hours` is a non-negative integer. validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer. - validate_latitude (float): Ensures `latitude` is within the range -90 to 90. - validate_longitude (float): Ensures `longitude` is within the range -180 to 180. :::{table} prediction :widths: 10 20 10 5 5 30 @@ -338,12 +352,9 @@ Validators: | ---- | -------------------- | ---- | --------- | ------- | ----------- | | hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the future for predictions | | historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the past for historical predictions data | -| latitude | `EOS_PREDICTION__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°) | -| longitude | `EOS_PREDICTION__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees, within -180 to 180 (°) | -| timezone | | `Optional[str]` | `ro` | `N/A` | Compute timezone based on latitude and longitude. | ::: -### Example Input +### Example Input/Output ```{eval-rst} .. code-block:: json @@ -351,25 +362,7 @@ Validators: { "prediction": { "hours": 48, - "historic_hours": 48, - "latitude": 52.52, - "longitude": 13.405 - } - } -``` - -### Example Output - -```{eval-rst} -.. code-block:: json - - { - "prediction": { - "hours": 48, - "historic_hours": 48, - "latitude": 52.52, - "longitude": 13.405, - "timezone": "Europe/Berlin" + "historic_hours": 48 } } ``` diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index df7260c..6bf82f3 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -295,8 +295,8 @@ The `PVForecastAkkudoktor` provider retrieves the PV power forecast data directl The following prediction configuration options of the PV system must be set: -- `prediction.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" -- `prediction.longitude`: Longitude in decimal degrees, within -180 to 180 (°) +- `general.latitude`: Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (°)" +- `general.longitude`: Longitude in decimal degrees, within -180 to 180 (°) For each plane of the PV system the following configuration options must be set: @@ -310,7 +310,7 @@ Example: ```Python { - "prediction": { + "general": { "latitude": 50.1234, "longitude": 9.7654, }, diff --git a/openapi.json b/openapi.json index 0d3626b..35af176 100644 --- a/openapi.json +++ b/openapi.json @@ -107,7 +107,7 @@ "type": "object" }, "ConfigCommonSettings-Input": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files.", + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", "properties": { "data_cache_subpath": { "anyOf": [ @@ -153,13 +153,43 @@ "default": "output", "description": "Sub-path for the EOS output data directory.", "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" } }, "title": "ConfigCommonSettings", "type": "object" }, "ConfigCommonSettings-Output": { - "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files.", + "description": "Settings for common configuration.\n\nGeneral configuration to set directories of cache and output files and system location (latitude\nand longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", "properties": { "config_file_path": { "anyOf": [ @@ -261,9 +291,53 @@ "default": "output", "description": "Sub-path for the EOS output data directory.", "title": "Data Output Subpath" + }, + "latitude": { + "anyOf": [ + { + "maximum": 90.0, + "minimum": -90.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 52.52, + "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "maximum": 180.0, + "minimum": -180.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": 13.405, + "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", + "title": "Longitude" + }, + "timezone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Compute timezone based on latitude and longitude.", + "readOnly": true, + "title": "Timezone" } }, "required": [ + "timezone", "data_output_path", "data_cache_path", "config_folder_path", @@ -290,7 +364,10 @@ "config_file_path": "/home/user/.config/net.akkudoktoreos.net/EOS.config.json", "config_folder_path": "/home/user/.config/net.akkudoktoreos.net", "data_cache_subpath": "cache", - "data_output_subpath": "output" + "data_output_subpath": "output", + "latitude": 52.52, + "longitude": 13.405, + "timezone": "Europe/Berlin" } }, "load": { @@ -324,13 +401,10 @@ } }, "prediction": { - "$ref": "#/components/schemas/PredictionCommonSettings-Output", + "$ref": "#/components/schemas/PredictionCommonSettings", "default": { "historic_hours": 48, - "hours": 48, - "latitude": 52.52, - "longitude": 13.405, - "timezone": "Europe/Berlin" + "hours": 48 } }, "pvforecast": { @@ -1989,8 +2063,8 @@ "title": "PVForecastPlaneSetting", "type": "object" }, - "PredictionCommonSettings-Input": { - "description": "General Prediction Configuration.\n\nThis class provides configuration for prediction settings, allowing users to specify\nparameters such as the forecast duration (in hours) and location (latitude and longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n hours (Optional[int]): Number of hours into the future for predictions.\n Must be non-negative.\n historic_hours (Optional[int]): Number of hours into the past for historical data.\n Must be non-negative.\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_hours (int): Ensures `hours` is a non-negative integer.\n validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", + "PredictionCommonSettings": { + "description": "General Prediction Configuration.\n\nThis class provides configuration for prediction settings, allowing users to specify\nparameters such as the forecast duration (in hours).\nValidators ensure each parameter is within a specified range.\n\nAttributes:\n hours (Optional[int]): Number of hours into the future for predictions.\n Must be non-negative.\n historic_hours (Optional[int]): Number of hours into the past for historical data.\n Must be non-negative.\n\nValidators:\n validate_hours (int): Ensures `hours` is a non-negative integer.\n validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.", "properties": { "historic_hours": { "anyOf": [ @@ -2019,122 +2093,11 @@ "default": 48, "description": "Number of hours into the future for predictions", "title": "Hours" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" } }, "title": "PredictionCommonSettings", "type": "object" }, - "PredictionCommonSettings-Output": { - "description": "General Prediction Configuration.\n\nThis class provides configuration for prediction settings, allowing users to specify\nparameters such as the forecast duration (in hours) and location (latitude and longitude).\nValidators ensure each parameter is within a specified range. A computed property, `timezone`,\ndetermines the time zone based on latitude and longitude.\n\nAttributes:\n hours (Optional[int]): Number of hours into the future for predictions.\n Must be non-negative.\n historic_hours (Optional[int]): Number of hours into the past for historical data.\n Must be non-negative.\n latitude (Optional[float]): Latitude in degrees, must be between -90 and 90.\n longitude (Optional[float]): Longitude in degrees, must be between -180 and 180.\n\nProperties:\n timezone (Optional[str]): Computed time zone string based on the specified latitude\n and longitude.\n\nValidators:\n validate_hours (int): Ensures `hours` is a non-negative integer.\n validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer.\n validate_latitude (float): Ensures `latitude` is within the range -90 to 90.\n validate_longitude (float): Ensures `longitude` is within the range -180 to 180.", - "properties": { - "historic_hours": { - "anyOf": [ - { - "minimum": 0.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": 48, - "description": "Number of hours into the past for historical predictions data", - "title": "Historic Hours" - }, - "hours": { - "anyOf": [ - { - "minimum": 0.0, - "type": "integer" - }, - { - "type": "null" - } - ], - "default": 48, - "description": "Number of hours into the future for predictions", - "title": "Hours" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 52.52, - "description": "Latitude in decimal degrees, between -90 and 90, north is positive (ISO 19115) (\u00b0)", - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "default": 13.405, - "description": "Longitude in decimal degrees, within -180 to 180 (\u00b0)", - "title": "Longitude" - }, - "timezone": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Compute timezone based on latitude and longitude.", - "readOnly": true, - "title": "Timezone" - } - }, - "required": [ - "timezone" - ], - "title": "PredictionCommonSettings", - "type": "object" - }, "PydanticDateTimeData": { "additionalProperties": { "anyOf": [ @@ -2404,7 +2367,7 @@ "prediction": { "anyOf": [ { - "$ref": "#/components/schemas/PredictionCommonSettings-Input" + "$ref": "#/components/schemas/PredictionCommonSettings" }, { "type": "null" diff --git a/single_test_optimization.py b/single_test_optimization.py index b832960..7b0f868 100755 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -30,11 +30,13 @@ def prepare_optimization_real_parameters() -> OptimizationParameters: """ # Make a config settings = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, # PV Forecast "pvforecast": { diff --git a/single_test_prediction.py b/single_test_prediction.py index fc2e479..0e6fd73 100644 --- a/single_test_prediction.py +++ b/single_test_prediction.py @@ -16,11 +16,13 @@ prediction_eos = get_prediction() def config_pvforecast() -> dict: """Configure settings for PV forecast.""" settings = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "pvforecast": { "provider": "PVForecastAkkudoktor", @@ -62,11 +64,13 @@ def config_pvforecast() -> dict: def config_weather() -> dict: """Configure settings for weather forecast.""" settings = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "weather": dict(), } @@ -76,11 +80,13 @@ def config_weather() -> dict: def config_elecprice() -> dict: """Configure settings for electricity price forecast.""" settings = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "elecprice": dict(), } @@ -90,12 +96,14 @@ def config_elecprice() -> dict: def config_load() -> dict: """Configure settings for load forecast.""" settings = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, - } + }, } return settings diff --git a/src/akkudoktoreos/config/config.py b/src/akkudoktoreos/config/config.py index 8478bf0..a7d3322 100644 --- a/src/akkudoktoreos/config/config.py +++ b/src/akkudoktoreos/config/config.py @@ -39,6 +39,7 @@ from akkudoktoreos.prediction.prediction import PredictionCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.weather import WeatherCommonSettings from akkudoktoreos.server.server import ServerCommonSettings +from akkudoktoreos.utils.datetimeutil import to_timezone from akkudoktoreos.utils.utils import UtilsCommonSettings, classproperty logger = get_logger(__name__) @@ -65,7 +66,22 @@ def get_absolute_path( class ConfigCommonSettings(SettingsBaseModel): """Settings for common configuration. - General configuration to set directories of cache and output files. + General configuration to set directories of cache and output files and system location (latitude + and longitude). + Validators ensure each parameter is within a specified range. A computed property, `timezone`, + determines the time zone based on latitude and longitude. + + Attributes: + latitude (Optional[float]): Latitude in degrees, must be between -90 and 90. + longitude (Optional[float]): Longitude in degrees, must be between -180 and 180. + + Properties: + timezone (Optional[str]): Computed time zone string based on the specified latitude + and longitude. + + Validators: + validate_latitude (float): Ensures `latitude` is within the range -90 to 90. + validate_longitude (float): Ensures `longitude` is within the range -180 to 180. """ _config_folder_path: ClassVar[Optional[Path]] = None @@ -83,7 +99,28 @@ class ConfigCommonSettings(SettingsBaseModel): default="cache", description="Sub-path for the EOS cache data directory." ) + latitude: Optional[float] = Field( + 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=13.405, + ge=-180.0, + le=180.0, + description="Longitude in decimal degrees, within -180 to 180 (°)", + ) + # Computed fields + @computed_field # type: ignore[prop-decorator] + @property + def timezone(self) -> Optional[str]: + """Compute timezone based on latitude and longitude.""" + if self.latitude and self.longitude: + return to_timezone(location=(self.latitude, self.longitude), as_string=True) + return None + @computed_field # type: ignore[prop-decorator] @property def data_output_path(self) -> Optional[Path]: diff --git a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py index 000ac8d..3d9918a 100644 --- a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py +++ b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py @@ -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.prediction.timezone}" + url = f"{source}/prices?start={date}&end={last_date}&tz={self.config.general.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.prediction.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) return akkudoktor_data def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray: @@ -160,7 +160,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider): 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.prediction.timezone) + orig_datetime = to_datetime(value.start, in_timezone=self.config.general.timezone) if highest_orig_datetime is None or orig_datetime > highest_orig_datetime: highest_orig_datetime = orig_datetime diff --git a/src/akkudoktoreos/prediction/loadakkudoktor.py b/src/akkudoktoreos/prediction/loadakkudoktor.py index 0d3cd8a..b10196a 100644 --- a/src/akkudoktoreos/prediction/loadakkudoktor.py +++ b/src/akkudoktoreos/prediction/loadakkudoktor.py @@ -129,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.prediction.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 808da39..0dd72e8 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -28,7 +28,7 @@ Attributes: from typing import List, Optional, Union -from pydantic import Field, computed_field +from pydantic import Field from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor @@ -41,34 +41,24 @@ from akkudoktoreos.prediction.pvforecastimport import PVForecastImport from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside from akkudoktoreos.prediction.weatherimport import WeatherImport -from akkudoktoreos.utils.datetimeutil import to_timezone class PredictionCommonSettings(SettingsBaseModel): """General Prediction Configuration. This class provides configuration for prediction settings, allowing users to specify - parameters such as the forecast duration (in hours) and location (latitude and longitude). - Validators ensure each parameter is within a specified range. A computed property, `timezone`, - determines the time zone based on latitude and longitude. + parameters such as the forecast duration (in hours). + Validators ensure each parameter is within a specified range. Attributes: hours (Optional[int]): Number of hours into the future for predictions. Must be non-negative. historic_hours (Optional[int]): Number of hours into the past for historical data. Must be non-negative. - latitude (Optional[float]): Latitude in degrees, must be between -90 and 90. - longitude (Optional[float]): Longitude in degrees, must be between -180 and 180. - - Properties: - timezone (Optional[str]): Computed time zone string based on the specified latitude - and longitude. Validators: validate_hours (int): Ensures `hours` is a non-negative integer. validate_historic_hours (int): Ensures `historic_hours` is a non-negative integer. - validate_latitude (float): Ensures `latitude` is within the range -90 to 90. - validate_longitude (float): Ensures `longitude` is within the range -180 to 180. """ hours: Optional[int] = Field( @@ -79,27 +69,6 @@ class PredictionCommonSettings(SettingsBaseModel): ge=0, description="Number of hours into the past for historical predictions data", ) - latitude: Optional[float] = Field( - 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=13.405, - ge=-180.0, - le=180.0, - description="Longitude in decimal degrees, within -180 to 180 (°)", - ) - - # Computed fields - @computed_field # type: ignore[prop-decorator] - @property - def timezone(self) -> Optional[str]: - """Compute timezone based on latitude and longitude.""" - if self.latitude and self.longitude: - return to_timezone(location=(self.latitude, self.longitude), as_string=True) - return None class Prediction(PredictionContainer): diff --git a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py index e690d89..b418c44 100644 --- a/src/akkudoktoreos/prediction/pvforecastakkudoktor.py +++ b/src/akkudoktoreos/prediction/pvforecastakkudoktor.py @@ -14,11 +14,13 @@ Classes: Example: # Set up the configuration with necessary fields for URL generation settings_data = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "pvforecast": { "provider": "PVForecastAkkudoktor", @@ -213,8 +215,8 @@ class PVForecastAkkudoktor(PVForecastProvider): """Build akkudoktor.net API request URL.""" base_url = "https://api.akkudoktor.net/forecast" query_params = [ - f"lat={self.config.prediction.latitude}", - f"lon={self.config.prediction.longitude}", + f"lat={self.config.general.latitude}", + f"lon={self.config.general.longitude}", ] for i in range(len(self.config.pvforecast.planes)): @@ -236,7 +238,7 @@ class PVForecastAkkudoktor(PVForecastProvider): "cellCoEff=-0.36", "inverterEfficiency=0.8", "albedo=0.25", - f"timezone={self.config.prediction.timezone}", + f"timezone={self.config.general.timezone}", "hourly=relativehumidity_2m%2Cwindspeed_10m", ] ) @@ -265,7 +267,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.prediction.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) return akkudoktor_data def _update_data(self, force_update: Optional[bool] = False) -> None: @@ -285,8 +287,8 @@ class PVForecastAkkudoktor(PVForecastProvider): akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore # Timezone of the PV system - 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}'." + if self.config.general.timezone != akkudoktor_data.meta.timezone: + error_msg = f"Configured timezone '{self.config.general.timezone}' does not match Akkudoktor timezone '{akkudoktor_data.meta.timezone}'." logger.error(f"Akkudoktor schema change: {error_msg}") raise ValueError(error_msg) @@ -306,7 +308,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.prediction.timezone) + dt = to_datetime(original_datetime, in_timezone=self.config.general.timezone) # Skip outdated forecast data if compare_datetimes(dt, self.start_datetime.start_of("day")).lt: @@ -375,11 +377,13 @@ if __name__ == "__main__": """ # Set up the configuration with necessary fields for URL generation settings_data = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "pvforecast": { "provider": "PVForecastAkkudoktor", diff --git a/src/akkudoktoreos/prediction/weatherbrightsky.py b/src/akkudoktoreos/prediction/weatherbrightsky.py index d8a4894..e05955e 100644 --- a/src/akkudoktoreos/prediction/weatherbrightsky.py +++ b/src/akkudoktoreos/prediction/weatherbrightsky.py @@ -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.prediction.latitude}&lon={self.config.prediction.longitude}&date={date}&last_date={last_date}&tz={self.config.prediction.timezone}" + f"{source}/weather?lat={self.config.general.latitude}&lon={self.config.general.longitude}&date={date}&last_date={last_date}&tz={self.config.general.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.prediction.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.general.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.prediction.latitude, self.config.prediction.longitude, cloud_cover + self.config.general.latitude, self.config.general.longitude, cloud_cover ) description = "Global Horizontal Irradiance (W/m2)" diff --git a/src/akkudoktoreos/prediction/weatherclearoutside.py b/src/akkudoktoreos/prediction/weatherclearoutside.py index a6a1887..d640fa6 100644 --- a/src/akkudoktoreos/prediction/weatherclearoutside.py +++ b/src/akkudoktoreos/prediction/weatherclearoutside.py @@ -91,13 +91,13 @@ class WeatherClearOutside(WeatherProvider): response: Weather forecast request reponse from ClearOutside. """ source = "https://clearoutside.com/forecast" - latitude = round(self.config.prediction.latitude, 2) - longitude = round(self.config.prediction.longitude, 2) + latitude = round(self.config.general.latitude, 2) + longitude = round(self.config.general.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.prediction.timezone) + self.update_datetime = to_datetime(in_timezone=self.config.general.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.prediction.latitude, self.config.prediction.longitude, cloud_cover + self.config.general.latitude, self.config.general.longitude, cloud_cover ) # Add GHI, DNI, DHI to clearout data diff --git a/src/akkudoktoreos/utils/visualize.py b/src/akkudoktoreos/utils/visualize.py index aba4356..a01e019 100644 --- a/src/akkudoktoreos/utils/visualize.py +++ b/src/akkudoktoreos/utils/visualize.py @@ -34,7 +34,7 @@ class VisualizationReport(ConfigMixin): self.pdf_pages = PdfPages(filename, metadata={}) # Initialize PdfPages without metadata self.version = version # overwrite version as test for constant output of pdf for test self.current_time = to_datetime( - as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.prediction.timezone + as_string="YYYY-MM-DD HH:mm:ss", in_timezone=self.config.general.timezone ) def add_chart_to_group(self, chart_func: Callable[[], None]) -> None: @@ -173,7 +173,7 @@ class VisualizationReport(ConfigMixin): plt.grid(True) # Add vertical line for the current date if within the axis range - current_time = pendulum.now(self.config.prediction.timezone) + current_time = pendulum.now(self.config.general.timezone) if timestamps[0].subtract(hours=2) <= current_time <= timestamps[-1]: plt.axvline(current_time, color="r", linestyle="--", label="Now") plt.text(current_time, plt.ylim()[1], "Now", color="r", ha="center", va="bottom") @@ -419,9 +419,7 @@ def prepare_visualize( start_hour: Optional[int] = 0, ) -> None: report = VisualizationReport(filename) - next_full_hour_date = ( - pendulum.now(report.config.prediction.timezone).start_of("hour").add(hours=1) - ) + next_full_hour_date = pendulum.now(report.config.general.timezone).start_of("hour").add(hours=1) # Group 1: report.create_line_chart_date( next_full_hour_date, # start_date diff --git a/tests/test_config.py b/tests/test_config.py index 9120db1..e0cb17b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,8 +3,9 @@ from pathlib import Path from unittest.mock import patch import pytest +from pydantic import ValidationError -from akkudoktoreos.config.config import ConfigEOS +from akkudoktoreos.config.config import ConfigCommonSettings, ConfigEOS from akkudoktoreos.core.logging import get_logger logger = get_logger(__name__) @@ -147,3 +148,67 @@ def test_config_copy(config_eos, monkeypatch): assert config_eos._get_config_file_path() == (temp_config_file_path, False) config_eos.update() assert temp_config_file_path.exists() + + +@pytest.mark.parametrize( + "latitude, longitude, expected_timezone", + [ + (40.7128, -74.0060, "America/New_York"), # Valid latitude/longitude + (None, None, None), # No location + (51.5074, -0.1278, "Europe/London"), # Another valid location + ], +) +def test_config_common_settings_valid(latitude, longitude, expected_timezone): + """Test valid settings for ConfigCommonSettings.""" + general_settings = ConfigCommonSettings( + latitude=latitude, + longitude=longitude, + ) + assert general_settings.latitude == latitude + assert general_settings.longitude == longitude + assert general_settings.timezone == expected_timezone + + +@pytest.mark.parametrize( + "field_name, invalid_value, expected_error", + [ + ("latitude", -91.0, "Input should be greater than or equal to -90"), + ("latitude", 91.0, "Input should be less than or equal to 90"), + ("longitude", -181.0, "Input should be greater than or equal to -180"), + ("longitude", 181.0, "Input should be less than or equal to 180"), + ], +) +def test_config_common_settings_invalid(field_name, invalid_value, expected_error): + """Test invalid settings for PredictionCommonSettings.""" + valid_data = { + "latitude": 40.7128, + "longitude": -74.0060, + } + assert ConfigCommonSettings(**valid_data) is not None + valid_data[field_name] = invalid_value + + with pytest.raises(ValidationError, match=expected_error): + ConfigCommonSettings(**valid_data) + + +def test_config_common_settings_no_location(): + """Test that timezone is None when latitude and longitude are not provided.""" + settings = ConfigCommonSettings(latitude=None, longitude=None) + assert settings.timezone is None + + +def test_config_common_settings_with_location(): + """Test that timezone is correctly computed when latitude and longitude are provided.""" + settings = ConfigCommonSettings(latitude=34.0522, longitude=-118.2437) + assert settings.timezone == "America/Los_Angeles" + + +def test_config_common_settings_timezone_none_when_coordinates_missing(): + """Test that timezone is None when latitude or longitude is missing.""" + config_no_latitude = ConfigCommonSettings(latitude=None, longitude=-74.0060) + config_no_longitude = ConfigCommonSettings(latitude=40.7128, longitude=None) + config_no_coords = ConfigCommonSettings(latitude=None, longitude=None) + + assert config_no_latitude.timezone is None + assert config_no_longitude.timezone is None + assert config_no_coords.timezone is None diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 8e6c189..6fa4401 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -39,49 +39,18 @@ def forecast_providers(): ] -@pytest.mark.parametrize( - "hours, historic_hours, latitude, longitude, expected_timezone", - [ - (48, 24, 40.7128, -74.0060, "America/New_York"), # Valid latitude/longitude - (0, 0, None, None, None), # No location - (100, 50, 51.5074, -0.1278, "Europe/London"), # Another valid location - ], -) -def test_prediction_common_settings_valid( - hours, historic_hours, latitude, longitude, expected_timezone -): - """Test valid settings for PredictionCommonSettings.""" - settings = PredictionCommonSettings( - hours=hours, - historic_hours=historic_hours, - latitude=latitude, - longitude=longitude, - ) - assert settings.hours == hours - assert settings.historic_hours == historic_hours - assert settings.latitude == latitude - assert settings.longitude == longitude - assert settings.timezone == expected_timezone - - @pytest.mark.parametrize( "field_name, invalid_value, expected_error", [ ("hours", -1, "Input should be greater than or equal to 0"), ("historic_hours", -5, "Input should be greater than or equal to 0"), - ("latitude", -91.0, "Input should be greater than or equal to -90"), - ("latitude", 91.0, "Input should be less than or equal to 90"), - ("longitude", -181.0, "Input should be greater than or equal to -180"), - ("longitude", 181.0, "Input should be less than or equal to 180"), ], ) -def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error): +def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error, config_eos): """Test invalid settings for PredictionCommonSettings.""" valid_data = { "hours": 48, "historic_hours": 24, - "latitude": 40.7128, - "longitude": -74.0060, } assert PredictionCommonSettings(**valid_data) is not None valid_data[field_name] = invalid_value @@ -90,31 +59,6 @@ def test_prediction_common_settings_invalid(field_name, invalid_value, expected_ PredictionCommonSettings(**valid_data) -def test_prediction_common_settings_no_location(): - """Test that timezone is None when latitude and longitude are not provided.""" - settings = PredictionCommonSettings(hours=48, historic_hours=24, latitude=None, longitude=None) - assert settings.timezone is None - - -def test_prediction_common_settings_with_location(): - """Test that timezone is correctly computed when latitude and longitude are provided.""" - settings = PredictionCommonSettings( - hours=48, historic_hours=24, latitude=34.0522, longitude=-118.2437 - ) - assert settings.timezone == "America/Los_Angeles" - - -def test_prediction_common_settings_timezone_none_when_coordinates_missing(): - """Test that timezone is None when latitude or longitude is missing.""" - config_no_latitude = PredictionCommonSettings(latitude=None, longitude=-74.0060) - config_no_longitude = PredictionCommonSettings(latitude=40.7128, longitude=None) - config_no_coords = PredictionCommonSettings(latitude=None, longitude=None) - - assert config_no_latitude.timezone is None - assert config_no_longitude.timezone is None - assert config_no_coords.timezone is None - - def test_initialization(prediction, forecast_providers): """Test that Prediction is initialized with the correct providers in sequence.""" assert isinstance(prediction, Prediction) diff --git a/tests/test_predictionabc.py b/tests/test_predictionabc.py index ffdf076..65a7c1b 100644 --- a/tests/test_predictionabc.py +++ b/tests/test_predictionabc.py @@ -88,27 +88,27 @@ class TestPredictionBase: @pytest.fixture def base(self, monkeypatch): # Provide default values for configuration - monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "50.0") - monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "10.0") + monkeypatch.setenv("EOS_PREDICTION__HOURS", "10") derived = DerivedBase() derived.config.reset_settings() + assert derived.config.prediction.hours == 10 return derived def test_config_value_from_env_variable(self, base, monkeypatch): # From Prediction Config - monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "2.5") + monkeypatch.setenv("EOS_PREDICTION__HOURS", "2") base.config.reset_settings() - assert base.config.prediction.latitude == 2.5 + assert base.config.prediction.hours == 2 def test_config_value_from_field_default(self, base, monkeypatch): - assert base.config.prediction.model_fields["hours"].default == 48 - assert base.config.prediction.hours == 48 - monkeypatch.setenv("EOS_PREDICTION__HOURS", "128") + assert base.config.prediction.model_fields["historic_hours"].default == 48 + assert base.config.prediction.historic_hours == 48 + monkeypatch.setenv("EOS_PREDICTION__HISTORIC_HOURS", "128") base.config.reset_settings() - assert base.config.prediction.hours == 128 - monkeypatch.delenv("EOS_PREDICTION__HOURS") + assert base.config.prediction.historic_hours == 128 + monkeypatch.delenv("EOS_PREDICTION__HISTORIC_HOURS") base.config.reset_settings() - assert base.config.prediction.hours == 48 + assert base.config.prediction.historic_hours == 48 def test_get_config_value_key_error(self, base): with pytest.raises(AttributeError): @@ -185,10 +185,6 @@ class TestPredictionProvider: # The following values are currently not set in EOS config, we can override monkeypatch.setenv("EOS_PREDICTION__HISTORIC_HOURS", "2") assert os.getenv("EOS_PREDICTION__HISTORIC_HOURS") == "2" - monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "37.7749") - assert os.getenv("EOS_PREDICTION__LATITUDE") == "37.7749" - monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "-122.4194") - assert os.getenv("EOS_PREDICTION__LONGITUDE") == "-122.4194" provider.config.reset_settings() ems_eos.set_start_datetime(sample_start_datetime) @@ -196,8 +192,6 @@ class TestPredictionProvider: assert provider.config.prediction.hours == config_eos.prediction.hours assert provider.config.prediction.historic_hours == 2 - assert provider.config.prediction.latitude == 37.7749 - assert provider.config.prediction.longitude == -122.4194 assert provider.start_datetime == sample_start_datetime assert provider.end_datetime == sample_start_datetime + to_duration( f"{provider.config.prediction.hours} hours" diff --git a/tests/test_pvforecastakkudoktor.py b/tests/test_pvforecastakkudoktor.py index 0618481..00956c0 100644 --- a/tests/test_pvforecastakkudoktor.py +++ b/tests/test_pvforecastakkudoktor.py @@ -25,11 +25,13 @@ FILE_TESTDATA_PV_FORECAST_RESULT_1 = DIR_TESTDATA.joinpath("pv_forecast_result_1 def sample_settings(config_eos): """Fixture that adds settings data to the global config.""" settings = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "pvforecast": { "provider": "PVForecastAkkudoktor", @@ -155,11 +157,13 @@ sample_value = AkkudoktorForecastValue( windspeed_10m=10.0, ) sample_config_data = { + "general": { + "latitude": 52.52, + "longitude": 13.405, + }, "prediction": { "hours": 48, "historic_hours": 24, - "latitude": 52.52, - "longitude": 13.405, }, "pvforecast": { "provider": "PVForecastAkkudoktor", diff --git a/tests/test_weatherbrightsky.py b/tests/test_weatherbrightsky.py index 135e269..8b454de 100644 --- a/tests/test_weatherbrightsky.py +++ b/tests/test_weatherbrightsky.py @@ -67,8 +67,8 @@ def test_invalid_provider(provider, monkeypatch): def test_invalid_coordinates(provider, monkeypatch): """Test invalid coordinates raise ValueError.""" - monkeypatch.setenv("EOS_PREDICTION__LATITUDE", "1000") - monkeypatch.setenv("EOS_PREDICTION__LONGITUDE", "1000") + monkeypatch.setenv("EOS_GENERAL__LATITUDE", "1000") + monkeypatch.setenv("EOS_GENERAL__LONGITUDE", "1000") with pytest.raises( ValueError, # match="Latitude '1000' and/ or longitude `1000` out of valid range." ): diff --git a/tests/test_weatherclearoutside.py b/tests/test_weatherclearoutside.py index eb8da9f..4ebd8f0 100644 --- a/tests/test_weatherclearoutside.py +++ b/tests/test_weatherclearoutside.py @@ -27,7 +27,7 @@ def provider(config_eos): "weather": { "provider": "ClearOutside", }, - "prediction": { + "general": { "latitude": 50.0, "longitude": 10.0, }, @@ -87,7 +87,7 @@ def test_invalid_coordinates(provider, config_eos): "weather": { "provider": "ClearOutside", }, - "prediction": { + "general": { "latitude": 1000.0, "longitude": 1000.0, },