diff --git a/README.md b/README.md index 44c93d608..95158e0d6 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,102 @@ -# 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) - +# Production Plan API + +This API calculates an optimal production plan for a set of power plants to meet a specified energy load, based on various input parameters like fuel costs, plant efficiency, and power limits. The resulting production plan is saved as a JSON file in the project directory. + +## Project Overview + +The Production Plan API is designed to receive energy load requirements and various power plant parameters. It calculates the most cost-effective distribution of energy across these plants to meet the requested load. This API returns a downloadable JSON file with the production plan, specifying the energy output for each plant. + +### Project Structure + +```bash + project/ +├── requirement.txt +├── src/ # Folder containing source code +│ ├── production_plan.py # Production planning algorithm +│ └── app.py # Flask server for the application interface +└── test/ # Folder containing tests + └── test_app.py # Unit tests for the Flask application +``` + +- **`src/`**: This folder contains the source code of the application: + - `production_plan.py`: This module implements the production algorithm. It includes calculation functions. + - `app.py`: This module sets up the Flask server for the application. It exposes an API. + +- **`test/`**: This folder contains unit tests for the application. + - `test_app.py`: This file contains tests to verify the functionality of the Flask application. The tests check that the API routes work as expected and that the planning algorithm responds correctly to various requests. +- **requirements.txt**: Lists the required dependencies for the project. + + +## Getting Started + +### Prerequisites + +Before starting, ensure you have: +- **Python 3.8** or higher installed on your system. +- **pip** (Python package manager) installed for managing dependencies. + +### Installation + +1. **Clone or Download** the project files to your local machine. +2. **Install dependencies** by navigating to the project directory in your terminal and running: + + ```bash + pip install -r requirements.txt + +## Running the API + +This section provides step-by-step instructions to run the Production Plan API, send a request, and verify the output. + +### 1. Start the API + +- Open a terminal in the project directory where `app.py` and `production_plan.py` are located. +- Run the following command to start the Flask API: + + ```bash + python app.py + +- If the server starts successfully, you should see output in the terminal indicating that the API is running, with a message similar to: + + ```bash + Running on http://127.0.0.1:8888 (Press CTRL+C to quit) + +### 2. Prepare the Input JSON File +- Create a JSON file named `payload.json` in the same directory as `app.py`. This file should contain the input data for the API, including details like the energy load, fuel costs, and power plant specifications. + +### 3. Send a POST Request to the API +- Open another terminal window in the same directory where `payload.json` is located. + +- Use the following `curl` command to send a `POST` request to the `/productionplan` endpoint: + ```bash + curl -X POST http://localhost:8888/productionplan -H "Content-Type: application/json" -d @payload.json + +- This command sends the contents of `payload.json` as a POST request to the API. + +### 4. Verify the Output + +- After the request completes successfully, check the project directory. You should see a file named `Merouane_Hadouch_HEADMIND_production_plan_result.json`. + +### 5. Tests + +- To run the tests: + ```bash + python -m unittest discover -s test + + +## Explanation of How the API Works + +### API Endpoint +- The API has one endpoint, `/productionplan`, which only accepts POST requests with JSON data as input. + +### Calculation + +- The `calculate_production_plan` function in `production_plan.py` processes this input data to calculate the optimal production levels for each plant. The goal is to distribute the energy output to meet the specified load in the most cost-effective way, taking into account each plant’s efficiency, minimum and maximum output limits, and fuel costs. + +### Output + +- The API saves the calculated production plan in a JSON file named `Merouane_Hadouch_HEADMIND_production_plan_result.json`. +- This file will specify each plant's production level in MWh + +### Confirmation Response + +- Once the calculation is complete and the file is generated, the API returns a confirmation message in JSON format, indicating that the output file has been successfully created. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..9b8d81563 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask==2.0.1 \ No newline at end of file diff --git a/src/app.py b/src/app.py new file mode 100644 index 000000000..7b4ac208d --- /dev/null +++ b/src/app.py @@ -0,0 +1,43 @@ +import sys +import os + +# Add the src directory to sys.path to ensure modules within src can be found +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "."))) + +from production_plan import calculate_production_plan + + +from flask import Flask, request, jsonify +from production_plan import calculate_production_plan +import json + +app = Flask(__name__) + +@app.route('/productionplan', methods=['POST']) +def production_plan(): + try: + # Retrieve JSON data from the request + data = request.get_json() + + # Calculate the production plan + result = calculate_production_plan(data) + + # Output file path + output_filename = 'Merouane_Hadouch_HEADMIND_production_plan_result.json' + + # Save the result in a JSON file in the current directory + with open(output_filename, 'w', encoding='utf-8') as f: + json.dump(result, f, indent=4) + + # Return a confirmation message with the file path + return jsonify({"message": f"The JSON file was successfully generated in the directory with the name '{output_filename}'"}), 200 + + except ValueError as e: + app.logger.error(f"ValueError: {str(e)}") + return jsonify({"error": str(e)}), 400 + except Exception as e: + app.logger.error(f"Unexpected error: {str(e)}") + return jsonify({"error": "An unexpected error occurred."}), 500 + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8888) diff --git a/src/payload.json b/src/payload.json new file mode 100644 index 000000000..01f91a2e0 --- /dev/null +++ b/src/payload.json @@ -0,0 +1,54 @@ +{ + "load": 480, + "fuels": + { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 60 + }, + "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 + } + ] + } \ No newline at end of file diff --git a/src/production_plan.py b/src/production_plan.py new file mode 100644 index 000000000..9b5ea87c3 --- /dev/null +++ b/src/production_plan.py @@ -0,0 +1,71 @@ +import json + +def calculate_production_plan(data): + load = data['load'] + fuels = data['fuels'] + powerplants = data['powerplants'] + + # Calculate the production cost for each power plant based on its type + for plant in powerplants: + if plant['type'] == 'windturbine': + # Zero cost for wind turbines + plant['cost'] = 0 + # Adjust max power based on wind percentage + plant['pmax'] *= fuels['wind(%)'] / 100 + elif plant['type'] == 'gasfired': + # Cost for gas-fired plants, including CO2 cost + plant['cost'] = (fuels['gas(euro/MWh)'] / plant['efficiency']) + (0.3 * fuels['co2(euro/ton)']) + elif plant['type'] == 'turbojet': + # Cost for turbojets + plant['cost'] = fuels['kerosine(euro/MWh)'] / plant['efficiency'] + + # Sort power plants in ascending order of cost + powerplants.sort(key=lambda x: x['cost']) + + # Initialize variables to store results and track remaining load + result = [] + remaining_load = load + + # Allocate production respecting the merit order + for plant in powerplants: + if remaining_load <= 0: + # If load is already satisfied, set production to zero + production = 0 + else: + # Calculate production for this plant within pmin and pmax limits + if plant['type'] == 'windturbine': + # Wind turbines have no pmin; their production is limited only by adjusted pmax + production = min(remaining_load, plant['pmax']) + else: + # Respect pmin and pmax for other types of power plants + production = max(plant['pmin'], min(remaining_load, plant['pmax'])) + + # Reduce the remaining load by the production amount + remaining_load -= production + + # Round production to the nearest 0.1 MW + production = round(production, 1) + result.append({"name": plant["name"], "p": production}) + + # Adjust so the total production exactly matches the load + total_production = sum([r['p'] for r in result]) + difference = round(load - total_production, 1) + + # Fine-tune adjustment to correct any rounding errors + if difference != 0: + # Find a power plant that can support the adjustment + for plant in result: + # Check limits to adjust production by a multiple of 0.1 + plant_data = next((p for p in powerplants if p["name"] == plant["name"]), None) + if plant_data and plant_data['pmin'] <= plant['p'] + difference <= plant_data['pmax']: + plant['p'] += difference + break + + # Final check that the total production equals the load + final_total_production = sum([r['p'] for r in result]) + if round(final_total_production, 1) != load: + raise ValueError("Adjustment error: Unable to meet the required load with available power plants.") + + return result + + diff --git a/test/test_app.py b/test/test_app.py new file mode 100644 index 000000000..ee1af0491 --- /dev/null +++ b/test/test_app.py @@ -0,0 +1,87 @@ +import unittest +import json +from src.app import app # Import the Flask app from src/app.py + +class ProductionPlanTestCase(unittest.TestCase): + def setUp(self): + # Set up the Flask test client + self.client = app.test_client() + self.client.testing = True + + # Sample input data similar to payload3.json + self.input_data = { + "load": 480, + "fuels": + { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 60 + }, + "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 + } + ] +} + + def test_production_plan(self): + # Send a POST request to the /productionplan endpoint + response = self.client.post( + '/productionplan', + data=json.dumps(self.input_data), + content_type='application/json' + ) + + # Assert that the response status code is 200 (success) + self.assertEqual(response.status_code, 200) + + # Parse the JSON response data + response_data = json.loads(response.data) + + # Check that the response contains a success message with the filename + self.assertIn("message", response_data) + self.assertTrue("Merouane_Hadouch_HEADMIND_production_plan_result.json" in response_data["message"]) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file