From 7609e7f5e3c7742686c24e0893085cb3d6e97dc5 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Thu, 9 Jan 2025 23:08:00 +0100 Subject: [PATCH 1/2] Cleanup: parameters: extra=forbid, optimize: battery, inverter optional * Don't allow extra fields for parameters/REST-API (at least for now while changing API). * Allow both battery and inverter to be set optionally (atm optional battery not implemented, no API constraints). * inverter: Remove default max_power_wh * single_test_optimization: Add more cli-parameters --- openapi.json | 36 ++++++++++--- single_test_optimization.py | 48 +++++++++++++---- src/akkudoktoreos/class_home_appliance.py | 64 ----------------------- src/akkudoktoreos/core/ems.py | 6 +-- src/akkudoktoreos/core/pydantic.py | 4 ++ src/akkudoktoreos/devices/battery.py | 3 +- src/akkudoktoreos/devices/generic.py | 5 +- src/akkudoktoreos/devices/inverter.py | 7 +-- src/akkudoktoreos/optimization/genetic.py | 35 +++++++------ tests/testdata/optimize_input_1.json | 4 +- tests/testdata/optimize_input_2.json | 3 ++ 11 files changed, 109 insertions(+), 106 deletions(-) mode change 100644 => 100755 single_test_optimization.py delete mode 100644 src/akkudoktoreos/class_home_appliance.py diff --git a/openapi.json b/openapi.json index 3ff91b1b..87a7e75f 100644 --- a/openapi.json +++ b/openapi.json @@ -6108,6 +6108,7 @@ "default": 100 } }, + "additionalProperties": false, "type": "object", "required": [ "capacity_wh" @@ -6231,6 +6232,7 @@ "description": "An array of floats representing the total load (consumption) in watts for different time intervals." } }, + "additionalProperties": false, "type": "object", "required": [ "pv_prognose_wh", @@ -6326,6 +6328,7 @@ "description": "An integer representing the usage duration of a household device in hours." } }, + "additionalProperties": false, "type": "object", "required": [ "consumption_wh", @@ -6338,11 +6341,14 @@ "max_power_wh": { "type": "number", "exclusiveMinimum": 0.0, - "title": "Max Power Wh", - "default": 10000 + "title": "Max Power Wh" } }, + "additionalProperties": false, "type": "object", + "required": [ + "max_power_wh" + ], "title": "InverterParameters" }, "OptimizationParameters": { @@ -6351,13 +6357,24 @@ "$ref": "#/components/schemas/EnergieManagementSystemParameters" }, "pv_akku": { - "$ref": "#/components/schemas/SolarPanelBatteryParameters" + "anyOf": [ + { + "$ref": "#/components/schemas/SolarPanelBatteryParameters" + }, + { + "type": "null" + } + ] }, "inverter": { - "$ref": "#/components/schemas/InverterParameters", - "default": { - "max_power_wh": 10000.0 - } + "anyOf": [ + { + "$ref": "#/components/schemas/InverterParameters" + }, + { + "type": "null" + } + ] }, "eauto": { "anyOf": [ @@ -6417,10 +6434,12 @@ "description": "Can be `null` or contain a previous solution (if available)." } }, + "additionalProperties": false, "type": "object", "required": [ "ems", "pv_akku", + "inverter", "eauto" ], "title": "OptimizationParameters" @@ -6507,6 +6526,7 @@ "description": "Can be `null` or contain an object representing the start of washing (if applicable)." } }, + "additionalProperties": false, "type": "object", "required": [ "ac_charge", @@ -8834,6 +8854,7 @@ "description": "Used Electricity Price, including predictions" } }, + "additionalProperties": false, "type": "object", "required": [ "Last_Wh_pro_Stunde", @@ -8917,6 +8938,7 @@ "default": 100 } }, + "additionalProperties": false, "type": "object", "required": [ "capacity_wh" diff --git a/single_test_optimization.py b/single_test_optimization.py old mode 100644 new mode 100755 index ac3fd099..c8026b90 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -2,9 +2,11 @@ import argparse import cProfile +import json import pstats import sys import time +from typing import Any import numpy as np @@ -295,7 +297,9 @@ def prepare_optimization_parameters() -> OptimizationParameters: ) -def run_optimization(real_world: bool = False, start_hour: int = 0, verbose: bool = False) -> dict: +def run_optimization( + real_world: bool, start_hour: int, verbose: bool, seed: int, parameters_file: str, ngen: int +) -> Any: """Run the optimization problem. Args: @@ -306,7 +310,10 @@ def run_optimization(real_world: bool = False, start_hour: int = 0, verbose: boo dict: Optimization result as a dictionary """ # Prepare parameters - if real_world: + if parameters_file: + with open(parameters_file, "r") as f: + parameters = OptimizationParameters(**json.load(f)) + elif real_world: parameters = prepare_optimization_real_parameters() else: parameters = prepare_optimization_parameters() @@ -318,12 +325,12 @@ def run_optimization(real_world: bool = False, start_hour: int = 0, verbose: boo # Initialize the optimization problem using the default configuration config_eos = get_config() config_eos.merge_settings_from_dict({"prediction_hours": 48, "optimization_hours": 48}) - opt_class = optimization_problem(verbose=verbose, fixed_seed=42) + opt_class = optimization_problem(verbose=verbose, fixed_seed=seed) # Perform the optimisation based on the provided parameters and start hour - result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour) + result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour, ngen=ngen) - return result.model_dump() + return result.model_dump_json() def main(): @@ -339,6 +346,19 @@ def main(): parser.add_argument( "--start-hour", type=int, default=0, help="Starting hour for optimization (default: 0)" ) + parser.add_argument( + "--parameters-file", + type=str, + default="", + help="Load optimization parameters from json file (default: unset)", + ) + parser.add_argument("--seed", type=int, default=42, help="Use fixed random seed (default: 42)") + parser.add_argument( + "--ngen", + type=int, + default=400, + help="Number of generations during optimization process (default: 400)", + ) args = parser.parse_args() @@ -351,12 +371,16 @@ def main(): real_world=args.real_world, start_hour=args.start_hour, verbose=args.verbose, + seed=args.seed, + parameters_file=args.parameters_file, + ngen=args.ngen, ) # Print profiling statistics stats = pstats.Stats(profiler) stats.strip_dirs().sort_stats("cumulative").print_stats(200) # Print result - print("\nOptimization Result:") + if args.verbose: + print("\nOptimization Result:") print(result) except Exception as e: @@ -367,12 +391,18 @@ def main(): try: start_time = time.time() result = run_optimization( - real_world=args.real_world, start_hour=args.start_hour, verbose=args.verbose + real_world=args.real_world, + start_hour=args.start_hour, + verbose=args.verbose, + seed=args.seed, + parameters_file=args.parameters_file, + ngen=args.ngen, ) end_time = time.time() elapsed_time = end_time - start_time - print(f"\nElapsed time: {elapsed_time:.4f} seconds.") - print("\nOptimization Result:") + if args.verbose: + print(f"\nElapsed time: {elapsed_time:.4f} seconds.") + print("\nOptimization Result:") print(result) except Exception as e: diff --git a/src/akkudoktoreos/class_home_appliance.py b/src/akkudoktoreos/class_home_appliance.py deleted file mode 100644 index 2a6d558e..00000000 --- a/src/akkudoktoreos/class_home_appliance.py +++ /dev/null @@ -1,64 +0,0 @@ -import numpy as np -from pydantic import BaseModel, Field - - -class HomeApplianceParameters(BaseModel): - consumption_wh: int = Field( - gt=0, - description="An integer representing the energy consumption of a household device in watt-hours.", - ) - duration_h: int = Field( - gt=0, - description="An integer representing the usage duration of a household device in hours.", - ) - - -class HomeAppliance: - def __init__(self, parameters: HomeApplianceParameters, hours: int): - self.hours = hours # Total duration for which the planning is done - self.consumption_wh = ( - parameters.consumption_wh - ) # Total energy consumption of the device in kWh - self.duration_h = parameters.duration_h # Duration of use in hours - self.load_curve = np.zeros(self.hours) # Initialize the load curve with zeros - - def set_starting_time(self, start_hour: int, global_start_hour: int = 0) -> None: - """Sets the start time of the device and generates the corresponding load curve. - - :param start_hour: The hour at which the device should start. - """ - self.reset() - # Check if the duration of use is within the available time frame - if start_hour + self.duration_h > self.hours: - raise ValueError("The duration of use exceeds the available time frame.") - if start_hour < global_start_hour: - raise ValueError("The start time is earlier than the available time frame.") - - # Calculate power per hour based on total consumption and duration - power_per_hour = self.consumption_wh / self.duration_h # Convert to watt-hours - - # Set the power for the duration of use in the load curve array - self.load_curve[start_hour : start_hour + self.duration_h] = power_per_hour - - def reset(self) -> None: - """Resets the load curve.""" - self.load_curve = np.zeros(self.hours) - - def get_load_curve(self) -> np.ndarray: - """Returns the current load curve.""" - return self.load_curve - - def get_load_for_hour(self, hour: int) -> float: - """Returns the load for a specific hour. - - :param hour: The hour for which the load is queried. - :return: The load in watts for the specified hour. - """ - if hour < 0 or hour >= self.hours: - raise ValueError("The specified hour is outside the available time frame.") - - return self.load_curve[hour] - - def get_latest_starting_point(self) -> int: - """Returns the latest possible start time at which the device can still run completely.""" - return self.hours - self.duration_h diff --git a/src/akkudoktoreos/core/ems.py b/src/akkudoktoreos/core/ems.py index 5063d13c..58bf364a 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -8,7 +8,7 @@ from akkudoktoreos.core.coreabc import ConfigMixin, PredictionMixin, SingletonMixin from akkudoktoreos.core.logging import get_logger -from akkudoktoreos.core.pydantic import PydanticBaseModel +from akkudoktoreos.core.pydantic import ParametersBaseModel, PydanticBaseModel from akkudoktoreos.devices.battery import Battery from akkudoktoreos.devices.generic import HomeAppliance from akkudoktoreos.devices.inverter import Inverter @@ -18,7 +18,7 @@ logger = get_logger(__name__) -class EnergieManagementSystemParameters(PydanticBaseModel): +class EnergieManagementSystemParameters(ParametersBaseModel): pv_prognose_wh: list[float] = Field( description="An array of floats representing the forecasted photovoltaic output in watts for different time intervals." ) @@ -50,7 +50,7 @@ def validate_list_length(self) -> Self: return self -class SimulationResult(PydanticBaseModel): +class SimulationResult(ParametersBaseModel): """This object contains the results of the simulation and provides insights into various parameters over the entire forecast period.""" Last_Wh_pro_Stunde: list[Optional[float]] = Field(description="TBD") diff --git a/src/akkudoktoreos/core/pydantic.py b/src/akkudoktoreos/core/pydantic.py index 0cebb4c9..28a29c7b 100644 --- a/src/akkudoktoreos/core/pydantic.py +++ b/src/akkudoktoreos/core/pydantic.py @@ -478,3 +478,7 @@ def from_series(cls, series: pd.Series, tz: Optional[str] = None) -> "PydanticDa dtype=str(series.dtype), tz=tz, ) + + +class ParametersBaseModel(PydanticBaseModel): + model_config = ConfigDict(extra="forbid") diff --git a/src/akkudoktoreos/devices/battery.py b/src/akkudoktoreos/devices/battery.py index 6f6fe477..e0116146 100644 --- a/src/akkudoktoreos/devices/battery.py +++ b/src/akkudoktoreos/devices/battery.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.devices.devicesabc import DeviceBase from akkudoktoreos.utils.utils import NumpyEncoder @@ -24,7 +25,7 @@ def initial_soc_percentage_field(description: str) -> int: return Field(default=0, ge=0, le=100, description=description) -class BaseBatteryParameters(BaseModel): +class BaseBatteryParameters(ParametersBaseModel): """Base class for battery parameters with fields for capacity, efficiency, and state of charge.""" capacity_wh: int = Field( diff --git a/src/akkudoktoreos/devices/generic.py b/src/akkudoktoreos/devices/generic.py index 60833867..1cd890f1 100644 --- a/src/akkudoktoreos/devices/generic.py +++ b/src/akkudoktoreos/devices/generic.py @@ -1,15 +1,16 @@ from typing import Optional import numpy as np -from pydantic import BaseModel, Field +from pydantic import Field from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.devices.devicesabc import DeviceBase logger = get_logger(__name__) -class HomeApplianceParameters(BaseModel): +class HomeApplianceParameters(ParametersBaseModel): consumption_wh: int = Field( gt=0, description="An integer representing the energy consumption of a household device in watt-hours.", diff --git a/src/akkudoktoreos/devices/inverter.py b/src/akkudoktoreos/devices/inverter.py index 8e32b16d..922df625 100644 --- a/src/akkudoktoreos/devices/inverter.py +++ b/src/akkudoktoreos/devices/inverter.py @@ -1,17 +1,18 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from scipy.interpolate import RegularGridInterpolator from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.devices.battery import Battery from akkudoktoreos.devices.devicesabc import DeviceBase logger = get_logger(__name__) -class InverterParameters(BaseModel): - max_power_wh: float = Field(default=10000, gt=0) +class InverterParameters(ParametersBaseModel): + max_power_wh: float = Field(gt=0) class Inverter(DeviceBase): diff --git a/src/akkudoktoreos/optimization/genetic.py b/src/akkudoktoreos/optimization/genetic.py index 8847f0ff..7a07b77e 100644 --- a/src/akkudoktoreos/optimization/genetic.py +++ b/src/akkudoktoreos/optimization/genetic.py @@ -6,7 +6,7 @@ import numpy as np from deap import algorithms, base, creator, tools -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import Field, field_validator, model_validator from typing_extensions import Self from akkudoktoreos.core.coreabc import ( @@ -16,6 +16,7 @@ ) from akkudoktoreos.core.ems import EnergieManagementSystemParameters, SimulationResult from akkudoktoreos.core.logging import get_logger +from akkudoktoreos.core.pydantic import ParametersBaseModel from akkudoktoreos.devices.battery import ( Battery, ElectricVehicleParameters, @@ -30,10 +31,10 @@ logger = get_logger(__name__) -class OptimizationParameters(BaseModel): +class OptimizationParameters(ParametersBaseModel): ems: EnergieManagementSystemParameters - pv_akku: SolarPanelBatteryParameters - inverter: InverterParameters = InverterParameters() + pv_akku: Optional[SolarPanelBatteryParameters] + inverter: Optional[InverterParameters] eauto: Optional[ElectricVehicleParameters] dishwasher: Optional[HomeApplianceParameters] = None temperature_forecast: Optional[list[Optional[float]]] = Field( @@ -60,7 +61,7 @@ def validate_start_solution( return start_solution -class OptimizeResponse(BaseModel): +class OptimizeResponse(ParametersBaseModel): """**Note**: The first value of "Last_Wh_per_hour", "Netzeinspeisung_Wh_per_hour", and "Netzbezug_Wh_per_hour", will be set to null in the JSON output and represented as NaN or None in the corresponding classes' data returns. This approach is adopted to ensure that the current hour's processing remains unchanged.""" ac_charge: list[float] = Field( @@ -565,11 +566,13 @@ def optimierung_ems( ) # Initialize PV and EV batteries - akku = Battery( - parameters.pv_akku, - hours=self.config.prediction_hours, - ) - akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) + akku: Optional[Battery] = None + if parameters.pv_akku: + akku = Battery( + parameters.pv_akku, + hours=self.config.prediction_hours, + ) + akku.set_charge_per_hour(np.full(self.config.prediction_hours, 1)) eauto: Optional[Battery] = None if parameters.eauto: @@ -595,11 +598,13 @@ def optimierung_ems( ) # Initialize the inverter and energy management system - inverter = Inverter( - sc, - parameters.inverter, - akku, - ) + inverter: Optional[Inverter] = None + if parameters.inverter: + inverter = Inverter( + sc, + parameters.inverter, + akku, + ) self.ems.set_parameters( parameters.ems, inverter=inverter, diff --git a/tests/testdata/optimize_input_1.json b/tests/testdata/optimize_input_1.json index a7ba9fc1..88000e6b 100644 --- a/tests/testdata/optimize_input_1.json +++ b/tests/testdata/optimize_input_1.json @@ -31,8 +31,8 @@ "initial_soc_percentage": 80, "min_soc_percentage": 15 }, - "wechselrichter": { - "max_leistung_wh": 10000 + "inverter": { + "max_power_wh": 10000 }, "eauto": { "capacity_wh": 60000, diff --git a/tests/testdata/optimize_input_2.json b/tests/testdata/optimize_input_2.json index bf8a9501..e550c6de 100644 --- a/tests/testdata/optimize_input_2.json +++ b/tests/testdata/optimize_input_2.json @@ -158,6 +158,9 @@ "initial_soc_percentage": 80, "min_soc_percentage": 0 }, + "inverter": { + "max_power_wh": 10000 + }, "eauto": { "capacity_wh": 60000, "charging_efficiency": 0.95, From 25d5e01a08741f42fa01b47167546e9863cdd7f6 Mon Sep 17 00:00:00 2001 From: Dominique Lasserre Date: Mon, 13 Jan 2025 13:10:53 +0100 Subject: [PATCH 2/2] Workflow docker-build: Don't try to authenticate for PRs * Secrets are not available anyway for forks. --- .github/workflows/docker-build.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ff588bc3..a60da760 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -89,12 +89,16 @@ jobs: - name: Login to Docker Hub uses: docker/login-action@v3 + # skip for pull requests + if: ${{ github.event_name != 'pull_request' }} with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Login to GHCR uses: docker/login-action@v3 + # skip for pull requests + if: ${{ github.event_name != 'pull_request' }} with: registry: ghcr.io username: ${{ github.actor }} @@ -102,6 +106,8 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + # skip for pull requests + if: ${{ github.event_name != 'pull_request' }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -114,21 +120,22 @@ jobs: labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,"push=${{ github.event_name != 'pull_request' }}","annotation-index.org.opencontainers.image.description=${{ env.EOS_REPO_DESCRIPTION }}" - #push: ${{ github.event_name != 'pull_request' }} - name: Generate artifact attestation DockerHub uses: actions/attest-build-provenance@v2 + if: ${{ github.event_name != 'pull_request' }} with: subject-name: docker.io/${{ env.DOCKERHUB_REPO }} subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: ${{ github.event_name != 'pull_request' }} + push-to-registry: true - name: Generate artifact attestation GitHub uses: actions/attest-build-provenance@v2 + if: ${{ github.event_name != 'pull_request' }} with: subject-name: ${{ env.GHCR_REPO }} subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: ${{ github.event_name != 'pull_request' }} + push-to-registry: true - name: Export digest run: |