diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..fdbe72b63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Dockerfile to run the API on port 8888 using an application factory +FROM python:3.13-slim + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY example_payloads ./example_payloads +COPY README.md ./README.md + +EXPOSE 8888 +# Run FastAPI using the factory function create_app +CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8888"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 000000000..d91619e28 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,17 @@ +# Dockerfile for running tests +FROM python:3.13-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt ./ +COPY requirements-test.txt ./ +RUN pip install --no-cache-dir -r requirements-test.txt + +# Copy source and tests +COPY app ./app +COPY tests ./tests +COPY example_payloads ./example_payloads + +# Default command runs tests +CMD ["pytest", "-q"] diff --git a/README.md b/README.md index 44c93d608..b15154ce9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,29 @@ # powerplant-coding-challenge +This repository contains a FastAPI service that solves the production planning challenge. + +## How to run +- Requirements: Python 3.13+ (tested with 3.13), pip +- Install deps: pip install -r requirements.txt (pip install -r requirements-test.txt for testing) +- Start API (port 8888): uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8888 --reload +- Endpoint: POST http://localhost:8888/productionplan + - Body: one of the payloads in example_payloads (payload1.json etc.) + - Response: list of {"name": str, "p": float} with 0.1 MW granularity and sum equal to load + +### Optional: Docker +- Build: docker build -t powerplant-api . +- Run: docker run -p 8888:8888 powerplant-api + +### Project layout +- app/api: FastAPI routes +- app/schemas: Pydantic request/response schemas +- app/services: Production planner algorithm + +### Notes +- CO2 cost (0.3 ton/MWh) is applied to gas-fired units as per the README extra challenge. +- Errors return 422 with a message when the load cannot be met or constraints make it infeasible. + +------------ ## Welcome ! diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 000000000..989043c67 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Package marker for the application diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 000000000..28b07eff6 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API package diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 000000000..d6eb7c532 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, HTTPException + +from app.schemas.payload import ProductionPlanRequest +from app.schemas.response import ProductionPlanResponseItem +from app.services.production_planner import plan_production + +router = APIRouter() + + +@router.post("/productionplan", response_model=list[ProductionPlanResponseItem]) +async def production_plan(payload: ProductionPlanRequest): + try: + plan = plan_production(payload) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + return [ProductionPlanResponseItem(name=p.name, p=p.p) for p in plan] diff --git a/app/main.py b/app/main.py new file mode 100644 index 000000000..825af5497 --- /dev/null +++ b/app/main.py @@ -0,0 +1,28 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.api.routes import router as api_router + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", +) +logger = logging.getLogger("powerplant") + + +def create_app() -> FastAPI: + application = FastAPI(title="Powerplant Production Planner", version="1.0.0") + + @application.exception_handler(Exception) + async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled error: %s", exc) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + + application.include_router(api_router) + return application diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 000000000..8d2fd8534 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schemas package diff --git a/app/schemas/payload.py b/app/schemas/payload.py new file mode 100644 index 000000000..f80ba39dd --- /dev/null +++ b/app/schemas/payload.py @@ -0,0 +1,27 @@ +from typing import Literal, Annotated + +from annotated_types import MinLen +from pydantic import BaseModel, Field, NonNegativeFloat, ConfigDict + + +class Fuels(BaseModel): + gas: float = Field(alias="gas(euro/MWh)") + kerosine: float = Field(alias="kerosine(euro/MWh)") + co2: float = Field(alias="co2(euro/ton)") + wind: float = Field(alias="wind(%)") + + model_config = ConfigDict(populate_by_name=True) + + +class PowerPlantIn(BaseModel): + name: str + type: Literal["gasfired", "turbojet", "windturbine"] + efficiency: float + pmin: float + pmax: float + + +class ProductionPlanRequest(BaseModel): + load: NonNegativeFloat + fuels: Fuels + powerplants: Annotated[list[PowerPlantIn], MinLen(1)] diff --git a/app/schemas/response.py b/app/schemas/response.py new file mode 100644 index 000000000..a993f9faa --- /dev/null +++ b/app/schemas/response.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class ProductionPlanResponseItem(BaseModel): + name: str + p: float diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 000000000..a70b3029a --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/app/services/production_planner.py b/app/services/production_planner.py new file mode 100644 index 000000000..3962641aa --- /dev/null +++ b/app/services/production_planner.py @@ -0,0 +1,231 @@ +from dataclasses import dataclass +from typing import List, Literal + +from app.schemas.payload import ProductionPlanRequest + + +@dataclass +class Dispatchable: + name: str + kind: Literal["windturbine", "gasfired", "turbojet"] + marginal_cost: float # euro per MWh + pmin: float + pmax: float + p: float = 0.0 + + @property + def headroom(self) -> float: + return max(0.0, self.pmax - self.p) + + @property + def reducible(self) -> float: + # how much we can reduce while staying above pmin + return max(0.0, self.p - self.pmin) + + +CO2_TON_PER_MWH = 0.3 # only for gas-fired according to challenge extras + + +def plan_production(req: ProductionPlanRequest) -> list[Dispatchable]: + load = float(req.load) + if load == 0: + return [ + Dispatchable(pp.name, pp.type, 0.0, 0.0, 0.0, 0.0) for pp in req.powerplants + ] + + # Build dispatchable units with effective costs and capacities + items: list[Dispatchable] = [] + wind_factor = max(0.0, min(100.0, float(req.fuels.wind))) / 100.0 + + for pp in req.powerplants: + if pp.type == "windturbine": + pmax_eff = pp.pmax * wind_factor + items.append( + Dispatchable( + name=pp.name, + kind=pp.type, + marginal_cost=0.0, + pmin=0.0, + pmax=pmax_eff, + ) + ) + elif pp.type == "gasfired": + # cost per MWh produced + fuel_cost = req.fuels.gas / pp.efficiency + co2_cost = CO2_TON_PER_MWH * req.fuels.co2 + items.append( + Dispatchable( + name=pp.name, + kind=pp.type, + marginal_cost=fuel_cost + co2_cost, + pmin=pp.pmin, + pmax=pp.pmax, + ) + ) + elif pp.type == "turbojet": + fuel_cost = req.fuels.kerosine / pp.efficiency + items.append( + Dispatchable( + name=pp.name, + kind=pp.type, + marginal_cost=fuel_cost, + pmin=pp.pmin, + pmax=pp.pmax, + ) + ) + else: + raise ValueError(f"Unsupported powerplant type: {pp.type}") + + # Sort by marginal cost asc; for equal cost, larger pmax first then better efficiency effect not tracked, fall back to name + items.sort(key=lambda d: (d.marginal_cost, -d.pmax, d.name)) + + remaining = load + + # First, fully utilize wind (cheapest, pmin 0) + for d in items: + if d.kind == "windturbine" and remaining > 0: + take = min(d.pmax, remaining) + d.p = take + remaining -= take + + # Then thermal units by merit order + committed: List[Dispatchable] = [ + d for d in items if d.p > 0 or d.kind == "windturbine" + ] + + for d in items: + if d.kind == "windturbine": + # already handled + continue + if remaining <= 0: + break + + # Try to fill remaining using already committed units (headroom) before starting a new one with pmin + if committed: + remaining = _increase_existing_to_fill(remaining, committed) + if remaining <= 0: + break + + # Need this unit + if remaining >= d.pmin: + take = min(d.pmax, remaining) + d.p = take + remaining -= take + committed.append(d) + else: + # remaining < pmin: try not to start this unit by increasing previous headroom + if committed: + rem2 = _increase_existing_to_fill(remaining, committed) + if rem2 <= 0: + remaining = 0.0 + break + # Otherwise, we must start this unit at pmin and reduce some previous output to keep balance + d.p = d.pmin + committed.append(d) + # We overshoot by (pmin - remaining) + overflow = d.pmin - remaining + reduced = _reduce_previous_to_absorb(overflow, committed[:-1]) + if reduced + 1e-9 < overflow: + # Could not absorb overflow -> infeasible + raise ValueError( + "Infeasible dispatch with given pmin/pmax constraints to match load exactly" + ) + remaining = 0.0 + + # After loop, if still remaining, try to increase existing including the most expensive if any left + if remaining > 0: + remaining = _increase_existing_to_fill( + remaining, [d for d in items if d.kind != "windturbine"] + ) # include all thermals + if remaining > 1e-6: + raise ValueError("Insufficient capacity to meet the load") + + # Rounding to 0.1 and adjustment + _round_and_adjust(items, load) + + # Return in original input order as typical expected response lists all plants + name_to_item = {d.name: d for d in items} + ordered = [name_to_item[pp.name] for pp in req.powerplants] + return ordered + + +def _increase_existing_to_fill(remaining: float, units: List[Dispatchable]) -> float: + if remaining <= 0: + return 0.0 + for u in units: + if remaining <= 0: + break + head = u.headroom + if head <= 0: + continue + inc = min(head, remaining) + u.p += inc + remaining -= inc + return remaining + + +def _reduce_previous_to_absorb(overflow: float, units: List[Dispatchable]) -> float: + reduced_total = 0.0 + # Reduce from most expensive first to keep merit order optimal + for u in sorted(units, key=lambda x: (-x.marginal_cost, x.pmin)): + if reduced_total >= overflow - 1e-9: + break + reducible = u.reducible + if reducible <= 0: + continue + take = min(reducible, overflow - reduced_total) + u.p -= take + reduced_total += take + return reduced_total + + +def _round_and_adjust(items: List[Dispatchable], load: float): + # Round to 0.1 MW + for d in items: + d.p = round(d.p * 10) / 10.0 + # Clip within bounds after rounding + if d.kind == "windturbine": + d.p = max(0.0, min(d.p, d.pmax)) + else: + d.p = max(d.pmin, min(d.p, d.pmax)) + + target = round(load * 10) / 10.0 + current = sum(d.p for d in items) + diff = round((target - current) * 10) / 10.0 + if abs(diff) < 0.05: + return + + step = 0.1 if diff > 0 else -0.1 + # For increasing, prefer cheapest with headroom; for decreasing, prefer most expensive with reducible + for _ in range(5000): + if abs(diff) < 0.05: + break + if step > 0: + # try units with headroom + candidates = sorted(items, key=lambda x: (x.marginal_cost, -x.pmax, x.name)) + changed = False + for u in candidates: + max_allow = u.pmax if u.kind != "windturbine" else u.pmax + if u.p + 1e-9 <= max_allow - 0.099: + u.p = round((u.p + 0.1) * 10) / 10.0 + diff = round((diff - 0.1) * 10) / 10.0 + changed = True + break + if not changed: + raise ValueError( + "Cannot adjust up to reach exact load at 0.1 MW granularity" + ) + else: + candidates = sorted(items, key=lambda x: (-x.marginal_cost, x.pmin)) + changed = False + for u in candidates: + min_allow = u.pmin if u.kind != "windturbine" else 0.0 + if u.p >= min_allow + 0.099: + u.p = round((u.p - 0.1) * 10) / 10.0 + diff = round((diff + 0.1) * 10) / 10.0 + changed = True + break + if not changed: + raise ValueError( + "Cannot adjust down to reach exact load at 0.1 MW granularity" + ) diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 000000000..bb792c1dd --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.4.2 +httpx==0.27.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d8216390c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.117.1 +annotated-types==0.7.0 +uvicorn[standard]==0.36.0 +pydantic==2.11.9 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..e6145b94c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from app.main import create_app + + +@pytest.fixture +def application(): + return create_app() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 000000000..4b17394f6 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,33 @@ +import json +from pathlib import Path +from fastapi.testclient import TestClient + + +BASE = Path(__file__).resolve().parents[1] + + +def load_payload(name: str): + p = BASE / "example_payloads" / name + return json.loads(p.read_text()) + + +def test_productionplan_endpoint_ok_payload1(application): + client = TestClient(application) + payload = load_payload("payload1.json") + resp = client.post("/productionplan", json=payload) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + # Length should match number of powerplants + assert len(data) == len(payload["powerplants"]) + # Sum of p equals load + total = sum(item["p"] for item in data) + assert abs(total - payload["load"]) < 1e-6 + + +def test_productionplan_rejects_negative_load(application): + client = TestClient(application) + payload = load_payload("payload1.json") + payload["load"] = -10 + resp = client.post("/productionplan", json=payload) + assert resp.status_code == 422 diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 000000000..8c9d1ffdc --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,45 @@ +import json +from pathlib import Path + +from app.schemas.payload import ProductionPlanRequest +from app.services.production_planner import plan_production + +BASE = Path(__file__).resolve().parents[1] + + +def load_payload(name: str): + p = BASE / "example_payloads" / name + return json.loads(p.read_text()) + + +def test_plan_production_meets_load_and_constraints_payload1(): + payload = load_payload("payload1.json") + req = ProductionPlanRequest(**payload) + result = plan_production(req) + + # Sum of outputs should equal load within small tolerance + total = sum(d.p for d in result) + assert abs(total - req.load) < 1e-6 + + # Each output within bounds + for d, pp in zip(result, req.powerplants): + # windturbine pmin considered 0 dynamically, but planner sets pmin=0 for wind + if d.kind != "windturbine": + assert pp.pmin - 1e-6 <= d.p <= pp.pmax + 1e-6 + else: + assert 0.0 - 1e-6 <= d.p <= pp.pmax * req.fuels.wind / 100.0 + 1e-6 + + +def test_plan_production_meets_load_and_constraints_payload2(): + payload = load_payload("payload2.json") + req = ProductionPlanRequest(**payload) + result = plan_production(req) + + total = sum(d.p for d in result) + assert abs(total - req.load) < 1e-6 + + for d, pp in zip(result, req.powerplants): + if d.kind != "windturbine": + assert pp.pmin - 1e-6 <= d.p <= pp.pmax + 1e-6 + else: + assert d.p == 0.0 # no wind diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 000000000..556de5cdf --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path +import pytest + +from app.schemas.payload import ProductionPlanRequest, Fuels + +BASE = Path(__file__).resolve().parents[1] + + +def load_payload(name: str): + p = BASE / "example_payloads" / name + return json.loads(p.read_text()) + + +def test_fuels_aliases_population(): + data = { + "gas(euro/MWh)": 10.0, + "kerosine(euro/MWh)": 20.0, + "co2(euro/ton)": 30.0, + "wind(%)": 40.0, + } + fuels = Fuels(**data) + # Attributes accessible by field name + assert fuels.gas == 10.0 + assert fuels.kerosine == 20.0 + assert fuels.co2 == 30.0 + assert fuels.wind == 40.0 + + +def test_production_plan_request_parses_example_payload(): + payload = load_payload("payload1.json") + req = ProductionPlanRequest(**payload) + assert req.load == payload["load"] + assert len(req.powerplants) == len(payload["powerplants"]) + + +def test_load_must_be_non_negative(): + with pytest.raises(ValueError): + ProductionPlanRequest( + load=-1, + fuels=Fuels( + **{ + "gas(euro/MWh)": 10.0, + "kerosine(euro/MWh)": 20.0, + "co2(euro/ton)": 30.0, + "wind(%)": 40.0, + } + ), + powerplants=[], + )