diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d840d37ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +api/code/.vscode/** +api/code/**/*.pyc diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 000000000..66e947ce4 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.10-slim + +# Just for Python Execution +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PYTHONPATH "${PYTHONPATH}:/powerplanner" + + +WORKDIR /powerplanner +COPY code/ /powerplanner/ +RUN pip install --no-cache-dir -r requirements.txt + + +EXPOSE 8888 + +CMD ["uvicorn", "run_server:app", "--host", "0.0.0.0", "--port", "8888"] diff --git a/api/code/.env b/api/code/.env new file mode 100755 index 000000000..643efb34e --- /dev/null +++ b/api/code/.env @@ -0,0 +1 @@ +PYTHONPATH=/home/tucho/workspaceTakeoff/powerplant-coding-challenge/api/code:$PYTHONPATH diff --git a/api/code/README.md b/api/code/README.md new file mode 100644 index 000000000..f13d5e03d --- /dev/null +++ b/api/code/README.md @@ -0,0 +1,28 @@ +# powerplant-coding-challenge - Candidate: Alberto Morales Morales + + +## Set-up + +Set the repo base path +REPO_BASE_PATH=... +export REPO_BASE_PATH + +### with conda + +conda create -n ppcch python=3.10 +conda activate ppcch + +### without conda + +### in both cases +cd $REPO_BASE_PATH/api/code +pip install -r requirements.txt + +## How to run +cd $REPO_BASE_PATH/api/code +python3 run_server.py + +### with Docker +cd $REPO_BASE_PATH/api +docker build -t powerplanner . +docker run -p 8888:8888 docker.io/library/powerplanner \ No newline at end of file diff --git a/api/code/builders/__init__.py b/api/code/builders/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/builders/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/builders/lp_problem_builder.py b/api/code/builders/lp_problem_builder.py new file mode 100644 index 000000000..fe037d225 --- /dev/null +++ b/api/code/builders/lp_problem_builder.py @@ -0,0 +1,25 @@ +import logging +from abc import ABC, abstractmethod +from typing import Tuple + +from config.constants import BS_LOGGER +from models.linear_programming import LinearProgrammingProblem +from models.energy_demand import EnergyDemand + + +class LinearProgrammingProblemBuilder(ABC): + def __init__(self): + self._logger = logging.getLogger(BS_LOGGER) + + @abstractmethod + def from_energy_demand(self, demand: EnergyDemand) -> Tuple[list[str], LinearProgrammingProblem]: + """Gets a linear programming problem, given an energy demand. + + Parameters: + demand (EnergyDemand): energy demand. + + Returns: + The linear programming problem corresponding to the demand + + """ + raise NotImplementedError("Subclasses should implement this!") \ No newline at end of file diff --git a/api/code/builders/lp_problem_builder_impl.py b/api/code/builders/lp_problem_builder_impl.py new file mode 100644 index 000000000..eb8f8de7e --- /dev/null +++ b/api/code/builders/lp_problem_builder_impl.py @@ -0,0 +1,54 @@ +import logging +from abc import ABC, abstractmethod +from typing import Tuple + +from config.constants import BS_LOGGER +from models.linear_programming import LinearProgrammingProblem +from models.energy_demand import EnergyDemand +from exceptions.exceptions import BuilderException + +class LinearProgrammingProblemBuilderImpl(ABC): + def __init__(self): + self._logger = logging.getLogger(BS_LOGGER) + + def from_energy_demand(self, demand: EnergyDemand) -> Tuple[list[str], LinearProgrammingProblem]: + + try: + load = demand.load + fuels = demand.fuels + powerplant_list = demand.powerplants + + constraint_vector = [load] + + fuels_dict = { + "gasfired": fuels.gas, + "turbojet": fuels.kerosine, + "windturbine": 0 + } + + powerplant_names = [] + objective_function = [] + constraint_matrix = [] + constraint_matrix_first_row = [] + bounds = [] + + for powerplant in powerplant_list: + powerplant_names.append(powerplant.name) + objective_function.append(fuels_dict[powerplant.type]) + if (powerplant.type == "windturbine"): + constraint_matrix_first_row.append(fuels.wind / 100) + else: + constraint_matrix_first_row.append(powerplant.efficiency) + powerplant_bounds = (powerplant.pmin, powerplant.pmax) + bounds.append(powerplant_bounds) + + constraint_matrix = [constraint_matrix_first_row] + + lp_problem = LinearProgrammingProblem(objective_function=objective_function, + constraint_matrix = constraint_matrix, + constraint_vector = constraint_vector, + bounds=bounds) + except Exception as e: + raise BuilderException(arg=e) + + return powerplant_names, lp_problem \ No newline at end of file diff --git a/api/code/builders/production_plan_builder.py b/api/code/builders/production_plan_builder.py new file mode 100644 index 000000000..96407e6b3 --- /dev/null +++ b/api/code/builders/production_plan_builder.py @@ -0,0 +1,25 @@ +import logging +from abc import ABC, abstractmethod + +from config.constants import BS_LOGGER +from models.linear_programming import ProblemResult +from models.production_plan import ProductionPlanItem + + +class ProductionPlanBuilder(ABC): + def __init__(self): + self._logger = logging.getLogger(BS_LOGGER) + + @abstractmethod + def from_problem_result(self, powerplant_names: list[str], problem_result: ProblemResult) -> list[ProductionPlanItem]: + """Gets a production plan, given a linear programming problem result. + + Parameters: + powerplant_names: array of powerplant names + problem_result (ProblemResult): linear programming problem result. + + Returns: + The production plan corresponding to the linear programming problem result + + """ + raise NotImplementedError("Subclasses should implement this!") diff --git a/api/code/builders/production_plan_builder_impl.py b/api/code/builders/production_plan_builder_impl.py new file mode 100644 index 000000000..cbe27bc92 --- /dev/null +++ b/api/code/builders/production_plan_builder_impl.py @@ -0,0 +1,24 @@ +import logging + +from config.constants import BS_LOGGER +from planner.planner import Planner +from solver.solver import Solver +from models.energy_demand import EnergyDemand +from models.production_plan import ProductionPlanItem +from models.linear_programming import LinearProgrammingProblem, ProblemResult + +from builders.production_plan_builder import ProductionPlanBuilder + + +class ProductionPlanBuilderImpl(ProductionPlanBuilder): + def __init__(self): + super().__init__() + self._logger = logging.getLogger(BS_LOGGER) + + def from_problem_result(self, powerplant_names: list[str], problem_result: ProblemResult) -> list[ProductionPlanItem]: + result_matrix = problem_result.solution + production_plan = [] + for n in range(len(result_matrix)): + production_plan_item = ProductionPlanItem(name=powerplant_names[n],p=result_matrix[n]) + production_plan.append(production_plan_item) + return production_plan \ No newline at end of file diff --git a/api/code/config/__init__.py b/api/code/config/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/config/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/config/appconfig.py b/api/code/config/appconfig.py new file mode 100644 index 000000000..2eb46b186 --- /dev/null +++ b/api/code/config/appconfig.py @@ -0,0 +1,80 @@ +""" + This is the Configuration System for the Powerplan API + + All these Variables should be exported in the Container and need to be readable + by the Process. + + If there is no needed configuration, the Container will End with ValueError. + +""" + +import logging +import os +from logging.config import dictConfig +from config.constants import BS_LOGGER, BS_CORRELATION_LOGGER +from config.traceability import CorrelationIdFilter + +LOG_LEVEL = os.getenv("LOG_LEVEL", "") +BS_LOG_LEVEL = os.getenv("BS_LOG_LEVEL", "") + +if LOG_LEVEL is None or LOG_LEVEL == "": + LOG_LEVEL = "WARNING" + +if BS_LOG_LEVEL is None or BS_LOG_LEVEL == "": + BS_LOG_LEVEL = LOG_LEVEL + +dictConfig( + { + "version": 1, + "formatters": { + "data-format": { + # DATE | BS LOGGER NAME | LEVEL | FILENAME | LINE | FUNCTION | MESSAGE + "format": "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(funcName)s - %(message)s", + "datefmt": "%Y%m%d%H%M%S", + }, + "correlation-format": { + # DATE | BS LOGGER NAME | LEVEL | FILENAME | LINE | FUNCTION | CORRELATION ID | MESSAGE + "format": "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(funcName)s - [%(correlation_id)s] - %(message)s", + "datefmt": "%Y%m%d%H%M%S", + }, + }, + "filters": { + "correlation_id_filter": { + "()": CorrelationIdFilter, + } + }, + "handlers": { + "default": { + "formatter": "data-format", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", # Default is stderr + }, + "correlation": { + "formatter": "correlation-format", + "class": "logging.StreamHandler", + "filters": ["correlation_id_filter"], + "stream": "ext://sys.stdout", # Default is stderr + }, + }, + "root": {"level": LOG_LEVEL, "handlers": ["default", "correlation"]}, + "loggers": { + BS_LOGGER: {"level": BS_LOG_LEVEL, "propagate": 0, "handlers": ["default"]}, + BS_CORRELATION_LOGGER: { + "level": BS_LOG_LEVEL, + "propagate": 0, + "handlers": ["correlation"], + }, + }, + } +) + +logger = logging.getLogger(BS_LOGGER) +logger.debug("After logger initialization") + +# Only for Log +APP_NAME = "powerplant-code-challenge" +APP_VERSION = os.getenv("APP_VERSION", "1.1.0") +SWAGGER_URL_PREFIX = os.getenv("SWAGGER_URL_PREFIX", "") + +# Feature List +FEATURE_ENABLE_SWAGGER_UI = os.getenv("FEATURE_ENABLE_SWAGGER_UI", "NO") diff --git a/api/code/config/constants.py b/api/code/config/constants.py new file mode 100644 index 000000000..996361d31 --- /dev/null +++ b/api/code/config/constants.py @@ -0,0 +1,2 @@ +BS_LOGGER = "powerplan_coding_challenge_logger" +BS_CORRELATION_LOGGER = "powerplan_coding_challenge_correlation_logger" diff --git a/api/code/config/traceability.py b/api/code/config/traceability.py new file mode 100644 index 000000000..e325ff723 --- /dev/null +++ b/api/code/config/traceability.py @@ -0,0 +1,39 @@ +import logging +import uuid +import contextvars + +# Context variable to store the correlation ID +correlation_id_ctx_var = contextvars.ContextVar("correlation_id", default=None) + + +def get_correlation_id(): + """ + Returns the current correlation ID. + """ + return correlation_id_ctx_var.get() + + +def set_correlation_id(correlation_id): + """ + Sets the correlation ID. + """ + correlation_id_ctx_var.set(correlation_id) + + +def generate_correlation_id(): + """ + Generates a new correlation ID and sets it in the context variable. + """ + correlation_id = str(uuid.uuid4()) + set_correlation_id(correlation_id) + return correlation_id + + +class CorrelationIdFilter(logging.Filter): + """ + Logging filter to add correlation ID to log records. + """ + + def filter(self, record): + record.correlation_id = get_correlation_id() or "N/A" + return True diff --git a/api/code/controllers/__init__.py b/api/code/controllers/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/controllers/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/controllers/common_endpoints.py b/api/code/controllers/common_endpoints.py new file mode 100644 index 000000000..565423cda --- /dev/null +++ b/api/code/controllers/common_endpoints.py @@ -0,0 +1,46 @@ +""" + This module defines common utility API endpoints for the Powerplant Coding Challenge API. + It includes functions to check the application's version, root, and health status. + + Endpoints: + - GET /: Root endpoint returning appname and version + - GET /version: Retrieve the current version of the application + - GET /healthz: Perform a health check of the application + +""" + +from datetime import datetime + +import logging + +from fastapi import HTTPException + +from config import appconfig +from config.constants import BS_LOGGER +from models.responses import HealthResponse +from fastapi import APIRouter + +commons_router = APIRouter() + + +@commons_router.get("/", include_in_schema=False) +def get_root(): + logging.getLogger(BS_LOGGER).info("Request received in /") + return {"appname": appconfig.APP_NAME, "version": appconfig.APP_VERSION} + + +@commons_router.get("/version", include_in_schema=False) +def get_version(): + logging.getLogger(BS_LOGGER).info("Request received in /version") + return appconfig.APP_VERSION + + +@commons_router.get("/healthz", response_model=HealthResponse, include_in_schema=False) +def check_health(): + logging.getLogger(BS_LOGGER).info("Request received in /healthz") + try: + current_time = datetime.now().strftime("%#m/%#d/%Y %#I:%M:%S %p") + # TODO run some heath checks + return HealthResponse(status="healthy", current_time=current_time) + except Exception as e: + raise HTTPException(status_code=500, detail=f"System Error: {e}") diff --git a/api/code/controllers/productionplan_endpoints.py b/api/code/controllers/productionplan_endpoints.py new file mode 100644 index 000000000..e1bb142cb --- /dev/null +++ b/api/code/controllers/productionplan_endpoints.py @@ -0,0 +1,67 @@ +""" + This module defines the main POST endpoint for the Powerplan Coding Challenge API. + It includes functions to handle the primary data submission operation. + + Endpoints: + - POST /productionplan: Handle the productionplan request according with the energy demand provided + + +""" +import logging + +from fastapi import HTTPException, APIRouter, Depends + +from config.constants import BS_CORRELATION_LOGGER +from config.traceability import generate_correlation_id +from models.energy_demand import EnergyDemand +from planner.planner import Planner +from planner.planner_factory_impl import PlannerFactoryImpl +from exceptions.exceptions import PlannerException + +productionplan_router = APIRouter() + +planner_factory = PlannerFactoryImpl() +planner = planner_factory.create_planner() + +# Dependency injection with FastAPI using Depends +def get_planner() -> Planner: + return planner + +@productionplan_router.post("/productionplan", status_code=200) +def post_production_plan(energy_demand: EnergyDemand, planner = Depends(get_planner)): + """ + This code defines a FastAPI endpoint that returns a ProductionPlan + according to the EnergyDemand. + + :param payload: + :return: + """ + + try: + correlation_id = generate_correlation_id() + + logging.getLogger(BS_CORRELATION_LOGGER).info( + f"Processing event with correlation ID {correlation_id}" + ) + + # Create response + production_plan = planner.production_plan(energy_demand) + return production_plan + + except PlannerException as pe: + logging.getLogger(BS_CORRELATION_LOGGER).error(f"Planner Exception {pe}") + raise HTTPException( + status_code=400, + detail=f"BadRequest. {pe}", + ) + + except TypeError as te: + logging.getLogger(BS_CORRELATION_LOGGER).error(f"TypeError {te}") + raise HTTPException( + status_code=400, + detail=f"BadRequest Typerror!! {te}", + ) + + except Exception as e: + logging.getLogger(BS_CORRELATION_LOGGER).error(f"Unexpected error: {e}") + raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/api/code/exceptions/__init__.py b/api/code/exceptions/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/exceptions/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/exceptions/error_codes.py b/api/code/exceptions/error_codes.py new file mode 100644 index 000000000..cbcb99da0 --- /dev/null +++ b/api/code/exceptions/error_codes.py @@ -0,0 +1,6 @@ +error_codes = { + 'BS_0000': 'Something went wrong: {}.', + 'BS_0001': 'The problem is infeasible, or there has been some problem solving it.', + 'BS_0002': 'Planner was not able to generate an energy plan according to the demand.', + 'BS_0003': 'There were some problem with the payload.', +} diff --git a/api/code/exceptions/exceptions.py b/api/code/exceptions/exceptions.py new file mode 100644 index 000000000..e4c7d5fb6 --- /dev/null +++ b/api/code/exceptions/exceptions.py @@ -0,0 +1,20 @@ +from gems_core.exceptions.exceptions import DataException +from exceptions.error_codes import error_codes + + +class BSError(DataException): + def __init__(self, code, arg=None): + super(BSError, self).__init__(code=code, module='BS', module_error_codes=error_codes, arg=arg) + + +class SolverException(BSError): + def __init__(self, arg): + super(SolverException, self).__init__(code='BS_0001', arg=arg) + +class PlannerException(BSError): + def __init__(self, arg): + super(PlannerException, self).__init__(code='BS_0002', arg=arg) + +class BuilderException(BSError): + def __init__(self, arg): + super(BuilderException, self).__init__(code='BS_0003', arg=arg) \ No newline at end of file diff --git a/api/code/gems_core/__init__.py b/api/code/gems_core/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/gems_core/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/gems_core/exceptions/__init__.py b/api/code/gems_core/exceptions/__init__.py new file mode 100644 index 000000000..d2291f064 --- /dev/null +++ b/api/code/gems_core/exceptions/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) GRPR. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/gems_core/exceptions/error_codes.py b/api/code/gems_core/exceptions/error_codes.py new file mode 100644 index 000000000..81e423319 --- /dev/null +++ b/api/code/gems_core/exceptions/error_codes.py @@ -0,0 +1,3 @@ +error_codes = { + 'CORE_0001': 'Bad Request: The request has the following errors: {}.' +} diff --git a/api/code/gems_core/exceptions/exceptions.py b/api/code/gems_core/exceptions/exceptions.py new file mode 100644 index 000000000..bca53e0b2 --- /dev/null +++ b/api/code/gems_core/exceptions/exceptions.py @@ -0,0 +1,33 @@ +from gems_core.exceptions.error_codes import error_codes + + +class DataException(Exception): + def __init__(self, code, module, module_error_codes=None, arg=None): + if module_error_codes is not None: + error_codes.update(module_error_codes) + self.code = code + self.module = module + self.arg = arg + + def __str__(self): + text = f'Error: {self.code}. {self.message()} [{self.module}]' + return text + + def message(self): + if self.arg is not None: + return error_codes[self.code].format(self.arg) + else: + return error_codes[self.code] + + +# Generic Errors + +class GenericError(DataException): + def __init__(self, code, arg=None): + super(GenericError, self).__init__(code=code, module='GEN', arg=arg) + + +class BadRequestError(GenericError): + def __init__(self, arg): + super(BadRequestError, self).__init__(code='CORE_0001', arg=arg) + diff --git a/api/code/models/__init__.py b/api/code/models/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/models/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/models/energy_demand.py b/api/code/models/energy_demand.py new file mode 100644 index 000000000..fd3aea33e --- /dev/null +++ b/api/code/models/energy_demand.py @@ -0,0 +1,43 @@ +""" + This class provide the abstraction of the payload messages received from client using pydantic library. + + + +""" + +from typing import List +from pydantic import BaseModel +from pydantic.fields import Field + + +class PowerPlantDemand(BaseModel): + name: str + type: str + efficiency: float + pmax: float + pmin: float + + +class FuelsEnergyDemand(BaseModel): + gas: float = Field( + title="gas(euro/MWh)", + description="The price of gas per MWh" + ) + kerosine: float = Field( + title="kerosine(euro/MWh)", + description="The price of kerosine per MWh" + ) + co2: float = Field( + title="co2(euro/ton)", + description="The price of emission allowances" + ) + wind: float = Field( + title="wind(%)", + description="Percentage of wind" + ) + + +class EnergyDemand(BaseModel): + load: int + fuels: FuelsEnergyDemand + powerplants: List[PowerPlantDemand] diff --git a/api/code/models/linear_programming.py b/api/code/models/linear_programming.py new file mode 100644 index 000000000..18c476f74 --- /dev/null +++ b/api/code/models/linear_programming.py @@ -0,0 +1,59 @@ +""" + This class provide the abstraction of the LinearProgrammingProblem and ProblemResult data structures. + + + +""" + + +import logging +from abc import ABC, abstractmethod + +from config.constants import BS_LOGGER + +class LinearProgrammingProblem(): + def __init__(self, objective_function=None, constraint_matrix=None, constraint_vector=None, bounds=None): + """Linear programming problem (simplyfied) data sructure. + + Parameters + ---------- + objective_function : 1-D array + The coefficients of the linear objective function to be minimized. + constraint_matrix : 2-D array, optional + The equality constraint matrix. Each row of ``constraint_matrix`` specifies the + coefficients of a linear equality constraint on ``x``. + constraint_vector : 1-D array, optional + The equality constraint vector. Each element of ``constraint_matrix @ x`` must equal + the corresponding element of ``constraint_vector``. + bounds : sequence, optional + A sequence of ``(min, max)`` pairs for each element in ``x``, defining + the minimum and maximum values of that decision variable. Use ``None`` + to indicate that there is no bound. By default, bounds are + ``(0, None)`` (all decision variables are non-negative). + """ + self._logger = logging.getLogger(BS_LOGGER) + self.objective_function = objective_function + self.constraint_matrix = constraint_matrix + self.constraint_vector = constraint_vector + self.bounds = bounds + +class ProblemResult(): + def __init__(self, solution, opt, status): + """Linear programming problem result (simplyfied) data sructure. + + Parameters + ---------- + solution : 1-D array + The values of the decision variables that minimizes the + objective function while satisfying the constraints. + opt : float + The optimal value of the objective function ``c @ x``. + status : int + An integer representing the exit status of the solving operation. + ``0`` : Terminated successfully. + ``-1`` : Terminated unsuccessfully. + """ + self._logger = logging.getLogger(BS_LOGGER) + self.solution = solution + self.opt = opt + self.status = status diff --git a/api/code/models/production_plan.py b/api/code/models/production_plan.py new file mode 100644 index 000000000..354024efe --- /dev/null +++ b/api/code/models/production_plan.py @@ -0,0 +1,13 @@ +""" + This class provide the abstraction of the responses sent back to the client using pydantic library. + + + +""" + +from pydantic import BaseModel + + +class ProductionPlanItem(BaseModel): + name: str + p: float diff --git a/api/code/models/responses.py b/api/code/models/responses.py new file mode 100644 index 000000000..eb960a066 --- /dev/null +++ b/api/code/models/responses.py @@ -0,0 +1,11 @@ +""" + This class provide the abstraction of some response data sructures using pydantic library. +""" + + +from pydantic import BaseModel + + +class HealthResponse(BaseModel): + status: str + current_time: str diff --git a/api/code/planner/__init__.py b/api/code/planner/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/planner/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/planner/planner.py b/api/code/planner/planner.py new file mode 100644 index 000000000..982652ed2 --- /dev/null +++ b/api/code/planner/planner.py @@ -0,0 +1,33 @@ +""" + This class provide the abstraction of class with the responsability of generating a valid + ProductionPlan given an EnergyDemand. + + + +""" + + +import logging +from abc import ABC, abstractmethod + +from config.constants import BS_LOGGER +from models.energy_demand import EnergyDemand +from models.production_plan import ProductionPlanItem + + +class Planner(ABC): + def __init__(self): + self._logger = logging.getLogger(BS_LOGGER) + + @abstractmethod + def production_plan(self, demand: EnergyDemand) -> list[ProductionPlanItem] : + """Generates a valid ProductionPlan given an EnergyDemand. + + Parameters: + demand (EnergyDemand): energy demand. + + Returns: + The a production plan according with the demand + + """ + raise NotImplementedError("Subclasses should implement this!") diff --git a/api/code/planner/planner_factory.py b/api/code/planner/planner_factory.py new file mode 100644 index 000000000..3b9834f7d --- /dev/null +++ b/api/code/planner/planner_factory.py @@ -0,0 +1,14 @@ +import logging +from abc import ABC, abstractmethod + +from config.constants import BS_LOGGER +from planner.planner import Planner + +class PlannerFactory(ABC): + def __init__(self): + self._logger = logging.getLogger(BS_LOGGER) + + @abstractmethod + def create_planner(self) -> Planner: + """Abstract""" + raise NotImplementedError("Subclasses should implement this!") \ No newline at end of file diff --git a/api/code/planner/planner_factory_impl.py b/api/code/planner/planner_factory_impl.py new file mode 100644 index 000000000..d3e4414b0 --- /dev/null +++ b/api/code/planner/planner_factory_impl.py @@ -0,0 +1,24 @@ +import logging +from abc import ABC, abstractmethod + +from config.constants import BS_LOGGER +from planner.planner import Planner +from planner.planner_factory import PlannerFactory +from planner.planner_impl import PlannerImpl +from solver.solver_impl import SolverImpl +from builders.production_plan_builder_impl import ProductionPlanBuilderImpl +from builders.lp_problem_builder_impl import LinearProgrammingProblemBuilderImpl + +class PlannerFactoryImpl(PlannerFactory): + def __init__(self): + super().__init__() + self._logger = logging.getLogger(BS_LOGGER) + + def create_planner(self) -> Planner: + solver = SolverImpl() + lp_problem_builder = LinearProgrammingProblemBuilderImpl() + production_plan_builder = ProductionPlanBuilderImpl() + planner = PlannerImpl(solver=solver, + lp_problem_builder=lp_problem_builder, + production_plan_builder=production_plan_builder) + return planner \ No newline at end of file diff --git a/api/code/planner/planner_impl.py b/api/code/planner/planner_impl.py new file mode 100644 index 000000000..1b1f0575a --- /dev/null +++ b/api/code/planner/planner_impl.py @@ -0,0 +1,39 @@ +import logging + +from config.constants import BS_LOGGER +from planner.planner import Planner +from solver.solver import Solver +from models.energy_demand import EnergyDemand +from models.production_plan import ProductionPlanItem +from models.linear_programming import LinearProgrammingProblem, ProblemResult +from builders.lp_problem_builder import LinearProgrammingProblemBuilder +from builders.production_plan_builder import ProductionPlanBuilder +from exceptions.exceptions import SolverException, PlannerException, BuilderException + +class PlannerImpl(Planner): + def __init__(self, + lp_problem_builder: LinearProgrammingProblemBuilder, + solver: Solver, + production_plan_builder: ProductionPlanBuilder): + super().__init__() + self._logger = logging.getLogger(BS_LOGGER) + self._lp_problem_builder = lp_problem_builder + self._solver = solver + self._production_plan_builder = production_plan_builder + + def production_plan(self, demand: EnergyDemand) -> list[ProductionPlanItem] : + productionPlan = None + try: + powerplant_names, lp_problem = self._lp_problem_builder.from_energy_demand(demand) + problem_result = self._solver.solve(lp_problem) + productionPlan = self._production_plan_builder.from_problem_result( + powerplant_names=powerplant_names, + problem_result=problem_result + ) + except BuilderException as be: + raise PlannerException(be) + except SolverException as se: + raise PlannerException(se) + except Exception as e: + raise e + return productionPlan diff --git a/api/code/requirements.txt b/api/code/requirements.txt new file mode 100644 index 000000000..bdbbf6781 --- /dev/null +++ b/api/code/requirements.txt @@ -0,0 +1,5 @@ +httpx~=0.26.0 +uvicorn~=0.30.1 +fastapi~=0.111.0 +pydantic~=2.7.1 +scipy~=1.9.1 \ No newline at end of file diff --git a/api/code/run_server.py b/api/code/run_server.py new file mode 100644 index 000000000..6ac90d01b --- /dev/null +++ b/api/code/run_server.py @@ -0,0 +1,47 @@ +""" + This is the Entry Point of the API. By default, we always disabled the rdocs, but + using FEATURE_ENABLE_SWAGGER_UI we can enable the UI for DEV/TESTING Environments. + + As any Docker Style API we want to be Environment Agnostic, so, all the configuration + must be done using ENV. + + The configuration for system and API will be on the appconfig module and we want to prevent + any start without these configuration files. + + To Enable a feature you must use YES (all in capitals) and NO or not set to Disable., i.e, + to enable the feature FEATURE_ENABLE_SWAGGER_UI, you must export as: + + $ export FEATURE_ENABLE_SWAGGER_UI=YES + +""" + +import uvicorn +import logging + +from fastapi import FastAPI + +from controllers.common_endpoints import commons_router +from controllers.productionplan_endpoints import productionplan_router +from config import appconfig +from config.constants import BS_LOGGER + +if appconfig.FEATURE_ENABLE_SWAGGER_UI == "YES": + if "" == appconfig.SWAGGER_URL_PREFIX: + root_path_value = "" + else: + root_path_value = f"/{appconfig.SWAGGER_URL_PREFIX}" + app = FastAPI( + root_path=root_path_value, + openapi_url="/spec", + docs_url="/swagger/index.html", + redoc_url=None, + ) +else: + app = FastAPI(openapi_url="/spec", docs_url=None, redoc_url=None) + +app.include_router(productionplan_router) +app.include_router(commons_router) + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8888) + logging.getLogger(BS_LOGGER).info("Powerplan Coding Challenge API is running!") diff --git a/api/code/setup.py b/api/code/setup.py new file mode 100644 index 000000000..1ebc9652d --- /dev/null +++ b/api/code/setup.py @@ -0,0 +1,46 @@ +import os + +import setuptools + +with open("README.md", "r") as fh: + long_description = fh.read() + +# populate install_requires from requirements.txt file +thelibFolder = os.path.dirname(os.path.realpath(__file__)) +requirementPath = thelibFolder + "/requirements.txt" +real_deps = [] +if os.path.isfile(requirementPath): + with open(requirementPath) as f: + real_deps = f.read().splitlines() + +# populate tests_require from test-requirements.txt file +thelibFolder = os.path.dirname(os.path.realpath(__file__)) +requirementPath = thelibFolder + "/requirements.txt" +tests_deps = [] +if os.path.isfile(requirementPath): + with open(requirementPath) as f: + tests_deps = f.read().splitlines() + +setuptools.setup( + name="powerplant-coding-challenge", + version="1.0.0", + author="Alberto Morales Morales", + author_email="data_devops@grpr.com", + description="Powerplant-coding-challenge API", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/gems-st-ib/powerplant-coding-challenge.git", + packages=setuptools.find_packages(exclude=["tests"]), + package_data={ + # If any package contains *.txt files, include them: + "": ["*.json"] + }, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + install_requires=real_deps, + extras_require={"testing": tests_deps}, + tests_require=tests_deps, +) diff --git a/api/code/solver/__init__.py b/api/code/solver/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/solver/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/solver/solver.py b/api/code/solver/solver.py new file mode 100644 index 000000000..9f2b8f05a --- /dev/null +++ b/api/code/solver/solver.py @@ -0,0 +1,23 @@ +import logging +from abc import ABC, abstractmethod + +from config.constants import BS_LOGGER +from models.linear_programming import LinearProgrammingProblem, ProblemResult + + +class Solver(ABC): + def __init__(self): + self._logger = logging.getLogger(BS_LOGGER) + + @abstractmethod + def solve(self, problem: LinearProgrammingProblem) -> ProblemResult : + """Solves the linear programming problem given as parameter. + + Parameters: + problem (LinearProgrammingProblem): linear programming problem to solve. + + Returns: + The solution of de linear programming problem given as argument + + """ + raise NotImplementedError("Subclasses should implement this!") \ No newline at end of file diff --git a/api/code/solver/solver_impl.py b/api/code/solver/solver_impl.py new file mode 100644 index 000000000..dd99eb337 --- /dev/null +++ b/api/code/solver/solver_impl.py @@ -0,0 +1,29 @@ +import logging + +from config.constants import BS_LOGGER +from solver.solver import Solver +from models.linear_programming import LinearProgrammingProblem, ProblemResult +from exceptions.exceptions import SolverException + +from scipy.optimize import linprog + +class SolverImpl(Solver): + def __init__(self): + super().__init__() + self._logger = logging.getLogger(BS_LOGGER) + + def solve(self, problem: LinearProgrammingProblem) -> ProblemResult : + self._logger.debug(f"Objective function coeficients: {problem.objective_function}") + self._logger.debug(f"Constraint matrix: {problem.constraint_matrix}") + self._logger.debug(f"Constraint vector: {problem.constraint_vector}") + self._logger.debug(f"Bounds: {problem.bounds}") + solution = linprog(c=problem.objective_function, + A_eq=problem.constraint_matrix, + b_eq=problem.constraint_vector, + bounds=problem.bounds, + method='highs-ds') + self._logger.debug(solution.message) + result = ProblemResult(solution=solution.x, opt=solution.fun, status=solution.status) + if result.status != 0: + raise SolverException(result.status) + return result \ No newline at end of file diff --git a/api/code/tests/__init__.py b/api/code/tests/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/tests/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/tests/requirements.txt b/api/code/tests/requirements.txt new file mode 100644 index 000000000..82e09671b --- /dev/null +++ b/api/code/tests/requirements.txt @@ -0,0 +1,5 @@ +uvicorn~=0.30.1 +fastapi~=0.111.0 +pydantic~=2.7.1 +pytest==7.1.3 +httpx~=0.26.0 \ No newline at end of file diff --git a/api/code/tests/test_integration_runner.py b/api/code/tests/test_integration_runner.py new file mode 100644 index 000000000..a8a38327a --- /dev/null +++ b/api/code/tests/test_integration_runner.py @@ -0,0 +1,21 @@ +import unittest + + +class TestIntegrationRunner(unittest.TestCase): + + def test_integration_testing(self): + # initialize the test suite + suite = unittest.TestSuite() + + # add tests to the test suite + + # initialize a runner, pass it your suite and run it + runner = unittest.TextTestRunner(verbosity=3) + result = runner.run(suite) + + if not result.wasSuccessful(): + exit(1) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/code/tests/test_unitary_runner.py b/api/code/tests/test_unitary_runner.py new file mode 100644 index 000000000..0a1b8d939 --- /dev/null +++ b/api/code/tests/test_unitary_runner.py @@ -0,0 +1,24 @@ +import unittest + +from tests.unitary.api import test_api + + +class TestUnitRunner(unittest.TestCase): + + def test_unit_testing(self): + # initialize the test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # add tests to the test suite + suite.addTests(loader.loadTestsFromModule(test_api)) + # initialize a runner, pass it your suite and run it + runner = unittest.TextTestRunner(verbosity=3) + result = runner.run(suite) + + if not result.wasSuccessful(): + exit(1) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/code/tests/unitary/api/__init__.py b/api/code/tests/unitary/api/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/tests/unitary/api/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/code/tests/unitary/api/test_api.py b/api/code/tests/unitary/api/test_api.py new file mode 100644 index 000000000..88717c00c --- /dev/null +++ b/api/code/tests/unitary/api/test_api.py @@ -0,0 +1,111 @@ +import random +import unittest + +from fastapi.testclient import TestClient + +from config import appconfig +from run_server import app + +client = TestClient(app) + + +class ForwardToTestCase(unittest.TestCase): + def test_update_job_not_matching_payload(self): + job_id = random.randrange(1, 9999) + code = random.randrange(1, 700) + job = { + "jobID": f"{job_id}", + "code": code, + "message": "Job Completed without errors or warnings", + "status": { + "success": 10, + "errors": 0, + "warnings": 0, + "added": 0, + "updated": 0, + }, + "errors": [], + "warnings": [], + } + response = client.post("/jobs/3333333", json=job) + self.assertEqual(response.status_code, 400) + + def test_update_invalid_payload_schema(self): + job_id = random.randrange(1, 9999) + code = random.randrange(1, 700) + job = { + "asdasdas": f"{job_id}", + "code": code, + "message": "Job Completed without errors or warnings", + "status": { + "success": 10, + "errors": 0, + "warnings": 0, + "added": 0, + "updated": 0, + }, + "errors": [], + "warnings": [], + } + response = client.post(f"/jobs/{job_id}", json=job) + self.assertEqual(response.status_code, 422) + + def test_update_job_one_success_post(self): + job_id = random.randrange(1, 9999) + code = random.randrange(1, 700) + job = { + "jobID": f"{job_id}", + "code": code, + "message": "Job Completed without errors or warnings", + "status": { + "success": 10, + "errors": 0, + "warnings": 0, + "added": 0, + "updated": 0, + }, + "errors": [], + "warnings": [], + } + response = client.post(f"/jobs/{job_id}", json=job) + self.assertEqual(response.status_code, 201) + + def test_update_job_one_fail_on_put(self): + response = client.put( + "/jobs/1", + json={ + "jobID": "1", + "code": 3, + "message": "Job Completed without errors or warnings", + "status": { + "success": 10, + "errors": 0, + "warnings": 0, + "added": 0, + "updated": 0, + }, + "errors": [], + "warnings": [], + }, + ) + self.assertEqual(response.status_code, 405) + + def test_health_invalid_request(self): + response = client.put("/healthz") + self.assertEqual(response.status_code, 405) + + def test_health_success(self): + response = client.get("/healthz") + self.assertEqual(response.status_code, 200) + + def test_version_invalid_request(self): + response = client.put("/version") + self.assertEqual(response.status_code, 405) + + def test_version_success(self): + response = client.get("/version") + self.assertEqual(response.status_code, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/code/tests/unitary/solver/__init__.py b/api/code/tests/unitary/solver/__init__.py new file mode 100644 index 000000000..426c35844 --- /dev/null +++ b/api/code/tests/unitary/solver/__init__.py @@ -0,0 +1,3 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Engie. All rights reserved. +# -------------------------------------------------------------------------- diff --git a/api/static/EnergyDemand.json b/api/static/EnergyDemand.json new file mode 100644 index 000000000..ca1c20e5e --- /dev/null +++ b/api/static/EnergyDemand.json @@ -0,0 +1,83 @@ +{ + "required": [ + "load", + "fuels", + "powerplants" + ], + "properties": { + "load": { + "type": "number", + "multipleOf": 1, + "description": "The amount of energy (MWh) that need to be generated during one hour", + "example": 330 + }, + "fuels": { + "required": [ + "gas(euro/MWh)", + "kerosine(euro/MWh)", + "co2(euro/ton)", + "wind(%)" + ], + "properties": { + "gas(euro/MWh)": { + "type": "number", + "description": "The price of gas per MWh" + }, + "kerosine(euro/MWh)": { + "type": "number", + "description": "The price of kerosine per MWh" + }, + "co2(euro/ton)": { + "type": "number", + "description": "The price of emission allowances" + }, + "wind(%)": { + "type": "number", + "description": "Percentage of wind" + } + }, + "type": "object" + }, + "powerplants": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "type", + "efficiency", + "pmax", + "pmin" + ], + "properties": { + "name": { + "type": "string", + "description": "Powerplant name" + }, + "type": { + "type": "string", + "description": "gasfired | turbojet | windturbine", + "enum": [ + "gasfired", + "turbojet", + "windturbine" + ] + }, + "efficiency": { + "type": "number", + "description": "The efficiency at which they convert a MWh of fuel into a MWh of electrical energy" + }, + "pmax": { + "type": "number", + "description": "The maximum amount of power the powerplant can generate" + }, + "pmin": { + "type": "number", + "description": "The minimum amount of power the powerplant generates when switched on" + } + } + } + } + }, + "type": "object" +} diff --git a/api/static/ProductionPlan.json b/api/static/ProductionPlan.json new file mode 100644 index 000000000..fc50dbd7b --- /dev/null +++ b/api/static/ProductionPlan.json @@ -0,0 +1,21 @@ +{ + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "p" + ], + "properties": { + "name": { + "type": "string", + "description": "Powerplant name" + }, + "p": { + "type": "number", + "multipleOf": 0.1, + "description": "How much power the Powerplant should deliver" + } + } + } +} diff --git a/api/static/openapi3_0.json b/api/static/openapi3_0.json new file mode 100644 index 000000000..993071619 --- /dev/null +++ b/api/static/openapi3_0.json @@ -0,0 +1,160 @@ +{ + "openapi": "3.0.3", + "servers": [ + { + "url": "{{server_ip}}" + } + ], + "info": { + "description": "Powerplant coding challenge based on the OpenAPI 3.0 specification.", + "version": "2.0.0", + "title": "Swagger Powerplant coding challenge - OpenAPI 3.0.3", + "contact": { + "email": "morales4dev@gmail.com" + } + }, + "tags": [ + { + "name": "plan", + "description": "Everything about production plan" + } + ], + "paths": { + "/productionplan": { + "post": { + "tags": [ + "plan" + ], + "summary": "Returns a production plan according to the load, fuels and powerplants data.", + "description": "Returns a production plan according with the demand.", + "operationId": "productionplanCaller", + "responses": { + "default": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductionPlan" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnergyDemand" + } + } + }, + "description": "Energy demand object" + } + } + } + }, + "components": { + "schemas": { + "EnergyDemand": { + "required": [ + "load", + "fuels", + "powerplants" + ], + "properties": { + "load": { + "type": "number", + "multipleOf": 1, + "description": "The amount of energy (MWh) that need to be generated during one hour", + "example": 330 + }, + "fuels": { + "required": [ + "gas(euro/MWh)", + "kerosine(euro/MWh)", + "co2(euro/ton)", + "wind(%)" + ], + "properties": { + "gas(euro/MWh)": { + "type": "number" + }, + "kerosine(euro/MWh)": { + "type": "number" + }, + "co2(euro/ton)": { + "type": "number" + }, + "wind(%)": { + "type": "number" + } + }, + "type": "object" + }, + "powerplants": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "type", + "efficiency", + "pmax", + "pmin" + ], + "properties": { + "name": { + "type": "string", + "description": "Powerplant name" + }, + "type": { + "type": "string", + "description": "gasfired | turbojet | windturbine", + "enum": [ + "gasfired", + "turbojet", + "windturbine" + ] + }, + "efficiency": { + "type": "number", + "description": "The efficiency at which they convert a MWh of fuel into a MWh of electrical energy" + }, + "pmax": { + "type": "number", + "description": "The maximum amount of power the powerplant can generate" + }, + "pmin": { + "type": "number", + "description": "The minimum amount of power the powerplant generates when switched on" + } + } + } + } + }, + "type": "object" + }, + "ProductionPlan": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name", + "p" + ], + "properties": { + "name": { + "type": "string", + "description": "Powerplant name" + }, + "p": { + "type": "number", + "multipleOf": 0.1, + "description": "How much power the Powerplant should deliver" + } + } + } + } + } + } + } diff --git a/api/static/openapi3_0.yaml b/api/static/openapi3_0.yaml new file mode 100644 index 000000000..87548d041 --- /dev/null +++ b/api/static/openapi3_0.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.3 +servers: + - url: "{{server_ip}}" +info: + description: |- + Powerplant coding challenge based on the OpenAPI 3.0 specification. + version: 2.0.0 + title: Swagger Powerplant coding challenge - OpenAPI 3.0.3 + contact: + email: morales4dev@gmail.com +tags: + - name: plan + description: Everything about production plan +paths: + /productionplan: + post: + tags: + - plan + summary: Returns a production plan according to the load, fuels and powerplants data. + description: Returns a production plan according with the demand. + operationId: productionplanCaller + responses: + default: + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionPlan' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EnergyDemand' + description: Energy demand object +components: + schemas: + EnergyDemand: + required: + - load + - fuels + - powerplants + properties: + load: + type: number + multipleOf: 1 + description: The amount of energy (MWh) that need to be generated during one hour + example: 330 + fuels: + required: + - gas(euro/MWh) + - kerosine(euro/MWh) + - co2(euro/ton) + - wind(%) + properties: + gas(euro/MWh): + type: number + kerosine(euro/MWh): + type: number + co2(euro/ton): + type: number + wind(%): + type: number + type: object + powerplants: + type: array + items: + type: object + required: + - name + - type + - efficiency + - pmax + - pmin + properties: + name: + type: string + description: Powerplant name + type: + type: string + description: gasfired | turbojet | windturbine + enum: + - 'gasfired' + - 'turbojet' + - 'windturbine' + efficiency: + type: number + description: The efficiency at which they convert a MWh of fuel into a MWh of electrical energy + pmax: + type: number + description: The maximum amount of power the powerplant can generate + pmin: + type: number + description: The minimum amount of power the powerplant generates when switched on + type: object + ProductionPlan: + type: array + items: + type: object + required: + - name + - p + properties: + name: + type: string + description: Powerplant name + p: + type: number + multipleOf: 0.1 + description: How much power the Powerplant should deliver