From 7dc3e584867de73ec9f73054a4b25a901bf45d79 Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:23:58 +0100 Subject: [PATCH 1/4] proto v0.1 --- config.yml | 3 ++ main.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 config.yml create mode 100644 main.py diff --git a/config.yml b/config.yml new file mode 100644 index 000000000..61301f5cd --- /dev/null +++ b/config.yml @@ -0,0 +1,3 @@ +API_SETTINGS: + endpoint: '/productionplan' + port: 8888 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 000000000..d3e77eb1a --- /dev/null +++ b/main.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +import json +from typing import List +from fastapi import FastAPI + + +app = FastAPI() + + +@dataclass +class Load: + value: int + + +@dataclass +class Fuels: + gas: int + kerosine: int + co2: int + wind: int + + +@dataclass +class Powerplant: + pid: int # Not Used Here but might be use for Slack Bus when Loss = Load + cable length + name: str + type: str + efficiency: float + pmin: int + pmax: int + injection: float = 0 + price: float = 0 + + def get_cost_per_mwh(self, fuels: Fuels) -> float: + """Calculate cost / MWh based on fuel type and efficiency""" + costs = { + 'gasfired': fuels.gas / self.efficiency, + 'turbojet': fuels.kerosine / self.efficiency, + 'windturbine': 0 + } + return costs[self.type] + + def get_available_power(self, fuels: Fuels) -> tuple[float, float]: + """Returns (min, max) available power """ + if self.type == 'windturbine': + max_power = self.pmax * (fuels.wind / 100.0) + return 0, max_power + return self.pmin, self.pmax + + +@app.get("/productionplan") +def api_response(): + path = './example_payloads/payload3.json' + load, fuels_stat, powerplants_list = load_data(path) + solve(powerplants_list=powerplants_list, load=load, fuels=fuels_stat) + return {pw.name:pw.injection for pw in powerplants_list} + + +def solve(powerplants_list: List[Powerplant], load: Load, fuels: Fuels): + for plant in powerplants_list: + plant.price = plant.get_cost_per_mwh(fuels) + + sorted_plants = sorted(powerplants_list, key = lambda x: x.price) + remaining_load = load.value + total_cost = 0 + + # Assign power to each plant in order of price + for plant in sorted_plants: + min_power, max_power = plant.get_available_power(fuels) + + if remaining_load > 0: + usable_power = min(max_power, remaining_load) # Try to use maximum possible power from this plant + if usable_power >= min_power: + plant.injection = round(usable_power,2) + remaining_load -= usable_power + total_cost += usable_power * plant.price + else: + plant.injection = 0 + else: + plant.injection = 0 + + # total_cost + # total_power = sum(p.injection for p in sorted_plants) + # load_met = abs(total_power - load.value) < 0.1, + return sorted_plants + +def load_data(path: str): + with open(path, 'r') as file: + payload = json.load(file) + load = Load(payload['load']) + fuels_stat = Fuels(*payload['fuels'].values()) + powerplants_list = [Powerplant(i + 1, **pw) for i, pw in enumerate(payload['powerplants'])] + return load,fuels_stat,powerplants_list + + + + + From 04a3b08756f3e75a47bc53d40d5900ac036e9699 Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Sat, 9 Nov 2024 20:32:59 +0100 Subject: [PATCH 2/4] + fastapi + uvicorn added + Split in multiple files + requirements.txt and poetry support --- PROJECT_DESC.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++ config.yml | 6 ++- core/__init__.py | 14 +++++++ core/api.py | 27 +++++++++++++ core/classes.py | 35 +++++++++++++++++ core/func.py | 27 +++++++++++++ core/utils.py | 39 +++++++++++++++++++ main.py | 98 +++------------------------------------------- pyproject.toml | 18 +++++++++ requirements.txt | 5 +++ tests/__init__.py | 0 tests/test.py | 35 +++++++++++++++++ 12 files changed, 308 insertions(+), 95 deletions(-) create mode 100644 PROJECT_DESC.md create mode 100644 core/__init__.py create mode 100644 core/api.py create mode 100644 core/classes.py create mode 100644 core/func.py create mode 100644 core/utils.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test.py diff --git a/PROJECT_DESC.md b/PROJECT_DESC.md new file mode 100644 index 000000000..44c93d608 --- /dev/null +++ b/PROJECT_DESC.md @@ -0,0 +1,99 @@ +# powerplant-coding-challenge + + +## Welcome ! + +Below you can find the description of a coding challenge that we ask people to perform when applying for a job in our team. + +The goal of this coding challenge is to provide the applicant some insight into the business we're in and as such provide the applicant an indication about the challenges she/he will be confronted with. Next, during the first interview we will use the applicant's implementation as a seed to discuss all kinds of interesting software engineering topics. + +Time is scarce, we know. Therefore we ask you not to spend more than 4 hours on this challenge. We know it is not possible to deliver a finished implementation of the challenge in only four hours. Even though your submission will not be complete, it will provide us plenty of information and topics to discuss later on during the talks. + +This coding-challenge is part of a formal process and is used in collaboration with the recruiting companies we work with. Submitting a pull-request will not automatically trigger the recruitement process. +## Who are we + +We are the IS team of the 'Short-term Power as-a-Service' (a.k.a. SPaaS) team within [GEM](https://gems.engie.com/). + +[GEM](https://gems.engie.com/), which stands for 'Global Energy Management', is the energy management arm of [ENGIE](https://www.engie.com/), one of the largest global energy players, +with access to local markets all over the world. + +SPaaS is a team consisting of around 100 people with experience in energy markets, IT and modeling. In smaller teams consisting of a mix of people with different experiences, we are active on the [day-ahead](https://en.wikipedia.org/wiki/European_Power_Exchange#Day-ahead_markets) market, [intraday markets](https://en.wikipedia.org/wiki/European_Power_Exchange#Intraday_markets) and [collaborate with the TSO to balance the grid continuously](https://en.wikipedia.org/wiki/Transmission_system_operator#Electricity_market_operations). + +## The challenge + +### In short +Calculate how much power each of a multitude of different [powerplants](https://en.wikipedia.org/wiki/Power_station) need to produce (a.k.a. the production-plan) when the [load](https://en.wikipedia.org/wiki/Load_profile) is given and taking into account the cost of the underlying energy sources (gas, kerosine) and the Pmin and Pmax of each powerplant. + +### More in detail + +The load is the continuous demand of power. The total load at each moment in time is forecasted. For instance for Belgium you can see the load forecasted by the grid operator [here](https://www.elia.be/en/grid-data/load-and-load-forecasts). + +At any moment in time, all available powerplants need to generate the power to exactly match the load. The cost of generating power can be different for every powerplant and is dependent on external factors: The cost of producing power using a [turbojet](https://en.wikipedia.org/wiki/Gas_turbine#Industrial_gas_turbines_for_power_generation), that runs on kerosine, is higher compared to the cost of generating power using a gas-fired powerplant because of gas being cheaper compared to kerosine and because of the [thermal efficiency](https://en.wikipedia.org/wiki/Thermal_efficiency) of a gas-fired powerplant being around 50% (2 units of gas will generate 1 unit of electricity) while that of a turbojet is only around 30%. The cost of generating power using windmills however is zero. Thus deciding which powerplants to activate is dependent on the [merit-order](https://en.wikipedia.org/wiki/Merit_order). + +When deciding which powerplants in the merit-order to activate (a.k.a. [unit-commitment problem](https://en.wikipedia.org/wiki/Unit_commitment_problem_in_electrical_power_production)) the maximum amount of power each powerplant can produce (Pmax) obviously needs to be taken into account. Additionally gas-fired powerplants generate a certain minimum amount of power when switched on, called the Pmin. + + +### Performing the challenge + +Build a REST API exposing an endpoint `/productionplan` that accepts a POST of which the body contains a payload as you can find in the `example_payloads` directory and that returns a json with the same structure as in `example_response.json` and that manages and logs run-time errors. + +For calculating the unit-commitment, we prefer you not to rely on an existing (linear-programming) solver but instead write an algorithm yourself. + +Implementations can be submitted in either C# (on .Net 5 or higher) or Python (3.8 or higher) as these are (currently) the main languages we use in SPaaS. Along with the implementation should be a README that describes how to compile (if applicable) and launch the application. + +- C# implementations should contain a project file to compile the application. +- Python implementations should contain a `requirements.txt` or a `pyproject.toml` (for use with poetry) to install all needed dependencies. + +#### Payload + +The payload contains 3 types of data: + - load: The load is the amount of energy (MWh) that need to be generated during one hour. + - fuels: based on the cost of the fuels of each powerplant, the merit-order can be determined which is the starting point for deciding which powerplants should be switched on and how much power they will deliver. Wind-turbine are either switched-on, and in that case generate a certain amount of energy depending on the % of wind, or can be switched off. + - gas(euro/MWh): the price of gas per MWh. Thus if gas is at 6 euro/MWh and if the efficiency of the powerplant is 50% (i.e. 2 units of gas will generate one unit of electricity), the cost of generating 1 MWh is 12 euro. + - kerosine(euro/Mwh): the price of kerosine per MWh. + - co2(euro/ton): the price of emission allowances (optionally to be taken into account). + - wind(%): percentage of wind. Example: if there is on average 25% wind during an hour, a wind-turbine with a Pmax of 4 MW will generate 1MWh of energy. + - powerplants: describes the powerplants at disposal to generate the demanded load. For each powerplant is specified: + - name: + - type: gasfired, turbojet or windturbine. + - efficiency: the efficiency at which they convert a MWh of fuel into a MWh of electrical energy. Wind-turbines do not consume 'fuel' and thus are considered to generate power at zero price. + - pmax: the maximum amount of power the powerplant can generate. + - pmin: the minimum amount of power the powerplant generates when switched on. + +#### response + +The response should be a json as in `example_payloads/response3.json`, which is the expected answer for `example_payloads/payload3.json`, specifying for each powerplant how much power each powerplant should deliver. The power produced by each powerplant has to be a multiple of 0.1 Mw and the sum of the power produced by all the powerplants together should equal the load. + +### Want more challenge? + +Having fun with this challenge and want to make it more realistic. Optionally, do one of the extra's below: + +#### Docker + +Provide a Dockerfile along with the implementation to allow deploying your solution quickly. + +#### CO2 + +Taken into account that a gas-fired powerplant also emits CO2, the cost of running the powerplant should also take into account the cost of the [emission allowances](https://en.wikipedia.org/wiki/Carbon_emission_trading). For this challenge, you may take into account that each MWh generated creates 0.3 ton of CO2. + +## Acceptance criteria + +For a submission to be reviewed as part of an application for a position in the team, the project needs to: + - contain a README.md explaining how to build and launch the API + - expose the API on port `8888` + +Failing to comply with any of these criteria will automatically disqualify the submission. + +## More info + +For more info on energy management, check out: + + - [Global Energy Management Solutions](https://www.youtube.com/watch?v=SAop0RSGdHM) + - [COO hydroelectric power station](https://www.youtube.com/watch?v=edamsBppnlg) + - [Management of supply](https://www.youtube.com/watch?v=eh6IIQeeX3c) - video made during winter 2018-2019 + +## FAQ + +##### Can an existing solver be used to calculate the unit-commitment +Implementations should not rely on an external solver and thus contain an algorithm written from scratch (clarified in the text as of version v1.1.0) + diff --git a/config.yml b/config.yml index 61301f5cd..1137a1837 100644 --- a/config.yml +++ b/config.yml @@ -1,3 +1,5 @@ -API_SETTINGS: - endpoint: '/productionplan' +API: + protocol: http + host: 0.0.0.0 + endpoint: productionplan port: 8888 \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 000000000..46ed61b97 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,14 @@ +from core.api import productionApp +from core.utils import get_config, load_config, format_payload +from core.func import solve +from core.classes import Fuels, Powerplant + +__all__ = [ + 'productionApp', + 'get_config', + 'load_config', + 'format_payload', + 'solve', + 'Fuels', + 'Powerplant' +] diff --git a/core/api.py b/core/api.py new file mode 100644 index 000000000..b6a99f7ad --- /dev/null +++ b/core/api.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +from core.utils import format_payload +from core.func import solve +from typing import Dict, Any +import os + +# https://fastapi.tiangolo.com/#recap +productionApp = FastAPI() + +class ProductionPlanRequest(BaseModel): + load: float + fuels: Dict[str, Any] + powerplants: list[dict] + +@productionApp.get("/") +async def default(): + endpoint = os.getenv('API_ENDPOINT', 'productionplan') + return {'message': f'do post request to /{endpoint}'} + +@productionApp.post("/{endpoint}") +async def get_productionplan(endpoint: str, payload: ProductionPlanRequest): + if endpoint != os.getenv('API_ENDPOINT', 'productionplan'): + raise HTTPException(status_code = 404, detail = "Endpoint not found") + load, fuels_stat, powerplants_list = format_payload(payload) + solve(powerplants_list = powerplants_list, load = load, fuels = fuels_stat) + return {pw.name: pw.injection for pw in powerplants_list} \ No newline at end of file diff --git a/core/classes.py b/core/classes.py new file mode 100644 index 000000000..7b6871cad --- /dev/null +++ b/core/classes.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass + +@dataclass +class Fuels: + gas: int + kerosine: int + co2: int + wind: int + +@dataclass +class Powerplant: + pid: int # Not Used Here but might be use for Slack Bus when Loss = Load + cable length ( etc...) + name: str + type: str + efficiency: float + pmin: int + pmax: int + injection: float = 0 + price: float = 0 + + def get_cost_per_mwh(self, fuels: Fuels) -> float: + """Calculate cost / MWh based on fuel type and efficiency""" + costs = { + 'gasfired': fuels.gas / self.efficiency, + 'turbojet': fuels.kerosine / self.efficiency, + 'windturbine': 0 + } + return costs[self.type] + + def get_available_power(self, fuels: Fuels) -> tuple[float, float]: + if self.type == 'windturbine': + max_power = self.pmax * (fuels.wind / 100.0) + return 0, max_power + return self.pmin, self.pmax + diff --git a/core/func.py b/core/func.py new file mode 100644 index 000000000..9001e7288 --- /dev/null +++ b/core/func.py @@ -0,0 +1,27 @@ +from core.classes import Fuels,Powerplant +from typing import List + +def solve(powerplants_list: List[Powerplant], load: int, fuels: Fuels): + for plant in powerplants_list: + plant.price = plant.get_cost_per_mwh(fuels) + + sorted_plants = sorted(powerplants_list, key = lambda x: x.price) + total_cost = 0 + # Assign power to each plant in order of price + for plant in sorted_plants: + min_power, max_power = plant.get_available_power(fuels) + if load > 0: + usable_power = min(max_power, load) # Try to use maximum possible power from this plant + if usable_power >= min_power: + plant.injection = round(usable_power,2) + load -= usable_power + total_cost += usable_power * plant.price + else: + plant.injection = 0 + else: + plant.injection = 0 + + # total_cost + # total_power = sum(p.injection for p in sorted_plants) + # load_met = abs(total_power - load.value) < 0.1, + return sorted_plants diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 000000000..fcab97554 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,39 @@ +from core.classes import Fuels, Powerplant +import yaml +import os + + +def get_config(default_path: str = 'config.yml'): + """Get configuration from environment variables or config file""" + # Load from config file first + config = load_config(default_path) + + # Override with environment variables if they exist + if os.getenv('DOCKER_ENV'): + config['API'].update({ + 'protocol': os.getenv('API_PROTOCOL', config['API']['protocol']), + 'host': os.getenv('API_HOST', config['API']['host']), + 'port': int(os.getenv('API_PORT', config['API']['port'])), + 'endpoint': os.getenv('API_ENDPOINT', config['API']['endpoint']) + }) + return config + + +def load_config(default_path: str = 'config.yml'): + """Load configuration from YAML file""" + with open(default_path) as stream: + try: + config = yaml.safe_load(stream) + except yaml.YAMLError as exc: + print(f"Error loading config file: {exc}") + exit(1) + return config + + +def format_payload(payload): + """Format the API payload into internal data structures""" + print(payload) + load = payload.load + fuels_stat = Fuels(*payload.fuels.values()) + powerplants_list = [Powerplant(i + 1, **pw) for i, pw in enumerate(payload.powerplants)] + return load, fuels_stat, powerplants_list \ No newline at end of file diff --git a/main.py b/main.py index d3e77eb1a..4441958c4 100644 --- a/main.py +++ b/main.py @@ -1,98 +1,10 @@ -from dataclasses import dataclass -import json -from typing import List -from fastapi import FastAPI - - -app = FastAPI() - - -@dataclass -class Load: - value: int - - -@dataclass -class Fuels: - gas: int - kerosine: int - co2: int - wind: int - - -@dataclass -class Powerplant: - pid: int # Not Used Here but might be use for Slack Bus when Loss = Load + cable length - name: str - type: str - efficiency: float - pmin: int - pmax: int - injection: float = 0 - price: float = 0 - - def get_cost_per_mwh(self, fuels: Fuels) -> float: - """Calculate cost / MWh based on fuel type and efficiency""" - costs = { - 'gasfired': fuels.gas / self.efficiency, - 'turbojet': fuels.kerosine / self.efficiency, - 'windturbine': 0 - } - return costs[self.type] - - def get_available_power(self, fuels: Fuels) -> tuple[float, float]: - """Returns (min, max) available power """ - if self.type == 'windturbine': - max_power = self.pmax * (fuels.wind / 100.0) - return 0, max_power - return self.pmin, self.pmax - - -@app.get("/productionplan") -def api_response(): - path = './example_payloads/payload3.json' - load, fuels_stat, powerplants_list = load_data(path) - solve(powerplants_list=powerplants_list, load=load, fuels=fuels_stat) - return {pw.name:pw.injection for pw in powerplants_list} - - -def solve(powerplants_list: List[Powerplant], load: Load, fuels: Fuels): - for plant in powerplants_list: - plant.price = plant.get_cost_per_mwh(fuels) - - sorted_plants = sorted(powerplants_list, key = lambda x: x.price) - remaining_load = load.value - total_cost = 0 - - # Assign power to each plant in order of price - for plant in sorted_plants: - min_power, max_power = plant.get_available_power(fuels) - - if remaining_load > 0: - usable_power = min(max_power, remaining_load) # Try to use maximum possible power from this plant - if usable_power >= min_power: - plant.injection = round(usable_power,2) - remaining_load -= usable_power - total_cost += usable_power * plant.price - else: - plant.injection = 0 - else: - plant.injection = 0 - - # total_cost - # total_power = sum(p.injection for p in sorted_plants) - # load_met = abs(total_power - load.value) < 0.1, - return sorted_plants - -def load_data(path: str): - with open(path, 'r') as file: - payload = json.load(file) - load = Load(payload['load']) - fuels_stat = Fuels(*payload['fuels'].values()) - powerplants_list = [Powerplant(i + 1, **pw) for i, pw in enumerate(payload['powerplants'])] - return load,fuels_stat,powerplants_list +import uvicorn +from core.api import productionApp +from core.utils import load_config +config = load_config('config.yml') +uvicorn.run(productionApp, host=config['API']['host'], port=config['API']['port']) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..bb345b261 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "powerplat-coding-challenge" +version = "0.1.1" +description = "Powerplants Coding Challenge" +authors = "Irvin Heslan" + +[tool.poetry.dependencies] +python = "^3.8" +fastapi = "^0.115.4" +pydantic = "^2.9.2" +PyYAML = "^6.0.2" +uvicorn = "^0.32.0" +requests = "^2.32.3" + + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..36fe4f80e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi~=0.115.4 +pydantic~=2.9.2 +PyYAML~=6.0.2 +uvicorn~=0.32.0 +requests~=2.32.3 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 000000000..4c3df6f4c --- /dev/null +++ b/tests/test.py @@ -0,0 +1,35 @@ +import requests +import json +import sys +import os + +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, project_root) +from core.utils import get_config + +def send_payload_example(): + # Load config and override with environment variables if set + config = get_config(os.path.join(project_root, 'config.yml')) + protocol = os.getenv('API_PROTOCOL', config['API']['protocol']) + host = os.getenv('API_HOST', config['API']['host']) + port = int(os.getenv('API_PORT', config['API']['port'])) + endpoint = os.getenv('API_ENDPOINT', config['API']['endpoint']) + + # Load test payload + path = os.path.join(project_root, 'example_payloads', 'payload3.json') + with open(path, 'r') as file: + payload = json.load(file) + + # Send request + url = f"{protocol}://{host}:{port}/{endpoint}" + try: + req = requests.post(url, json=payload) + req.raise_for_status() + print(f"Success! Response: {req.text}") + except requests.exceptions.RequestException as e: + print(f"Error making request: {e}") + if hasattr(e, 'response') and e.response is not None: + print(f"Response text: {e.response.text}") + +if __name__ == '__main__': + send_payload_example() \ No newline at end of file From 0e32258368c4777cbdaf6d58f046b6a7b7790210 Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Sun, 10 Nov 2024 11:48:26 +0100 Subject: [PATCH 3/4] + dockerfile + README more detailed + fix some errors + added logging system --- Dockerfile | 17 +++++ README.md | 167 ++++++++++++++++++++++++++++++------------------ config.yml | 2 +- core/api.py | 40 ++++++++++-- core/classes.py | 13 +++- core/func.py | 49 ++++++++++---- core/utils.py | 112 ++++++++++++++++++++++++++------ main.py | 25 +++++++- tests/test.py | 34 +++++++--- 9 files changed, 345 insertions(+), 114 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..448e678d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use Python 3.8 slim image as base +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +ENV DOCKER_ENV=1 +ENV CONFIG=config.yml +ENV API_PROTOCOL=http +ENV API_HOST=0.0.0.0 +ENV API_PORT=8888 +ENV API_ENDPOINT=productionplan +EXPOSE 8888 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 44c93d608..4401c1ffc 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,144 @@ -# powerplant-coding-challenge +## Prerequisites +- Python 3.8 or higher +- Docker (optional) -## Welcome ! +## Installation +### Local Installation -Below you can find the description of a coding challenge that we ask people to perform when applying for a job in our team. +1. Clone the repository: +```bash +git clone +cd powerplant-coding-challenge +``` -The goal of this coding challenge is to provide the applicant some insight into the business we're in and as such provide the applicant an indication about the challenges she/he will be confronted with. Next, during the first interview we will use the applicant's implementation as a seed to discuss all kinds of interesting software engineering topics. +2. Create a virtual environment (optional but recommended): +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` -Time is scarce, we know. Therefore we ask you not to spend more than 4 hours on this challenge. We know it is not possible to deliver a finished implementation of the challenge in only four hours. Even though your submission will not be complete, it will provide us plenty of information and topics to discuss later on during the talks. +3. Install dependencies: +```bash +pip install -r requirements.txt -This coding-challenge is part of a formal process and is used in collaboration with the recruiting companies we work with. Submitting a pull-request will not automatically trigger the recruitement process. -## Who are we -We are the IS team of the 'Short-term Power as-a-Service' (a.k.a. SPaaS) team within [GEM](https://gems.engie.com/). +### Docker Installation -[GEM](https://gems.engie.com/), which stands for 'Global Energy Management', is the energy management arm of [ENGIE](https://www.engie.com/), one of the largest global energy players, -with access to local markets all over the world. +1. Clone the repository: +```bash +git clone +cd powerplant-coding-challenge +``` -SPaaS is a team consisting of around 100 people with experience in energy markets, IT and modeling. In smaller teams consisting of a mix of people with different experiences, we are active on the [day-ahead](https://en.wikipedia.org/wiki/European_Power_Exchange#Day-ahead_markets) market, [intraday markets](https://en.wikipedia.org/wiki/European_Power_Exchange#Intraday_markets) and [collaborate with the TSO to balance the grid continuously](https://en.wikipedia.org/wiki/Transmission_system_operator#Electricity_market_operations). +2. Build the Docker image: +```bash +docker build -t powerplant-api . +``` -## The challenge +## Configuration -### In short -Calculate how much power each of a multitude of different [powerplants](https://en.wikipedia.org/wiki/Power_station) need to produce (a.k.a. the production-plan) when the [load](https://en.wikipedia.org/wiki/Load_profile) is given and taking into account the cost of the underlying energy sources (gas, kerosine) and the Pmin and Pmax of each powerplant. +The API configuration is stored by default in `config.yml`. Default settings: -### More in detail +```yaml +API: + protocol: http + host: 0.0.0.0 + endpoint: productionplan + port: 8888 +``` -The load is the continuous demand of power. The total load at each moment in time is forecasted. For instance for Belgium you can see the load forecasted by the grid operator [here](https://www.elia.be/en/grid-data/load-and-load-forecasts). +These settings can be overrided with Docker env variables. +Env variables will ALWAYS override setting file. -At any moment in time, all available powerplants need to generate the power to exactly match the load. The cost of generating power can be different for every powerplant and is dependent on external factors: The cost of producing power using a [turbojet](https://en.wikipedia.org/wiki/Gas_turbine#Industrial_gas_turbines_for_power_generation), that runs on kerosine, is higher compared to the cost of generating power using a gas-fired powerplant because of gas being cheaper compared to kerosine and because of the [thermal efficiency](https://en.wikipedia.org/wiki/Thermal_efficiency) of a gas-fired powerplant being around 50% (2 units of gas will generate 1 unit of electricity) while that of a turbojet is only around 30%. The cost of generating power using windmills however is zero. Thus deciding which powerplants to activate is dependent on the [merit-order](https://en.wikipedia.org/wiki/Merit_order). +``` +ENV DOCKER_ENV=1 +ENV API_PROTOCOL=http +ENV API_HOST=0.0.0.0 +ENV API_PORT=8888 +ENV API_ENDPOINT=productionplan +EXPOSE 8888 +``` -When deciding which powerplants in the merit-order to activate (a.k.a. [unit-commitment problem](https://en.wikipedia.org/wiki/Unit_commitment_problem_in_electrical_power_production)) the maximum amount of power each powerplant can produce (Pmax) obviously needs to be taken into account. Additionally gas-fired powerplants generate a certain minimum amount of power when switched on, called the Pmin. +or by setting up a custom config_file +``` +ENV DOCKER_ENV=1 +ENV CONFIG=mycustom_config.yml +``` -### Performing the challenge -Build a REST API exposing an endpoint `/productionplan` that accepts a POST of which the body contains a payload as you can find in the `example_payloads` directory and that returns a json with the same structure as in `example_response.json` and that manages and logs run-time errors. -For calculating the unit-commitment, we prefer you not to rely on an existing (linear-programming) solver but instead write an algorithm yourself. -Implementations can be submitted in either C# (on .Net 5 or higher) or Python (3.8 or higher) as these are (currently) the main languages we use in SPaaS. Along with the implementation should be a README that describes how to compile (if applicable) and launch the application. +## Usage -- C# implementations should contain a project file to compile the application. -- Python implementations should contain a `requirements.txt` or a `pyproject.toml` (for use with poetry) to install all needed dependencies. +### Running Locally -#### Payload +1. Start the API server: +```bash +python main.py +``` -The payload contains 3 types of data: - - load: The load is the amount of energy (MWh) that need to be generated during one hour. - - fuels: based on the cost of the fuels of each powerplant, the merit-order can be determined which is the starting point for deciding which powerplants should be switched on and how much power they will deliver. Wind-turbine are either switched-on, and in that case generate a certain amount of energy depending on the % of wind, or can be switched off. - - gas(euro/MWh): the price of gas per MWh. Thus if gas is at 6 euro/MWh and if the efficiency of the powerplant is 50% (i.e. 2 units of gas will generate one unit of electricity), the cost of generating 1 MWh is 12 euro. - - kerosine(euro/Mwh): the price of kerosine per MWh. - - co2(euro/ton): the price of emission allowances (optionally to be taken into account). - - wind(%): percentage of wind. Example: if there is on average 25% wind during an hour, a wind-turbine with a Pmax of 4 MW will generate 1MWh of energy. - - powerplants: describes the powerplants at disposal to generate the demanded load. For each powerplant is specified: - - name: - - type: gasfired, turbojet or windturbine. - - efficiency: the efficiency at which they convert a MWh of fuel into a MWh of electrical energy. Wind-turbines do not consume 'fuel' and thus are considered to generate power at zero price. - - pmax: the maximum amount of power the powerplant can generate. - - pmin: the minimum amount of power the powerplant generates when switched on. +The API will be available at `http://localhost:8888` -#### response +### Running with Docker -The response should be a json as in `example_payloads/response3.json`, which is the expected answer for `example_payloads/payload3.json`, specifying for each powerplant how much power each powerplant should deliver. The power produced by each powerplant has to be a multiple of 0.1 Mw and the sum of the power produced by all the powerplants together should equal the load. +1. Run the container: +```bash +docker run -p 8888:8888 powerplant-api +``` -### Want more challenge? +The API will be available at `http://localhost:8888` -Having fun with this challenge and want to make it more realistic. Optionally, do one of the extra's below: -#### Docker +## API Endpoints -Provide a Dockerfile along with the implementation to allow deploying your solution quickly. +### GET / +Returns a simple message directing users to use the POST endpoint. -#### CO2 +### POST /productionplan +Calculate the optimal production plan for power plants. -Taken into account that a gas-fired powerplant also emits CO2, the cost of running the powerplant should also take into account the cost of the [emission allowances](https://en.wikipedia.org/wiki/Carbon_emission_trading). For this challenge, you may take into account that each MWh generated creates 0.3 ton of CO2. +**Request Format:** +```json +{ + "load": float, + "fuels": { + "gas": float, + "kerosine": float, + "co2": float, + "wind": float + }, + "powerplants": [ + { + "name": string, + "type": string, + "efficiency": float, + "pmin": int, + "pmax": int + } + ] +} +``` -## Acceptance criteria +**Response Format:** +```json +{ + "powerplant-1": float, + "powerplant-2": float, + ... +} +``` -For a submission to be reviewed as part of an application for a position in the team, the project needs to: - - contain a README.md explaining how to build and launch the API - - expose the API on port `8888` +## Logging -Failing to comply with any of these criteria will automatically disqualify the submission. +All the logs file are in logs/folder if the folder is not existing it will -## More info -For more info on energy management, check out: +## Testing - - [Global Energy Management Solutions](https://www.youtube.com/watch?v=SAop0RSGdHM) - - [COO hydroelectric power station](https://www.youtube.com/watch?v=edamsBppnlg) - - [Management of supply](https://www.youtube.com/watch?v=eh6IIQeeX3c) - video made during winter 2018-2019 - -## FAQ - -##### Can an existing solver be used to calculate the unit-commitment -Implementations should not rely on an external solver and thus contain an algorithm written from scratch (clarified in the text as of version v1.1.0) +To test the API with example payloads: +```bash +python ./tests/test.py +``` \ No newline at end of file diff --git a/config.yml b/config.yml index 1137a1837..9d7def8d7 100644 --- a/config.yml +++ b/config.yml @@ -2,4 +2,4 @@ API: protocol: http host: 0.0.0.0 endpoint: productionplan - port: 8888 \ No newline at end of file + port: 8888 diff --git a/core/api.py b/core/api.py index b6a99f7ad..4bd530809 100644 --- a/core/api.py +++ b/core/api.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel -from core.utils import format_payload +from core.utils import format_payload, logger from core.func import solve from typing import Dict, Any import os @@ -8,20 +8,48 @@ # https://fastapi.tiangolo.com/#recap productionApp = FastAPI() + class ProductionPlanRequest(BaseModel): load: float fuels: Dict[str, Any] powerplants: list[dict] + @productionApp.get("/") async def default(): endpoint = os.getenv('API_ENDPOINT', 'productionplan') - return {'message': f'do post request to /{endpoint}'} + logger.info("Root endpoint accessed") + return {'message': f'do post request to /{endpoint}?with_co2=1 to include CO2 costs'} + @productionApp.post("/{endpoint}") -async def get_productionplan(endpoint: str, payload: ProductionPlanRequest): +async def get_productionplan(endpoint: str, payload: ProductionPlanRequest, with_co2: bool = False): + """Calculate production plan with optional CO2 cost consideration.""" + logger.info(f"Production plan request received for endpoint: {endpoint}") + logger.debug(f"CO2 calculation: {'enabled' if with_co2 else 'disabled'}") + if endpoint != os.getenv('API_ENDPOINT', 'productionplan'): + logger.warning(f"Invalid endpoint requested: {endpoint}") raise HTTPException(status_code = 404, detail = "Endpoint not found") - load, fuels_stat, powerplants_list = format_payload(payload) - solve(powerplants_list = powerplants_list, load = load, fuels = fuels_stat) - return {pw.name: pw.injection for pw in powerplants_list} \ No newline at end of file + + try: + load, fuels_stat, powerplants_list = format_payload(payload) + powerplants_list, total_cost = solve(powerplants_list = powerplants_list, load = load, fuels = fuels_stat, with_co2 = with_co2) + + formated_pwplants_dict = {pw.name: pw.injection for pw in powerplants_list} + + if not with_co2: + # Asked Project Answer + result = formated_pwplants_dict + else: + # Custom Answer no specification for Answer in the project README + formated_pwplants_dict['cost_in_euro'] = round(total_cost, 1) + result = formated_pwplants_dict + + logger.info("Successfully calculated production plan") + logger.debug(f"Result: {formated_pwplants_dict}") + return result + except Exception as e: + logger.error(f"Error processing request: {str(e)}") + raise HTTPException(status_code = 500, detail = str(e)) + diff --git a/core/classes.py b/core/classes.py index 7b6871cad..dfbfda7d9 100644 --- a/core/classes.py +++ b/core/classes.py @@ -18,18 +18,29 @@ class Powerplant: injection: float = 0 price: float = 0 - def get_cost_per_mwh(self, fuels: Fuels) -> float: + def get_cost_per_mwh(self, fuels: Fuels, mw_co2_em:int=0) -> float: """Calculate cost / MWh based on fuel type and efficiency""" + costs = { 'gasfired': fuels.gas / self.efficiency, 'turbojet': fuels.kerosine / self.efficiency, 'windturbine': 0 } + + if mw_co2_em: + co2_costs = { + 'gasfired': mw_co2_em * fuels.co2, + 'turbojet': mw_co2_em * fuels.co2, + 'windturbine': 0 + } + return costs[self.type] + co2_costs[self.type] return costs[self.type] + def get_available_power(self, fuels: Fuels) -> tuple[float, float]: if self.type == 'windturbine': max_power = self.pmax * (fuels.wind / 100.0) return 0, max_power return self.pmin, self.pmax + diff --git a/core/func.py b/core/func.py index 9001e7288..635644b90 100644 --- a/core/func.py +++ b/core/func.py @@ -1,27 +1,52 @@ -from core.classes import Fuels,Powerplant +from core.classes import Fuels, Powerplant from typing import List +from core.utils import logger -def solve(powerplants_list: List[Powerplant], load: int, fuels: Fuels): + +def solve(powerplants_list: List[Powerplant], load: int, fuels: Fuels, with_co2: bool = False): + """ + Solve the power production plan considering optional CO2 costs. + """ + logger.info(f"Starting production plan calculation (CO2: {'enabled' if with_co2 else 'disabled'})") + logger.info(f"Target load: {load} MW") + + # Calculate costs for each plant for plant in powerplants_list: - plant.price = plant.get_cost_per_mwh(fuels) + co2_em = 0.3 if with_co2 else 0 + plant.price = plant.get_cost_per_mwh(fuels, co2_em) + logger.debug(f"Plant {plant.name} cost: {plant.price}/MWh") + # Sort by price sorted_plants = sorted(powerplants_list, key = lambda x: x.price) + logger.debug("Merit order: " + ", ".join(p.name for p in sorted_plants)) + total_cost = 0 - # Assign power to each plant in order of price + remaining_load = load + + # Assign power to each plant for plant in sorted_plants: min_power, max_power = plant.get_available_power(fuels) - if load > 0: - usable_power = min(max_power, load) # Try to use maximum possible power from this plant + logger.debug(f"Processing {plant.name} (min: {min_power}, max: {max_power})") + + if remaining_load > 0: + usable_power = min(max_power, remaining_load) if usable_power >= min_power: - plant.injection = round(usable_power,2) - load -= usable_power + plant.injection = round(usable_power, 2) + remaining_load -= usable_power total_cost += usable_power * plant.price + logger.debug(f"Assigned {plant.injection}MW to {plant.name}") else: plant.injection = 0 + logger.debug(f"Skipped {plant.name} - minimum power requirement not met") else: plant.injection = 0 + logger.debug(f"Skipped {plant.name} - load requirement met") + + total_power = sum(p.injection for p in powerplants_list) + logger.info(f"Total power assigned: {total_power}MW") + logger.info(f"Total cost: €{total_cost:.2f}") + load_met = abs(total_power - load) < 0.1, + if not load_met: + logger.warning(f"Load not matched. {abs(total_power - load):.2f}MW needed.") - # total_cost - # total_power = sum(p.injection for p in sorted_plants) - # load_met = abs(total_power - load.value) < 0.1, - return sorted_plants + return sorted_plants,total_cost diff --git a/core/utils.py b/core/utils.py index fcab97554..5a777481e 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,21 +1,81 @@ from core.classes import Fuels, Powerplant import yaml -import os +import sys +from os import getenv, path, makedirs +import logging +from datetime import datetime +class Logger: + # my default logger config + _instance = None + _initialized = False -def get_config(default_path: str = 'config.yml'): + def __new__(cls): + if cls._instance is None: + cls._instance = super(Logger, cls).__new__(cls) + return cls._instance + + def __init__(self): + if not Logger._initialized: + log_dir = 'logs' + if not path.exists(log_dir): + makedirs(log_dir) + self.logger = logging.getLogger('PowerPlantAPI') + self.logger.setLevel(logging.DEBUG) + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_formatter = logging.Formatter( + '%(levelname)s: %(message)s' + ) + log_file = path.join(log_dir, f'powerplant_{datetime.now().strftime("%Y%m%d")}.log') + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(file_formatter) + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(console_formatter) + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + Logger._initialized = True + + def debug(self, message): self.logger.debug(message) + def info(self, message): self.logger.info(message) + def warning(self, message): self.logger.warning(message) + def error(self, message): self.logger.error(message) + def critical(self, message): self.logger.critical(message) + + +DEFAULT_CONFIG = { + 'API': { + 'protocol': 'http', + 'host': '0.0.0.0', + 'endpoint': 'productionplan', + 'port': 8888 + } +} +logger = Logger() + + +def get_config(): """Get configuration from environment variables or config file""" # Load from config file first - config = load_config(default_path) - - # Override with environment variables if they exist - if os.getenv('DOCKER_ENV'): - config['API'].update({ - 'protocol': os.getenv('API_PROTOCOL', config['API']['protocol']), - 'host': os.getenv('API_HOST', config['API']['host']), - 'port': int(os.getenv('API_PORT', config['API']['port'])), - 'endpoint': os.getenv('API_ENDPOINT', config['API']['endpoint']) - }) + config = DEFAULT_CONFIG + # Override with environment variables when it run in docker + if getenv('DOCKER_ENV'): + logger.info("Docker environment detected, overriding with environment variables") + if getenv('CONFIG'): + config = load_config(getenv('CONFIG')) + else: + config['API'].update({ + 'protocol': getenv('API_PROTOCOL', config['API']['protocol']), + 'host': getenv('API_HOST', config['API']['host']), + 'port': int(getenv('API_PORT', config['API']['port'])), + 'endpoint': getenv('API_ENDPOINT', config['API']['endpoint']) + }) + else: + logger.info(f"Loading configuration from config.yml") + config = load_config('config.yml') return config @@ -24,16 +84,28 @@ def load_config(default_path: str = 'config.yml'): with open(default_path) as stream: try: config = yaml.safe_load(stream) + logger.debug(f"Successfully loaded config: {config}") except yaml.YAMLError as exc: - print(f"Error loading config file: {exc}") - exit(1) + logger.error(f"Error loading config file: {exc}") + logger.info(f"Loading default integrated config : {DEFAULT_CONFIG}") + config = DEFAULT_CONFIG + except FileNotFoundError: + logger.critical(f"Config file not found: {default_path}") + logger.info(f"Loading default integrated config : {DEFAULT_CONFIG}") + config = DEFAULT_CONFIG return config def format_payload(payload): - """Format the API payload into internal data structures""" - print(payload) - load = payload.load - fuels_stat = Fuels(*payload.fuels.values()) - powerplants_list = [Powerplant(i + 1, **pw) for i, pw in enumerate(payload.powerplants)] - return load, fuels_stat, powerplants_list \ No newline at end of file + # Assume Payload format won't change / order won't change (fuels and powerplant struct) + try: + logger.info("Processing payload") + load = payload.load + fuels_stat = Fuels(*payload.fuels.values()) + powerplants_list = [Powerplant(i + 1, **pw) for i, pw in enumerate(payload.powerplants)] + return load, fuels_stat, powerplants_list + except Exception as e: + logger.error(f"Error formatting payload: {str(e)} \nBe sure format/order is correct") + raise + + diff --git a/main.py b/main.py index 4441958c4..a547748a1 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,29 @@ import uvicorn from core.api import productionApp -from core.utils import load_config +from core.utils import logger,get_config -config = load_config('config.yml') -uvicorn.run(productionApp, host=config['API']['host'], port=config['API']['port']) +def main(): + logger.info("Starting Power Plant Production Planning API") + config = get_config() + logger.info(f"Server configuration:") + logger.info(f"\tHost: {config['API']['host']}") + logger.info(f"\tPort: {config['API']['port']}") + logger.info(f"\tProtocol: {config['API']['protocol']}") + logger.info(f"\tEndpoint: {config['API']['endpoint']}") + try: + # Run the server + uvicorn.run( + productionApp, + host = config['API']['host'], + port = config['API']['port'], + ) + except Exception as e: + logger.critical(f"Failed to start server: {str(e)}") + raise + +if __name__ == "__main__": + main() diff --git a/tests/test.py b/tests/test.py index 4c3df6f4c..9c84bd0c6 100644 --- a/tests/test.py +++ b/tests/test.py @@ -3,33 +3,47 @@ import sys import os + + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) sys.path.insert(0, project_root) -from core.utils import get_config +from core.utils import load_config -def send_payload_example(): - # Load config and override with environment variables if set - config = get_config(os.path.join(project_root, 'config.yml')) +def prepare_payload(path:str = ''): + # Get config or Env var + config = load_config(os.path.join(project_root, 'config.yml')) protocol = os.getenv('API_PROTOCOL', config['API']['protocol']) host = os.getenv('API_HOST', config['API']['host']) port = int(os.getenv('API_PORT', config['API']['port'])) endpoint = os.getenv('API_ENDPOINT', config['API']['endpoint']) + url = f"{protocol}://{host}:{port}/{endpoint}" - # Load test payload - path = os.path.join(project_root, 'example_payloads', 'payload3.json') with open(path, 'r') as file: payload = json.load(file) + return url,payload - # Send request - url = f"{protocol}://{host}:{port}/{endpoint}" +def send_payload(url,payload): try: req = requests.post(url, json=payload) req.raise_for_status() - print(f"Success! Response: {req.text}") + return req except requests.exceptions.RequestException as e: print(f"Error making request: {e}") if hasattr(e, 'response') and e.response is not None: print(f"Response text: {e.response.text}") + + if __name__ == '__main__': - send_payload_example() \ No newline at end of file + payload_path = os.path.join(project_root, 'example_payloads', 'payload3.json') + api_url,json_payload = prepare_payload(path=payload_path) + + # Send classic payload + resp = send_payload(api_url,json_payload).text + print("Response :") + print(resp,end="\n"*2) + + # Send payload with co2 + resp_with_co2 = send_payload(f"{api_url}?with_co2=1",json_payload).text + print("Response with CO2 at 0.3:") + print(resp_with_co2) \ No newline at end of file From 616aa490856a277754db84b783b64e02d7dd9adf Mon Sep 17 00:00:00 2001 From: irvin <7606712+Osyna@users.noreply.github.com> Date: Sun, 10 Nov 2024 11:50:03 +0100 Subject: [PATCH 4/4] + dockerfile + README more detailed + fix some errors + added logging system --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4401c1ffc..91a6cf75d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +## Info + +powerplant-coding-challenge +Submission by Irvin Heslan + ## Prerequisites - Python 3.8 or higher @@ -132,7 +137,8 @@ Calculate the optimal production plan for power plants. ## Logging -All the logs file are in logs/folder if the folder is not existing it will +All the logs file are in logs/folder if the folder is not existing it will create the folder logs. +One log per day of run. ## Testing