diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..31f58eab5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +## .NET +bin/ +obj/ +*.user +*.suo +*.cache +*.dll +*.exe +*.pdb + +## IDE +.vs/ +.vscode/ +*.swp + +## OS +.DS_Store +Thumbs.db diff --git a/PowerplantCodingChallenge/.dockerignore b/PowerplantCodingChallenge/.dockerignore new file mode 100755 index 000000000..b5f3778ef --- /dev/null +++ b/PowerplantCodingChallenge/.dockerignore @@ -0,0 +1,34 @@ +# Directories +**/bin/ +**/obj/ +**/out/ +**/publish/ +**/TestResults/ + +# Files +**/*.trx +**/*.user +**/*.suo +**/*.cache +**/.vs/ +**/.vscode/ +**/.idea/ +**/*.log + +# Git +.git +.gitignore +.gitattributes + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Documentation +README.md +challenge.md +**/*.md + +# Test data (optional - uncomment if you don't want to include example payloads) +# example_payloads/ diff --git a/PowerplantCodingChallenge/Dockerfile b/PowerplantCodingChallenge/Dockerfile new file mode 100755 index 000000000..8aa6679ea --- /dev/null +++ b/PowerplantCodingChallenge/Dockerfile @@ -0,0 +1,40 @@ +# Stage 1: Build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy solution and project files +COPY PowerplantCodingChallenge.slnx ./ +COPY PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.csproj ./PowerplantCodingChallenge.API/ +COPY PowerplantCodingChallenge.Application/PowerplantCodingChallenge.Application.csproj ./PowerplantCodingChallenge.Application/ +COPY PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj ./PowerplantCodingChallenge.Domain/ +COPY PowerplantCodingChallenge.Tests/PowerplantCodingChallenge.Tests.csproj ./PowerplantCodingChallenge.Tests/ + +# Restore dependencies +RUN dotnet restore PowerplantCodingChallenge.slnx + +# Copy all source files +COPY . . + +# Build the API project +WORKDIR /src/PowerplantCodingChallenge.API +RUN dotnet build -c Release -o /app/build + +# Publish the application +RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false + +# Stage 2: Runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +# Copy published files from build stage +COPY --from=build /app/publish . + +# Expose port 8888 +EXPOSE 8888 + +# Set environment variables +ENV ASPNETCORE_URLS=http://+:8888 +ENV ASPNETCORE_ENVIRONMENT=Production + +# Run the application +ENTRYPOINT ["dotnet", "PowerplantCodingChallenge.API.dll"] diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Controllers/ProductionPlanController.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Controllers/ProductionPlanController.cs new file mode 100755 index 000000000..9f8712921 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Controllers/ProductionPlanController.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Mvc; +using PowerplantCodingChallenge.Application.DTOs; +using PowerplantCodingChallenge.Application.Interfaces; + +namespace PowerplantCodingChallenge.API.Controllers; + +/// +/// Controller for calculating the powerplant production plan +/// +[ApiController] +[Route("productionplan")] +[Produces("application/json")] +public class ProductionPlanController : ControllerBase +{ + private readonly IProductionPlanService _productionPlanService; + private readonly ILogger _logger; + + public ProductionPlanController( + IProductionPlanService productionPlanService, + ILogger logger) + { + _productionPlanService = productionPlanService; + _logger = logger; + } + + /// + /// Calculates the optimal production plan to satisfy the requested load + /// + /// Market data and powerplant configuration (load, fuel prices, available powerplants) + /// Production plan indicating the power (in MW) to be generated by each powerplant + /// + /// Request example: + /// + /// POST /productionplan + /// { + /// "load": 910, + /// "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 + /// } + /// ] + /// } + /// + /// Expected response: + /// + /// [ + /// { + /// "name": "gasfiredbig1", + /// "p": 460.0 + /// } + /// ] + /// + /// + /// Production plan calculated successfully + /// Invalid data or load cannot be satisfied + /// Internal server error + [HttpPost] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)] + public ActionResult> Post([FromBody] ProductionPlanRequest request) + { + _logger.LogInformation("Received production plan request for load: {Load} MWh", request.Load); + + try + { + var result = _productionPlanService.CalculateProductionPlan(request); + + _logger.LogInformation("Production plan calculated successfully. Total plants: {Count}", result.Count); + + return Ok(result); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Cannot satisfy load requirement"); + return BadRequest(new { error = ex.Message }); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid argument in request"); + return BadRequest(new { error = ex.Message }); + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.csproj new file mode 100755 index 000000000..6d88c58d6 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.http b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.http new file mode 100755 index 000000000..c63f28790 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/PowerplantCodingChallenge.API.http @@ -0,0 +1,6 @@ +@PowerplantCodingChallenge.API_HostAddress = http://localhost:5268 + +GET {{PowerplantCodingChallenge.API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Program.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Program.cs new file mode 100755 index 000000000..7da24092b --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Program.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Reflection; +using Microsoft.OpenApi.Models; +using PowerplantCodingChallenge.Application.Interfaces; +using PowerplantCodingChallenge.Application.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + + +builder.Services.AddScoped(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Powerplant Coding Challenge API", + Description = "REST API to calculate the optimal powerplant production plan based on the requested load and the merit-order", + Contact = new OpenApiContact + { + Name = "SPaaS Team - GEM ENGIE", + Url = new Uri("https://github.com/gem-spaas/powerplant-coding-challenge") + } + }); + + var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + options.IncludeXmlComments(xmlPath); +}); + +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenAnyIP(8888); +}); + +var app = builder.Build(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var error = context.Features.Get(); + if (error != null) + { + var logger = context.RequestServices.GetRequiredService>(); + logger.LogError(error.Error, "Unhandled exception occurred"); + + await context.Response.WriteAsJsonAsync(new + { + error = "An error occurred while processing your request.", + details = app.Environment.IsDevelopment() ? error.Error.Message : null + }); + } + }); +}); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapControllers(); + +app.Run(); diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Properties/launchSettings.json b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Properties/launchSettings.json new file mode 100755 index 000000000..c36c765f3 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "PowerplantCodingChallenge.API": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53285;http://localhost:53286" + } + } +} \ No newline at end of file diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/appsettings.Development.json b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/appsettings.Development.json new file mode 100755 index 000000000..ff66ba6b2 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.API/appsettings.json b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/appsettings.json new file mode 100755 index 000000000..4d566948d --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.API/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/MarketDataDto.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/MarketDataDto.cs new file mode 100755 index 000000000..018c8fd1f --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/MarketDataDto.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Application.DTOs; + +/// +/// Market data: fuel prices and environmental conditions +/// +public class MarketDataDto +{ + /// + /// Gas price in euro per MWh + /// + [JsonPropertyName("gas(euro/MWh)")] + [Range(0, double.MaxValue)] + public double GasPrice { get; set; } + + /// + /// Kerosine price in euro per MWh + /// + [JsonPropertyName("kerosine(euro/MWh)")] + [Range(0, double.MaxValue)] + public double KerosinePrice { get; set; } + + /// + /// CO2 emission allowance price in euro per ton + /// + [JsonPropertyName("co2(euro/ton)")] + [Range(0, double.MaxValue)] + public double Co2Price { get; set; } + + /// + /// Wind percentage (0-100%). E.g. 60 means 60% wind available + /// + [JsonPropertyName("wind(%)")] + [Range(0, 100)] + public double WindPercentage { get; set; } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/PowerPlantDto.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/PowerPlantDto.cs new file mode 100755 index 000000000..5b9d3eea4 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/PowerPlantDto.cs @@ -0,0 +1,45 @@ +using PowerplantCodingChallenge.Domain.Enums; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Application.DTOs; + +/// +/// Represents a powerplant with its technical characteristics +/// +public class PowerPlantDto +{ + /// + /// Name of the powerplant + /// + [JsonPropertyName("name")] + [Required] + public string Name { get; set; } = string.Empty; + + /// + /// Powerplant type: gasfired, turbojet or windturbine + /// + [JsonPropertyName("type")] + public PowerPlantType Type { get; set; } + + /// + /// Conversion efficiency (between 0 and 1). E.g. 0.53 means 53% efficiency + /// + [JsonPropertyName("efficiency")] + [Range(0, 1)] + public double Efficiency { get; set; } + + /// + /// Minimum power (in MW) the powerplant can produce when switched on + /// + [JsonPropertyName("pmin")] + [Range(0, double.MaxValue)] + public double Pmin { get; set; } + + /// + /// Maximum power (in MW) the powerplant can produce + /// + [JsonPropertyName("pmax")] + [Range(0, double.MaxValue)] + public double Pmax { get; set; } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/ProductionPlanRequest.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/ProductionPlanRequest.cs new file mode 100755 index 000000000..252ddbe64 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/ProductionPlanRequest.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Application.DTOs; + +/// +/// Request to calculate the production plan +/// +public class ProductionPlanRequest +{ + /// + /// Load (in MWh) to be satisfied during one hour + /// + [JsonPropertyName("load")] + [Range(0, double.MaxValue)] + public double Load { get; set; } + + /// + /// Market data: fuel prices and environmental conditions + /// + [JsonPropertyName("fuels")] + [Required] + public MarketDataDto Fuels { get; set; } = new(); + + /// + /// List of available powerplants + /// + [JsonPropertyName("powerplants")] + [Required] + public List Powerplants { get; set; } = []; +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/ProductionPlanResponse.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/ProductionPlanResponse.cs new file mode 100755 index 000000000..73c706a2d --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/DTOs/ProductionPlanResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Application.DTOs; + +/// +/// Response indicating the power to be produced by a given powerplant +/// +public class ProductionPlanResponse +{ + /// + /// Name of the powerplant + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Power (in MW) to be produced by this powerplant. Value rounded to 0.1 MW. + /// + [JsonPropertyName("p")] + public double P { get; set; } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Interfaces/IProductionPlanService.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Interfaces/IProductionPlanService.cs new file mode 100755 index 000000000..b46f51dba --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Interfaces/IProductionPlanService.cs @@ -0,0 +1,8 @@ +using PowerplantCodingChallenge.Application.DTOs; + +namespace PowerplantCodingChallenge.Application.Interfaces; + +public interface IProductionPlanService +{ + List CalculateProductionPlan(ProductionPlanRequest request); +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Models/PowerPlantCost.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Models/PowerPlantCost.cs new file mode 100755 index 000000000..a79185313 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Models/PowerPlantCost.cs @@ -0,0 +1,25 @@ +using PowerplantCodingChallenge.Domain.Enums; + +namespace PowerplantCodingChallenge.Application.Models; + +/// +/// Represents a powerplant with its calculated production cost +/// +public class PowerPlantCost +{ + public required string Name { get; set; } + public PowerPlantType Type { get; set; } + public double Efficiency { get; set; } + public double Pmin { get; set; } + public double Pmax { get; set; } + + /// + /// Production cost per MWh (€/MWh) + /// + public double CostPerMwh { get; set; } + + /// + /// Effective maximum power (for wind turbines: Pmax * wind%) + /// + public double EffectivePmax { get; set; } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/PowerplantCodingChallenge.Application.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/PowerplantCodingChallenge.Application.csproj new file mode 100755 index 000000000..2978a9625 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/PowerplantCodingChallenge.Application.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Services/ProductionPlanService.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Services/ProductionPlanService.cs new file mode 100755 index 000000000..93880a74c --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Application/Services/ProductionPlanService.cs @@ -0,0 +1,153 @@ +using PowerplantCodingChallenge.Application.DTOs; +using PowerplantCodingChallenge.Application.Interfaces; +using PowerplantCodingChallenge.Application.Models; +using PowerplantCodingChallenge.Domain.Enums; + +namespace PowerplantCodingChallenge.Application.Services; + +public class ProductionPlanService : IProductionPlanService +{ + public List CalculateProductionPlan(ProductionPlanRequest request) + { + double load = request.Load; + MarketDataDto fuels = request.Fuels; + List powerPlants = request.Powerplants; + + List plantsWithCost = CalculatePlantCosts(powerPlants, fuels); + + List sortedPlants = plantsWithCost + .OrderBy(p => p.CostPerMwh) + .ThenByDescending(p => p.EffectivePmax) + .ToList(); + + Dictionary allocations = AllocatePower(sortedPlants, load); + + return request.Powerplants + .Select(p => new ProductionPlanResponse + { + Name = p.Name, + P = RoundToOneTenth(allocations.GetValueOrDefault(p.Name, 0)) + }) + .ToList(); + } + + private List CalculatePlantCosts(List plants, MarketDataDto fuels) + { + return plants.Select(p => + { + var (costPerMwh, effectivePmax) = p.Type switch + { + PowerPlantType.windturbine => ( + 0.0, + p.Pmax * (fuels.WindPercentage / 100.0) + ), + PowerPlantType.gasfired => ( + fuels.GasPrice / p.Efficiency, + p.Pmax + ), + PowerPlantType.turbojet => ( + fuels.KerosinePrice / p.Efficiency, + p.Pmax + ), + _ => throw new ArgumentException($"Unknown powerplant type: {p.Type}") + }; + + return new PowerPlantCost + { + Name = p.Name, + Type = p.Type, + Efficiency = p.Efficiency, + Pmin = p.Pmin, + Pmax = p.Pmax, + CostPerMwh = costPerMwh, + EffectivePmax = effectivePmax + }; + }).ToList(); + } + + private Dictionary AllocatePower(List sortedPlants, double load) + { + Dictionary allocations = sortedPlants.ToDictionary(p => p.Name, _ => 0.0); + double remainingLoad = load; + + foreach (var plant in sortedPlants) + { + if (remainingLoad <= 0) break; + + double effectivePmax = plant.EffectivePmax; + double effectivePmin = plant.Pmin; + + if (effectivePmax <= 0) continue; + + if (remainingLoad < effectivePmin) + { + bool adjusted = TryAdjustPreviousAllocations( + sortedPlants, allocations, plant, remainingLoad); + + if (adjusted) + { + allocations[plant.Name] = effectivePmin; + remainingLoad = 0; + } + continue; + } + + var allocation = Math.Min(effectivePmax, remainingLoad); + + if (allocation < effectivePmin) + { + allocation = 0; + } + + allocations[plant.Name] = allocation; + remainingLoad -= allocation; + } + + if (remainingLoad > 0.1) + { + throw new InvalidOperationException( + $"Cannot satisfy load. Remaining: {remainingLoad} MWh"); + } + + return allocations; + } + + private bool TryAdjustPreviousAllocations( + List sortedPlants, + Dictionary allocations, + PowerPlantCost currentPlant, + double remainingLoad) + { + double deficit = currentPlant.Pmin - remainingLoad; + + for (int i = sortedPlants.Count - 1; i >= 0; i--) + { + var prevPlant = sortedPlants[i]; + if (prevPlant.Name == currentPlant.Name) continue; + + var currentAllocation = allocations[prevPlant.Name]; + if (currentAllocation <= 0) continue; + + double minAllocation = prevPlant.Pmin; + double canReduce = currentAllocation - minAllocation; + + if (canReduce >= deficit) + { + allocations[prevPlant.Name] = currentAllocation - deficit; + return true; + } + else if (canReduce > 0) + { + allocations[prevPlant.Name] = 0; + deficit -= canReduce; + } + } + + return deficit <= 0.1; + } + + private static double RoundToOneTenth(double value) + { + return Math.Round(value, 1, MidpointRounding.AwayFromZero); + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Entities/PowerPlant.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Entities/PowerPlant.cs new file mode 100755 index 000000000..798e8a054 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Entities/PowerPlant.cs @@ -0,0 +1,16 @@ +using PowerplantCodingChallenge.Domain.Enums; +using System; +using System.Collections.Generic; +using System.Text; + +namespace PowerplantCodingChallenge.Domain.Entities +{ + public class PowerPlant + { + public string Name { get; set; } + public PowerPlantType Type { get; set; } + public double Efficiency { get; set; } + public int Pmin { get; set; } + public int Pmax { get; set; } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Enums/PowerPlantType.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Enums/PowerPlantType.cs new file mode 100755 index 000000000..56d9aad05 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/Enums/PowerPlantType.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace PowerplantCodingChallenge.Domain.Enums; + +/// +/// Available powerplant types +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PowerPlantType +{ + /// Gas-fired powerplant + gasfired, + + /// Kerosine-powered turbojet + turbojet, + + /// Wind turbine + windturbine +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj new file mode 100755 index 000000000..527147bf6 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Domain/PowerplantCodingChallenge.Domain.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Tests/PowerplantCodingChallenge.Tests.csproj b/PowerplantCodingChallenge/PowerplantCodingChallenge.Tests/PowerplantCodingChallenge.Tests.csproj new file mode 100755 index 000000000..8667047d1 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Tests/PowerplantCodingChallenge.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + \ No newline at end of file diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.Tests/UnitTest1.cs b/PowerplantCodingChallenge/PowerplantCodingChallenge.Tests/UnitTest1.cs new file mode 100755 index 000000000..9ef79641b --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.Tests/UnitTest1.cs @@ -0,0 +1,54 @@ +using PowerplantCodingChallenge.Application.DTOs; +using PowerplantCodingChallenge.Application.Services; +using Xunit; + +namespace PowerplantCodingChallenge.Tests; + +public class ProductionPlanServiceTests +{ + [Fact] + public void CalculateProductionPlan_WithPayload3_ReturnsExpectedTotal() + { + var request = new ProductionPlanRequest + { + Load = 910, + Fuels = new MarketDataDto + { + GasPrice = 13.4, + KerosinePrice = 50.8, + Co2Price = 20, + WindPercentage = 60 + }, + Powerplants = new List + { + new PowerPlantDto { Name = "gasfiredbig1", Type = PowerplantCodingChallenge.Domain.Enums.PowerPlantType.gasfired, Efficiency = 0.53, Pmin = 100, Pmax = 460 }, + new PowerPlantDto { Name = "gasfiredbig2", Type = PowerplantCodingChallenge.Domain.Enums.PowerPlantType.gasfired, Efficiency = 0.53, Pmin = 100, Pmax = 460 }, + new PowerPlantDto { Name = "gasfiredsomewhatsmaller", Type = PowerplantCodingChallenge.Domain.Enums.PowerPlantType.gasfired, Efficiency = 0.37, Pmin = 40, Pmax = 210 }, + new PowerPlantDto { Name = "tj1", Type = PowerplantCodingChallenge.Domain.Enums.PowerPlantType.turbojet, Efficiency = 0.3, Pmin = 0, Pmax = 16 }, + new PowerPlantDto { Name = "windpark1", Type = PowerplantCodingChallenge.Domain.Enums.PowerPlantType.windturbine, Efficiency = 1, Pmin = 0, Pmax = 150 }, + new PowerPlantDto { Name = "windpark2", Type = PowerplantCodingChallenge.Domain.Enums.PowerPlantType.windturbine, Efficiency = 1, Pmin = 0, Pmax = 36 } + } + }; + + var service = new ProductionPlanService(); + var result = service.CalculateProductionPlan(request); + + var expected = new Dictionary + { + ["windpark1"] = 90.0, + ["windpark2"] = 21.6, + ["gasfiredbig1"] = 460.0, + ["gasfiredbig2"] = 338.4, + ["gasfiredsomewhatsmaller"] = 0.0, + ["tj1"] = 0.0 + }; + + var byName = result.ToDictionary(r => r.Name, r => r.P); + + foreach (var kv in expected) + { + Assert.True(byName.ContainsKey(kv.Key), $"Missing plant {kv.Key} in result"); + Assert.Equal(kv.Value, byName[kv.Key], 1); + } + } +} diff --git a/PowerplantCodingChallenge/PowerplantCodingChallenge.slnx b/PowerplantCodingChallenge/PowerplantCodingChallenge.slnx new file mode 100755 index 000000000..e929a1d61 --- /dev/null +++ b/PowerplantCodingChallenge/PowerplantCodingChallenge.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/PowerplantCodingChallenge/README.md b/PowerplantCodingChallenge/README.md new file mode 100755 index 000000000..bb7b6529d --- /dev/null +++ b/PowerplantCodingChallenge/README.md @@ -0,0 +1,99 @@ +# Powerplant Coding Challenge + +REST API to calculate an optimal power production plan based on the merit-order. + +## Architecture + +The project uses a Clean Architecture with 3 layers: + +- **PowerplantCodingChallenge.API** — Controllers, middleware, Swagger config +- **PowerplantCodingChallenge.Application** — Services, DTOs, interfaces +- **PowerplantCodingChallenge.Domain** — Entities, enums + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## Running the Application + +### Command Line + +```bash +git clone git@github.com:gokhanguneyDav/powerplant-coding-challenge.git +cd PowerplantCodingChallenge +dotnet build +dotnet run --project PowerplantCodingChallenge.API +``` + +### Visual Studio + +1. Open `PowerplantCodingChallenge.sln` +2. Set `PowerplantCodingChallenge.API` as the startup project +3. Press F5 + +## API + +The API is documented with Swagger/OpenAPI. Once running, access it at http://localhost:8888/swagger. + +### Endpoint + +``` +POST http://localhost:8888/productionplan +Content-Type: application/json +``` + +### Request Example + +```json +{ + "load": 910, + "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": "windpark1", "type": "windturbine", "efficiency": 1, "pmin": 0, "pmax": 150 } + ] +} +``` + +### Response Example + +```json +[ + { "name": "windpark1", "p": 90.0 }, + { "name": "gasfiredbig1", "p": 460.0 } +] +``` + +## Algorithm + +The algorithm uses the merit-order to decide which powerplants to activate: + +1. Compute cost per MWh for each plant (wind = 0, gas = `gas_price / efficiency`, turbojet = `kerosine_price / efficiency`) +2. Sort by ascending cost (merit-order) +3. Allocate power greedily while respecting Pmin/Pmax constraints and matching the total load exactly (rounded to 0.1 MW) + +When the remaining load falls below a plant's Pmin, the algorithm tries to reduce output from previously activated plants to make room. + +## Docker + +Requires [Docker](https://www.docker.com/get-started) with Compose. + +```bash +docker-compose up -d +docker-compose logs -f +``` + +The API will be available at http://localhost:8888/productionplan. + +## Testing + +```bash +curl -X POST http://localhost:8888/productionplan \ + -H "Content-Type: application/json" \ + -d @example_payloads/payload3.json +``` diff --git a/PowerplantCodingChallenge/docker-compose.yml b/PowerplantCodingChallenge/docker-compose.yml new file mode 100755 index 000000000..638b78a64 --- /dev/null +++ b/PowerplantCodingChallenge/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + powerplant-api: + image: powerplant-coding-challenge:latest + container_name: powerplant-api + build: + context: . + dockerfile: Dockerfile + ports: + - "8888:8888" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8888 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/productionplan"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s