From e347dac0855111651da3f16d56911f8252f2b6dd Mon Sep 17 00:00:00 2001 From: Comrade Pingu Date: Sun, 26 Apr 2026 00:46:48 +0200 Subject: [PATCH 1/2] Architecture implemented Architecture implemented, docker and swagger enabled. --- .gitignore | 4 ++ PowerplantCodingChallenge/.dockerignore | 30 +++++++++++++ .../PowerplantCodingChallenge.Business.csproj | 18 ++++++++ .../ProductionPlanService.cs | 22 ++++++++++ .../PowerplantCodingChallenge.Domain.csproj | 9 ++++ .../Request/Enumeration/PlantType.cs | 12 ++++++ .../Request/FuelPrices.cs | 19 +++++++++ .../Request/PowerPlant.cs | 24 +++++++++++ .../Request/ProductionPlanRequest.cs | 16 +++++++ .../Response/ProductionPlanItem.cs | 13 ++++++ .../IProductionPlanService.cs | 10 +++++ ...PowerplantCodingChallenge.IBusiness.csproj | 13 ++++++ .../Controllers/ProductionPlanController.cs | 42 +++++++++++++++++++ .../Dockerfile | 32 ++++++++++++++ .../PowerplantCodingChallenge.WebAPI.csproj | 20 +++++++++ .../PowerplantCodingChallenge.WebAPI.http | 6 +++ .../Program.cs | 40 ++++++++++++++++++ .../Properties/launchSettings.json | 42 +++++++++++++++++++ .../appsettings.Development.json | 8 ++++ .../appsettings.json | 9 ++++ .../PowerplantCodingChallenge.slnx | 6 +++ PowerplantCodingChallenge/README.md | 37 ++++++++++++++++ 22 files changed, 432 insertions(+) create mode 100644 .gitignore create mode 100644 PowerplantCodingChallenge/.dockerignore create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/PowerplantCodingChallenge.Business.csproj create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/Enumeration/PlantType.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/FuelPrices.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/PowerPlant.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/ProductionPlanRequest.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Response/ProductionPlanItem.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/IProductionPlanService.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/PowerplantCodingChallenge.IBusiness.csproj create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Dockerfile create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.csproj create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.http create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Program.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Properties/launchSettings.json create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.Development.json create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.json create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.slnx create mode 100644 PowerplantCodingChallenge/README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d0dd0fd74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vs/ +bin/ +obj/ +*.user \ No newline at end of file diff --git a/PowerplantCodingChallenge/.dockerignore b/PowerplantCodingChallenge/.dockerignore new file mode 100644 index 000000000..fe1152bdb --- /dev/null +++ b/PowerplantCodingChallenge/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/PowerplantCodingChallenge.Business.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/PowerplantCodingChallenge.Business.csproj new file mode 100644 index 000000000..732e567a9 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/PowerplantCodingChallenge.Business.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs new file mode 100644 index 000000000..6f0c6159b --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; +using PowerplantCodingChallenge.Domain.Request; +using PowerplantCodingChallenge.Domain.Response; +using PowerplantCodingChallenge.IBusiness; + +namespace PowerplantCodingChallenge.Business +{ + public sealed class ProductionPlanService : IProductionPlanService + { + private readonly ILogger _logger; + + public ProductionPlanService(ILogger logger) + { + _logger = logger; + } + + public IEnumerable GetProductionPlan(ProductionPlanRequest request) + { + throw new NotImplementedException(); + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj new file mode 100644 index 000000000..b76014470 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/Enumeration/PlantType.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/Enumeration/PlantType.cs new file mode 100644 index 000000000..8d6074739 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/Enumeration/PlantType.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Domain.Request.Enumeration +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum PlantType + { + [JsonStringEnumMemberName("gasfired")] GasFired = 0, + [JsonStringEnumMemberName("turbojet")] TurboJet = 1, + [JsonStringEnumMemberName("windturbine")] WindTurbine = 2 + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/FuelPrices.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/FuelPrices.cs new file mode 100644 index 000000000..2eb5b32f4 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/FuelPrices.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Domain.Request +{ + public sealed class FuelPrices + { + [JsonPropertyName("gas(euro/MWh)")] + public decimal GasEuroPerMWh { get; init; } + + [JsonPropertyName("kerosine(euro/MWh)")] + public decimal KerosineEuroPerMWh { get; init; } + + [JsonPropertyName("co2(euro/ton)")] + public decimal Co2EuroPerTon { get; init; } + + [JsonPropertyName("wind(%)")] + public decimal WindPercentage { get; init; } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/PowerPlant.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/PowerPlant.cs new file mode 100644 index 000000000..629eb80af --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/PowerPlant.cs @@ -0,0 +1,24 @@ +using PowerplantCodingChallenge.Domain.Request.Enumeration; +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Domain.Request +{ + public sealed class PowerPlant + { + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlantType Type { get; init; } + + [JsonPropertyName("efficiency")] + public decimal Efficiency { get; init; } + + [JsonPropertyName("pmin")] + public decimal PowerMin { get; init; } + + [JsonPropertyName("pmax")] + public decimal PowerMax { get; init; } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/ProductionPlanRequest.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/ProductionPlanRequest.cs new file mode 100644 index 000000000..a3d675c17 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Request/ProductionPlanRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Domain.Request +{ + public sealed class ProductionPlanRequest + { + [JsonPropertyName("load")] + public decimal Load { get; init; } + + [JsonPropertyName("fuels")] + public FuelPrices Fuels { get; init; } = new(); + + [JsonPropertyName("powerplants")] + public List PowerPlants { get; init; } = []; + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Response/ProductionPlanItem.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Response/ProductionPlanItem.cs new file mode 100644 index 000000000..1d0d63560 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Response/ProductionPlanItem.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Domain.Response +{ + public sealed class ProductionPlanItem + { + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("p")] + public decimal Power { get; init; } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/IProductionPlanService.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/IProductionPlanService.cs new file mode 100644 index 000000000..326d84380 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/IProductionPlanService.cs @@ -0,0 +1,10 @@ +using PowerplantCodingChallenge.Domain.Request; +using PowerplantCodingChallenge.Domain.Response; + +namespace PowerplantCodingChallenge.IBusiness +{ + public interface IProductionPlanService + { + IEnumerable GetProductionPlan(ProductionPlanRequest request); + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/PowerplantCodingChallenge.IBusiness.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/PowerplantCodingChallenge.IBusiness.csproj new file mode 100644 index 000000000..cdf86a3b9 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.IBusiness/PowerplantCodingChallenge.IBusiness.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs new file mode 100644 index 000000000..4a9de885e --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using PowerplantCodingChallenge.Domain.Request; +using PowerplantCodingChallenge.IBusiness; + +namespace PowerplantCodingChallenge.WebAPI.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ProductionPlanController : ControllerBase + { + + private readonly ILogger _logger; + private readonly IProductionPlanService _pps; + + public ProductionPlanController(ILogger logger, IProductionPlanService pps) + { + _logger = logger; + _pps = pps; + } + + [HttpPost(Name = "GetProductionPlan")] + public IActionResult GetProductionPlan(ProductionPlanRequest request) + { + try + { + var response = _pps.GetProductionPlan(request); + return Ok(response); + } + catch (NotImplementedException ex) + { + _logger.LogError(ex, "Call attempted on an unimplemented method."); + return StatusCode(501, new { message = "This method is not yet supported. Please try again later." }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error while computing production plan."); + return StatusCode(500, new { message = "An unexpected error occurred." }); + } + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Dockerfile b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Dockerfile new file mode 100644 index 000000000..2194b9c8f --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Dockerfile @@ -0,0 +1,32 @@ +# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +# This stage is used when running from VS in fast mode (Default for Debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8888 + + +# This stage is used to build the service project +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.csproj", "PowerplantCodingChallenge.WebAPI/"] +COPY ["PowerplantCodingChallenge.Business/PowerplantCodingChallenge.Business.csproj", "PowerplantCodingChallenge.Business/"] +COPY ["PowerplantCodingChallenge.IBusiness/PowerplantCodingChallenge.IBusiness.csproj", "PowerplantCodingChallenge.IBusiness/"] +COPY ["PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj", "PowerplantCodingChallenge.Domain/"] +RUN dotnet restore "./PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.csproj" +COPY . . +WORKDIR "/src/PowerplantCodingChallenge.WebAPI" +RUN dotnet build "./PowerplantCodingChallenge.WebAPI.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# This stage is used to publish the service project to be copied to the final stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./PowerplantCodingChallenge.WebAPI.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PowerplantCodingChallenge.WebAPI.dll"] \ No newline at end of file diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.csproj new file mode 100644 index 000000000..d806a15e0 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + Linux + + + + + + + + + + + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.http b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.http new file mode 100644 index 000000000..cc681d47e --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/PowerplantCodingChallenge.WebAPI.http @@ -0,0 +1,6 @@ +@PowerplantCodingChallenge.WebAPI_HostAddress = http://localhost:5059 + +GET {{PowerplantCodingChallenge.WebAPI_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Program.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Program.cs new file mode 100644 index 000000000..8f9927840 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Program.cs @@ -0,0 +1,40 @@ +using PowerplantCodingChallenge.IBusiness; +using PowerplantCodingChallenge.Business; +using Microsoft.OpenApi; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseUrls("http://0.0.0.0:8888"); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "PowerplantCodingChallenge.WebAPI", + Version = "v1" + }); +}); + +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(options => + { + options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; + }); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Properties/launchSettings.json b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Properties/launchSettings.json new file mode 100644 index 000000000..e6c871e14 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Properties/launchSettings.json @@ -0,0 +1,42 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:8888" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchBrowser": true, + "launchUrl": "http://{ServiceHost}:{ServicePort}/swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://+:8888" + }, + "httpPort": 8888, + "useSSL": false + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:22455", + "sslPort": 0 + } + } +} \ No newline at end of file diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.Development.json b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.json b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.slnx b/PowerplantCodingChallenge/PowerplantCodingChallenge.slnx new file mode 100644 index 000000000..c19c2e256 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/PowerplantCodingChallenge/README.md b/PowerplantCodingChallenge/README.md new file mode 100644 index 000000000..2ea5568a7 --- /dev/null +++ b/PowerplantCodingChallenge/README.md @@ -0,0 +1,37 @@ +# Powerplant Coding Challenge + +## Requirements + +- .NET 10 SDK + + +## Run the application + +```bash +dotnet run + +``` + +API available at: + +``` +http://localhost:8888 + +``` + +Swagger UI: + +``` +http://localhost:8888/swagger + +``` + +## Endpoint + +POST `/api/ProductionPlan` + +## Notes + +- Application listens on port 8888 as required + +- No external solver used \ No newline at end of file From d45c2c937ac2b9a1484e01951d758bd7b06c9be5 Mon Sep 17 00:00:00 2001 From: Comrade Pingu Date: Sun, 26 Apr 2026 02:28:57 +0200 Subject: [PATCH 2/2] V1 implemented Basic implementation of the business logic finished. ON/OFF feature for power plants not yet available --- .../Extensions/CollectionExtensions.cs | 12 ++++ .../Extensions/NumericExtensions.cs | 10 ++++ .../Factories/AllocationCandidateFactory.cs | 55 +++++++++++++++++++ .../Models/AllocationCandidate.cs | 31 +++++++++++ .../Models/Exceptions/BusinessException.cs | 7 +++ .../ProductionPlanService.cs | 45 ++++++++++++++- .../Controllers/ProductionPlanController.cs | 7 ++- 7 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/CollectionExtensions.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/NumericExtensions.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Factories/AllocationCandidateFactory.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/AllocationCandidate.cs create mode 100644 PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/Exceptions/BusinessException.cs diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/CollectionExtensions.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/CollectionExtensions.cs new file mode 100644 index 000000000..e81307a8f --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/CollectionExtensions.cs @@ -0,0 +1,12 @@ +using PowerplantCodingChallenge.Business.Models; + +namespace PowerplantCodingChallenge.Business.Extensions +{ + internal static class CollectionExtensions + { + internal static IEnumerable SortByMeritOrder(this IEnumerable candidates) + => candidates + .OrderBy(c => c.MarginalCost) + .ThenBy(c => c.PowerMaxTenths); + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/NumericExtensions.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/NumericExtensions.cs new file mode 100644 index 000000000..e0dea6cbc --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Extensions/NumericExtensions.cs @@ -0,0 +1,10 @@ +namespace PowerplantCodingChallenge.Business.Extensions +{ + internal static class NumericExtensions + { + internal static int ToTenths(this decimal value) + { + return (int)Math.Round(value * 10m, MidpointRounding.AwayFromZero); + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Factories/AllocationCandidateFactory.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Factories/AllocationCandidateFactory.cs new file mode 100644 index 000000000..ce359d211 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Factories/AllocationCandidateFactory.cs @@ -0,0 +1,55 @@ +using PowerplantCodingChallenge.Business.Extensions; +using PowerplantCodingChallenge.Business.Models; +using PowerplantCodingChallenge.Domain.Request; +using PowerplantCodingChallenge.Domain.Request.Enumeration; + +namespace PowerplantCodingChallenge.Business.Factories +{ + internal static class AllocationCandidateFactory + { + public static List CreateCandidates(ProductionPlanRequest request) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + if (request.Fuels == null) + { + throw new ArgumentNullException(nameof(request.Fuels)); + } + if (request.PowerPlants == null) + { + throw new ArgumentNullException(nameof(request.PowerPlants)); + } + + return request.PowerPlants.Select(pp => CreateCandidate(pp, request.Fuels)).ToList(); + } + + private static AllocationCandidate CreateCandidate(PowerPlant powerPlant, FuelPrices fuels) + => new AllocationCandidate + { + Name = powerPlant.Name, + Type = powerPlant.Type, + MarginalCost = GetMarginalCost(powerPlant, fuels), + PowerMinTenths = powerPlant.PowerMin.ToTenths(), + PowerMaxTenths = GetAvailablePowerMax(powerPlant, fuels).ToTenths(), + AllocatedPowerTenths = 0 + }; + + private static decimal GetAvailablePowerMax(PowerPlant powerPlant, FuelPrices fuels) + => powerPlant.Type switch + { + PlantType.WindTurbine => powerPlant.PowerMax * fuels.WindPercentage / 100m, + _ => powerPlant.PowerMax + }; + + private static decimal GetMarginalCost(PowerPlant powerPlant, FuelPrices fuels) + => powerPlant.Type switch + { + PlantType.WindTurbine => 0m, + PlantType.GasFired => fuels.GasEuroPerMWh / powerPlant.Efficiency, + PlantType.TurboJet => fuels.KerosineEuroPerMWh / powerPlant.Efficiency, + _ => throw new InvalidOperationException($"Unknown plant type : {powerPlant.Type}") + }; + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/AllocationCandidate.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/AllocationCandidate.cs new file mode 100644 index 000000000..9ec87917f --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/AllocationCandidate.cs @@ -0,0 +1,31 @@ +using PowerplantCodingChallenge.Domain.Request.Enumeration; + +namespace PowerplantCodingChallenge.Business.Models +{ + internal sealed class AllocationCandidate + { + public string Name { get; init; } = string.Empty; + + public PlantType Type { get; init; } + + /// + /// Cost per MWh, used for sorting by merit-order + /// + public decimal MarginalCost { get; init; } + + /// + /// Minimum production if plant is active (in tenths of MW) + /// + public int PowerMinTenths { get; init; } + + /// + /// Maximum available production (in tenths of MW) + /// + public int PowerMaxTenths { get; init; } + + /// + /// Computed allocated production (in tenths of MW) + /// + public int AllocatedPowerTenths { get; set; } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/Exceptions/BusinessException.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/Exceptions/BusinessException.cs new file mode 100644 index 000000000..164c4e63b --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/Models/Exceptions/BusinessException.cs @@ -0,0 +1,7 @@ +namespace PowerplantCodingChallenge.Business.Models.Exceptions +{ + public sealed class BusinessException : Exception + { + public BusinessException(string message) : base(message) { } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs index 6f0c6159b..f693b4b14 100644 --- a/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Business/ProductionPlanService.cs @@ -1,4 +1,7 @@ using Microsoft.Extensions.Logging; +using PowerplantCodingChallenge.Business.Extensions; +using PowerplantCodingChallenge.Business.Factories; +using PowerplantCodingChallenge.Business.Models.Exceptions; using PowerplantCodingChallenge.Domain.Request; using PowerplantCodingChallenge.Domain.Response; using PowerplantCodingChallenge.IBusiness; @@ -16,7 +19,47 @@ public ProductionPlanService(ILogger logger) public IEnumerable GetProductionPlan(ProductionPlanRequest request) { - throw new NotImplementedException(); + var candidates = AllocationCandidateFactory.CreateCandidates(request); + var sorted = candidates.SortByMeritOrder(); + var loadTenths = request.Load.ToTenths(); + var totalMin = 0; + + foreach (var candidate in sorted) + { + candidate.AllocatedPowerTenths = candidate.PowerMinTenths; + totalMin += candidate.PowerMinTenths; + } + + if (totalMin > loadTenths) + { + throw new BusinessException("Load cannot be satisfied due to minimum production constraints."); + } + + int remaining = loadTenths - totalMin; + + foreach (var candidate in sorted) + { + if (remaining <= 0) + break; + + int available = candidate.PowerMaxTenths - candidate.AllocatedPowerTenths; + int toAdd = Math.Min(available, remaining); + + candidate.AllocatedPowerTenths += toAdd; + remaining -= toAdd; + } + + if (remaining > 0) + { + throw new BusinessException("Unable to meet required load."); + } + + return candidates + .Select(c => new ProductionPlanItem + { + Name = c.Name, + Power = (decimal)c.AllocatedPowerTenths / 10 + }); } } } diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs index 4a9de885e..a2cca6a28 100644 --- a/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.WebAPI/Controllers/ProductionPlanController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using PowerplantCodingChallenge.Business.Models.Exceptions; using PowerplantCodingChallenge.Domain.Request; using PowerplantCodingChallenge.IBusiness; @@ -27,10 +28,10 @@ public IActionResult GetProductionPlan(ProductionPlanRequest request) var response = _pps.GetProductionPlan(request); return Ok(response); } - catch (NotImplementedException ex) + catch (BusinessException ex) { - _logger.LogError(ex, "Call attempted on an unimplemented method."); - return StatusCode(501, new { message = "This method is not yet supported. Please try again later." }); + _logger.LogError(ex, "Business error while computing production plan."); + return BadRequest(new { message = ex.Message }); } catch (Exception ex) {