From d922a70375e692a26771aa89edef7be0c45186ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sat, 19 Oct 2024 16:08:34 +0100 Subject: [PATCH 01/14] Initial commit - setup fastapi --- .gitignore | 162 +++++++++++++++++++++++++++++++++++++++++++ Makefile | 18 +++++ main.py | 34 +++++++++ requirements.txt | 1 + requirements_dev.txt | 1 + 5 files changed, 216 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 requirements_dev.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..efa407c35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..2a24f0dfe --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: help +help: # Show help for each of the Makefile target. + @grep -E '^[a-zA-Z0-9 _]+:.*#' Makefile | sort | while read -r l; do printf "\033[1;32m$$(echo $$l | cut -f 1 -d':')\033[00m:$$(echo $$l | cut -f 2- -d'#')\n"; done + +install_requirements: # Installl packages requirements for run main code + python -m pip install -r requirements.txt + +install_requirements_dev: # Installl packages requirements for dev/test and linter + python -m pip install -r requirements_dev.txt + +linter: + flake8 . + +run_dev: + fastapi dev main.py --port 8888 + +run: + fastapi run main.py --port 8888 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 000000000..3eb590095 --- /dev/null +++ b/main.py @@ -0,0 +1,34 @@ +from typing import Union +from decimal import Decimal +from fastapi import FastAPI +from pydantic import BaseModel + + +class Payload(BaseModel): + load: int + fuels: object + powerplants: object + + +class Response(BaseModel): + name: str + p: Decimal + + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"Hello": "World"} + + +@app.get("/items/{item_id}") +def read_item(item_id: int, q: Union[str, None] = None): + return {"item_id": item_id, "q": q} + + +@app.post("/productionplan") +def productionplan(payload: Payload) -> list[Response]: + print(payload) + return [{"name": "windpark1", "p": 90.0}, {"name": "windpark2", "p": 21.6}] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..13712cc18 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +fastapi[standard] diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 000000000..0a81b7e60 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +flake8==7.1.1 \ No newline at end of file From 6e15f3e47326a1284485f4936d8bc8f86c880515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sat, 19 Oct 2024 16:45:36 +0100 Subject: [PATCH 02/14] Update models fields --- main.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/main.py b/main.py index 3eb590095..5fe9b87c7 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,27 @@ -from typing import Union from decimal import Decimal from fastapi import FastAPI -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class FuelModel(BaseModel): + gas: Decimal = Field(alias="gas(euro/MWh)") + kerosine: Decimal = Field(alias="kerosine(euro/MWh)") + co2: Decimal = Field(alias="co2(euro/ton)") + wind: Decimal = Field(alias="wind(%)") + + +class PowerplantsModel(BaseModel): + name: str + type: str + efficiency: Decimal = Field(le=1, gt=0) + pmin: Decimal + pmax: Decimal class Payload(BaseModel): load: int - fuels: object - powerplants: object + fuels: FuelModel + powerplants: list[PowerplantsModel] class Response(BaseModel): @@ -23,11 +37,6 @@ def read_root(): return {"Hello": "World"} -@app.get("/items/{item_id}") -def read_item(item_id: int, q: Union[str, None] = None): - return {"item_id": item_id, "q": q} - - @app.post("/productionplan") def productionplan(payload: Payload) -> list[Response]: print(payload) From 1ab54c56801eeaaa67898243a79a53ac1dfe377a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sat, 19 Oct 2024 16:56:31 +0100 Subject: [PATCH 03/14] Create models file --- main.py | 31 ++----------------------------- models.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 models.py diff --git a/main.py b/main.py index 5fe9b87c7..291c78c77 100644 --- a/main.py +++ b/main.py @@ -1,33 +1,6 @@ -from decimal import Decimal -from fastapi import FastAPI -from pydantic import BaseModel, Field - - -class FuelModel(BaseModel): - gas: Decimal = Field(alias="gas(euro/MWh)") - kerosine: Decimal = Field(alias="kerosine(euro/MWh)") - co2: Decimal = Field(alias="co2(euro/ton)") - wind: Decimal = Field(alias="wind(%)") - - -class PowerplantsModel(BaseModel): - name: str - type: str - efficiency: Decimal = Field(le=1, gt=0) - pmin: Decimal - pmax: Decimal - - -class Payload(BaseModel): - load: int - fuels: FuelModel - powerplants: list[PowerplantsModel] - - -class Response(BaseModel): - name: str - p: Decimal +from fastapi import FastAPI +from models import Payload, Response app = FastAPI() diff --git a/models.py b/models.py new file mode 100644 index 000000000..4a72522ea --- /dev/null +++ b/models.py @@ -0,0 +1,28 @@ +from decimal import Decimal +from pydantic import BaseModel, Field + + +class FuelModel(BaseModel): + gas: Decimal = Field(alias="gas(euro/MWh)") + kerosine: Decimal = Field(alias="kerosine(euro/MWh)") + co2: Decimal = Field(alias="co2(euro/ton)") + wind: Decimal = Field(alias="wind(%)") + + +class PowerplantsModel(BaseModel): + name: str + type: str + efficiency: Decimal = Field(le=1, gt=0) + pmin: Decimal + pmax: Decimal + + +class Payload(BaseModel): + load: int + fuels: FuelModel + powerplants: list[PowerplantsModel] + + +class Response(BaseModel): + name: str + p: Decimal From 8f251ed7bc1d2622ea2972c427b23157ac4f9651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:08:41 +0100 Subject: [PATCH 04/14] Add PowerCalculator class --- power_calculator.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 power_calculator.py diff --git a/power_calculator.py b/power_calculator.py new file mode 100644 index 000000000..f8e5d69b7 --- /dev/null +++ b/power_calculator.py @@ -0,0 +1,56 @@ +from models import FuelModel, PowerplantsModel +from decimal import Decimal + + +class PowerCalculator(): + load: int + fuels: FuelModel + powerplants: list[PowerplantsModel] + price_costs: dict + + def __init__(self, load: int, fuels: FuelModel, powerplants: list[PowerplantsModel]) -> None: + self.load = load + self.fuels = fuels + self.powerplants = powerplants + self.price_cost = { + 'gasfired': self.fuels.gas, + 'turbojet': self.fuels.kerosine, + 'windturbine': 0, + } + self.sorted_powerplants() + + def sorted_powerplants(self): + self.powerplants = sorted( + self.powerplants, + key=lambda powerplant: powerplant.get_cost_eficiency( + self.price_cost.get(powerplant.type) + ) + ) + + def calc_power(self, powerplant): + if powerplant.type == 'gasfired': + return self.fuels.gas * powerplant.efficiency + if powerplant.type == 'turbojet': + return self.fuels.kerosine * powerplant.efficiency + if powerplant.type == 'windturbine': + return powerplant.pmax * self.fuels.wind / 100 + raise ValueError('Powerplant not identified') + + def get_powerplants_power(self): + pending_power = Decimal(self.load) + result = [] + for powerplant in self.powerplants: + power = 0 + if pending_power > 0: + ref_value = pending_power if powerplant.type != 'windturbine' else powerplant.pmin + power = max(self.calc_power(powerplant), ref_value) + power = min(power, powerplant.pmax) + power = power + + result.append({ + 'name': powerplant.name, + 'p': round(power, 1) + }) + pending_power -= power + + return result From 1b24e82a2a393a036c677aece01de45bcc5e4c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:09:09 +0100 Subject: [PATCH 05/14] Add PowerplantsModel.get_cost_eficiency method --- models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/models.py b/models.py index 4a72522ea..4c3b11e4e 100644 --- a/models.py +++ b/models.py @@ -16,6 +16,11 @@ class PowerplantsModel(BaseModel): pmin: Decimal pmax: Decimal + def get_cost_eficiency(self, price): + if self.type == 'windturbine': + return 0 + return price / self.efficiency + class Payload(BaseModel): load: int From 1cdf4fb64ed735d7abb4bdff33f7a5c9d554667d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:09:28 +0100 Subject: [PATCH 06/14] Calculate powerplants --- main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 291c78c77..f1a3ab18a 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from models import Payload, Response +from power_calculator import PowerCalculator app = FastAPI() @@ -13,4 +14,10 @@ def read_root(): @app.post("/productionplan") def productionplan(payload: Payload) -> list[Response]: print(payload) - return [{"name": "windpark1", "p": 90.0}, {"name": "windpark2", "p": 21.6}] + + service = PowerCalculator( + load=payload.load, + fuels=payload.fuels, + powerplants=payload.powerplants + ) + return service.get_powerplants_power() From 2221f8dfb72714bdf7654c5c0e8e2501d364ed7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:09:37 +0100 Subject: [PATCH 07/14] Add flake8 config file --- .flake8 | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..e8b867b1e --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +exclude = .git,__pycache__ +max-complexity = 10 +max-line-length = 120 \ No newline at end of file From 4a6efef7279d9edf8e7a1ee067276da8f34d86c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:18:09 +0100 Subject: [PATCH 08/14] Run app in port 8888 by default --- main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/main.py b/main.py index f1a3ab18a..3c7442172 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,8 @@ from fastapi import FastAPI from models import Payload, Response from power_calculator import PowerCalculator +import uvicorn + app = FastAPI() @@ -21,3 +23,7 @@ def productionplan(payload: Payload) -> list[Response]: powerplants=payload.powerplants ) return service.get_powerplants_power() + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8888) From d33ba788cc88604f8791bc31fcdf91f0b0df5bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:22:05 +0100 Subject: [PATCH 09/14] Add new README.md file with app instructions --- README.md | 113 ++++++++++----------------------------------- README.original.md | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 89 deletions(-) create mode 100644 README.original.md diff --git a/README.md b/README.md index 44c93d608..bc56b75cb 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,34 @@ -# powerplant-coding-challenge +# Python version +- Python 3.11 -## Welcome ! +# Requirements -Below you can find the description of a coding challenge that we ask people to perform when applying for a job in our team. +To run the app, it is necessary to install dependencies: -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. +``` +python -m pip install -r requirements.txt +``` -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. +Plus: for dev packages, it is necessary to install dev packages -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 +``` +python -m pip install -r requirements_dev.txt +``` -We are the IS team of the 'Short-term Power as-a-Service' (a.k.a. SPaaS) team within [GEM](https://gems.engie.com/). +# How to run -[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. +Fast API DEV mode +``` +fastapi dev main.py --port 8888 +``` -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) +Fast API Production mode +``` +fastapi run main.py --port 8888 +``` +Starting app manually +``` +python main.py +``` \ No newline at end of file diff --git a/README.original.md b/README.original.md new file mode 100644 index 000000000..44c93d608 --- /dev/null +++ b/README.original.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) + From 591027a9af030dfc801efad4e2afaa2110c79c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:44:58 +0100 Subject: [PATCH 10/14] Add Dockerfile --- Dockerfile | 10 ++++++++++ README.md | 15 +++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..08ba11bd8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-alpine + +WORKDIR /src + +COPY requirements.txt main.py power_calculator.py models.py /src/ + +RUN python -m pip install -r requirements.txt + +CMD ["fastapi", "run", "--port", "8888"] + diff --git a/README.md b/README.md index bc56b75cb..1d464fc1a 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,19 @@ fastapi run main.py --port 8888 Starting app manually ``` python main.py +``` + +# Docker image + +It is possible to execute app using Docker. + +Build image: + +``` +docker build -t powerplant:latest . +``` + +Run image +``` +docker container run -p 8888:8888 powerplant:latest ``` \ No newline at end of file From f119e09a4603178e216a4fd04dc4eee670575168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 16:45:44 +0100 Subject: [PATCH 11/14] Update Makefile --- Makefile | 14 ++++++++++---- README.md | 6 +++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 2a24f0dfe..80d211a84 100644 --- a/Makefile +++ b/Makefile @@ -8,11 +8,17 @@ install_requirements: # Installl packages requirements for run main code install_requirements_dev: # Installl packages requirements for dev/test and linter python -m pip install -r requirements_dev.txt -linter: +linter: # Flake8 checks flake8 . -run_dev: +run_dev: # Run fastapi app in development mode fastapi dev main.py --port 8888 -run: - fastapi run main.py --port 8888 \ No newline at end of file +run: # Run fastapi app in production mode + fastapi run main.py --port 8888 + +build_image: # Build docker image + docker build -t powerplant:latest . + +run_image: # Run fastapi using docker image + docker container run -p 8888:8888 powerplant:latest \ No newline at end of file diff --git a/README.md b/README.md index 1d464fc1a..589959cad 100644 --- a/README.md +++ b/README.md @@ -46,4 +46,8 @@ docker build -t powerplant:latest . Run image ``` docker container run -p 8888:8888 powerplant:latest -``` \ No newline at end of file +``` + +# Makefile + +This repository contains a Makefile to help it with some commands \ No newline at end of file From 836daf4b50903a77412cb79fad55da1ca88bc1ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 22:14:33 +0100 Subject: [PATCH 12/14] Update README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 589959cad..94b34c340 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Requirements -To run the app, it is necessary to install dependencies: +Before running the app, it is necessary to install dependencies: ``` python -m pip install -r requirements.txt @@ -16,7 +16,9 @@ Plus: for dev packages, it is necessary to install dev packages python -m pip install -r requirements_dev.txt ``` -# How to run +# Running the app + +After install the requirements, run the app with one of the following commands: Fast API DEV mode ``` From abf2e6592fbeb3b0eb40b3166e2b9b9d87408a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 22:24:39 +0100 Subject: [PATCH 13/14] Remote print --- main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.py b/main.py index 3c7442172..2d1435d7a 100644 --- a/main.py +++ b/main.py @@ -15,8 +15,6 @@ def read_root(): @app.post("/productionplan") def productionplan(payload: Payload) -> list[Response]: - print(payload) - service = PowerCalculator( load=payload.load, fuels=payload.fuels, From 0d04282b465a74463c819b014284c88817da8755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leandro=20Ara=C3=BAjo?= Date: Sun, 20 Oct 2024 22:41:36 +0100 Subject: [PATCH 14/14] Update README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 94b34c340..72ceae58f 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Starting app manually python main.py ``` +# Interactive API docs + +Accessing the path `/docs` (ex `http://localhost:8888/docs`) it is possible to access the interactive API docs + # Docker image It is possible to execute app using Docker.