diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..54eaff3bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# Files and folders you don’t need in the image +__pycache__/ +*.pyc +*.pyo + +# Tests and examples (not needed in production) +tests/ +example_payloads/ + +# Documentation +README.md +README_hagr27.md + +# Static files (if you don’t use them in the backend app inside the container) +static/ + +# Virtual environment and local configurations +venv/ +.env + +# Git +.git +.gitignore + +# Logs and other temporary files +*.log +*.tmp +*.swp +*.swo + +# Docker Compose (usually not needed inside the image) +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..8849d3527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,247 @@ +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# 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/#use-with-ide +.pdm.toml + +# 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/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,windows,macos,linux \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d3beed417 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Use a specific, stable Python version with a lightweight base image for faster builds and smaller image size +FROM python:3.9-slim + +# Prevent Python from writing .pyc files and enable unbuffered output to ensure logs appear in real-time +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Define the working directory inside the container where the app code will reside +WORKDIR /app + +# Copy only the requirements file first to utilize Docker cache efficiently for dependency installation +COPY requirements.txt . + +# Install Python dependencies without cache to reduce image size +RUN pip install --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code into the container +COPY . . + +# Expose the port that the application will listen on (should match the port used by the app server) +EXPOSE 8888 + +# Define the default command to run the app with Uvicorn server, listening on all interfaces and the specified port +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8888"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..2c3892639 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 hagr27 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README_hagr27.md b/README_hagr27.md new file mode 100644 index 000000000..fd998f894 --- /dev/null +++ b/README_hagr27.md @@ -0,0 +1,291 @@ +[![Python](https://img.shields.io/badge/Python-3.9+-yellow?style=for-the-badge&logo=python&logoColor=white&labelColor=101010)](https://python.org) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.100.1-00a393?style=for-the-badge&logo=fastapi&logoColor=white&labelColor=101010)](https://fastapi.tiangolo.com) +[![Uvicorn](https://img.shields.io/badge/Uvicorn-0.22.0-b19cd9?style=for-the-badge&logo=uvicorn&logoColor=white&labelColor=111011)](https://www.uvicorn.org) +[![Pydantic](https://img.shields.io/badge/Pydantic-2.6.4-ff69b4?style=for-the-badge&logo=pydantic&logoColor=white&labelColor=111011)](https://docs.pydantic.dev) +[![Docker](https://img.shields.io/badge/Docker-Container-blue?style=for-the-badge&logo=docker&logoColor=white&labelColor=111011)](https://www.docker.com) + +# **powerplant-coding-challenge** + +## About this Repository + +This repository provides a solution to the coding challenge described in the official repository: + +πŸ”— [gem-spaas/powerplant-coding-challenge](https://github.com/gem-spaas/powerplant-coding-challenge) + +The goal of the challenge is to simulate the calculation of an optimal power production plan, which determines how much power each plant should produce based on a given load. The main factors considered are: + +- Fuel costs (e.g., gas, kerosine), +- Minimum (Pmin) and maximum (Pmax) generation capacities of each plant. + +# Production Plan API + +A RESTful API built with FastAPI that generates optimal electricity production plans using the merit order principle. It considers power demand, fuel costs, and plant constraints (`pmin`, `pmax`) to allocate production efficiently and cost-effectively. + +The project follows a layered architecture with clear separation of concerns: API (routers), data validation (Pydantic models), and business logic (services). Core logic is encapsulated in the `ProductionPlanCalculator` service, which applies a step-by-step heuristic based on plant costs. Design patterns like Service and simplified MVC ensure modularity, scalability, and maintainability. While the current implementation handles key constraints well, there’s room for improvement in allocation precision for edge cases. + +## Architecture + +Below is the proposed architecture designed to address the problem outlined in the API implementation. + +![Architecture Diagram](static/images/architecture.png) + +## Features + +- Compute optimized production plans based on input constraints and fuel prices. +- Supports multiple plant types: `gasfired`, `turbojet`, and `windturbine`. +- Input validation with **Pydantic** models. +- Interactive API documentation via **Swagger UI**. + +## Functional Requirements + +- The API must accept a POST request with the following input: + - A required load (in MW) to be distributed among the available power plants. + - A list of power plants with their characteristics (`name`, `type`, `efficiency`, `pmin`, `pmax`). + - Current fuel prices (gas, kerosine, COβ‚‚ cost, wind availability). + +- The system must return a list of power production values per plant such that: + - The total production equals the required load (within a small tolerance). + - Each plant respects its individual constraints (`pmin`, `pmax`). + - The solution is cost-optimal, prioritizing the cheapest plants (merit order). + +- The algorithm must be able to: + - Allocate power to wind turbines with zero cost first. + - Use gas-fired or turbojet plants based on fuel cost and efficiency. + - Handle edge cases where the load cannot be covered respecting all constraints. + +## Project Structure + +The project structure is as follows: + +``` +powerplant-coding-challenge/ +β”‚ +β”œβ”€β”€ app/ # Main application package +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ main.py # Entry point for the FastAPI app +β”‚ β”‚ +β”‚ β”œβ”€β”€ models/ # Data models and Pydantic schemas +β”‚ β”‚ β”œβ”€β”€ powerplant.py # Power plant data model +β”‚ β”‚ └── __init__.py +β”‚ β”‚ +β”‚ β”œβ”€β”€ routers/ # API route definitions +β”‚ β”‚ β”œβ”€β”€ production_plan.py # Endpoint for power production calculation +β”‚ β”‚ └── __init__.py +β”‚ β”‚ +β”‚ └── services/ # Business logic and calculations +β”‚ β”œβ”€β”€ powerplant_cost.py # Cost calculation logic +β”‚ β”œβ”€β”€ powerplant_calculator.py # Power allocation optimization logic +β”‚ └── __init__.py +β”‚ +β”œβ”€β”€ tests/ # Unit tests +β”‚ β”œβ”€β”€ test_main.py # test for the root endpoint +β”‚ β”‚ +β”‚ β”œβ”€β”€ models/ # Data models and Pydantic schemas tests +β”‚ β”‚ └── test_powerplant.py # Test for PowerPlantData model +β”‚ β”‚ +β”‚ β”œβ”€β”€ routers/ # API route definitions tests +β”‚ β”‚ └── test_production_plan.py # Test for production_plan endpoint +β”‚ β”‚ +β”‚ └── services/ # Business logic and calculations tests +β”‚ β”œβ”€β”€ test_powerplant_cost.py # Test for powerplant_cost service +β”‚ └── test_powerplant_calculator.py # Test for powerplant_calculator service +β”‚ +β”œβ”€β”€ example_payloads/ # Sample input/output JSON payloads +β”‚ β”œβ”€β”€ payload1.json +β”‚ β”œβ”€β”€ payload2.json +β”‚ β”œβ”€β”€ payload3.json +β”‚ └── response3.json +β”‚ +β”œβ”€β”€ static/ # Static files (images, CSS, etc.) +β”‚ └── images/ +β”‚ β”œβ”€β”€ architecture.png +β”‚ β”œβ”€β”€ api.png +β”‚ └── test_api.png +β”‚ +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ Dockerfile # Docker image configuration +β”œβ”€β”€ docker-compose.yml # Docker Compose setup +β”œβ”€β”€ .dockerignore # Docker ignore file +β”œβ”€β”€ .gitignore # Git ignore file +β”œβ”€β”€ README_hagr27.md # Specific documentation for this project +└── README.md # General description of the coding challenge +``` + +## Prerequisites + +Make sure the following tools are installed on your machine: + +- [Python v3.9+](https://www.python.org/downloads/) +- [Pip](https://pip.pypa.io/en/stable/installation/) +- [Docker](https://docs.docker.com/get-docker/) +- [Git](https://git-scm.com/downloads) +- [Curl](https://curl.se/download.html) +- [Postman](https://www.postman.com/downloads/) +- **Python packages**: `fastapi`, `uvicorn`, `pydantic`, `python-dotenv`, `requests`, `pytest`. +> ***Python is only needed if you want to run the project without Docker.*** + +## Run without Docker +- If you prefer to run the app locally without Docker: +```bash +# Install dependencies +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Start the API +uvicorn app.main:app --reload --port 8888 +``` + +## Installation & Run with Docker + +1. Clone the repository: + +```bash +git clone https://github.com/hagr27/powerplant-coding-challenge.git +cd powerplant-coding-challenge + +# Checkout the branch for the challenge (feat/production-planning-challenge) +git checkout feat/production-planning-challenge +``` + +2. Build the Docker containers: + +```bash +docker-compose up --build +``` + +3. Verify the API is running + +```bash +curl http://localhost:8888/ +``` +> - Interactive API documentation (Swagger UI) is available at: [http://localhost:8888/docs](http://localhost:8888/docs) +> ![Imagen_api_swagger](static/images/api.png) + +## Usage + +Send a POST request to `/production_plan` with a JSON payload containing:: + +- `load`: Required power load (MWh). +- `fuels`: Object with fuel prices and wind percentage. +- `powerplants`: List of available plants and their characteristics. + +Example payloads can be found in the `example_payloads` directory. + +To calculate the production plan, send a POST request to the `/production_plan` endpoint with the JSON payload. + +The API will return a JSON response with the name and assigned power for each plant. + +## Input + +The API accepts a JSON payload with the following fields: + +- `load`: Total load in MWh per hour. +- `fuels`: Fuel prices and wind availability. +- `powerplants`: List of power plant data available. + +Example payloads can be found in the `example_payloads` directory. + +Example Input: + +```json +{ + "load": 910, # Total load in MWh per hour + "fuels": { # Fuel prices and wind availability + "gas(euro/MWh)": 13.4, # Gas price in €/MWh + "kerosine(euro/MWh)": 50.8, # Kerosine price in €/MWh + "co2(euro/ton)": 20, # CO2 emission price in €/ton + "wind(%)": 60 # Wind availability percentage (0-100) + }, + "powerplants": [ # List of power plant data available + { + "name": "gasfiredbig1", # Name of the powerplant + "type": "gasfired", # Type of power plant (gasfired, turbojet, windturbine) + "efficiency": 0.54, # Efficiency of the plant + "pmin": 100, # Minimum power output in MW + "pmax": 460 # Maximum power output in MW + }, + { + "name": "tj1", + "type": "turbojet", + "efficiency": 0.3, + "pmin": 0, + "pmax": 200 + }, + { + "name": "windpark1", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 250 + } + ] +} + +``` + +## Output + +The API will return a JSON response with the name and assigned power for each plant. The output will be sorted by cost (lowest to highest) and available power (highest to lowest). + +Example output: + +```json +[ + { + "name": "windpark1", + "p": 250.0 + }, + { + "name": "gasfiredbig1", + "p": 460.0 + }, + { + "name": "tj1", + "p": 200.0 + } +] +``` +## Stop & remove the container + +To stop and remove the container, run the following command: +```bash +docker rm -f powerplant_app # Force removal +docker rmi hagr27/powerplant_app:v1 # Remove image + +# Validate that the container and image are no longer present +docker ps -a | grep powerplant_app # Check if container is still running +docker images | grep hagr27/powerplant_app # Check if image is still present +``` + +## Testing + +The following section presents the tests conducted using **pytest**. + +### Integration Tests + +Integration tests are implemented using the **FastAPI** framework. The tests are located in the `tests` directory and are grouped by functionality. For example, the `test_production_plan.py` file contains tests for the `/production_plan` endpoint, which calculates the optimal production plan based on the provided input, you need stay in the root directory of the project `/powerplant-coding-challenge`. + +To run the integration tests, execute the following command: + +```bash +PYTHONPATH=. pytest -v +``` + +![test_api](static/images/test_api.png) + +### Test Coverage + +To measure the test coverage of the codebase, the following command can be used: + +```bash +pytest --cov=app --cov-report=html +``` + +This command generates an HTML report that shows the coverage of the codebase. The report can be accessed at `htmlcov/index.html`. + +## License +MIT License + +## Author +Developed by [hagr27](https://github.com/hagr27) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/main.py b/app/main.py new file mode 100644 index 000000000..5639dbd6e --- /dev/null +++ b/app/main.py @@ -0,0 +1,21 @@ +# Third-party imports +from fastapi import FastAPI + +# Local application imports +from app.routers import production_plan + +# FastAPI application instance with metadata +app = FastAPI( + title="Production Plan API", + description="""Electricity production plan calculator based on merit order. \n + Author: Henry Gerena - hagr27""", + version="1.0.0" +) + +# Include the router for production plan endpoints +app.include_router(production_plan.router) + +@app.get("/", tags=["Root"]) +async def root(): + """Health check endpoint to verify the API is running.""" + return {"message": "API is active"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/models/powerplant.py b/app/models/powerplant.py new file mode 100644 index 000000000..ac8d5267e --- /dev/null +++ b/app/models/powerplant.py @@ -0,0 +1,94 @@ +# Third-party imports +from enum import Enum +from typing import List +from pydantic import BaseModel, PositiveFloat, NonNegativeFloat, field_validator, model_validator, Field + + +# Internal runtime class used during the production plan calculation +class PowerPlant: + """ + Represents a power plant used in runtime calculations. + Contains computed attributes such as cost per MWh and available power. + """ + def __init__(self, name: str, type: str, efficiency: float, pmin: float, pmax: float): + self.name = name + self.type = type + self.efficiency = efficiency + self.pmin = pmin + self.pmax = pmax + self.cost_per_mwh = 0.0 # Computed cost in €/MWh + self.available_capacity = 0.0 # Adjusted maximum power after wind, etc. + + +# Enum representing allowed types of power plants +class PlantTypes(str, Enum): + """ + Allowed power plant types. + """ + GASFIRED = "gasfired" + TURBOJET = "turbojet" + WINDTURBINE = "windturbine" + + +# Input model for a power plant (from client request) +class PowerPlantData(BaseModel): + """ + Input schema for each power plant. + Validates efficiency and power constraints. + """ + name: str + type: PlantTypes + efficiency: PositiveFloat + pmin: NonNegativeFloat + pmax: NonNegativeFloat + + @model_validator(mode="after") + def check_min_max_power(self): + """ + Ensures that pmin does not exceed pmax. + """ + if self.pmin > self.pmax: + raise ValueError(f"{self.name}: pmin must be less than or equal to pmax") + return self + + +# Input model for fuel prices and wind percentage +class Fuels(BaseModel): + """ + Fuel prices and wind availability used in cost calculations. + Field aliases match the keys expected in the input JSON. + """ + gas: NonNegativeFloat = Field(alias="gas(euro/MWh)") + kerosine: NonNegativeFloat = Field(alias="kerosine(euro/MWh)") + co2: NonNegativeFloat = Field(alias="co2(euro/ton)") + wind: NonNegativeFloat = Field(alias="wind(%)") + + +# Main input model for the API request +class ProductionPlanRequest(BaseModel): + """ + Full request body containing the required load, + fuel data, power plant list, and an optional overproduction flag. + """ + load: NonNegativeFloat + fuels: Fuels + powerplants: List[PowerPlantData] + + +# Output model for the API response +class ProductionPlanResponse(BaseModel): + """ + Response model for the production plan output. + Indicates how much power each plant will produce. + """ + name: str + p: NonNegativeFloat + + @field_validator('p') + def round_p_to_one_decimal(cls, v): + return round(v, 1) + + def model_dump(self, *args, **kwargs): + d = super().model_dump(*args, **kwargs) + d["p"] = float(f"{d['p']:.1f}") + return d diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/routers/production_plan.py b/app/routers/production_plan.py new file mode 100644 index 000000000..186ca4375 --- /dev/null +++ b/app/routers/production_plan.py @@ -0,0 +1,48 @@ +# Third-party imports +from fastapi import APIRouter, HTTPException +from typing import List +import logging + +# Local application imports +from app.services.powerplant_calculator import ProductionPlanCalculator +from app.models.powerplant import ProductionPlanRequest, ProductionPlanResponse + +# Set up logging for this module +logger = logging.getLogger(__name__) + +# Create API router with specific prefix and metadata +router = APIRouter( + prefix="/production_plan", + tags=["production_plan"], + responses={404: {"message": "Not Found"}}, +) + +# Instantiate the production plan calculator service +calculator = ProductionPlanCalculator() + +# Endpoint to calculate the production plan +@router.post( + "", + response_model=List[ProductionPlanResponse], + summary="Calculate production plan", + description="Calculates the electricity production plan using the merit order method.", +) +async def production_plan(payload: ProductionPlanRequest): + """ + Receives production plan request and returns the computed result using the + merit order algorithm. + """ + try: + logger.info(f"Received request for load: {payload.load} MW") + + # Calculate production plan + plan = calculator.generate_optimized_plan(payload) + + # Log total production for traceability + logger.info(f"Total production: {sum(p['p'] for p in plan)} MW") + + return plan + + except Exception as e: + logger.error(f"Error processing request: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/services/powerplant_calculator.py b/app/services/powerplant_calculator.py new file mode 100644 index 000000000..39733f604 --- /dev/null +++ b/app/services/powerplant_calculator.py @@ -0,0 +1,175 @@ +# Third-party imports +from typing import List, Dict +import logging + +# Local application imports +from app.models.powerplant import ProductionPlanRequest, PowerPlant, PlantTypes +from app.services.powerplant_cost import calculate_powerplant_cost + +# Logger setup +logger = logging.getLogger(__name__) + + +class ProductionPlanCalculator: + """ + Core class to calculate the production plan using the merit order strategy. + """ + + def __init__(self) -> None: + # Stores list of PowerPlant instances + self.plants: List[PowerPlant] = [] + + def generate_optimized_plan(self, payload: ProductionPlanRequest) -> List[Dict[str, float]]: + """ + Process input payload and return an optimized production plan. + + Args: + payload (ProductionPlanRequest): Input request containing load, fuels, and power plants. + + Returns: + List[Dict[str, float]]: List of dicts with plant name and assigned production. + + Raises: + Exception: Propagates any exception encountered during processing. + """ + try: + load = payload.load + fuels = payload.fuels + powerplants_data = payload.powerplants + + self.plants = [] + for plant_data in powerplants_data: + # Instantiate PowerPlant and calculate cost + plant = PowerPlant( + plant_data.name, + plant_data.type, + plant_data.efficiency, + plant_data.pmin, + plant_data.pmax, + ) + calculate_powerplant_cost( + plant, + fuels.gas, + fuels.kerosine, + fuels.co2, + fuels.wind, + ) + self.plants.append(plant) + + # Sort plants by cost (ascending) and available power (descending) + self.plants.sort(key=lambda p: (p.cost_per_mwh, -p.available_capacity)) + logger.info(f"Merit order: {[f'{p.name}({p.cost_per_mwh:.2f})' for p in self.plants]}") + + plan = self._allocate_production_capacity(load) + + logger.info(f"Final production plan: {plan}") + return plan + + except Exception as e: + logger.error(f"Error calculating production plan: {e}") + raise + + def _allocate_production_capacity(self, target_load: float) -> List[Dict[str, float]]: + """ + Optimize production allocation among power plants to meet the target load. + + Args: + target_load (float): The required total production load. + + Returns: + List[Dict[str, float]]: Production plan with allocated power per plant. + """ + production_plan = [{"name": plant.name, "p": 0.0} for plant in self.plants] + remaining_load = target_load + + # Assign wind power first as it is usually cost-free and limited by availability + remaining_load = self._allocate_wind_power_capacity(production_plan, remaining_load) + + # Then assign power from conventional plants using merit order + remaining_load = self._allocate_conventional_power_capacity(production_plan, remaining_load) + + # Adjust minor differences to meet exact load within tolerance + self._fine_tune_production_allocation(production_plan, target_load) + + return production_plan + + def _allocate_wind_power_capacity( + self, production_plan: List[Dict[str, float]], remaining_load: float + ) -> float: + """ + Assign production from wind turbines up to their available capacity. + + Args: + production_plan (List[Dict[str, float]]): Current production allocations. + remaining_load (float): Load left to be allocated. + + Returns: + float: Updated remaining load after wind power assignment. + """ + for i, plant in enumerate(self.plants): + if plant.type == PlantTypes.WINDTURBINE and remaining_load > 0: + production = min(plant.available_capacity, remaining_load) + production_plan[i]["p"] = production + remaining_load -= production + return remaining_load + + def _allocate_conventional_power_capacity( + self, production_plan: List[Dict[str, float]], remaining_load: float + ) -> float: + """ + Assign production from conventional plants based on merit order and load requirements. + + Args: + production_plan (List[Dict[str, float]]): Current production allocations. + remaining_load (float): Load left to be allocated. + + Returns: + float: Updated remaining load after conventional power assignment. + """ + for i, plant in enumerate(self.plants): + if plant.type == PlantTypes.WINDTURBINE: + continue + if remaining_load <= 0: + break + + prev_prod = production_plan[i]["p"] + min_prod = plant.pmin if prev_prod == 0 else 0 + max_prod = plant.available_capacity + + if remaining_load < min_prod and prev_prod == 0: + continue + + possible_prod = min(max_prod, remaining_load + prev_prod) + + production = max(min_prod, prev_prod) + production = min(production, possible_prod) + + extra_needed = remaining_load - (production - prev_prod) + if extra_needed > 0: + production = min(production + extra_needed, possible_prod) + + production_plan[i]["p"] = production + remaining_load -= (production - prev_prod) + + return remaining_load + + def _fine_tune_production_allocation( + self, production_plan: List[Dict[str, float]], target_load: float + ) -> None: + """ + Adjust minor differences in production plan to exactly meet the target load. + + Args: + production_plan (List[Dict[str, float]]): Current production allocations. + target_load (float): Required total production load. + """ + total = sum(p["p"] for p in production_plan) + diff = target_load - total + + if abs(diff) > 0.1: + for i in reversed(range(len(production_plan))): + plant = self.plants[i] + new_p = production_plan[i]["p"] + diff + if plant.pmin <= new_p <= plant.pmax: + production_plan[i]["p"] = new_p + break diff --git a/app/services/powerplant_cost.py b/app/services/powerplant_cost.py new file mode 100644 index 000000000..152ca3b13 --- /dev/null +++ b/app/services/powerplant_cost.py @@ -0,0 +1,47 @@ +# Local application import +from app.models.powerplant import PowerPlant, PlantTypes + + +def calculate_powerplant_cost( + plant: PowerPlant, + gas_price: float, + kerosine_price: float, + co2_price: float, + wind_availability_percent: float +) -> None: + """ + Calculate the cost per MWh and available capacity for a given power plant + based on its type and current fuel prices. Modifies the `plant` object in place. + + Parameters: + plant (PowerPlant): The power plant for which the cost is calculated. + gas_price (float): Current price of gas in €/MWh. + kerosine_price (float): Current price of kerosine in €/MWh. + co2_price (float): Current CO2 emission price in €/ton. + wind_availability_percent (float): Wind availability percentage (0-100). + + Raises: + ValueError: If the plant type is not recognized or if + wind_availability_percent is out of range. + """ + if plant.type == PlantTypes.WINDTURBINE: + # Wind turbines have no fuel cost; power depends on wind availability + plant.cost_per_mwh = 0.0 + plant.available_capacity = (plant.pmax * wind_availability_percent / 100) + + elif plant.type == PlantTypes.GASFIRED: + # Calculate fuel and CO2 costs for gas-fired plants + fuel_cost = gas_price / plant.efficiency + co2_cost = (0.3 * co2_price) / plant.efficiency + plant.cost_per_mwh = fuel_cost + co2_cost + plant.available_capacity = plant.pmax + + elif plant.type == PlantTypes.TURBOJET: + # Only fuel cost for turbojets; no CO2 considered + fuel_cost = kerosine_price / plant.efficiency + plant.cost_per_mwh = fuel_cost + plant.available_capacity = plant.pmax + + else: + # Raise error for unknown power plant types + raise ValueError(f"Unknown plant type: {plant.type}") diff --git a/app/tests/models/test_powerplant.py b/app/tests/models/test_powerplant.py new file mode 100644 index 000000000..94f64744e --- /dev/null +++ b/app/tests/models/test_powerplant.py @@ -0,0 +1,116 @@ +# Third-party imports +import pytest +from pydantic import ValidationError +from app.models.powerplant import ( + PowerPlantData, PlantTypes, Fuels, ProductionPlanRequest, ProductionPlanResponse +) + + +def test_powerplantdata_valid(): + """ + Test creation of a valid PowerPlantData instance. + Verifies that the fields are set correctly. + """ + data = PowerPlantData( + name="plant1", + type=PlantTypes.GASFIRED, + efficiency=0.5, + pmin=10.0, + pmax=100.0 + ) + assert data.name == "plant1" + assert data.efficiency == 0.5 + + +def test_powerplantdata_invalid_efficiency(): + """ + Test that a PowerPlantData instance with negative efficiency + raises a ValidationError. + """ + with pytest.raises(ValidationError): + PowerPlantData( + name="plant1", + type=PlantTypes.TURBOJET, + efficiency=-0.1, + pmin=0, + pmax=10 + ) + + +def test_powerplantdata_invalid_pmin_greater_than_pmax(): + """ + Test that a PowerPlantData instance with pmin greater than pmax + raises a ValidationError with the appropriate message. + """ + with pytest.raises(ValidationError) as exc_info: + PowerPlantData( + name="plant2", + type=PlantTypes.WINDTURBINE, + efficiency=0.9, + pmin=50, + pmax=10 + ) + assert "pmin must be less than or equal to pmax" in str(exc_info.value) + + +def test_fuels_parsing_from_alias(): + """ + Test that Fuels model correctly parses input data using field aliases. + """ + input_data = { + "gas(euro/MWh)": 10.5, + "kerosine(euro/MWh)": 5.2, + "co2(euro/ton)": 20.0, + "wind(%)": 60.0 + } + fuels = Fuels(**input_data) + assert fuels.wind == 60.0 + assert fuels.co2 == 20.0 + + +def test_production_plan_request_valid(): + """ + Test creation of a valid ProductionPlanRequest from input dictionary. + Verifies correct parsing of nested Fuels and PowerPlantData. + """ + input_data = { + "load": 100.0, + "fuels": { + "gas(euro/MWh)": 13.0, + "kerosine(euro/MWh)": 50.0, + "co2(euro/ton)": 20.0, + "wind(%)": 40.0 + }, + "powerplants": [ + { + "name": "gasplant1", + "type": "gasfired", + "efficiency": 0.8, + "pmin": 10, + "pmax": 100 + }, + { + "name": "windplant1", + "type": "windturbine", + "efficiency": 1.0, + "pmin": 0, + "pmax": 50 + } + ] + } + + plan = ProductionPlanRequest(**input_data) + assert plan.load == 100.0 + assert len(plan.powerplants) == 2 + assert plan.fuels.gas == 13.0 + + +def test_production_plan_response_rounding(): + """ + Test that ProductionPlanResponse rounds the power output to one decimal place. + Also checks that model_dump returns the rounded value. + """ + response = ProductionPlanResponse(name="plant1", p=12.345) + assert response.p == 12.3 # rounded to 1 decimal place + dumped = response.model_dump() + assert dumped["p"] == 12.3 # correctly formatted in the output diff --git a/app/tests/routers/test_production_plan.py b/app/tests/routers/test_production_plan.py new file mode 100644 index 000000000..bafc9131f --- /dev/null +++ b/app/tests/routers/test_production_plan.py @@ -0,0 +1,179 @@ +# Third-party imports +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +@pytest.fixture +def valid_payload(): + """ + Provides a valid payload for testing the /production_plan endpoint. + + Returns: + dict: A sample valid payload with load, fuels, and powerplants data. + """ + return { + "load": 100, + "fuels": { + "gas(euro/MWh)": 13.0, + "kerosine(euro/MWh)": 50.0, + "co2(euro/ton)": 20.0, + "wind(%)": 60.0 + }, + "powerplants": [ + { + "name": "gasfire1", + "type": "gasfired", + "efficiency": 0.8, + "pmin": 10, + "pmax": 100 + }, + { + "name": "wind1", + "type": "windturbine", + "efficiency": 1.0, + "pmin": 0, + "pmax": 50 + } + ] + } + + +def test_production_plan_success(valid_payload): + """ + Tests that the /production_plan endpoint returns a successful response + when provided with a valid payload. + + Asserts: + - Response status code is 200 OK. + - Response JSON is a list of plants with 'name' and 'p' keys. + - The sum of power outputs 'p' approximately equals the requested load. + """ + response = client.post("/production_plan", json=valid_payload) + + assert response.status_code == 200 + data = response.json() + + assert isinstance(data, list) + assert all("name" in plant and "p" in plant for plant in data) + assert pytest.approx(sum(p["p"] for p in data), 0.1) == 100 + + +def test_production_plan_invalid_payload(): + """ + Tests that the /production_plan endpoint returns a 422 Unprocessable Entity + when provided with an invalid payload (e.g., pmin > pmax in powerplant). + """ + invalid_payload = { + "load": 50, + "fuels": { + "gas(euro/MWh)": 10, + "kerosine(euro/MWh)": 20, + "co2(euro/ton)": 30, + "wind(%)": 40 + }, + "powerplants": [ + { + "name": "brokenplant", + "type": "gasfired", + "efficiency": 0.9, + "pmin": 60, # pmin is greater than pmax, invalid input + "pmax": 40 + } + ] + } + + response = client.post("/production_plan", json=invalid_payload) + assert response.status_code == 422 # Unprocessable Entity + + +def test_production_plan_with_specific_case(): + """ + Test the /production_plan endpoint with a detailed input scenario. + + This test verifies that the endpoint correctly computes the production distribution + among various types of power plants (gas-fired, turbojet, windturbines) based on + fuel costs, plant efficiencies, and wind availability, ensuring: + """ + payload = { + "load": 910, + "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 + } + ] + } + + expected_response = [ + {"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} + ] + + response = client.post("/production_plan", json=payload) + + assert response.status_code == 200 + + result = response.json() + + # Validate total load matches expected + total_production = sum(plant["p"] for plant in result) + assert abs(total_production - payload["load"]) < 0.01, \ + f"Total production {total_production} does not match required load {payload['load']}" + + # Validate each plant's output against expected values + for expected in expected_response: + match = next((item for item in result if item["name"] == expected["name"]), None) + assert match is not None, f"Missing plant: {expected['name']}" + assert abs(match["p"] - expected["p"]) < 0.01, \ + f"Incorrect production for {expected['name']}: got {match['p']}, expected {expected['p']}" diff --git a/app/tests/services/test_powerplant_calculator.py b/app/tests/services/test_powerplant_calculator.py new file mode 100644 index 000000000..7fc2c7f07 --- /dev/null +++ b/app/tests/services/test_powerplant_calculator.py @@ -0,0 +1,106 @@ +# Third-party imports +import pytest +from app.models.powerplant import ProductionPlanRequest, Fuels, PowerPlantData, PlantTypes +from app.services.powerplant_calculator import ProductionPlanCalculator + +@pytest.fixture +def sample_payload(): + """ + Provides a sample valid ProductionPlanRequest payload + with fuels and powerplants data for testing. + + Returns: + ProductionPlanRequest: Sample payload object. + """ + fuels_data = { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 60 + } + + fuels = Fuels.model_validate(fuels_data) + + powerplants = [ + PowerPlantData(name="Wind1", type=PlantTypes.WINDTURBINE, efficiency=1.0, pmin=0, pmax=150), + PowerPlantData(name="Gas1", type=PlantTypes.GASFIRED, efficiency=0.53, pmin=100, pmax=460), + PowerPlantData(name="Turbo1", type=PlantTypes.TURBOJET, efficiency=0.3, pmin=0, pmax=16), + ] + return ProductionPlanRequest(load=480, fuels=fuels, powerplants=powerplants) + + +def test_generate_optimized_plan_total_load(sample_payload): + """ + Test that the generated optimized plan: + - Is a list of dicts with 'name' and 'p' keys. + - The sum of the production matches approximately the requested load. + """ + calculator = ProductionPlanCalculator() + plan = calculator.generate_optimized_plan(sample_payload) + + assert isinstance(plan, list) + assert all("name" in p and "p" in p for p in plan) + + total_production = sum(p["p"] for p in plan) + assert abs(total_production - sample_payload.load) < 1e-1 + + +def test_production_allocation_prioritizes_wind(sample_payload): + """ + Test that wind turbine production does not exceed its maximum + available power based on wind availability percentage. + """ + calculator = ProductionPlanCalculator() + plan = calculator.generate_optimized_plan(sample_payload) + + wind_plant = next(p for p in plan if p["name"] == "Wind1") + assert wind_plant["p"] <= 150 * 0.6 + + +def test_error_on_invalid_payload(): + """ + Test that passing an invalid payload (None) raises an Exception. + """ + calculator = ProductionPlanCalculator() + with pytest.raises(Exception): + calculator.generate_optimized_plan(None) + + +def test_merit_order_sorting(sample_payload): + """ + Test that powerplants are sorted in ascending order of cost_per_mwh + after generating the plan. + """ + calculator = ProductionPlanCalculator() + _ = calculator.generate_optimized_plan(sample_payload) + plants = calculator.plants + + costs = [p.cost_per_mwh for p in plants] + assert costs == sorted(costs) + + +def test_fine_tune_adjustment(): + """ + Test the internal fine-tuning method adjusts production to match + target load within acceptable tolerance without violating pmin/pmax. + """ + calculator = ProductionPlanCalculator() + from app.models.powerplant import PowerPlant, PlantTypes + + p1 = PowerPlant("P1", PlantTypes.GASFIRED, 0.5, 100, 200) + p1.cost_per_mwh = 10 + p1.available_capacity = 200 + p2 = PowerPlant("P2", PlantTypes.GASFIRED, 0.7, 50, 150) + p2.cost_per_mwh = 20 + p2.available_capacity = 150 + calculator.plants = [p1, p2] + + production_plan = [{"name": "P1", "p": 190}, {"name": "P2", "p": 50}] + target_load = 240 + calculator._fine_tune_production_allocation(production_plan, target_load) + + total = sum(p["p"] for p in production_plan) + assert abs(total - target_load) < 0.1 + + for i, p in enumerate(calculator.plants): + assert p.pmin <= production_plan[i]["p"] <= p.pmax diff --git a/app/tests/services/test_powerplant_cost.py b/app/tests/services/test_powerplant_cost.py new file mode 100644 index 000000000..c00eeb7af --- /dev/null +++ b/app/tests/services/test_powerplant_cost.py @@ -0,0 +1,50 @@ +# Third-party imports +import pytest +from app.models.powerplant import PowerPlant, PlantTypes +from app.services.powerplant_cost import calculate_powerplant_cost + +def test_windturbine_cost_and_capacity(): + """ + Test that wind turbine cost is zero and available capacity + is correctly calculated based on wind availability percentage. + """ + plant = PowerPlant("Wind1", PlantTypes.WINDTURBINE, efficiency=1.0, pmin=0, pmax=100) + calculate_powerplant_cost(plant, gas_price=10, kerosine_price=20, co2_price=30, wind_availability_percent=50) + assert plant.cost_per_mwh == 0.0 + assert plant.available_capacity == 50 # 50% of 100 + + +def test_gasfired_cost_and_capacity(): + """ + Test gas-fired plant cost calculation including fuel and CO2 costs, + and ensure available capacity equals pmax. + """ + plant = PowerPlant("Gas1", PlantTypes.GASFIRED, efficiency=0.5, pmin=0, pmax=100) + gas_price = 10 + co2_price = 30 + calculate_powerplant_cost(plant, gas_price, kerosine_price=0, co2_price=co2_price, wind_availability_percent=0) + expected_cost = (gas_price / plant.efficiency) + (0.3 * co2_price / plant.efficiency) + assert plant.cost_per_mwh == expected_cost + assert plant.available_capacity == 100 + + +def test_turbojet_cost_and_capacity(): + """ + Test turbojet plant cost calculation based on kerosine price, + and verify available capacity equals pmax. + """ + plant = PowerPlant("Turbo1", PlantTypes.TURBOJET, efficiency=0.8, pmin=0, pmax=100) + kerosine_price = 20 + calculate_powerplant_cost(plant, gas_price=0, kerosine_price=kerosine_price, co2_price=0, wind_availability_percent=0) + expected_cost = kerosine_price / plant.efficiency + assert plant.cost_per_mwh == expected_cost + assert plant.available_capacity == 100 + + +def test_unknown_plant_type_raises(): + """ + Test that an unknown plant type raises a ValueError. + """ + plant = PowerPlant("Unknown1", "solar", efficiency=1.0, pmin=0, pmax=100) + with pytest.raises(ValueError, match="Unknown plant type: solar"): + calculate_powerplant_cost(plant, gas_price=0, kerosine_price=0, co2_price=0, wind_availability_percent=0) diff --git a/app/tests/test_main.py b/app/tests/test_main.py new file mode 100644 index 000000000..e9d793b74 --- /dev/null +++ b/app/tests/test_main.py @@ -0,0 +1,13 @@ +# Third-party imports +from fastapi.testclient import TestClient +from app.main import app # Adjust the import path according to your project structure + +client = TestClient(app) + +def test_root(): + """ + Test the root endpoint returns status 200 and expected JSON message. + """ + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"message": "API is active"} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..807b8d647 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + powerplant_app: + # Build the Docker image from the Dockerfile in the current directory + build: + context: . + dockerfile: Dockerfile + + # Name the container instance + container_name: powerplant_app + + # Name and tag the image (useful for pushing to Docker Hub or versioning) + image: hagr27/powerplant_app:v1 + + # Map port 8888 of the container to port 8888 on the host machine + ports: + - "8888:8888" + + # Mount the current directory to /app inside the container (for live code updates) - [dev] + volumes: + - .:/app + + # Environment variables for Python to improve behavior inside the container + environment: + - PYTHONDONTWRITEBYTECODE=1 # Prevent Python from writing .pyc files + - PYTHONUNBUFFERED=1 # Force stdout and stderr to be unbuffered + + # Command to start the FastAPI app with Uvicorn on host 0.0.0.0 and port 8888 + command: uvicorn app.main:app --host 0.0.0.0 --port 8888 --reload # [dev] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..f24ab58b0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.100.1 +pydantic==2.6.4 +uvicorn[standard]==0.22.0 \ No newline at end of file diff --git a/static/images/api.png b/static/images/api.png new file mode 100644 index 000000000..fbc85f903 Binary files /dev/null and b/static/images/api.png differ diff --git a/static/images/architecture.png b/static/images/architecture.png new file mode 100644 index 000000000..44b2bd5b4 Binary files /dev/null and b/static/images/architecture.png differ diff --git a/static/images/test_api.png b/static/images/test_api.png new file mode 100644 index 000000000..529b8907f Binary files /dev/null and b/static/images/test_api.png differ