diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ff588bc..a60da76 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: | diff --git a/openapi.json b/openapi.json index 3ff91b1..87a7e75 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 ac3fd09..c8026b9 --- 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 2a6d558..0000000 --- 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 5063d13..58bf364 100644 --- a/src/akkudoktoreos/core/ems.py +++ b/src/akkudoktoreos/core/ems.py @@ -8,7 +8,7 @@ from typing_extensions import Self 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 @@ from akkudoktoreos.utils.utils import NumpyEncoder 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 @@ class EnergieManagementSystemParameters(PydanticBaseModel): 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 0cebb4c..28a29c7 100644 --- a/src/akkudoktoreos/core/pydantic.py +++ b/src/akkudoktoreos/core/pydantic.py @@ -478,3 +478,7 @@ class PydanticDateTimeSeries(PydanticBaseModel): 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 6f6fe47..e011614 100644 --- a/src/akkudoktoreos/devices/battery.py +++ b/src/akkudoktoreos/devices/battery.py @@ -4,6 +4,7 @@ import numpy as np 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 6083386..1cd890f 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 8e32b16..922df62 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 8847f0f..7a07b77 100644 --- a/src/akkudoktoreos/optimization/genetic.py +++ b/src/akkudoktoreos/optimization/genetic.py @@ -6,7 +6,7 @@ from typing import Any, Optional 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.coreabc import ( ) 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 @@ from akkudoktoreos.utils.utils import NumpyEncoder 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 @@ class OptimizationParameters(BaseModel): 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 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi ) # 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 @@ class optimization_problem(ConfigMixin, DevicesMixin, EnergyManagementSystemMixi ) # 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 a7ba9fc..88000e6 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 bf8a950..e550c6d 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,