Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## .NET
bin/
obj/
*.user
*.suo
*.cache
*.dll
*.exe
*.pdb

## IDE
.vs/
.vscode/
*.swp

## OS
.DS_Store
Thumbs.db
34 changes: 34 additions & 0 deletions PowerplantCodingChallenge/.dockerignore
Original file line number Diff line number Diff line change
@@ -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/
40 changes: 40 additions & 0 deletions PowerplantCodingChallenge/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Mvc;
using PowerplantCodingChallenge.Application.DTOs;
using PowerplantCodingChallenge.Application.Interfaces;

namespace PowerplantCodingChallenge.API.Controllers;

/// <summary>
/// Controller for calculating the powerplant production plan
/// </summary>
[ApiController]
[Route("productionplan")]
[Produces("application/json")]
public class ProductionPlanController : ControllerBase
{
private readonly IProductionPlanService _productionPlanService;
private readonly ILogger<ProductionPlanController> _logger;

public ProductionPlanController(
IProductionPlanService productionPlanService,
ILogger<ProductionPlanController> logger)
{
_productionPlanService = productionPlanService;
_logger = logger;
}

/// <summary>
/// Calculates the optimal production plan to satisfy the requested load
/// </summary>
/// <param name="request">Market data and powerplant configuration (load, fuel prices, available powerplants)</param>
/// <returns>Production plan indicating the power (in MW) to be generated by each powerplant</returns>
/// <remarks>
/// 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
/// }
/// ]
///
/// </remarks>
/// <response code="200">Production plan calculated successfully</response>
/// <response code="400">Invalid data or load cannot be satisfied</response>
/// <response code="500">Internal server error</response>
[HttpPost]
[ProducesResponseType(typeof(List<ProductionPlanResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public ActionResult<List<ProductionPlanResponse>> 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 });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PowerplantCodingChallenge.Application\PowerplantCodingChallenge.Application.csproj" />
</ItemGroup>

<ItemGroup>
<Folder Include="Properties\" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@PowerplantCodingChallenge.API_HostAddress = http://localhost:5268

GET {{PowerplantCodingChallenge.API_HostAddress}}/weatherforecast/
Accept: application/json

###
83 changes: 83 additions & 0 deletions PowerplantCodingChallenge/PowerplantCodingChallenge.API/Program.cs
Original file line number Diff line number Diff line change
@@ -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<IProductionPlanService, ProductionPlanService>();

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<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>();
if (error != null)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"profiles": {
"PowerplantCodingChallenge.API": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53285;http://localhost:53286"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace PowerplantCodingChallenge.Application.DTOs;

/// <summary>
/// Market data: fuel prices and environmental conditions
/// </summary>
public class MarketDataDto
{
/// <summary>
/// Gas price in euro per MWh
/// </summary>
[JsonPropertyName("gas(euro/MWh)")]
[Range(0, double.MaxValue)]
public double GasPrice { get; set; }

/// <summary>
/// Kerosine price in euro per MWh
/// </summary>
[JsonPropertyName("kerosine(euro/MWh)")]
[Range(0, double.MaxValue)]
public double KerosinePrice { get; set; }

/// <summary>
/// CO2 emission allowance price in euro per ton
/// </summary>
[JsonPropertyName("co2(euro/ton)")]
[Range(0, double.MaxValue)]
public double Co2Price { get; set; }

/// <summary>
/// Wind percentage (0-100%). E.g. 60 means 60% wind available
/// </summary>
[JsonPropertyName("wind(%)")]
[Range(0, 100)]
public double WindPercentage { get; set; }
}
Loading