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