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