Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The payload contains 3 types of data:

#### response

The response should be a json as in `example_response.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.
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?

Expand Down
18 changes: 18 additions & 0 deletions code/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Byte-compiled / optimized / DLL files
__pycache__/
__pycache__/
*.py[cod]
*$py.class

# Distribution / packaging
.Python
build/

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
19 changes: 19 additions & 0 deletions code/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM python:3.11.5-slim
RUN apt-get update && apt-get install -y curl build-essential && apt-get clean

# Install Poetry
ENV POETRY_VERSION=1.8.5
RUN curl -sSL https://install.python-poetry.org | POETRY_VERSION=$POETRY_VERSION python3 -

# Poetry to PATH
ENV PATH="/root/.local/bin:$PATH"

# Run App on port 8888
WORKDIR /app
COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-root --no-interaction
COPY . .
EXPOSE 8888

# Start app
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8888", "--reload"]
55 changes: 55 additions & 0 deletions code/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# ⚡ PowerPlant Coding Challenge – API

This repository contains a FastAPI-based backend built as part of the PowerPlant coding challenge. The API receives powerplant data (according to specifications) and calculates optimal production levels based on constraints.

---

## Tech Stack

- Python ^3.11
- FastAPI
- Poetry
- Docker

---

## 🚀 Quickstart

### Launch with Docker

First, make sure you’re in the project root which is ..code/ where the `Dockerfile` is located:

```bash
# Clone repository
git clone https://github.com/your-username/powerplant-coding-challenge.git
cd powerplant-coding-challenge/code

# Build image
docker build -t powerplant-api .

# Start API
docker run -p 8888:8888 powerplant-api
```

### 📦 Launch without Docker

Requirements:
- Poetry
- Python ^3.11

```bash
# Clone repository
git clone https://github.com/your-username/powerplant-coding-challenge.git
cd powerplant-coding-challenge/code

# Install dependencies
poetry install

# Run API
poetry run uvicorn "app.main:app" --host 0.0.0.0 --port 8888 --reload
```

The API will now be running at: [http://localhost:8888](http://localhost:8888)



Empty file added code/app/__init__py
Empty file.
20 changes: 20 additions & 0 deletions code/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from fastapi import FastAPI, HTTPException
from app.schemas.request import ProductionPlanRequest
import app.service.merit_order as sm

app = FastAPI()

@app.get("/")
def root():
return 'ENGIE PowerPlants API Ok'

@app.post("/productionplan")
def exec_production_plan(payload: ProductionPlanRequest):
try:
plan = sm.calculate_production_plan(payload)
return plan
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8888)
Empty file added code/app/schemas/__init__.py
Empty file.
22 changes: 22 additions & 0 deletions code/app/schemas/request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from pydantic import BaseModel, Field, confloat, conlist
from typing import List, Literal

PowerPlantType = Literal["gasfired", "turbojet", "windturbine"]

class Fuels(BaseModel):
gas: float = Field(..., alias="gas(euro/MWh)", gt=0, description="Price of gas per megawatt-hour euro/MWh)")
kerosine: float = Field(..., alias="kerosine(euro/MWh)", gt=0, description="Price of kerosene per megawatt-hour (euro/MWh)")
co2: float = Field(..., alias="co2(euro/ton)", ge=0, description="Cost of CO2 emission allowances per ton (euro/ton)")
wind: float = Field(..., alias="wind(%)", ge=0, le=100, description="Wind availability percentage (0-100%)")

class PowerPlant(BaseModel):
name: str
type: PowerPlantType
efficiency: confloat(gt=0, le=1)
pmin: float = Field(..., ge=0, description="Potencia mínima cuando está activa")
pmax: float = Field(..., ge=0, description="Potencia máxima generable")

class ProductionPlanRequest(BaseModel):
load: float = Field(..., gt=0, description="Total load required")
fuels: Fuels
powerplants: conlist(PowerPlant, min_length=1)
5 changes: 5 additions & 0 deletions code/app/schemas/response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel

class ProductionPlanResponse(BaseModel):
name: str
p: float
Empty file added code/app/service/__init__.py
Empty file.
64 changes: 64 additions & 0 deletions code/app/service/merit_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from app.schemas.request import *
from app.schemas.response import ProductionPlanResponse


def get_cost_for_plant(plant: PowerPlant, fuels: Fuels) -> float:
""" Calculate the costs for a given plant acording its type, applying C02 """
if plant.type == "gasfired":
fuel_price = fuels.gas
co2_price = fuels.co2 * 0.3
return (fuel_price / plant.efficiency) + co2_price
elif plant.type == "turbojet":
fuel_price = fuels.kerosine
return fuel_price / plant.efficiency
elif plant.type == "windturbine":
return 0
else:
raise ValueError(f"ERROR: Unknown powerplant type: {plant.type}")

def get_adjusted_pmax(plant: PowerPlant, fuels: Fuels) -> float:
if plant.type == "windturbine":
return plant.pmax * (fuels.wind / 100)
return plant.pmax

def calculate_production_plan(input: ProductionPlanRequest):
load_remaining = input.load
result = []

# Calculate costs for each plant
enriched_plants = []
for plant in input.powerplants:
try:
cost = get_cost_for_plant(plant, input.fuels)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
available_pmax = get_adjusted_pmax(plant, input.fuels)
enriched_plants.append((plant, cost, available_pmax))

# Sort plants by cost ASC (objective: use low cost plants first)
sorted_plants = sorted(enriched_plants, key=lambda p: p[1])

for plant, cost, available in sorted_plants:
max_power = available
min_power = plant.pmin if load_remaining > 0 else 0
prod = min(max_power, load_remaining)
prod = max(prod, min_power) if load_remaining >= min_power else 0
prod = round(min(prod, load_remaining), 1)
result.append(ProductionPlanResponse(name=plant.name, p=prod))
load_remaining -= prod

# Adjustment if sum doesn't match load
total = sum(p.p for p in result)
if abs(total - input.load) > 1e-2:
# raise HTTPException(status_code=400, detail=f"WARNING: Couldn't meet the load exactly. Load achieved: {total:.2f} MWh")
return JSONResponse(
status_code=206,
content={
"message": f"Couldn't meet the load exactly. Load achieved: {total:.2f} MWh",
"production_attempt": [p.dict() for p in result]
}
)

return result
Loading