diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..8a497e7fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +**/.git +**/.vs +**/.vscode +**/bin +**/obj +**/TestResults +**/*.user +**/*.suo +README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..317094b0f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.cs] +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..856385183 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +[Bb]in/ +[Oo]bj/ +.vs/ +.vscode/ +.idea/ +*.user +*.suo +*.swp +*.cache +TestResults/ +coverage/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 000000000..d31956ce6 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + false + enable + enable + latest + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..54ac23ced --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +COPY PowerplantProductionPlan.sln ./ +COPY Directory.Build.props ./ +COPY global.json ./ +COPY src/Powerplant.Api/Powerplant.Api.csproj src/Powerplant.Api/ +COPY tests/Powerplant.Api.Tests/Powerplant.Api.Tests.csproj tests/Powerplant.Api.Tests/ + +RUN dotnet restore PowerplantProductionPlan.sln + +COPY . . +RUN dotnet publish src/Powerplant.Api/Powerplant.Api.csproj \ + --configuration Release \ + --output /app/publish \ + /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:8888 \ + DOTNET_RUNNING_IN_CONTAINER=true + +EXPOSE 8888 + +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "Powerplant.Api.dll"] diff --git a/PowerplantProductionPlan.sln b/PowerplantProductionPlan.sln new file mode 100644 index 000000000..27b1f8f0a --- /dev/null +++ b/PowerplantProductionPlan.sln @@ -0,0 +1,30 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11819.183 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Powerplant.Api", "src\Powerplant.Api\Powerplant.Api.csproj", "{916AB86E-B84F-4A61-A719-82C889D357CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Powerplant.Api.Tests", "tests\Powerplant.Api.Tests\Powerplant.Api.Tests.csproj", "{C58E130D-DB33-4932-ABCA-59BD77995162}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {916AB86E-B84F-4A61-A719-82C889D357CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {916AB86E-B84F-4A61-A719-82C889D357CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {916AB86E-B84F-4A61-A719-82C889D357CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {916AB86E-B84F-4A61-A719-82C889D357CF}.Release|Any CPU.Build.0 = Release|Any CPU + {C58E130D-DB33-4932-ABCA-59BD77995162}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C58E130D-DB33-4932-ABCA-59BD77995162}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C58E130D-DB33-4932-ABCA-59BD77995162}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C58E130D-DB33-4932-ABCA-59BD77995162}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {61019972-2ADA-4956-AFF8-501E3C16F430} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 44c93d608..3069608dc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,62 @@ -# powerplant-coding-challenge +# Powerplant Coding Challenge + +REST API for calculating a powerplant production plan. + +## Start the API + +The API listens on port `8888` when started with Docker. + +Swagger UI is available at: + +```text +http://localhost:8888/swagger +``` + +The production-plan endpoint is: + +```text +POST http://localhost:8888/productionplan +``` + +## Using Docker + +Run these commands from the repository root, where the `Dockerfile` is located: + +```bash +docker build -t powerplant-coding-challenge . +docker run --rm -p 8888:8888 --name powerplant-coding-challenge powerplant-coding-challenge +``` + +The container may log something like: + +```text +Now listening on: http://[::]:8888 +``` + +That is normal. It means the API is listening inside the container on port `8888`. +From your host machine, open: + +```text +http://localhost:8888/swagger +``` + +## Without Docker + +1. Open the solution in Visual Studio or JetBrains Rider. +2. Build the solution. +3. Select the API project as the startup project: + +```text +powerplant-coding-challenge/src/Powerplant.Api/Powerplant.Api.csproj +``` + +4. Run the project. + +## Requirements + +- .NET 9 SDK, when running without Docker +- Docker Desktop, when running with Docker + ## Welcome ! diff --git a/global.json b/global.json new file mode 100644 index 000000000..cdbb589ed --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/Powerplant.Api/Application/Abstractions/IProductionPlanService.cs b/src/Powerplant.Api/Application/Abstractions/IProductionPlanService.cs new file mode 100644 index 000000000..c17b0d78c --- /dev/null +++ b/src/Powerplant.Api/Application/Abstractions/IProductionPlanService.cs @@ -0,0 +1,13 @@ +using Powerplant.Api.Domain; +using Powerplant.Api.Generated; + +namespace Powerplant.Api.Application.Abstractions +{ + public interface IProductionPlanService + { + IReadOnlyCollection Calculate( + decimal Load, + FuelPrices fuelPrices, + List powerPlants); + } +} diff --git a/src/Powerplant.Api/Application/Services/ProductionPlanService.cs b/src/Powerplant.Api/Application/Services/ProductionPlanService.cs new file mode 100644 index 000000000..ad51267ab --- /dev/null +++ b/src/Powerplant.Api/Application/Services/ProductionPlanService.cs @@ -0,0 +1,38 @@ +using Powerplant.Api.Application.Abstractions; +using Powerplant.Api.Domain; +using Powerplant.Api.Generated; + +namespace Powerplant.Api.Application.Services +{ + public class ProductionPlanService : IProductionPlanService + { + public IReadOnlyCollection Calculate( + decimal Load, + FuelPrices fuelPrices, + List powerPlants) + { + + var result = new List(); + var remainingLoad = Load; + + var sortedPowerPlants = powerPlants + .OrderBy(p => p.CostPerMWh(fuelPrices)) + .ToList(); + + foreach (PowerPlant powerPlant in sortedPowerPlants) + { + var possibleLoad = powerPlant.GetPossibleLoad(remainingLoad, fuelPrices); + result.Add(new ProductionPlanEntry + { + Name = powerPlant.Name, + Power = possibleLoad + }); + remainingLoad -= possibleLoad; + + } + return result; + + } + + } +} diff --git a/src/Powerplant.Api/Contracts/openapi.yaml b/src/Powerplant.Api/Contracts/openapi.yaml new file mode 100644 index 000000000..f3138d622 --- /dev/null +++ b/src/Powerplant.Api/Contracts/openapi.yaml @@ -0,0 +1,158 @@ +openapi: 3.0.3 +info: + title: Powerplant Production Plan API + version: 1.0.0 + description: API for the powerplant production-plan. +servers: + - url: / +paths: + /productionplan: + post: + operationId: calculateProductionPlan + tags: + - ProductionPlan + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionPlanRequest' + responses: + '200': + description: Production plan + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ProductionPlanResponseItem' + '400': + description: Invalid request payload. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '422': + description: The request is syntactically valid but no feasible plan can satisfy the load. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '500': + description: Unexpected server error. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '501': + description: Production-plan algorithm is not implemented yet in this scaffold. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' +components: + schemas: + ProductionPlanRequest: + type: object + required: + - load + - fuels + - powerplants + properties: + load: + type: number + format: decimal + example: 480 + fuels: + $ref: '#/components/schemas/Fuels' + powerplants: + type: array + items: + $ref: '#/components/schemas/PowerPlantDto' + + Fuels: + type: object + required: + - gas(euro/MWh) + - kerosine(euro/MWh) + - co2(euro/ton) + - wind(%) + additionalProperties: + type: number + format: decimal + example: + gas(euro/MWh): 13.4 + kerosine(euro/MWh): 50.8 + co2(euro/ton): 20 + wind(%): 60 + + PowerPlantDto: + type: object + required: + - name + - type + - efficiency + - pmin + - pmax + properties: + name: + type: string + example: gasfiredbig1 + type: + type: string + enum: + - gasfired + - turbojet + - windturbine + example: gasfired + efficiency: + type: number + format: decimal + example: 0.53 + pmin: + type: number + format: decimal + example: 100 + pmax: + type: number + format: decimal + example: 460 + + ProductionPlanResponseItem: + type: object + required: + - name + - p + properties: + name: + type: string + example: windpark1 + p: + type: number + format: decimal + example: 90.0 + ProblemDetails: + type: object + properties: + type: + type: string + nullable: true + title: + type: string + nullable: true + example: Bad Request + status: + type: integer + format: int32 + nullable: true + example: 400 + detail: + type: string + nullable: true + example: The request payload is invalid. + instance: + type: string + nullable: true + example: /productionplan + additionalProperties: true + diff --git a/src/Powerplant.Api/Controllers/ProductionPlanController.cs b/src/Powerplant.Api/Controllers/ProductionPlanController.cs new file mode 100644 index 000000000..7385bf1f5 --- /dev/null +++ b/src/Powerplant.Api/Controllers/ProductionPlanController.cs @@ -0,0 +1,45 @@ +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Powerplant.Api.Application.Abstractions; +using Powerplant.Api.Domain; +using Powerplant.Api.Generated; +using ProblemDetails = Powerplant.Api.Generated.ProblemDetails; + +namespace Powerplant.Api.Controllers; + +public sealed class ProductionPlanController : ProductionPlanControllerBaseControllerBase +{ + private readonly IProductionPlanService _productionPlanService; + private readonly IMapper _mapper; + + public ProductionPlanController(IProductionPlanService productionPlanService, IMapper mapper) + { + _productionPlanService = productionPlanService; + _mapper = mapper; + } + + public override Task>> CalculateProductionPlan([BindRequired, FromBody] ProductionPlanRequest body, CancellationToken cancellationToken = default) + { + if (body is null) + { + return Result(BadRequest(new ProblemDetails + { + Title = "Invalid request payload", + Detail = "The request body is required.", + Status = StatusCodes.Status400BadRequest + })); + } + + var plan = _productionPlanService.Calculate(body.Load, _mapper.Map(body.Fuels), _mapper.Map>(body.Powerplants)); + + var response = _mapper.Map>(plan); + return Result(Ok(response)); + } + + private Task>> Result( + ActionResult> result) + { + return Task.FromResult(result); + } +} diff --git a/src/Powerplant.Api/Domain/FuelPrices.cs b/src/Powerplant.Api/Domain/FuelPrices.cs new file mode 100644 index 000000000..48d73dec9 --- /dev/null +++ b/src/Powerplant.Api/Domain/FuelPrices.cs @@ -0,0 +1,13 @@ +namespace Powerplant.Api.Domain +{ + public class FuelPrices + { + public required decimal GasEuroPerMWh { get; init; } + + public required decimal KerosineEuroPerMWh { get; init; } + + public required decimal Co2EuroPerTon { get; init; } + + public required decimal WindPercentage { get; init; } + } +} diff --git a/src/Powerplant.Api/Domain/PowerPlant.cs b/src/Powerplant.Api/Domain/PowerPlant.cs new file mode 100644 index 000000000..fb4d09573 --- /dev/null +++ b/src/Powerplant.Api/Domain/PowerPlant.cs @@ -0,0 +1,43 @@ +namespace Powerplant.Api.Domain +{ + public class PowerPlant + { + public required string Name { get; init; } + + public required PowerPlantType Type { get; init; } + + public required decimal Efficiency { get; init; } + + public required decimal PMin { get; init; } + + public required decimal PMax { get; init; } + + public decimal CostPerMWh(FuelPrices fuelPrices) + { + return Type switch + { + PowerPlantType.GasFired => (fuelPrices.GasEuroPerMWh / Efficiency) + (fuelPrices.Co2EuroPerTon * 0.3m), + PowerPlantType.TurboJet => (fuelPrices.KerosineEuroPerMWh / Efficiency) + (fuelPrices.Co2EuroPerTon * 0.3m), + PowerPlantType.WindTurbine => 0, + _ => throw new InvalidOperationException($"Unknown power plant type: {Type}") + }; + } + + public decimal GetPossibleLoad(decimal load, FuelPrices fuelPrices) + { + if (load < PMin) + { + // If the load is less than the minimum power output, we cannot produce any power + return 0; + } + + if (Type == PowerPlantType.WindTurbine) + { + // Wind turbines can produce up to their maximum capacity regardless of fuel prices + return Math.Min(PMax*fuelPrices.WindPercentage/100, load); + } + // For other power plants, we can produce up to PMax or the remaining load, whichever is smaller + return Math.Min(PMax, load); + } + } +} diff --git a/src/Powerplant.Api/Domain/PowerPlantType.cs b/src/Powerplant.Api/Domain/PowerPlantType.cs new file mode 100644 index 000000000..7305ca9ce --- /dev/null +++ b/src/Powerplant.Api/Domain/PowerPlantType.cs @@ -0,0 +1,10 @@ +namespace Powerplant.Api.Domain +{ + + public enum PowerPlantType + { + GasFired, + TurboJet, + WindTurbine + } +} diff --git a/src/Powerplant.Api/Domain/ProductionPlanEntry.cs b/src/Powerplant.Api/Domain/ProductionPlanEntry.cs new file mode 100644 index 000000000..235b79936 --- /dev/null +++ b/src/Powerplant.Api/Domain/ProductionPlanEntry.cs @@ -0,0 +1,9 @@ +namespace Powerplant.Api.Domain +{ + public class ProductionPlanEntry + { + public required string Name { get; init; } + + public required decimal Power { get; init; } + } +} diff --git a/src/Powerplant.Api/Generated/ProductionPlanControllerBase.g.cs b/src/Powerplant.Api/Generated/ProductionPlanControllerBase.g.cs new file mode 100644 index 000000000..76079224f --- /dev/null +++ b/src/Powerplant.Api/Generated/ProductionPlanControllerBase.g.cs @@ -0,0 +1,187 @@ +//---------------------- +// +// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org) +// +//---------------------- + +#nullable enable + +#pragma warning disable 108 // Disable "CS0108 '{derivedDto}.ToJson()' hides inherited member '{dtoBase}.ToJson()'. Use the new keyword if hiding was intended." +#pragma warning disable 114 // Disable "CS0114 '{derivedDto}.RaisePropertyChanged(String)' hides inherited member 'dtoBase.RaisePropertyChanged(String)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword." +#pragma warning disable 472 // Disable "CS0472 The result of the expression is always 'false' since a value of type 'Int32' is never equal to 'null' of type 'Int32?' +#pragma warning disable 612 // Disable "CS0612 '...' is obsolete" +#pragma warning disable 649 // Disable "CS0649 Field is never assigned to, and will always have its default value null" +#pragma warning disable 1573 // Disable "CS1573 Parameter '...' has no matching param tag in the XML comment for ... +#pragma warning disable 1591 // Disable "CS1591 Missing XML comment for publicly visible type or member ..." +#pragma warning disable 8073 // Disable "CS8073 The result of the expression is always 'false' since a value of type 'T' is never equal to 'null' of type 'T?'" +#pragma warning disable 3016 // Disable "CS3016 Arrays as attribute arguments is not CLS-compliant" +#pragma warning disable 8600 // Disable "CS8600 Converting null literal or possible null value to non-nullable type" +#pragma warning disable 8602 // Disable "CS8602 Dereference of a possibly null reference" +#pragma warning disable 8603 // Disable "CS8603 Possible null reference return" +#pragma warning disable 8604 // Disable "CS8604 Possible null reference argument for parameter" +#pragma warning disable 8625 // Disable "CS8625 Cannot convert null literal to non-nullable reference type" +#pragma warning disable 8765 // Disable "CS8765 Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes)." + +namespace Powerplant.Api.Generated +{ + using System = global::System; + + [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public abstract class ProductionPlanControllerBaseControllerBase : Microsoft.AspNetCore.Mvc.ControllerBase + { + /// Production plan + [Microsoft.AspNetCore.Mvc.HttpPost, Microsoft.AspNetCore.Mvc.Route("productionplan")] + public abstract System.Threading.Tasks.Task>> CalculateProductionPlan([Microsoft.AspNetCore.Mvc.FromBody] [Microsoft.AspNetCore.Mvc.ModelBinding.BindRequired] ProductionPlanRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ProductionPlanRequest + { + + [System.Text.Json.Serialization.JsonPropertyName("load")] + public decimal Load { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("fuels")] + [System.ComponentModel.DataAnnotations.Required] + public Fuels Fuels { get; set; } = new Fuels(); + + [System.Text.Json.Serialization.JsonPropertyName("powerplants")] + [System.ComponentModel.DataAnnotations.Required] + public System.Collections.Generic.List Powerplants { get; set; } = new System.Collections.Generic.List(); + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class Fuels : System.Collections.Generic.Dictionary + { + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class PowerPlantDto + { + + [System.Text.Json.Serialization.JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("type")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] + public PowerPlantDtoType Type { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("efficiency")] + public decimal Efficiency { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("pmin")] + public decimal Pmin { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("pmax")] + public decimal Pmax { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ProductionPlanResponseItem + { + + [System.Text.Json.Serialization.JsonPropertyName("name")] + [System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = true)] + public string Name { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("p")] + public decimal P { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public partial class ProblemDetails + { + + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string? Type { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("title")] + public string? Title { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("status")] + public int? Status { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("detail")] + public string? Detail { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("instance")] + public string? Instance { get; set; } = default!; + + private System.Collections.Generic.IDictionary? _additionalProperties; + + [System.Text.Json.Serialization.JsonExtensionData] + public System.Collections.Generic.IDictionary AdditionalProperties + { + get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } + set { _additionalProperties = value; } + } + + } + + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] + public enum PowerPlantDtoType + { + + [System.Runtime.Serialization.EnumMember(Value = @"gasfired")] + Gasfired = 0, + + [System.Runtime.Serialization.EnumMember(Value = @"turbojet")] + Turbojet = 1, + + [System.Runtime.Serialization.EnumMember(Value = @"windturbine")] + Windturbine = 2, + + } + + +} + +#pragma warning restore 108 +#pragma warning restore 114 +#pragma warning restore 472 +#pragma warning restore 612 +#pragma warning restore 649 +#pragma warning restore 1573 +#pragma warning restore 1591 +#pragma warning restore 8073 +#pragma warning restore 3016 +#pragma warning restore 8600 +#pragma warning restore 8602 +#pragma warning restore 8603 +#pragma warning restore 8604 +#pragma warning restore 8625 +#pragma warning restore 8765 \ No newline at end of file diff --git a/src/Powerplant.Api/Generated/README.md b/src/Powerplant.Api/Generated/README.md new file mode 100644 index 000000000..29519b228 --- /dev/null +++ b/src/Powerplant.Api/Generated/README.md @@ -0,0 +1,5 @@ +# Generated source + +NSwag writes controller and DTO code into this folder during `dotnet build`. + +Do not edit generated `.g.cs` files directly. Edit `../Contracts/openapi.yaml` and rebuild. diff --git a/src/Powerplant.Api/Mapping/FuelsToFuelPricesMapper.cs b/src/Powerplant.Api/Mapping/FuelsToFuelPricesMapper.cs new file mode 100644 index 000000000..3c67bb162 --- /dev/null +++ b/src/Powerplant.Api/Mapping/FuelsToFuelPricesMapper.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using AutoMapper; +using Powerplant.Api.Domain; +using Powerplant.Api.Generated; + +namespace Powerplant.Api.Mapping; + +public sealed class FuelsToFuelPricesMapper : ITypeConverter +{ + public FuelPrices Convert(Fuels source, FuelPrices destination, ResolutionContext context) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + return new FuelPrices + { + GasEuroPerMWh = GetRequiredDecimal(source, "gas(euro/MWh)"), + KerosineEuroPerMWh = GetRequiredDecimal(source, "kerosine(euro/MWh)"), + Co2EuroPerTon = GetRequiredDecimal(source, "co2(euro/ton)"), + WindPercentage = GetRequiredDecimal(source, "wind(%)") + }; + } + + private static decimal GetRequiredDecimal( + Dictionary properties, + string key) + { + if (!properties.TryGetValue(key, out var value)) + { + throw new ArgumentException($"Missing required fuel value '{key}'."); + } + + return value; + } + +} diff --git a/src/Powerplant.Api/Mapping/ProductionPlanMapping.cs b/src/Powerplant.Api/Mapping/ProductionPlanMapping.cs new file mode 100644 index 000000000..d051f6bc4 --- /dev/null +++ b/src/Powerplant.Api/Mapping/ProductionPlanMapping.cs @@ -0,0 +1,32 @@ +using AutoMapper; +using Powerplant.Api.Domain; +using Powerplant.Api.Generated; + +namespace Powerplant.Api.Mapping; + +public sealed class ProductionPlanMappingProfile : Profile +{ + public ProductionPlanMappingProfile() + { + CreateMap() + .ConvertUsing(); + CreateMap() + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => MapPowerPlantType(src.Type))) + .ForMember(dest => dest.PMin, opt => opt.MapFrom(src => Convert.ToDecimal(src.Pmin))) + .ForMember(dest => dest.PMax, opt => opt.MapFrom(src => Convert.ToDecimal(src.Pmax))) + .ForMember(dest => dest.Efficiency, opt => opt.MapFrom(src => Convert.ToDecimal(src.Efficiency))); + CreateMap() + .ForMember(dest => dest.P, opt => opt.MapFrom(src => Convert.ToDouble(src.Power))); + } + + private static PowerPlantType MapPowerPlantType(PowerPlantDtoType type) + { + return type switch + { + PowerPlantDtoType.Gasfired => PowerPlantType.GasFired, + PowerPlantDtoType.Turbojet => PowerPlantType.TurboJet, + PowerPlantDtoType.Windturbine => PowerPlantType.WindTurbine, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "Unsupported powerplant type.") + }; + } +} diff --git a/src/Powerplant.Api/Middleware/ExceptionHandlingMiddleware.cs b/src/Powerplant.Api/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 000000000..edaa9c1f3 --- /dev/null +++ b/src/Powerplant.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Powerplant.Api.Middleware; + +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + + public ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger, + IHostEnvironment environment) + { + _next = next; + _logger = logger; + _environment = environment; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + _logger.LogWarning("Request was cancelled by the client. TraceId: {TraceId}", context.TraceIdentifier); + } + catch (BadHttpRequestException exception) + { + _logger.LogWarning(exception, "Bad request. TraceId: {TraceId}", context.TraceIdentifier); + await WriteProblemDetailsAsync(context, StatusCodes.Status400BadRequest, "Bad request", exception); + } + catch (NotImplementedException exception) + { + _logger.LogInformation(exception, "Feature not implemented. TraceId: {TraceId}", context.TraceIdentifier); + await WriteProblemDetailsAsync(context, StatusCodes.Status501NotImplemented, "Not implemented", exception); + } + catch (Exception exception) + { + _logger.LogError(exception, "Unhandled runtime error. TraceId: {TraceId}", context.TraceIdentifier); + await WriteProblemDetailsAsync(context, StatusCodes.Status500InternalServerError, "Unexpected server error", exception); + } + } + + private async Task WriteProblemDetailsAsync( + HttpContext context, + int statusCode, + string title, + Exception exception) + { + if (context.Response.HasStarted) + { + _logger.LogWarning("The response has already started; problem details cannot be written."); + return; + } + + context.Response.Clear(); + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/problem+json"; + + var problem = new ProblemDetails + { + Status = statusCode, + Title = title, + Detail = _environment.IsDevelopment() ? exception.Message : null, + Instance = context.Request.Path + }; + + await context.Response.WriteAsJsonAsync(problem); + } +} diff --git a/src/Powerplant.Api/Powerplant.Api.csproj b/src/Powerplant.Api/Powerplant.Api.csproj new file mode 100644 index 000000000..e28bd4482 --- /dev/null +++ b/src/Powerplant.Api/Powerplant.Api.csproj @@ -0,0 +1,43 @@ + + + + net9.0 + Powerplant.Api + Powerplant.Api + true + $(NoWarn);1591 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Powerplant.Api/Program.cs b/src/Powerplant.Api/Program.cs new file mode 100644 index 000000000..412f2e3a5 --- /dev/null +++ b/src/Powerplant.Api/Program.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using NSwag.AspNetCore; +using Powerplant.Api.Application.Abstractions; +using Powerplant.Api.Application.Services; +using Powerplant.Api.Mapping; +using Powerplant.Api.Middleware; +using System.Text.Json; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + }); + +builder.Services.AddProblemDetails(); +builder.Services.AddScoped(); +builder.Services.AddAutoMapper( + cfg => { }, + typeof(ProductionPlanMappingProfile)); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); + +var app = builder.Build(); + +app.UseMiddleware(); + +app.MapGet("/swagger/v1/openapi.yaml", async (IWebHostEnvironment environment, CancellationToken cancellationToken) => +{ + var contractPath = Path.Combine(environment.ContentRootPath, "Contracts", "openapi.yaml"); + + if (!File.Exists(contractPath)) + { + return Results.NotFound(new { message = "OpenAPI contract was not found." }); + } + + var yaml = await File.ReadAllTextAsync(contractPath, cancellationToken); + return Results.Text(yaml, "application/yaml"); +}) +.WithName("OpenApiYaml") +.ExcludeFromDescription(); + +app.UseSwaggerUi(settings => +{ + settings.Path = "/swagger"; + settings.DocumentPath = "/swagger/v1/openapi.yaml"; +}); + +app.MapControllers(); +app.MapGet("/", () => Results.Redirect("/swagger")); + +app.Run(); + +public partial class Program; diff --git a/src/Powerplant.Api/Properties/launchSettings.json b/src/Powerplant.Api/Properties/launchSettings.json new file mode 100644 index 000000000..971bd1335 --- /dev/null +++ b/src/Powerplant.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:8888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Powerplant.Api/appsettings.Development.json b/src/Powerplant.Api/appsettings.Development.json new file mode 100644 index 000000000..34f00ef13 --- /dev/null +++ b/src/Powerplant.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information" + } + } +} diff --git a/src/Powerplant.Api/appsettings.json b/src/Powerplant.Api/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/src/Powerplant.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Powerplant.Api/nswag.json b/src/Powerplant.Api/nswag.json new file mode 100644 index 000000000..a7e9137a9 --- /dev/null +++ b/src/Powerplant.Api/nswag.json @@ -0,0 +1,34 @@ +{ + "runtime": "Net90", + "documentGenerator": { + "fromDocument": { + "url": "Contracts/openapi.yaml", + "output": null + } + }, + "codeGenerators": { + "openApiToCSharpController": { + "controllerBaseClass": "Microsoft.AspNetCore.Mvc.ControllerBase", + "controllerStyle": "Abstract", + "controllerTarget": "AspNetCore", + "useCancellationToken": true, + "useActionResultType": true, + "routeNamingStrategy": "None", + "generateModelValidationAttributes": true, + "generateDataAnnotations": true, + "generateNullableReferenceTypes": true, + "generateOptionalPropertiesAsNullable": true, + "generateDefaultValues": true, + "generateDtoTypes": true, + "jsonLibrary": "SystemTextJson", + "className": "ProductionPlanControllerBase", + "namespace": "Powerplant.Api.Generated", + "operationGenerationMode": "MultipleClientsFromOperationId", + "responseArrayType": "System.Collections.Generic.ICollection", + "responseDictionaryType": "System.Collections.Generic.IDictionary", + "parameterArrayType": "System.Collections.Generic.IEnumerable", + "parameterDictionaryType": "System.Collections.Generic.IDictionary", + "output": "Generated/ProductionPlanControllerBase.g.cs" + } + } +} diff --git a/tests/Powerplant.Api.Tests/Application/ProductionPlanServiceTests.cs b/tests/Powerplant.Api.Tests/Application/ProductionPlanServiceTests.cs new file mode 100644 index 000000000..3a9aa871f --- /dev/null +++ b/tests/Powerplant.Api.Tests/Application/ProductionPlanServiceTests.cs @@ -0,0 +1,152 @@ +using Powerplant.Api.Application.Services; +using Powerplant.Api.Domain; +using Xunit; + +namespace Powerplant.Api.Tests.Application; + +public sealed class ProductionPlanServiceTests +{ + [Fact] + public void Calculate_UsesMeritOrderAndAllocatesRemainingLoad() + { + var service = new ProductionPlanService(); + + var fuelPrices = CreateFuelPrices(); + + var powerPlants = new List + { + CreatePowerPlant("tj1", PowerPlantType.TurboJet, 0.3m, 0m, 16m), + CreatePowerPlant("gasfiredsomewhatsmaller", PowerPlantType.GasFired, 0.37m, 40m, 210m), + CreatePowerPlant("windpark2", PowerPlantType.WindTurbine, 1m, 0m, 36m), + CreatePowerPlant("gasfiredbig2", PowerPlantType.GasFired, 0.53m, 100m, 460m), + CreatePowerPlant("windpark1", PowerPlantType.WindTurbine, 1m, 0m, 150m), + CreatePowerPlant("gasfiredbig1", PowerPlantType.GasFired, 0.53m, 100m, 460m) + }; + + var result = service.Calculate(480m, fuelPrices, powerPlants).ToList(); + + Assert.Collection( + result, + item => + { + Assert.Equal("windpark2", item.Name); + Assert.Equal(21.6m, item.Power); + }, + item => + { + Assert.Equal("windpark1", item.Name); + Assert.Equal(90m, item.Power); + }, + item => + { + Assert.Equal("gasfiredbig2", item.Name); + Assert.Equal(368.4m, item.Power); + }, + item => + { + Assert.Equal("gasfiredbig1", item.Name); + Assert.Equal(0m, item.Power); + }, + item => + { + Assert.Equal("gasfiredsomewhatsmaller", item.Name); + Assert.Equal(0m, item.Power); + }, + item => + { + Assert.Equal("tj1", item.Name); + Assert.Equal(0m, item.Power); + }); + } + + [Fact] + public void Calculate_WhenFirstPlantCanSatisfyLoad_SetsRemainingPlantsToZero() + { + var service = new ProductionPlanService(); + + var fuelPrices = CreateFuelPrices(); + + var powerPlants = new List + { + CreatePowerPlant("gasfiredbig1", PowerPlantType.GasFired, 0.53m, 100m, 460m), + CreatePowerPlant("tj1", PowerPlantType.TurboJet, 0.3m, 0m, 16m) + }; + + var result = service.Calculate(200m, fuelPrices, powerPlants).ToList(); + + Assert.Equal("gasfiredbig1", result[0].Name); + Assert.Equal(200m, result[0].Power); + + Assert.Equal("tj1", result[1].Name); + Assert.Equal(0m, result[1].Power); + } + + [Fact] + public void Calculate_WhenRemainingLoadIsBelowPMin_DoesNotStartPlant() + { + var service = new ProductionPlanService(); + + var fuelPrices = CreateFuelPrices(); + + var powerPlants = new List + { + CreatePowerPlant("gasfiredbig1", PowerPlantType.GasFired, 0.53m, 100m, 460m), + CreatePowerPlant("gasfiredsmall", PowerPlantType.GasFired, 0.37m, 40m, 210m) + }; + + var result = service.Calculate(480m, fuelPrices, powerPlants).ToList(); + + Assert.Equal("gasfiredbig1", result[0].Name); + Assert.Equal(460m, result[0].Power); + + Assert.Equal("gasfiredsmall", result[1].Name); + Assert.Equal(0m, result[1].Power); + } + + [Fact] + public void Calculate_ReturnsOneEntryForEveryPowerPlant() + { + var service = new ProductionPlanService(); + + var fuelPrices = CreateFuelPrices(); + + var powerPlants = new List + { + CreatePowerPlant("windpark1", PowerPlantType.WindTurbine, 1m, 0m, 150m), + CreatePowerPlant("gasfiredbig1", PowerPlantType.GasFired, 0.53m, 100m, 460m), + CreatePowerPlant("tj1", PowerPlantType.TurboJet, 0.3m, 0m, 16m) + }; + + var result = service.Calculate(120m, fuelPrices, powerPlants); + + Assert.Equal(3, result.Count); + } + + private static FuelPrices CreateFuelPrices() + { + return new FuelPrices + { + GasEuroPerMWh = 13.4m, + KerosineEuroPerMWh = 50.8m, + Co2EuroPerTon = 20m, + WindPercentage = 60m + }; + } + + private static PowerPlant CreatePowerPlant( + string name, + PowerPlantType type, + decimal efficiency, + decimal pMin, + decimal pMax) + { + return new PowerPlant + { + Name = name, + Type = type, + Efficiency = efficiency, + PMin = pMin, + PMax = pMax + }; + } +} diff --git a/tests/Powerplant.Api.Tests/Domain/PowerPlantTests.cs b/tests/Powerplant.Api.Tests/Domain/PowerPlantTests.cs new file mode 100644 index 000000000..1ab9302cf --- /dev/null +++ b/tests/Powerplant.Api.Tests/Domain/PowerPlantTests.cs @@ -0,0 +1,174 @@ +using Powerplant.Api.Domain; +using Xunit; + +namespace Powerplant.Api.Tests.Domain; + +public sealed class PowerPlantTests +{ + [Fact] + public void CostPerMWh_ForGasFiredPlant_UsesGasPriceEfficiencyAndCo2() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "gasfiredbig1", + Type = PowerPlantType.GasFired, + Efficiency = 0.53m, + PMin = 100m, + PMax = 460m + }; + + var result = powerPlant.CostPerMWh(fuelPrices); + + var expected = fuelPrices.GasEuroPerMWh / 0.53m + fuelPrices.Co2EuroPerTon * 0.3m; + + Assert.Equal(expected, result); + } + + [Fact] + public void CostPerMWh_ForTurboJet_UsesKerosinePriceEfficiencyAndCo2() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "tj1", + Type = PowerPlantType.TurboJet, + Efficiency = 0.3m, + PMin = 0m, + PMax = 16m + }; + + var result = powerPlant.CostPerMWh(fuelPrices); + + var expected = fuelPrices.KerosineEuroPerMWh / 0.3m + fuelPrices.Co2EuroPerTon * 0.3m; + + Assert.Equal(expected, result); + } + + [Fact] + public void CostPerMWh_ForWindTurbine_ReturnsZero() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "windpark1", + Type = PowerPlantType.WindTurbine, + Efficiency = 1m, + PMin = 0m, + PMax = 150m + }; + + var result = powerPlant.CostPerMWh(fuelPrices); + + Assert.Equal(0m, result); + } + + [Fact] + public void GetPossibleLoad_WhenLoadIsBelowPMin_ReturnsZero() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "gasfiredbig1", + Type = PowerPlantType.GasFired, + Efficiency = 0.53m, + PMin = 100m, + PMax = 460m + }; + + var result = powerPlant.GetPossibleLoad(50m, fuelPrices); + + Assert.Equal(0m, result); + } + + [Fact] + public void GetPossibleLoad_ForGasFiredPlant_WhenLoadIsAbovePMax_ReturnsPMax() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "gasfiredbig1", + Type = PowerPlantType.GasFired, + Efficiency = 0.53m, + PMin = 100m, + PMax = 460m + }; + + var result = powerPlant.GetPossibleLoad(500m, fuelPrices); + + Assert.Equal(460m, result); + } + + [Fact] + public void GetPossibleLoad_ForGasFiredPlant_WhenLoadIsBetweenPMinAndPMax_ReturnsLoad() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "gasfiredbig1", + Type = PowerPlantType.GasFired, + Efficiency = 0.53m, + PMin = 100m, + PMax = 460m + }; + + var result = powerPlant.GetPossibleLoad(250m, fuelPrices); + + Assert.Equal(250m, result); + } + + [Fact] + public void GetPossibleLoad_ForWindTurbine_UsesWindPercentage() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "windpark1", + Type = PowerPlantType.WindTurbine, + Efficiency = 1m, + PMin = 0m, + PMax = 150m + }; + + var result = powerPlant.GetPossibleLoad(500m, fuelPrices); + + Assert.Equal(90m, result); + } + + [Fact] + public void GetPossibleLoad_ForWindTurbine_WhenLoadIsLowerThanAvailableWindPower_ReturnsLoad() + { + var fuelPrices = CreateFuelPrices(); + + var powerPlant = new PowerPlant + { + Name = "windpark1", + Type = PowerPlantType.WindTurbine, + Efficiency = 1m, + PMin = 0m, + PMax = 150m + }; + + var result = powerPlant.GetPossibleLoad(50m, fuelPrices); + + Assert.Equal(50m, result); + } + + private static FuelPrices CreateFuelPrices() + { + return new FuelPrices + { + GasEuroPerMWh = 13.4m, + KerosineEuroPerMWh = 50.8m, + Co2EuroPerTon = 20m, + WindPercentage = 60m + }; + } +} diff --git a/tests/Powerplant.Api.Tests/Powerplant.Api.Tests.csproj b/tests/Powerplant.Api.Tests/Powerplant.Api.Tests.csproj new file mode 100644 index 000000000..0dbe01757 --- /dev/null +++ b/tests/Powerplant.Api.Tests/Powerplant.Api.Tests.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + \ No newline at end of file