diff --git a/README.txt b/README.txt new file mode 100644 index 000000000..46f6dc22e --- /dev/null +++ b/README.txt @@ -0,0 +1,105 @@ +# Power Plant Production Plan API + +A REST API that calculates optimal power production plans for multiple power plants based on load demand, fuel costs, and plant constraints. + +## Quick Start + +### Prerequisites +- Python 3.7+ + +### Installation +```bash +pip install -r requirements.txt +``` + +### Launch Application +```bash +python app.py +``` + +The API will start on `http://localhost:8888` + +## API Usage + +### Endpoint +**POST** `/productionplan` + +### Request Example +```bash +curl -X POST http://localhost:8888/productionplan \ + -H "Content-Type: application/json" \ + -d @payload1.json +``` + +Or test with the provided script: +```bash +python test_api.py +``` + +### Request Format +```json +{ + "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 + } + ] +} +``` + +### Response Format +```json +[ + {"name": "windpark1", "p": 90.0}, + {"name": "gasfiredbig1", "p": 390.0}, + {"name": "tj1", "p": 0.0} +] +``` + +## Test Cases + +Three test payloads are provided: +- `payload1.json` - Normal load with 60% wind +- `payload2.json` - Normal load with 0% wind +- `payload3.json` - High load (910 MW) with 60% wind + +## Algorithm Overview + +1. **Calculate costs** for each plant type: + - Wind: 0 €/MWh + - Gas: (fuel_cost + CO2_cost) / efficiency + - Turbojet: fuel_cost / efficiency + +2. **Sort by merit order** (cheapest first) + +3. **Allocate production**: + - Use wind power first (free) + - Fill remaining load with conventional plants + - Respect Pmin/Pmax constraints + +## Health Check + +**GET** `/health` returns `{"status": "healthy"}` + +## Error Handling + +- Returns appropriate HTTP status codes +- Logs all requests and errors +- Validates input payload structure + +## Technical Notes + +- Production values rounded to 0.1 MW precision +- CO2 emissions: 0.3 tons per MWh for gas plants +- Wind production calculated as: `pmax * wind_percentage / 100` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 000000000..543e05095 --- /dev/null +++ b/app.py @@ -0,0 +1,214 @@ +from flask import Flask, request, jsonify +import logging +from typing import List, Dict, Any +import math + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = Flask(__name__) + +class PowerPlant: + def __init__(self, name: str, type: str, efficiency: float, pmin: int, pmax: int): + self.name = name + self.type = type + self.efficiency = efficiency + self.pmin = pmin + self.pmax = pmax + self.cost_per_mwh = 0.0 + self.available_power = 0 + + def calculate_cost(self, gas_price: float, kerosine_price: float, co2_price: float, wind_percentage: float): + """Calculate the cost per MWh for this power plant""" + if self.type == "windturbine": + self.cost_per_mwh = 0.0 + self.available_power = int(self.pmax * wind_percentage / 100) + elif self.type == "gasfired": + # Cost = fuel_cost / efficiency + CO2_cost + fuel_cost_per_mwh = gas_price / self.efficiency + co2_cost_per_mwh = (0.3 * co2_price) / self.efficiency # 0.3 ton CO2 per MWh + self.cost_per_mwh = fuel_cost_per_mwh + co2_cost_per_mwh + self.available_power = self.pmax + elif self.type == "turbojet": + fuel_cost_per_mwh = kerosine_price / self.efficiency + self.cost_per_mwh = fuel_cost_per_mwh + self.available_power = self.pmax + + def __repr__(self): + return f"PowerPlant({self.name}, cost={self.cost_per_mwh:.2f}, available={self.available_power})" + +class ProductionPlanCalculator: + def __init__(self): + self.plants = [] + + def calculate_production_plan(self, payload: Dict[str, Any]) -> List[Dict[str, Any]]: + """Calculate the production plan based on merit order optimization""" + try: + load = payload['load'] + fuels = payload['fuels'] + powerplants_data = payload['powerplants'] + + # Create PowerPlant objects and calculate costs + self.plants = [] + for plant_data in powerplants_data: + plant = PowerPlant( + plant_data['name'], + plant_data['type'], + plant_data['efficiency'], + plant_data['pmin'], + plant_data['pmax'] + ) + plant.calculate_cost( + fuels['gas(euro/MWh)'], + fuels['kerosine(euro/MWh)'], + fuels['co2(euro/ton)'], + fuels['wind(%)'] + ) + self.plants.append(plant) + + # Sort plants by merit order (cost per MWh, wind first) + self.plants.sort(key=lambda p: (p.cost_per_mwh, -p.available_power)) + + logger.info(f"Merit order: {[f'{p.name}({p.cost_per_mwh:.2f})' for p in self.plants]}") + + # Calculate production plan + production_plan = self._optimize_production(load) + + return production_plan + + except Exception as e: + logger.error(f"Error calculating production plan: {str(e)}") + raise + + def _optimize_production(self, target_load: int) -> List[Dict[str, Any]]: + """Optimize production using a greedy approach with merit order""" + remaining_load = target_load + production_plan = [] + + # Initialize all plants with 0 production + for plant in self.plants: + production_plan.append({"name": plant.name, "p": 0}) + + # First pass: Use wind turbines to their full available capacity + for i, plant in enumerate(self.plants): + if plant.type == "windturbine" and remaining_load > 0: + production = min(plant.available_power, remaining_load) + # Round to nearest 0.1 MW + production = round(production * 10) / 10 + production_plan[i]["p"] = production + remaining_load -= production + logger.info(f"Wind: {plant.name} produces {production} MW, remaining load: {remaining_load}") + + # Second pass: Use conventional plants in merit order + plant_indices = list(range(len(self.plants))) + + while remaining_load > 0.1: # Continue until load is satisfied (within 0.1 MW tolerance) + made_progress = False + + for i in plant_indices: + plant = self.plants[i] + current_production = production_plan[i]["p"] + + # Skip wind turbines (already handled) and plants at max capacity + if plant.type == "windturbine" or current_production >= plant.pmax: + continue + + # Calculate how much more this plant can produce + if current_production == 0: + # Plant is off, need to consider pmin + min_increment = max(plant.pmin, 0.1) + max_possible = min(plant.pmax, remaining_load + current_production) + else: + # Plant is already running, can increment by 0.1 + min_increment = 0.1 + max_possible = min(plant.pmax, remaining_load + current_production) + + if max_possible >= min_increment: + # Calculate optimal increment + if current_production == 0: + increment = min(remaining_load, plant.pmax) + increment = max(increment, plant.pmin) + else: + increment = min(remaining_load, plant.pmax - current_production) + + # Round to nearest 0.1 MW + increment = round(increment * 10) / 10 + + if increment >= 0.1: + production_plan[i]["p"] = round((current_production + increment) * 10) / 10 + remaining_load = round((remaining_load - increment) * 10) / 10 + made_progress = True + logger.info(f"{plant.name} produces {production_plan[i]['p']} MW, remaining load: {remaining_load}") + + if remaining_load <= 0.1: + break + + if not made_progress: + logger.warning(f"Could not satisfy remaining load of {remaining_load} MW") + break + + # Final adjustment to exactly match the load + total_production = sum(item["p"] for item in production_plan) + difference = target_load - total_production + + if abs(difference) > 0.1: + logger.warning(f"Production difference: {difference} MW") + # Try to adjust the last active plant + for i in reversed(range(len(production_plan))): + if production_plan[i]["p"] > 0: + plant = self.plants[i] + new_production = production_plan[i]["p"] + difference + if plant.pmin <= new_production <= plant.pmax: + production_plan[i]["p"] = round(new_production * 10) / 10 + break + + return production_plan + +calculator = ProductionPlanCalculator() + +@app.route('/productionplan', methods=['POST']) +def production_plan(): + """REST endpoint to calculate production plan""" + try: + if not request.is_json: + return jsonify({"error": "Request must be JSON"}), 400 + + payload = request.get_json() + + # Validate payload + if not payload or 'load' not in payload or 'fuels' not in payload or 'powerplants' not in payload: + return jsonify({"error": "Invalid payload structure"}), 400 + + logger.info(f"Received request for load: {payload['load']} MW") + + # Calculate production plan + production_plan = calculator.calculate_production_plan(payload) + + # Validate result + total_production = sum(item["p"] for item in production_plan) + logger.info(f"Total production: {total_production} MW, Target load: {payload['load']} MW") + + return jsonify(production_plan) + + except Exception as e: + logger.error(f"Error processing request: {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint""" + return jsonify({"status": "healthy"}) + +@app.errorhandler(404) +def not_found(error): + return jsonify({"error": "Endpoint not found"}), 404 + +@app.errorhandler(500) +def internal_error(error): + logger.error(f"Internal server error: {str(error)}") + return jsonify({"error": "Internal server error"}), 500 + +if __name__ == '__main__': + logger.info("Starting Power Plant Production Plan API on port 8888") + app.run(host='0.0.0.0', port=8888, debug=False) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..fd7575e5a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask +Werkzeug \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 000000000..35c82242d --- /dev/null +++ b/test_api.py @@ -0,0 +1,95 @@ +import requests +import json +import time + +# Test payloads +payload1 = { + "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_api(): + base_url = "http://localhost:8888" + + print("=== Testing API ===") + try: + response = requests.post( + f"{base_url}/productionplan", + json=payload1, + headers={"Content-Type": "application/json"} + ) + + print(f"Status Code: {response.status_code}") + + if response.status_code == 200: + result = response.json() + print("✅ Success! Production Plan:") + + total_production = 0 + for plant in result: + print(f" {plant['name']}: {plant['p']} MW") + total_production += plant['p'] + + print(f"\nTotal Production: {total_production} MW") + print(f"Target Load: {payload1['load']} MW") + + else: + print(f"❌ Error: {response.text}") + + except requests.exceptions.ConnectionError: + print("❌ Cannot connect to API. Make sure the server is running!") + print("Run: python app.py") + except Exception as e: + print(f"❌ Error: {str(e)}") + +if __name__ == "__main__": + test_api() \ No newline at end of file