diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..a12a538a5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "Power Plants API", + "context": "..", + "dockerFile": "../Dockerfile", + "workspaceFolder": "/app/src" , + "appPort": [8888], + "customization/vscode/settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.formatting.provider": "black", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true + }, + "customizations/vscode/extensions": [ + "ms-python.python", + "esbenp.prettier-vscode" + ], + "postCreateCommand": "pip install --no-cache-dir -r requirements.txt", + "customizations": { + "vscode": { + "extensions": [ + "mhutchie.git-graph", + "ms-python.isort", + "ms-azuretools.vscode-docker", + "RandomFractalsInc.vscode-data-preview", + "ms-python.black-formatter", + "njpwerner.autodocstring", + "ms-python.python", + "ms-python.pylint", + "MS-CEINTL.vscode-language-pack-es", + "GitHub.copilot" + ] + } + }, + "remoteUser": "root" +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..97d5be2f2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,37 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: FastAPI", + "type": "python", + "request": "launch", + "module": "uvicorn", + "args": [ + "src.api:app", + "--host", + "0.0.0.0", + "--port", + "8888", + "--reload" + ], + "jinja": true, + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "justMyCode": true + }, + { + "name": "Python: Tests", + "type": "python", + "request": "launch", + "module": "pytest", + "cwd": "${workspaceFolder}", + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "console": "integratedTerminal", + "justMyCode": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..3e5844863 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "src/tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..378c2eef2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Use the official Python 3.11 image from the Docker Hub +FROM python:3.11-slim + +# Install curl and other necessary dependencies +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Copy the requirements file into the container +COPY requirements.txt /usr/src/app/requirements.txt + +# Set the working directory to /usr/src/app +WORKDIR /usr/src/app + +# Install required packages +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the contents of the src directory into the container's /usr/src/app directory +COPY src/ . + +# Expose the port the app runs on +EXPOSE 8888 + +# Command to run the FastAPI application +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8888"] \ No newline at end of file diff --git a/README.md b/README.md index 44c93d608..0e859f1f5 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,112 @@ -# powerplant-coding-challenge +# Powerplant Coding Challenge +This project is a FastAPI application that calculates the optimal production plan to meet a given load with available power plants. The application is containerized using Poetry & Docker. -## Welcome ! +## Table of Contents -Below you can find the description of a coding challenge that we ask people to perform when applying for a job in our team. +- [Installation](#installation) +- [Poetry](#poetry) +- [Docker](#docker) +- [Project Structure](#project-structure) -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. +## Installation -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. +### Prerequisites -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 +- Docker +- Docker Compose (optional) +- Python 3.11 (if running locally without Docker) -We are the IS team of the 'Short-term Power as-a-Service' (a.k.a. SPaaS) team within [GEM](https://gems.engie.com/). +### Clone the Repository -[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. +```sh +git clone https://github.com/your-username/powerplant-coding-challenge.git +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). +## Poetry -## The challenge +### Setup with Poetry -### 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. +1. Install Poetry: -### More in detail + Follow the instructions on the [Poetry Installation](https://python-poetry.org/docs/#installation) page. For example, using the recommended installation script: -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). + ```sh + curl -sSL https://install.python-poetry.org | python3 - + ``` -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). +2. Install Dependencies: -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. + ```sh + poetry install + ``` +3. Activate the Virtual Environment: + ```sh + poetry env activate + ``` -### Performing the challenge +4. Run the API: + ```sh + uvicorn src.api:app --host 0.0.0.0 --port 8888 + ``` -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. +5. Run the tests (pytest): + ```sh + pytest src/tests/test_api.py + ``` -For calculating the unit-commitment, we prefer you not to rely on an existing (linear-programming) solver but instead write an algorithm yourself. +6. Example request: -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. + Use `curl` or any API client to send a POST request to the `/productionplan` endpoint: -- 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. + ```sh + curl -X POST "http://localhost:8888/productionplan" -H "Content-Type: application/json" -d @payload.json + ``` -#### 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. +## Docker -#### response +### Setup 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. Build the Docker image: -### Want more challenge? + ```sh + docker build -t powerplant-coding-challenge . + ``` -Having fun with this challenge and want to make it more realistic. Optionally, do one of the extra's below: +2. Run the Docker container: -#### Docker + ```sh + docker run -d -p 8888:8888 powerplant-coding-challenge + ``` -Provide a Dockerfile along with the implementation to allow deploying your solution quickly. +3. Example request: -#### CO2 + Use `curl` or any API client to send a POST request to the `/productionplan` endpoint: -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) + ```sh + curl -X POST "http://localhost:8888/productionplan" -H "Content-Type: application/json" -d @payload.json + ``` +## Project Structure +``` +project-root/ +├── Dockerfile +├── README.md +├── requirements.txt +├── pyproject.toml +├── src/ +│ ├── api.py +│ ├── tests/ +│ │ ├── example_payloads/ +│ │ │ ├── payload1.json +│ │ │ ├── response1.json +│ │ │ ├── payload2.json +│ │ │ ├── response2.json +│ │ │ ├── payload3.json +│ │ │ ├── response3.json +│ └── └── test_api.py +└── payload.json +``` diff --git a/example_payloads/payload2.json b/payload.json similarity index 100% rename from example_payloads/payload2.json rename to payload.json diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..543f7c1da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "powerplant-coding-challenge" +version = "0.1.0" +description = "A FastAPI application to calculate optimal production plan for power plants." +authors = ["Gustavo Manzano "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" +fastapi = "^0.78.0" +uvicorn = "^0.17.6" +pydantic = "^1.9.0" + +[tool.poetry.dev-dependencies] +pytest = "^7.1.2" +pytest-cov = "^3.0.0" + +[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..61212481f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.95.1 +uvicorn==0.22.0 +httpx==0.23.0 +pydantic==1.10.2 +pytest==7.0 \ No newline at end of file diff --git a/src/api.py b/src/api.py new file mode 100644 index 000000000..199452f91 --- /dev/null +++ b/src/api.py @@ -0,0 +1,130 @@ +from fastapi import FastAPI +from pydantic import BaseModel, Field +from typing import List + +class FuelCosts(BaseModel): + gas: float = Field(13.4, alias="gas(euro/MWh)") + kerosine: float = Field(50.8, alias="kerosine(euro/MWh)") + co2: float = Field(20, alias="co2(euro/ton)") + wind: float = Field(0, alias="wind(%)") + +class PowerPlant(BaseModel): + name: str + type: str + efficiency: float + pmax: float + pmin: float + +class Payload(BaseModel): + load: float + fuels: FuelCosts + powerplants: List[PowerPlant] + +class ProductionPlanResponse(BaseModel): + name: str + p: float + +def calculate_cost(plant: PowerPlant, fuels: FuelCosts) -> float: + """ + Calculate the cost of generating power for a given power plant based on its type and efficiency. + + Args: + plant (PowerPlant): The power plant for which to calculate the generation cost. + fuels (FuelCosts): The current fuel costs, including gas and kerosine prices. + + Returns: + float: The cost of generating one MWh of electricity for the specified power plant. + Returns infinity for non-gas and non-turbojet plants as they are not considered. + """ + if plant.type == "gasfired": + return fuels.gas / plant.efficiency + elif plant.type == "turbojet": + return fuels.kerosine / plant.efficiency + elif plant.type == "windturbine": + return 0.0 # Wind power is free + return float('inf') # Other types are not considered here + +def calculate_production_plan(load: float, fuels: FuelCosts, powerplants: List[PowerPlant]) -> List[ProductionPlanResponse]: + """ + Calculate the optimal production plan to meet the given load with the available power plants. + + Args: + load (float): The total load that needs to be met. + fuels (FuelCosts): The current fuel costs, including gas and kerosine prices. + powerplants (List[PowerPlant]): A list of available power plants. + + Returns: + List[ProductionPlanResponse]: A list of allocations for each power plant to meet the load. + """ + allocations = [] + + # Step 1: Get Merit Order for All Plants + merit_order = powerplants.copy() + merit_order.sort(key=lambda x: calculate_cost(x, fuels)) + + remaining_load = load + + # Step 2: Allocate Power Based on Merit Order + for i, plant in enumerate(merit_order): + if remaining_load <= 0: + # If the load is already met, allocate 0 power for the remaining plants + allocations.append(ProductionPlanResponse(name=plant.name, p=0.0)) + continue + + if plant.type == "windturbine": + # Calculate the available wind power based on the wind percentage + wind_power = plant.pmax * (fuels.wind / 100) + # Allocate power from wind turbines as much as needed to meet the remaining load + allocated_power = min(wind_power, remaining_load) + allocations.append(ProductionPlanResponse(name=plant.name, p=round(allocated_power, 1))) + + # Reduce the remaining load by the allocated wind power + remaining_load -= allocated_power + + elif remaining_load < plant.pmin: + # If the remaining load is less than the minimum power the plant can produce, + # we have to allocate at least the plant's minimum power + allocations.append(ProductionPlanResponse(name=plant.name, p=plant.pmin)) + + # Adjust the allocation of the previous plant to ensure we don't exceed the total load + if i > 0: + previous_allocation = allocations[-2] + previous_allocation.p = max(0.0, load - plant.pmin) + + # Set remaining load to 0 as we have allocated the required load + remaining_load = 0 + + else: + # Allocate the minimum power required for the current plant + allocation = plant.pmin + allocations.append(ProductionPlanResponse(name=plant.name, p=allocation)) + remaining_load -= allocation + + # Allocate additional power up to the plant's maximum capacity if needed + if remaining_load > 0: + max_increase = min(plant.pmax - allocation, remaining_load) + allocations[-1].p += max_increase + remaining_load -= max_increase + + # Ensure all plants are included in the response + for plant in powerplants: + if not any(p.name == plant.name for p in allocations): + allocations.append(ProductionPlanResponse(name=plant.name, p=0.0)) + + return allocations + +app = FastAPI() + +@app.post("/productionplan", response_model=list[ProductionPlanResponse]) +def production_plan(payload: Payload): + load = payload.load + fuels = payload.fuels + powerplants = payload.powerplants + + # Calculate production plan + response = calculate_production_plan(load, fuels, powerplants) + return response + +if __name__ == '__main__': + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8888) \ No newline at end of file diff --git a/example_payloads/payload1.json b/src/tests/example_payloads/payload1.json similarity index 100% rename from example_payloads/payload1.json rename to src/tests/example_payloads/payload1.json diff --git a/src/tests/example_payloads/payload2.json b/src/tests/example_payloads/payload2.json new file mode 100644 index 000000000..f3c7525db --- /dev/null +++ b/src/tests/example_payloads/payload2.json @@ -0,0 +1,54 @@ +{ + "load": 480, + "fuels": + { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 0 + }, + "powerplants": [ + { + "name": "gasfiredbig1", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredbig2", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredsomewhatsmaller", + "type": "gasfired", + "efficiency": 0.37, + "pmin": 40, + "pmax": 210 + }, + { + "name": "tj1", + "type": "turbojet", + "efficiency": 0.3, + "pmin": 0, + "pmax": 16 + }, + { + "name": "windpark1", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 150 + }, + { + "name": "windpark2", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 36 + } + ] +} diff --git a/example_payloads/payload3.json b/src/tests/example_payloads/payload3.json similarity index 100% rename from example_payloads/payload3.json rename to src/tests/example_payloads/payload3.json diff --git a/src/tests/example_payloads/response1.json b/src/tests/example_payloads/response1.json new file mode 100644 index 000000000..968ddece4 --- /dev/null +++ b/src/tests/example_payloads/response1.json @@ -0,0 +1,26 @@ +[ + { + "name": "windpark1", + "p": 90.0 + }, + { + "name": "windpark2", + "p": 21.6 + }, + { + "name": "gasfiredbig1", + "p": 368.4 + }, + { + "name": "gasfiredbig2", + "p": 0.0 + }, + { + "name": "gasfiredsomewhatsmaller", + "p": 0.0 + }, + { + "name": "tj1", + "p": 0.0 + } +] \ No newline at end of file diff --git a/src/tests/example_payloads/response2.json b/src/tests/example_payloads/response2.json new file mode 100644 index 000000000..a92e3445b --- /dev/null +++ b/src/tests/example_payloads/response2.json @@ -0,0 +1,26 @@ +[ + { + "name": "windpark1", + "p": 0.0 + }, + { + "name": "windpark2", + "p": 0.0 + }, + { + "name": "gasfiredbig1", + "p": 380.0 + }, + { + "name": "gasfiredbig2", + "p": 100.0 + }, + { + "name": "gasfiredsomewhatsmaller", + "p": 0.0 + }, + { + "name": "tj1", + "p": 0.0 + } +] \ No newline at end of file diff --git a/example_payloads/response3.json b/src/tests/example_payloads/response3.json similarity index 93% rename from example_payloads/response3.json rename to src/tests/example_payloads/response3.json index 1dd9ed852..545c3ef1d 100644 --- a/example_payloads/response3.json +++ b/src/tests/example_payloads/response3.json @@ -1,26 +1,26 @@ -[ - { - "name": "windpark1", - "p": 90.0 - }, - { - "name": "windpark2", - "p": 21.6 - }, - { - "name": "gasfiredbig1", - "p": 460.0 - }, - { - "name": "gasfiredbig2", - "p": 338.4 - }, - { - "name": "gasfiredsomewhatsmaller", - "p": 0.0 - }, - { - "name": "tj1", - "p": 0.0 - } +[ + { + "name": "windpark1", + "p": 90.0 + }, + { + "name": "windpark2", + "p": 21.6 + }, + { + "name": "gasfiredbig1", + "p": 460.0 + }, + { + "name": "gasfiredbig2", + "p": 338.4 + }, + { + "name": "gasfiredsomewhatsmaller", + "p": 0.0 + }, + { + "name": "tj1", + "p": 0.0 + } ] \ No newline at end of file diff --git a/src/tests/test_api.py b/src/tests/test_api.py new file mode 100644 index 000000000..e1cea33a9 --- /dev/null +++ b/src/tests/test_api.py @@ -0,0 +1,57 @@ +import pytest +from fastapi.testclient import TestClient +import json + +from api import app + +client = TestClient(app) + +@pytest.mark.parametrize("payload_file,expected_response_file", [ + ("src/tests/example_payloads/payload1.json", "src/tests/example_payloads/response1.json"), + ("src/tests/example_payloads/payload2.json", "src/tests/example_payloads/response2.json"), + ("src/tests/example_payloads/payload3.json", "src/tests/example_payloads/response3.json"), +]) +def test_production_plan(payload_file, expected_response_file): + # Load the JSON payload from file + with open(payload_file, 'r') as f: + payload = json.load(f) + + # Load the expected response from file + with open(expected_response_file, 'r') as f: + expected_response = json.load(f) + + # Make a POST request to the /productionplan endpoint + response = client.post("/productionplan", json=payload) + + # Assert the status code and the response data + assert response.status_code == 200 + assert response.json() == expected_response + + +@pytest.mark.parametrize("payload_file", [ + "src/tests/example_payloads/payload1.json", + "src/tests/example_payloads/payload2.json", + "src/tests/example_payloads/payload3.json", +]) +def test_production_plan_data_types(payload_file): + # Load the JSON payload from file + with open(payload_file, 'r') as f: + payload = json.load(f) + + # Make a POST request to the /productionplan endpoint + response = client.post("/productionplan", json=payload) + + # Assert the status code + assert response.status_code == 200 + + # Assert the response is a list + response_data = response.json() + assert isinstance(response_data, list) + + # Check if each element in the response has the correct structure + for plant in response_data: + assert "name" in plant + assert "p" in plant + assert isinstance(plant["name"], str) + assert isinstance(plant["p"], (float, int)) + assert round(plant["p"], 2) == plant["p"], f"Value for {plant['name']} is not rounded to 2 decimals: {plant['p']}" \ No newline at end of file