diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a18e12795 --- /dev/null +++ b/.gitignore @@ -0,0 +1,622 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rider,visualstudio,visualstudiocode,aspnetcore +# Edit at https://www.toptal.com/developers/gitignore?templates=rider,visualstudio,visualstudiocode,aspnetcore + +### ASPNETCore ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/ + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### VisualStudio ### +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser + +# User-specific files (MonoDevelop/Xamarin Studio) + +# Mono auto generated files +mono_crash.* + +# Build results +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +# Uncomment if you have tasks that create the project's static files in wwwroot + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results + +# NUnit +nunit-*.xml + +# Build Results of an ATL Project + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_h.h +*.iobj +*.ipdb +*_wpftmp.csproj +*.tlog + +# Chutzpah Test files + +# Visual C++ cache files + +# Visual Studio profiler + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace + +# Guidance Automation Toolkit + +# ReSharper is a .NET coding add-in + +# TeamCity is a build add-in + +# DotCover is a Code Coverage Tool + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results + +# NCrunch + +# MightyMoose + +# Web workbench (sass) + +# Installshield output folder + +# DocProject is a documentation generator add-in + +# Click-Once directory + +# Publish Web Output +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted + +# NuGet Packages +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files + +# Microsoft Azure Build Output + +# Microsoft Azure Emulator + +# Windows Store app package directories and files +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) + +# RIA/Silverlight projects + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.ndf + +# Business Intelligence projects +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes + +# GhostDoc plugin setting file + +# Node.js Tools for Visual Studio + +# Visual Studio 6 build log + +# Visual Studio 6 workspace options file + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output + +# Paket dependency manager + +# FAKE - F# Make + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +*.code-workspace + +# Local History for Visual Studio Code + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider + +### VisualStudio Patch ### +# Additional files built by Visual Studio + +# End of https://www.toptal.com/developers/gitignore/api/rider,visualstudio,visualstudiocode,aspnetcore +Explain \ No newline at end of file diff --git a/PowerPlant.Api.IntegrationTests/ApiIntegrationTests.cs b/PowerPlant.Api.IntegrationTests/ApiIntegrationTests.cs new file mode 100644 index 000000000..e6c90ab0c --- /dev/null +++ b/PowerPlant.Api.IntegrationTests/ApiIntegrationTests.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using PowerPlant.Api.Enums; +using PowerPlant.Api.IntegrationTests.Models; +using PowerPlant.Api.Models; + +namespace PowerPlant.Api.IntegrationTests; + +public class ProductionPlanControllerTests : IClassFixture> +{ + private readonly HttpClient _client; + + public ProductionPlanControllerTests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Post_ValidInput_ReturnsExpectedResult() + { + //Arrange + var input = new ProductionPlanRequest + { + Load = 910, + Fuels = new FuelsDTO() + { + GasPricePerMWh = 13.4M, + KerosinePricePerMWh = 50.8M, + Co2PricePerTon = 20M, + WindPercentage = 60 + }, + PowerPlants = new List + { + new() + { + Name = "gasfiredbig1", + Type = PowerPlantTypeDTO.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + }, + new() + { + Name = "gasfiredbig2", + Type = PowerPlantTypeDTO.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + }, + new() + { + Name = "gasfiredsomewhatsmaller", + Type = PowerPlantTypeDTO.gasfired, + Efficiency = 0.37M, + Pmin = 40M, + Pmax = 210M + }, + new() + { + Name = "tj1", + Type = PowerPlantTypeDTO.turbojet, + Efficiency = 0.3M, + Pmin = 0M, + Pmax = 16M + }, + new() + { + Name = "windpark1", + Type = PowerPlantTypeDTO.windturbine, + Efficiency = 1M, + Pmin = 0M, + Pmax = 150M + }, + new() + { + Name = "windpark2", + Type = PowerPlantTypeDTO.windturbine, + Efficiency = 1M, + Pmin = 0M, + Pmax = 36M + } + } + }; + + var expectedOutput = new List + { + new() + { + Name = "windpark1", + P = 90.0M + }, + new() + { + Name = "windpark2", + P = 21.6M + }, + new() + { + Name = "gasfiredbig1", + P = 460.0M + }, + new() + { + Name = "gasfiredbig2", + P = 338.4M + }, + new() + { + Name = "gasfiredsomewhatsmaller", + P = 0.0M + }, + new() + { + Name = "tj1", + P = 0.0M + }, + }; + + var jsonString = JsonSerializer.Serialize(input); + var content = new StringContent(jsonString, Encoding.UTF8, "application/json"); + + //Act + var response = await _client.PostAsync("/productionplan", content); + + //Assert + response.EnsureSuccessStatusCode(); + + var resultJson = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(resultJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + result.Should().NotBeNull(); + result.Length.Should().Be(6); + result.Should().BeEquivalentTo(expectedOutput); + } + + [Fact] + public async Task Post_InValidInput_ReturnsExpected_ValidationException() + { + //Arrange + var input = new ProductionPlanRequest + { + Load = 910, + Fuels = new FuelsDTO() + { + GasPricePerMWh = 13.4M, + KerosinePricePerMWh = 50.8M, + Co2PricePerTon = 20M, + WindPercentage = 60 + }, + PowerPlants = new List + { + new() + { + Name = "", + Type = PowerPlantTypeDTO.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + } + } + }; + + var jsonString = JsonSerializer.Serialize(input); + var content = new StringContent(jsonString, Encoding.UTF8, "application/json"); + + //Act + var response = await _client.PostAsync("/productionplan", content); + + //Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var resultJson = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(resultJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + result.Title.Should().Be("Validation failures occurred."); + result.ErrorDetails.Count.Should().Be(1); + foreach (var error in result.ErrorDetails) + { + error.Key.Should().Be("PowerPlants[0].Name"); + error.Value.Should().BeEquivalentTo(new List { "Power plant name is required." }); + } + } + + [Fact] + public async Task Post_InValidLoadInput_ReturnsExpected_BusinessException() + { + //Arrange + var input = new ProductionPlanRequest + { + Load = 2000, + Fuels = new FuelsDTO() + { + GasPricePerMWh = 13.4M, + KerosinePricePerMWh = 50.8M, + Co2PricePerTon = 20M, + WindPercentage = 60 + }, + PowerPlants = new List + { + new() + { + Name = "test", + Type = PowerPlantTypeDTO.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + } + } + }; + + var jsonString = JsonSerializer.Serialize(input); + var content = new StringContent(jsonString, Encoding.UTF8, "application/json"); + + //Act + var response = await _client.PostAsync("/productionplan", content); + + //Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var resultJson = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(resultJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + result.Title.Should().Be("Business failure occured."); + result.ErrorDetails.Should().Be("Powerplants cannot generated enough power for the load requested."); + } +} \ No newline at end of file diff --git a/PowerPlant.Api.IntegrationTests/Models/BusinessErrorResponse.cs b/PowerPlant.Api.IntegrationTests/Models/BusinessErrorResponse.cs new file mode 100644 index 000000000..c0de18c1f --- /dev/null +++ b/PowerPlant.Api.IntegrationTests/Models/BusinessErrorResponse.cs @@ -0,0 +1,10 @@ +namespace PowerPlant.Api.IntegrationTests.Models; + +public class BusinessErrorResponse +{ + public string Type { get; set; } = null!; + public string Title { get; set; } = null!; + public int Status { get; set; } + public string TraceId { get; set; } = null!; + public string ErrorDetails { get; set; } = null!; +} \ No newline at end of file diff --git a/PowerPlant.Api.IntegrationTests/Models/ValidationErrorResponse.cs b/PowerPlant.Api.IntegrationTests/Models/ValidationErrorResponse.cs new file mode 100644 index 000000000..80e94db3a --- /dev/null +++ b/PowerPlant.Api.IntegrationTests/Models/ValidationErrorResponse.cs @@ -0,0 +1,10 @@ +namespace PowerPlant.Api.IntegrationTests.Models; + +public class ValidationErrorResponse +{ + public string Type { get; set; } = null!; + public string Title { get; set; } = null!; + public int Status { get; set; } + public string TraceId { get; set; } = null!; + public Dictionary> ErrorDetails { get; set; } = null!; +} \ No newline at end of file diff --git a/PowerPlant.Api.IntegrationTests/PowerPlant.Api.IntegrationTests.csproj b/PowerPlant.Api.IntegrationTests/PowerPlant.Api.IntegrationTests.csproj new file mode 100644 index 000000000..fc5c7fd27 --- /dev/null +++ b/PowerPlant.Api.IntegrationTests/PowerPlant.Api.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + true + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/PowerPlant.Api/Common/DecimalJsonConverter.cs b/PowerPlant.Api/Common/DecimalJsonConverter.cs new file mode 100644 index 000000000..4836993f8 --- /dev/null +++ b/PowerPlant.Api/Common/DecimalJsonConverter.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PowerPlant.Api.Common; + +public class DecimalJsonConverter : JsonConverter +{ + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + if (decimal.TryParse(reader.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal value)) + { + return value; + } + } + else if (reader.TokenType == JsonTokenType.Number) + { + return reader.GetDecimal(); + } + + throw new JsonException(); + } + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString("F1", CultureInfo.InvariantCulture)); + } +} \ No newline at end of file diff --git a/PowerPlant.Api/Controllers/ErrorsController.cs b/PowerPlant.Api/Controllers/ErrorsController.cs new file mode 100644 index 000000000..4cf834a9c --- /dev/null +++ b/PowerPlant.Api/Controllers/ErrorsController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using PowerPlant.Application.Exceptions; + +namespace PowerPlant.Api.Controllers; + +[ApiController] +[Route("/error")] +public class ErrorsController : ControllerBase +{ + [ApiExplorerSettings(IgnoreApi = true)] + public IActionResult Error() + { + var exception = HttpContext.Features.Get()?.Error; + + return Problem( + title: exception?.Message, + statusCode: exception switch + { + ValidationException => 400, + BusinessException => 400, + _ => 500 + } + ); + } +} \ No newline at end of file diff --git a/PowerPlant.Api/Controllers/ProductionPlanController.cs b/PowerPlant.Api/Controllers/ProductionPlanController.cs new file mode 100644 index 000000000..e3c33b6b3 --- /dev/null +++ b/PowerPlant.Api/Controllers/ProductionPlanController.cs @@ -0,0 +1,32 @@ +using MapsterMapper; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using PowerPlant.Api.Models; +using PowerPlant.Application.Queries.ProductionPlan; + +namespace PowerPlant.Api.Controllers; + +[ApiController] +[Route("[controller]")] +public class ProductionPlanController : ControllerBase +{ + private readonly IMapper _mapper; + private readonly ISender _mediator; + + public ProductionPlanController( + IMapper mapper, + ISender mediator) + { + _mapper = mapper; + _mediator = mediator; + } + + [HttpPost(Name = "productionplan")] + [Produces("application/json")] + public async Task GenerateProductionPlan([FromBody] ProductionPlanRequest request) + { + var query = _mapper.Map(request); + var response = await _mediator.Send(query); + return Ok(_mapper.Map>(response)); + } +} \ No newline at end of file diff --git a/PowerPlant.Api/Enums/PowerPlantTypeDTO.cs b/PowerPlant.Api/Enums/PowerPlantTypeDTO.cs new file mode 100644 index 000000000..9f4f93912 --- /dev/null +++ b/PowerPlant.Api/Enums/PowerPlantTypeDTO.cs @@ -0,0 +1,8 @@ +namespace PowerPlant.Api.Enums; + +public enum PowerPlantTypeDTO +{ + gasfired, + turbojet, + windturbine +} \ No newline at end of file diff --git a/PowerPlant.Api/Errors/PowerPlantProblemDetailsFactory.cs b/PowerPlant.Api/Errors/PowerPlantProblemDetailsFactory.cs new file mode 100644 index 000000000..db1b3d835 --- /dev/null +++ b/PowerPlant.Api/Errors/PowerPlantProblemDetailsFactory.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Options; +using PowerPlant.Application.Exceptions; + +namespace PowerPlant.Api.Errors; + +public class PowerPlantProblemDetailsFactory: ProblemDetailsFactory +{ + private readonly ApiBehaviorOptions _options; + private readonly Action? _configure; + + public PowerPlantProblemDetailsFactory( + IOptions options, + IOptions? problemDetailsOptions = null) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _configure = problemDetailsOptions?.Value?.CustomizeProblemDetails; + } + + public override ProblemDetails CreateProblemDetails( + HttpContext httpContext, + int? statusCode = null, + string? title = null, + string? type = null, + string? detail = null, + string? instance = null) + { + statusCode ??= 500; + + var problemDetails = new ProblemDetails + { + Status = statusCode, + Title = title, + Type = type, + Detail = detail, + Instance = instance, + }; + + ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value); + + return problemDetails; + } + + public override ValidationProblemDetails CreateValidationProblemDetails( + HttpContext httpContext, + ModelStateDictionary modelStateDictionary, + int? statusCode = null, + string? title = null, + string? type = null, + string? detail = null, + string? instance = null) + { + ArgumentNullException.ThrowIfNull(modelStateDictionary); + + statusCode ??= 400; + + var problemDetails = new ValidationProblemDetails(modelStateDictionary) + { + Status = statusCode, + Type = type, + Detail = detail, + Instance = instance, + }; + + if (title != null) + { + problemDetails.Title = title; + } + + ApplyProblemDetailsDefaults(httpContext, problemDetails, statusCode.Value); + + return problemDetails; + } + + private void ApplyProblemDetailsDefaults(HttpContext httpContext, ProblemDetails problemDetails, int statusCode) + { + problemDetails.Status ??= statusCode; + + if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData)) + { + problemDetails.Title ??= clientErrorData.Title; + problemDetails.Type ??= clientErrorData.Link; + } + + var traceId = Activity.Current?.Id ?? httpContext?.TraceIdentifier; + + if (traceId != null) + { + problemDetails.Extensions["traceId"] = traceId; + } + + var exception = httpContext?.Features.Get()?.Error; + + if (exception is not null) + { + object errorOutput = exception switch + { + ValidationException validationException => validationException.Errors, + BusinessException businessException => businessException.ErrorMessage, + _ => exception.InnerException?.Message ?? "Internal error occured." + }; + + problemDetails.Extensions.Add("ErrorDetails", errorOutput); + } + + _configure?.Invoke(new() { HttpContext = httpContext!, ProblemDetails = problemDetails }); + } +} \ No newline at end of file diff --git a/PowerPlant.Api/Models/FuelsDTO.cs b/PowerPlant.Api/Models/FuelsDTO.cs new file mode 100644 index 000000000..6619491d3 --- /dev/null +++ b/PowerPlant.Api/Models/FuelsDTO.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace PowerPlant.Api.Models; + +public record FuelsDTO +{ + [JsonPropertyName("gas(euro/MWh)")] + public decimal GasPricePerMWh { get; set; } + + [JsonPropertyName("kerosine(euro/MWh)")] + public decimal KerosinePricePerMWh { get; set; } + + [JsonPropertyName("co2(euro/ton)")] + public decimal Co2PricePerTon { get; set; } + + [JsonPropertyName("wind(%)")] + public decimal WindPercentage { get; set; } +} \ No newline at end of file diff --git a/PowerPlant.Api/Models/PowerPlantDTO.cs b/PowerPlant.Api/Models/PowerPlantDTO.cs new file mode 100644 index 000000000..b2c4f0c42 --- /dev/null +++ b/PowerPlant.Api/Models/PowerPlantDTO.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using PowerPlant.Api.Enums; + +namespace PowerPlant.Api.Models; + +public record PowerPlantDTO +{ + [JsonPropertyName("name")] + public string Name { get; set; } = null!; + + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PowerPlantTypeDTO Type { get; set; } + + [JsonPropertyName("efficiency")] + public decimal Efficiency { get; set; } + + [JsonPropertyName("pmin")] + public decimal Pmin { get; set; } + + [JsonPropertyName("pmax")] + public decimal Pmax { get; set; } +} \ No newline at end of file diff --git a/PowerPlant.Api/Models/ProductionPlanRequest.cs b/PowerPlant.Api/Models/ProductionPlanRequest.cs new file mode 100644 index 000000000..0c7fb29a5 --- /dev/null +++ b/PowerPlant.Api/Models/ProductionPlanRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace PowerPlant.Api.Models; + +public record ProductionPlanRequest +{ + [JsonPropertyName("load")] + public decimal Load { get; set; } + + [JsonPropertyName("fuels")] + public FuelsDTO Fuels { get; set; } = null!; + + [JsonPropertyName("powerplants")] + public List PowerPlants { get; set; } = null!; +} \ No newline at end of file diff --git a/PowerPlant.Api/Models/ProductionPlanResponseDTO.cs b/PowerPlant.Api/Models/ProductionPlanResponseDTO.cs new file mode 100644 index 000000000..7a784fc4e --- /dev/null +++ b/PowerPlant.Api/Models/ProductionPlanResponseDTO.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using PowerPlant.Api.Common; + +namespace PowerPlant.Api.Models; + +public record ProductionPlanResponseDTO +{ + [JsonPropertyName("name")] + public string Name { get; set; } = null!; + + [JsonPropertyName("p")] + [JsonConverter(typeof(DecimalJsonConverter))] + public decimal P { get; set; } +} \ No newline at end of file diff --git a/PowerPlant.Api/PowerPlant.Api.csproj b/PowerPlant.Api/PowerPlant.Api.csproj new file mode 100644 index 000000000..f0faa2a9d --- /dev/null +++ b/PowerPlant.Api/PowerPlant.Api.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/PowerPlant.Api/PowerPlant.Api.http b/PowerPlant.Api/PowerPlant.Api.http new file mode 100644 index 000000000..7071a0233 --- /dev/null +++ b/PowerPlant.Api/PowerPlant.Api.http @@ -0,0 +1,180 @@ +@HostAddress = http://localhost:8888 + +### Payload 1 +POST {{HostAddress}}/productionplan/ +Accept: application/json + +{ + "load": 480, + "fuels": + { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 60 + }, + "powerplants": [ + { + "name": "gasfiredbig1", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredbig2", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredsomewhatsmaller", + "type": "gasfired", + "efficiency": 0.37, + "pmin": 40, + "pmax": 210 + }, + { + "name": "tj1", + "type": "turbojet", + "efficiency": 0.3, + "pmin": 0, + "pmax": 16 + }, + { + "name": "windpark1", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 150 + }, + { + "name": "windpark2", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 36 + } + ] +} + +### Payload 2 +POST {{HostAddress}}/productionplan/ +Accept: application/json + +{ + "load": 480, + "fuels": + { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 0 + }, + "powerplants": [ + { + "name": "gasfiredbig1", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredbig2", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredsomewhatsmaller", + "type": "gasfired", + "efficiency": 0.37, + "pmin": 40, + "pmax": 210 + }, + { + "name": "tj1", + "type": "turbojet", + "efficiency": 0.3, + "pmin": 0, + "pmax": 16 + }, + { + "name": "windpark1", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 150 + }, + { + "name": "windpark2", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 36 + } + ] +} + +### Payload 3 +POST {{HostAddress}}/productionplan/ +Accept: application/json + +{ + "load": 910, + "fuels": + { + "gas(euro/MWh)": 13.4, + "kerosine(euro/MWh)": 50.8, + "co2(euro/ton)": 20, + "wind(%)": 60 + }, + "powerplants": [ + { + "name": "gasfiredbig1", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredbig2", + "type": "gasfired", + "efficiency": 0.53, + "pmin": 100, + "pmax": 460 + }, + { + "name": "gasfiredsomewhatsmaller", + "type": "gasfired", + "efficiency": 0.37, + "pmin": 40, + "pmax": 210 + }, + { + "name": "tj1", + "type": "turbojet", + "efficiency": 0.3, + "pmin": 0, + "pmax": 16 + }, + { + "name": "windpark1", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 150 + }, + { + "name": "windpark2", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 36 + } + ] +} + +### \ No newline at end of file diff --git a/PowerPlant.Api/Program.cs b/PowerPlant.Api/Program.cs new file mode 100644 index 000000000..a2e1c06c2 --- /dev/null +++ b/PowerPlant.Api/Program.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc.Infrastructure; +using PowerPlant.Api.Errors; +using PowerPlant.Application.Extensions; +using PowerPlant.Domain.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddApplication(); +builder.Services.AddSingleton(); +builder.Services.AddDomainServices(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseExceptionHandler("/error"); +app.UseHttpsRedirection(); +app.MapControllers(); +app.Run(); + +public partial class Program { } \ No newline at end of file diff --git a/PowerPlant.Api/Properties/launchSettings.json b/PowerPlant.Api/Properties/launchSettings.json new file mode 100644 index 000000000..ff89afd92 --- /dev/null +++ b/PowerPlant.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:61291", + "sslPort": 44328 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:8888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:8888", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PowerPlant.Api/appsettings.Development.json b/PowerPlant.Api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/PowerPlant.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/PowerPlant.Api/appsettings.json b/PowerPlant.Api/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/PowerPlant.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/PowerPlant.Application.UniTests/Common/ProductionPlanQueryTests.cs b/PowerPlant.Application.UniTests/Common/ProductionPlanQueryTests.cs new file mode 100644 index 000000000..b2eee1a0a --- /dev/null +++ b/PowerPlant.Application.UniTests/Common/ProductionPlanQueryTests.cs @@ -0,0 +1,74 @@ +using PowerPlant.Application.Queries.ProductionPlan; +using PowerPlant.Domain.Entities; +using PowerPlant.Domain.Enums; + +namespace PowerPlant.Application.UniTests.Common; + +public static class ProductionPlanQueryTests +{ + public static ProductionPlanQuery GetDefaultPowerPlanInput() + { + return new ProductionPlanQuery + { + Load = 910M, + Fuels = new Fuels + { + GasPricePerMWh = 13.4M, + KerosinePricePerMWh = 50.8M, + Co2PricePerTon = 20M, + WindPercentage = 60 + }, + PowerPlants = new List() + { + new() + { + Name = "gasfiredbig1", + Type = PowerPlantType.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + }, + new() + { + Name = "gasfiredbig2", + Type = PowerPlantType.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + }, + new() + { + Name = "gasfiredsomewhatsmaller", + Type = PowerPlantType.gasfired, + Efficiency = 0.37M, + Pmin = 40M, + Pmax = 210M + }, + new() + { + Name = "tj1", + Type = PowerPlantType.turbojet, + Efficiency = 0.3M, + Pmin = 0M, + Pmax = 16M + }, + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 0M, + Pmax = 150 + }, + new() + { + Name = "windpark2", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 0M, + Pmax = 36M + } + } + }; + } +} \ No newline at end of file diff --git a/PowerPlant.Application.UniTests/PowerPlant.Application.UniTests.csproj b/PowerPlant.Application.UniTests/PowerPlant.Application.UniTests.csproj new file mode 100644 index 000000000..89e27a697 --- /dev/null +++ b/PowerPlant.Application.UniTests/PowerPlant.Application.UniTests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/PowerPlant.Application.UniTests/ProductionPlan/Queries/ProductionPlanQueryValidatorTest.cs b/PowerPlant.Application.UniTests/ProductionPlan/Queries/ProductionPlanQueryValidatorTest.cs new file mode 100644 index 000000000..2af059aab --- /dev/null +++ b/PowerPlant.Application.UniTests/ProductionPlan/Queries/ProductionPlanQueryValidatorTest.cs @@ -0,0 +1,211 @@ +using FluentAssertions; +using PowerPlant.Application.Queries.ProductionPlan; +using PowerPlant.Application.UniTests.Common; +using PowerPlant.Domain.Enums; + +namespace PowerPlant.Application.UniTests.ProductionPlan.Queries; + +public class ProductionPlanQueryValidatorTest +{ + private ProductionPlanQueryValidator _productionPlanQueryValidator; + + [SetUp] + public void Setup() + { + _productionPlanQueryValidator = new ProductionPlanQueryValidator(); + } + + [Test] + public void ValidInputQuery_ShouldNotBreakDuringValidation() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(0); + } + + [Test] + public void InvalidInputQuery_LoadCantBeEmpty() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.Load = 0; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(2); + result.Errors[0].ErrorMessage.Should().Be("Load is required."); + result.Errors[1].ErrorMessage.Should().Be("Load must be greater than 0."); + } + + [Test] + public void InvalidInputQuery_GasPriceCantBeNegative() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.Fuels.GasPricePerMWh = 0; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(2); + result.Errors[0].ErrorMessage.Should().Be("Gas Price is required."); + result.Errors[1].ErrorMessage.Should().Be("Gas Price must be greater than 0."); + } + + [Test] + public void InvalidInputQuery_KerosinePriceCantBeNegative() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.Fuels.KerosinePricePerMWh = 0; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(2); + result.Errors[0].ErrorMessage.Should().Be("Kerosine Price is required."); + result.Errors[1].ErrorMessage.Should().Be("Kerosine Price must be greater than 0."); + } + + [Test] + public void InvalidInputQuery_CO2PriceCantBeNegative() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.Fuels.Co2PricePerTon = 0; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(2); + result.Errors[0].ErrorMessage.Should().Be("CO2 Price is required."); + result.Errors[1].ErrorMessage.Should().Be("CO2 Price must be greater than 0."); + } + + [Test] + public void InvalidInputQuery_WindPercentageCantBeNegative() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.Fuels.WindPercentage = -1; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Wind percentage must be greater than or equal to 0."); + } + + [Test] + public void InvalidInputQuery_PowerplantMustHaveAName() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.PowerPlants = new List + { + new() + { + Name = "", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 0, + Pmax = 150 + } + }; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Power plant name is required."); + } + + [Test] + public void InvalidInputQuery_PowerplantCantHaveNegativeEfficiency() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.PowerPlants = new List + { + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = -1M, + Pmin = 0, + Pmax = 150 + } + }; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Power plant efficiency must be between 0 and 1."); + } + + [Test] + public void InvalidInputQuery_PowerplantCantEfficiencyBiggerThen1() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.PowerPlants = new List + { + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = 1.1M, + Pmin = 0, + Pmax = 150 + } + }; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Power plant efficiency must be between 0 and 1."); + } + + [Test] + public void InvalidInputQuery_PowerplantCantHavePMinNegative() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.PowerPlants = new List + { + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = -1, + Pmax = 150 + } + }; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Power plant minimum power must be greater than or equal to 0."); + } + + [Test] + public void InvalidInputQuery_PowerplantCantHavePMaxAt0() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.PowerPlants = new List + { + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 0, + Pmax = 0 + } + }; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Power plant maximum power must be greater than 0."); + } + + [Test] + public void InvalidInputQuery_PowerplantPMaxMustBeGreaterThenPMin() + { + var input = ProductionPlanQueryTests.GetDefaultPowerPlanInput(); + input.PowerPlants = new List + { + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 20, + Pmax = 10 + } + }; + + var result = _productionPlanQueryValidator.Validate(input); + result.Errors.Count.Should().Be(1); + result.Errors[0].ErrorMessage.Should().Be("Power plant maximum power must be greater than or equal to the minimum power."); + } +} \ No newline at end of file diff --git a/PowerPlant.Application.UniTests/ProductionPlan/Services/ProductionPlanServiceTests.cs b/PowerPlant.Application.UniTests/ProductionPlan/Services/ProductionPlanServiceTests.cs new file mode 100644 index 000000000..a58ba9324 --- /dev/null +++ b/PowerPlant.Application.UniTests/ProductionPlan/Services/ProductionPlanServiceTests.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using MapsterMapper; +using Moq; +using PowerPlant.Application.Exceptions; +using PowerPlant.Application.Services; +using PowerPlant.Application.UniTests.Common; +using PowerPlant.Domain.Entities; +using PowerPlant.Domain.Interfaces; +using PowerPlant.Domain.UnitTests.Common; + +namespace PowerPlant.Application.UniTests.ProductionPlan.Services; + +public class ProductionPlanServiceTests +{ + private ProductionPlanService _productionPlanService; + + private readonly Mock _mapperMock; + private readonly Mock _costComputationServiceMock; + private readonly Mock _unitCommitmentStrategyServiceMock; + + public ProductionPlanServiceTests() + { + _mapperMock = new Mock(); + _costComputationServiceMock = new Mock(); + _unitCommitmentStrategyServiceMock = new Mock(); + } + + [SetUp] + public void Setup() + { + _productionPlanService = + new ProductionPlanService( + _mapperMock.Object, + _costComputationServiceMock.Object, + _unitCommitmentStrategyServiceMock.Object); + } + + [Test] + public void ValidInputQuery_ShouldNotBreakDuringValidation() + { + var input = ProductionPlanTests.GetDefaultPowerPlanInput(); + + var computationServiceOutput = input; + computationServiceOutput.PowerPlants[0].Cost = 36.603773584905660377358490566M; + computationServiceOutput.PowerPlants[1].Cost = 36.603773584905660377358490566M; + computationServiceOutput.PowerPlants[2].Cost = 52.432432432432432432432432432M; + computationServiceOutput.PowerPlants[3].Cost = 169.33333333333333333333333333M; + computationServiceOutput.PowerPlants[4].Cost = 0; + computationServiceOutput.PowerPlants[5].Cost = 0; + + var unitCommitmentServiceOutput = new List + { + new ("windpark1", 90.0M), + new ("windpark2", 21.6M), + new ("gasfiredbig1", 460.0M), + new ("gasfiredbig2", 338.4M), + new ("gasfiredsomewhatsmaller", 0), + new ("tj1", 0) + }; + + _costComputationServiceMock.Setup(x => x.ComputeCost(input)) + .Returns(computationServiceOutput); + _unitCommitmentStrategyServiceMock.Setup(x => x.Resolve(It.IsAny())) + .Returns(unitCommitmentServiceOutput); + _mapperMock.Setup(x => x.Map(It.IsAny())) + .Returns(new Domain.Entities.ProductionPlan()); + + Action result = () => _productionPlanService.GenerateProductionPlan(input); + result.Should().NotThrow(); + } + + [Test] + public void Invalid_Load_Throw_Business_Exception() + { + var input = ProductionPlanTests.GetDefaultPowerPlanInput(); + input.Load = 2000m; + + var computationServiceOutput = input; + computationServiceOutput.PowerPlants[0].Cost = 36.603773584905660377358490566M; + computationServiceOutput.PowerPlants[1].Cost = 36.603773584905660377358490566M; + computationServiceOutput.PowerPlants[2].Cost = 52.432432432432432432432432432M; + computationServiceOutput.PowerPlants[3].Cost = 169.33333333333333333333333333M; + computationServiceOutput.PowerPlants[4].Cost = 0; + computationServiceOutput.PowerPlants[5].Cost = 0; + + var unitCommitmentServiceOutput = new List + { + new ("windpark1", 90.0M), + new ("windpark2", 21.6M), + new ("gasfiredbig1", 460.0M), + new ("gasfiredbig2", 338.4M), + new ("gasfiredsomewhatsmaller", 0), + new ("tj1", 0) + }; + + _costComputationServiceMock.Setup(x => x.ComputeCost(input)) + .Returns(computationServiceOutput); + _unitCommitmentStrategyServiceMock.Setup(x => x.Resolve(It.IsAny())) + .Returns(unitCommitmentServiceOutput); + _mapperMock.Setup(x => x.Map(It.IsAny())) + .Returns(new Domain.Entities.ProductionPlan()); + + + Action result = () => _productionPlanService.GenerateProductionPlan(input); + result.Should().Throw() + .WithMessage("Business failure occured.") + .Where(exception => exception.ErrorMessage == "Powerplants cannot generated enough power for the load requested."); + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Behaviors/ValidationBehavior.cs b/PowerPlant.Application/Behaviors/ValidationBehavior.cs new file mode 100644 index 000000000..7597b8748 --- /dev/null +++ b/PowerPlant.Application/Behaviors/ValidationBehavior.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using MediatR; + +namespace PowerPlant.Application.Behaviors; + +public class ValidationBehavior : IPipelineBehavior where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); + var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList(); + + if (failures.Count != 0) + throw new Exceptions.ValidationException(failures); + } + return await next(); + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Exceptions/BusinessException.cs b/PowerPlant.Application/Exceptions/BusinessException.cs new file mode 100644 index 000000000..23ce4d542 --- /dev/null +++ b/PowerPlant.Application/Exceptions/BusinessException.cs @@ -0,0 +1,16 @@ +namespace PowerPlant.Application.Exceptions; + +public class BusinessException : Exception +{ + public string ErrorMessage { get; set; } = string.Empty; + + public BusinessException() : base("Business failure occured.") + { + + } + + public BusinessException(string errorMessage) : this() + { + ErrorMessage = errorMessage; + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Exceptions/ValidationException.cs b/PowerPlant.Application/Exceptions/ValidationException.cs new file mode 100644 index 000000000..4bccaced5 --- /dev/null +++ b/PowerPlant.Application/Exceptions/ValidationException.cs @@ -0,0 +1,24 @@ +using FluentValidation.Results; + +namespace PowerPlant.Application.Exceptions; + +public class ValidationException : Exception +{ + + public IDictionary Errors { get; } + public ValidationException() : base("Validation failures occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) : this() + { + Errors = failures + .GroupBy( + ex => ex.PropertyName, + ex => ex.ErrorMessage) + .ToDictionary( + group => group.Key, + group => group.ToArray()); + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Extensions/ServiceCollectionExtensions.cs b/PowerPlant.Application/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..8a291963b --- /dev/null +++ b/PowerPlant.Application/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using FluentValidation; +using Mapster; +using MapsterMapper; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using PowerPlant.Application.Behaviors; +using PowerPlant.Application.Interfaces; +using PowerPlant.Application.Services; + +namespace PowerPlant.Application.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ServiceCollectionExtensions).Assembly)); + services.AddScoped(); + + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + var config = TypeAdapterConfig.GlobalSettings; + config.Scan(Assembly.GetExecutingAssembly()); + + services.AddSingleton(config); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Interfaces/IProductionPlanService.cs b/PowerPlant.Application/Interfaces/IProductionPlanService.cs new file mode 100644 index 000000000..499cf9b29 --- /dev/null +++ b/PowerPlant.Application/Interfaces/IProductionPlanService.cs @@ -0,0 +1,9 @@ +using PowerPlant.Application.Queries.ProductionPlan; +using PowerPlant.Domain.Entities; + +namespace PowerPlant.Application.Interfaces; + +public interface IProductionPlanService +{ + List GenerateProductionPlan(ProductionPlan request); +} \ No newline at end of file diff --git a/PowerPlant.Application/Mappers/ProductionPlanMapper.cs b/PowerPlant.Application/Mappers/ProductionPlanMapper.cs new file mode 100644 index 000000000..c4fe9fd29 --- /dev/null +++ b/PowerPlant.Application/Mappers/ProductionPlanMapper.cs @@ -0,0 +1,16 @@ +using Mapster; +using PowerPlant.Application.Queries.ProductionPlan; +using PowerPlant.Domain.Entities; + +namespace PowerPlant.Application.Mappers; + +public class ProductionPlanMapper : IRegister +{ + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map( + dest => dest.P, + src => src.Power); + } +} \ No newline at end of file diff --git a/PowerPlant.Application/PowerPlant.Application.csproj b/PowerPlant.Application/PowerPlant.Application.csproj new file mode 100644 index 000000000..f70be372e --- /dev/null +++ b/PowerPlant.Application/PowerPlant.Application.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQuery.cs b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQuery.cs new file mode 100644 index 000000000..f2ed0c058 --- /dev/null +++ b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQuery.cs @@ -0,0 +1,11 @@ +using MediatR; +using PowerPlant.Domain.Entities; + +namespace PowerPlant.Application.Queries.ProductionPlan; + +public class ProductionPlanQuery : IRequest> +{ + public decimal Load { get; set; } + public Fuels Fuels { get; set; } = null!; + public List PowerPlants { get; set; } = new(); +} \ No newline at end of file diff --git a/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQueryHandler.cs b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQueryHandler.cs new file mode 100644 index 000000000..5c43d1d2d --- /dev/null +++ b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQueryHandler.cs @@ -0,0 +1,28 @@ +using MapsterMapper; +using MediatR; +using PowerPlant.Application.Interfaces; + +namespace PowerPlant.Application.Queries.ProductionPlan; + +public class ProductionPlanQueryHandler : IRequestHandler> +{ + private readonly IMapper _mapper; + private readonly IProductionPlanService _productionPlanService; + + public ProductionPlanQueryHandler( + IMapper mapper, + IProductionPlanService productionPlanService) + { + _mapper = mapper; + _productionPlanService = productionPlanService; + } + + public Task> Handle(ProductionPlanQuery request, CancellationToken cancellationToken) + { + var serviceRequest = _mapper.Map(request); + var generatedProductionPlan = _productionPlanService.GenerateProductionPlan(serviceRequest); + var result = _mapper.Map>(generatedProductionPlan); + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQueryValidator.cs b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQueryValidator.cs new file mode 100644 index 000000000..4efb2d1b5 --- /dev/null +++ b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanQueryValidator.cs @@ -0,0 +1,78 @@ +using FluentValidation; +using PowerPlant.Domain.Enums; + +namespace PowerPlant.Application.Queries.ProductionPlan; + +public class ProductionPlanQueryValidator : AbstractValidator +{ + public ProductionPlanQueryValidator() + { + AddRuleForLoad(); + AddRuleForFuels(); + AddRuleForPowerPlants(); + } + + private void AddRuleForLoad() + { + RuleFor(query => query.Load) + .NotEmpty().WithMessage("Load is required.") + .GreaterThan(0).WithMessage("Load must be greater than 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("Load must be less than or equal to {MaxValue}."); + } + + private void AddRuleForFuels() + { + RuleFor(query => query.Fuels.GasPricePerMWh) + .NotEmpty().WithMessage("Gas Price is required.") + .GreaterThan(0).WithMessage("Gas Price must be greater than 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("Gas Price must be less than or equal to {MaxValue}."); + + RuleFor(query => query.Fuels.KerosinePricePerMWh) + .NotEmpty().WithMessage("Kerosine Price is required.") + .GreaterThan(0).WithMessage("Kerosine Price must be greater than 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("Kerosine Price must be less than or equal to {MaxValue}."); + + RuleFor(query => query.Fuels.Co2PricePerTon) + .NotEmpty().WithMessage("CO2 Price is required.") + .GreaterThan(0).WithMessage("CO2 Price must be greater than 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("CO2 Price must be less than or equal to {MaxValue}."); + + RuleFor(query => query.Fuels.WindPercentage) + .GreaterThanOrEqualTo(0).WithMessage("Wind percentage must be greater than or equal to 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("Wind percentage must be less than or equal to {MaxValue}."); + } + + private void AddRuleForPowerPlants() + { + RuleForEach(query => query.PowerPlants) + .ChildRules(powerPlant => + { + powerPlant + .RuleFor(powerPlant => powerPlant.Name) + .NotEmpty().WithMessage("Power plant name is required."); + + powerPlant + .RuleFor(powerPlant => powerPlant.Type) + .Must(type => Enum.IsDefined(typeof(PowerPlantType), type)).WithMessage("Power plant type is invalid."); + + powerPlant + .RuleFor(powerPlant => powerPlant.Efficiency) + .InclusiveBetween(0M, 1M).WithMessage("Power plant efficiency must be between 0 and 1."); + + powerPlant + .RuleFor(powerPlant => powerPlant.Pmin) + .GreaterThanOrEqualTo(0).WithMessage("Power plant minimum power must be greater than or equal to 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("Power plant minimum power must be less than or equal to {MaxValue}."); + + powerPlant + .RuleFor(powerPlant => powerPlant.Pmax) + .GreaterThan(0).WithMessage("Power plant maximum power must be greater than 0.") + .LessThanOrEqualTo(decimal.MaxValue).WithMessage("Power plant maximum power must be less than or equal to {MaxValue}."); + + powerPlant + .RuleFor(powerPlant => powerPlant.Pmax) + .GreaterThanOrEqualTo(powerPlant => powerPlant.Pmin) + .WithMessage("Power plant maximum power must be greater than or equal to the minimum power."); + }); + } +} \ No newline at end of file diff --git a/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanResponse.cs b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanResponse.cs new file mode 100644 index 000000000..c45312a8f --- /dev/null +++ b/PowerPlant.Application/Queries/ProductionPlan/ProductionPlanResponse.cs @@ -0,0 +1,7 @@ +namespace PowerPlant.Application.Queries.ProductionPlan; + +public class ProductionPlanResponse +{ + public string Name { get; set; } = null!; + public decimal P { get; set; } +} \ No newline at end of file diff --git a/PowerPlant.Application/Services/ProductionPlanService.cs b/PowerPlant.Application/Services/ProductionPlanService.cs new file mode 100644 index 000000000..d65a47ef6 --- /dev/null +++ b/PowerPlant.Application/Services/ProductionPlanService.cs @@ -0,0 +1,39 @@ +using MapsterMapper; +using PowerPlant.Application.Exceptions; +using PowerPlant.Application.Interfaces; +using PowerPlant.Application.Queries.ProductionPlan; +using PowerPlant.Domain.Entities; +using PowerPlant.Domain.Interfaces; + +namespace PowerPlant.Application.Services; + +public class ProductionPlanService : IProductionPlanService +{ + private readonly IMapper _mapper; + private readonly ICostComputationService _costComputationService; + private readonly IUnitCommitmentStrategyService _unitCommitmentStrategyService; + public ProductionPlanService( + IMapper mapper, + ICostComputationService costComputationService, + IUnitCommitmentStrategyService unitCommitmentStrategyService) + { + _mapper = mapper; + _costComputationService = costComputationService; + _unitCommitmentStrategyService = unitCommitmentStrategyService; + } + + public List GenerateProductionPlan(ProductionPlan request) + { + var productionPlan = _costComputationService.ComputeCost(request); + + var isAbleToManageLoad = productionPlan.PowerPlants.Sum(x => x.Pmax) >= productionPlan.Load; + if(!isAbleToManageLoad) + { + throw new BusinessException("Powerplants cannot generated enough power for the load requested."); + } + var unitCommitmentForecast = _unitCommitmentStrategyService.Resolve(productionPlan); + var result = _mapper.Map>(unitCommitmentForecast); + + return result; + } +} \ No newline at end of file diff --git a/PowerPlant.Domain.UnitTests/Common/ProductionPlanTests.cs b/PowerPlant.Domain.UnitTests/Common/ProductionPlanTests.cs new file mode 100644 index 000000000..8fa295a12 --- /dev/null +++ b/PowerPlant.Domain.UnitTests/Common/ProductionPlanTests.cs @@ -0,0 +1,73 @@ +using PowerPlant.Domain.Entities; +using PowerPlant.Domain.Enums; + +namespace PowerPlant.Domain.UnitTests.Common; + +public static class ProductionPlanTests +{ + public static Entities.ProductionPlan GetDefaultPowerPlanInput() + { + return new Entities.ProductionPlan + { + Load = 910M, + Fuels = new Fuels + { + GasPricePerMWh = 13.4M, + KerosinePricePerMWh = 50.8M, + Co2PricePerTon = 20M, + WindPercentage = 60 + }, + PowerPlants = new List() + { + new() + { + Name = "gasfiredbig1", + Type = PowerPlantType.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + }, + new() + { + Name = "gasfiredbig2", + Type = PowerPlantType.gasfired, + Efficiency = 0.53M, + Pmin = 100M, + Pmax = 460M + }, + new() + { + Name = "gasfiredsomewhatsmaller", + Type = PowerPlantType.gasfired, + Efficiency = 0.37M, + Pmin = 40M, + Pmax = 210M + }, + new() + { + Name = "tj1", + Type = PowerPlantType.turbojet, + Efficiency = 0.3M, + Pmin = 0M, + Pmax = 16M + }, + new() + { + Name = "windpark1", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 0M, + Pmax = 150 + }, + new() + { + Name = "windpark2", + Type = PowerPlantType.windturbine, + Efficiency = 1M, + Pmin = 0M, + Pmax = 36M + } + } + }; + } +} \ No newline at end of file diff --git a/PowerPlant.Domain.UnitTests/PowerPlant.Domain.UnitTests.csproj b/PowerPlant.Domain.UnitTests/PowerPlant.Domain.UnitTests.csproj new file mode 100644 index 000000000..95715c028 --- /dev/null +++ b/PowerPlant.Domain.UnitTests/PowerPlant.Domain.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/PowerPlant.Domain.UnitTests/ProductionPlan/Services/CostComputationServiceTests.cs b/PowerPlant.Domain.UnitTests/ProductionPlan/Services/CostComputationServiceTests.cs new file mode 100644 index 000000000..e60a15769 --- /dev/null +++ b/PowerPlant.Domain.UnitTests/ProductionPlan/Services/CostComputationServiceTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using PowerPlant.Domain.Enums; +using PowerPlant.Domain.Services; +using PowerPlant.Domain.UnitTests.Common; + +namespace PowerPlant.Domain.UnitTests.ProductionPlan.Services; + +public class CostComputationServiceTests +{ + private CostComputationService _costComputationService; + + [SetUp] + public void Setup() + { + _costComputationService = new CostComputationService(); + } + + [Test] + public void Valid_Default_Input_Should_Return_Expected_Cost() + { + var input = ProductionPlanTests.GetDefaultPowerPlanInput(); + + var result = _costComputationService.ComputeCost(input); + + result.PowerPlants[0].Cost.Should().Be(36.603773584905660377358490566M); + result.PowerPlants[1].Cost.Should().Be(36.603773584905660377358490566M); + result.PowerPlants[2].Cost.Should().Be(52.432432432432432432432432432M); + result.PowerPlants[3].Cost.Should().Be(169.33333333333333333333333333M); + result.PowerPlants[4].Cost.Should().Be(0); + result.PowerPlants[5].Cost.Should().Be(0); + } +} \ No newline at end of file diff --git a/PowerPlant.Domain.UnitTests/ProductionPlan/Services/UnitCommitmentStrategyServiceTests.cs b/PowerPlant.Domain.UnitTests/ProductionPlan/Services/UnitCommitmentStrategyServiceTests.cs new file mode 100644 index 000000000..2b476e4f6 --- /dev/null +++ b/PowerPlant.Domain.UnitTests/ProductionPlan/Services/UnitCommitmentStrategyServiceTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using PowerPlant.Domain.Services; +using PowerPlant.Domain.UnitTests.Common; + +namespace PowerPlant.Domain.UnitTests.ProductionPlan.Services; + +public class UnitCommitmentStrategyServiceTests +{ + private UnitCommitmentStrategyService _unitCommitmentStrategyService; + + [SetUp] + public void Setup() + { + _unitCommitmentStrategyService = new UnitCommitmentStrategyService(); + } + + [Test] + public void Valid_Default_Input_Should_Return_Expected_UnitCommitment() + { + var input = ProductionPlanTests.GetDefaultPowerPlanInput(); + input.PowerPlants[0].Cost = 36.603773584905660377358490566M; + input.PowerPlants[1].Cost = 36.603773584905660377358490566M; + input.PowerPlants[2].Cost = 52.432432432432432432432432432M; + input.PowerPlants[3].Cost = 169.33333333333333333333333333M; + input.PowerPlants[4].Cost = 0; + input.PowerPlants[4].Pmax = 90M; + input.PowerPlants[5].Cost = 0; + input.PowerPlants[5].Pmax = 21.6M; + + var result = _unitCommitmentStrategyService.Resolve(input); + result.Count.Should().Be(6); + + result[0].Name.Should().Be("windpark1"); + result[0].Power.Should().Be(90.0M); + result[1].Name.Should().Be("windpark2"); + result[1].Power.Should().Be(21.6M); + result[2].Name.Should().Be("gasfiredbig1"); + result[2].Power.Should().Be(460.0M); + result[3].Name.Should().Be("gasfiredbig2"); + result[3].Power.Should().Be(338.4M); + result[4].Name.Should().Be("gasfiredsomewhatsmaller"); + result[4].Power.Should().Be(0); + result[5].Name.Should().Be("tj1"); + result[5].Power.Should().Be(0); + } + + [Test] + public void Valid_Input_But_Need_To_Adapt_Previous_Added_PowerPlant_Should_Return_Expected_UnitCommitment() + { + var input = ProductionPlanTests.GetDefaultPowerPlanInput(); + input.Load = 210M; + input.PowerPlants[0].Cost = 36.603773584905660377358490566M; + input.PowerPlants[1].Cost = 36.603773584905660377358490566M; + input.PowerPlants[2].Cost = 52.432432432432432432432432432M; + input.PowerPlants[3].Cost = 169.33333333333333333333333333M; + input.PowerPlants[4].Cost = 0; + input.PowerPlants[4].Pmax = 90M; + input.PowerPlants[5].Cost = 0; + input.PowerPlants[5].Pmax = 21.6M; + + var result = _unitCommitmentStrategyService.Resolve(input); + result.Count.Should().Be(6); + + result[0].Name.Should().Be("windpark1"); + result[0].Power.Should().Be(90.0M); + result[1].Name.Should().Be("windpark2"); + result[1].Power.Should().Be(20M); + result[2].Name.Should().Be("gasfiredbig1"); + result[2].Power.Should().Be(100.0M); + result[3].Name.Should().Be("gasfiredbig2"); + result[3].Power.Should().Be(0); + result[4].Name.Should().Be("gasfiredsomewhatsmaller"); + result[4].Power.Should().Be(0); + result[5].Name.Should().Be("tj1"); + result[5].Power.Should().Be(0); + } +} \ No newline at end of file diff --git a/PowerPlant.Domain/Constants/CO2Cost.cs b/PowerPlant.Domain/Constants/CO2Cost.cs new file mode 100644 index 000000000..f1414e341 --- /dev/null +++ b/PowerPlant.Domain/Constants/CO2Cost.cs @@ -0,0 +1,6 @@ +namespace PowerPlant.Domain.Constants; + +public static class CO2Cost +{ + public const decimal GasFired = 0.3m; +} \ No newline at end of file diff --git a/PowerPlant.Domain/Entities/Fuels.cs b/PowerPlant.Domain/Entities/Fuels.cs new file mode 100644 index 000000000..cae63f33f --- /dev/null +++ b/PowerPlant.Domain/Entities/Fuels.cs @@ -0,0 +1,12 @@ +namespace PowerPlant.Domain.Entities; + +public class Fuels +{ + public decimal GasPricePerMWh { get; set; } + + public decimal KerosinePricePerMWh { get; set; } + + public decimal Co2PricePerTon { get; set; } + + public decimal WindPercentage { get; set; } +} \ No newline at end of file diff --git a/PowerPlant.Domain/Entities/PowerPlant.cs b/PowerPlant.Domain/Entities/PowerPlant.cs new file mode 100644 index 000000000..3b9b40dc8 --- /dev/null +++ b/PowerPlant.Domain/Entities/PowerPlant.cs @@ -0,0 +1,20 @@ +using PowerPlant.Domain.Enums; + +namespace PowerPlant.Domain.Entities; + +public class PowerPlant +{ + public string Name { get; set; } = null!; + + public PowerPlantType Type { get; set; } + + public decimal Efficiency { get; set; } + + public decimal Pmin { get; set; } + + public decimal Pmax { get; set; } + + public decimal Power { get; set; } + + public decimal Cost { get; set; } +} \ No newline at end of file diff --git a/PowerPlant.Domain/Entities/ProductionPlan.cs b/PowerPlant.Domain/Entities/ProductionPlan.cs new file mode 100644 index 000000000..395c22483 --- /dev/null +++ b/PowerPlant.Domain/Entities/ProductionPlan.cs @@ -0,0 +1,10 @@ +namespace PowerPlant.Domain.Entities; + +public class ProductionPlan +{ + public decimal Load { get; set; } + + public Fuels Fuels { get; set; } = null!; + + public List PowerPlants { get; set; } = null!; +} \ No newline at end of file diff --git a/PowerPlant.Domain/Entities/UnitCommitment.cs b/PowerPlant.Domain/Entities/UnitCommitment.cs new file mode 100644 index 000000000..e8adfdce4 --- /dev/null +++ b/PowerPlant.Domain/Entities/UnitCommitment.cs @@ -0,0 +1,20 @@ +namespace PowerPlant.Domain.Entities; + +public class UnitCommitment +{ + public string Name { get; private set; } + public decimal Power { get; private set; } + + public UnitCommitment( + string name, + decimal power) + { + Name = name; + Power = power; + } + + public void AdjustPower(decimal newPower) + { + Power = newPower; + } +} \ No newline at end of file diff --git a/PowerPlant.Domain/Enums/PowerPlantType.cs b/PowerPlant.Domain/Enums/PowerPlantType.cs new file mode 100644 index 000000000..7b4cd57ff --- /dev/null +++ b/PowerPlant.Domain/Enums/PowerPlantType.cs @@ -0,0 +1,8 @@ +namespace PowerPlant.Domain.Enums; + +public enum PowerPlantType +{ + gasfired, + turbojet, + windturbine +} \ No newline at end of file diff --git a/PowerPlant.Domain/Extensions/ServiceCollectionExtensions.cs b/PowerPlant.Domain/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..35e5aca35 --- /dev/null +++ b/PowerPlant.Domain/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using PowerPlant.Domain.Interfaces; +using PowerPlant.Domain.Services; + +namespace PowerPlant.Domain.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddDomainServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/PowerPlant.Domain/Interfaces/ICostComputationService.cs b/PowerPlant.Domain/Interfaces/ICostComputationService.cs new file mode 100644 index 000000000..969d4801c --- /dev/null +++ b/PowerPlant.Domain/Interfaces/ICostComputationService.cs @@ -0,0 +1,8 @@ +using PowerPlant.Domain.Entities; + +namespace PowerPlant.Domain.Interfaces; + +public interface ICostComputationService +{ + ProductionPlan ComputeCost(ProductionPlan productionPlan); +} \ No newline at end of file diff --git a/PowerPlant.Domain/Interfaces/IUnitCommitmentStrategyService.cs b/PowerPlant.Domain/Interfaces/IUnitCommitmentStrategyService.cs new file mode 100644 index 000000000..c4a644d12 --- /dev/null +++ b/PowerPlant.Domain/Interfaces/IUnitCommitmentStrategyService.cs @@ -0,0 +1,8 @@ +using PowerPlant.Domain.Entities; + +namespace PowerPlant.Domain.Interfaces; + +public interface IUnitCommitmentStrategyService +{ + List Resolve(ProductionPlan productionPlan); +} \ No newline at end of file diff --git a/PowerPlant.Domain/PowerPlant.Domain.csproj b/PowerPlant.Domain/PowerPlant.Domain.csproj new file mode 100644 index 000000000..1b711c934 --- /dev/null +++ b/PowerPlant.Domain/PowerPlant.Domain.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/PowerPlant.Domain/Services/CostComputationService.cs b/PowerPlant.Domain/Services/CostComputationService.cs new file mode 100644 index 000000000..816363db7 --- /dev/null +++ b/PowerPlant.Domain/Services/CostComputationService.cs @@ -0,0 +1,49 @@ +using PowerPlant.Domain.Constants; +using PowerPlant.Domain.Entities; +using PowerPlant.Domain.Enums; +using PowerPlant.Domain.Interfaces; + +namespace PowerPlant.Domain.Services; + +public class CostComputationService : ICostComputationService +{ + public ProductionPlan ComputeCost(ProductionPlan productionPlan) + { + foreach (var powerPlant in productionPlan.PowerPlants) + { + switch (powerPlant.Type) + { + case PowerPlantType.windturbine: + powerPlant.Cost = 0; + powerPlant.Pmax = (powerPlant.Pmax / 100) * productionPlan.Fuels.WindPercentage; + break; + case PowerPlantType.gasfired: + var fuelCost = ComputeFuelCost(productionPlan.Fuels.GasPricePerMWh, powerPlant.Efficiency); + var co2Cost = ComputeCo2Cost(productionPlan.Fuels.Co2PricePerTon, powerPlant.Efficiency, CO2Cost.GasFired); + powerPlant.Cost = fuelCost + co2Cost; + break; + case PowerPlantType.turbojet: + powerPlant.Cost = ComputeFuelCost(productionPlan.Fuels.KerosinePricePerMWh, powerPlant.Efficiency); + break; + default: + throw new NotImplementedException(); + } + } + return productionPlan; + } + + private static decimal ComputeFuelCost( + decimal fuelPricePerMWh, + decimal efficiency) + { + return fuelPricePerMWh / efficiency; + } + + private static decimal ComputeCo2Cost( + decimal co2PricePerTon, + decimal efficiency, + decimal numberOfCo2TonsGeneratedPerTon) + { + return (co2PricePerTon / efficiency) * numberOfCo2TonsGeneratedPerTon; + } +} \ No newline at end of file diff --git a/PowerPlant.Domain/Services/UnitCommitmentStrategyService.cs b/PowerPlant.Domain/Services/UnitCommitmentStrategyService.cs new file mode 100644 index 000000000..1b28268e8 --- /dev/null +++ b/PowerPlant.Domain/Services/UnitCommitmentStrategyService.cs @@ -0,0 +1,92 @@ +using PowerPlant.Domain.Entities; +using PowerPlant.Domain.Interfaces; + +namespace PowerPlant.Domain.Services; + +public class UnitCommitmentStrategyService : IUnitCommitmentStrategyService +{ + public List Resolve(ProductionPlan productionPlan) + { + var sortedPowerPlants = SortByMeritOrder(productionPlan.PowerPlants).ToArray(); + var unitCommitments = new List(); + var remainingLoad = productionPlan.Load; + + for (var i = 0; i < sortedPowerPlants.Length; i++) + { + var powerPlant = sortedPowerPlants[i]; + + if (remainingLoad > 0) + { + remainingLoad = ManageRemainingLoad(powerPlant, remainingLoad, sortedPowerPlants, i, ref unitCommitments); + } + else + { + unitCommitments.Add(new UnitCommitment(powerPlant.Name, 0)); + } + } + + return unitCommitments; + } + + private decimal ManageRemainingLoad( + Entities.PowerPlant powerPlant, + decimal remainingLoad, + Entities.PowerPlant[] sortedPowerPlants, + int index, + ref List unitCommitments) + { + var power = Math.Max(powerPlant.Pmin, Math.Min(powerPlant.Pmax, remainingLoad)); + + if (power == powerPlant.Pmax) + { + unitCommitments.Add(new UnitCommitment(powerPlant.Name, powerPlant.Pmax)); + remainingLoad -= powerPlant.Pmax; + } + else if (power == remainingLoad) + { + unitCommitments.Add(new UnitCommitment(powerPlant.Name, remainingLoad)); + remainingLoad = 0; + } + else + { + var exceedingAmountOfPower = powerPlant.Pmin - remainingLoad; + var isBalancingSolvable = + TryPowerBalancing(sortedPowerPlants, ref unitCommitments, exceedingAmountOfPower, index - 1); + + if (isBalancingSolvable) + { + unitCommitments.Add(new UnitCommitment(powerPlant.Name, powerPlant.Pmin)); + remainingLoad = 0; + } + } + + return remainingLoad; + } + + private static IEnumerable SortByMeritOrder(IEnumerable powerPlants) + { + return powerPlants.OrderBy(p => p.Cost); + } + + private bool TryPowerBalancing( + Entities.PowerPlant[] sortedPowerPlants, + ref List unitCommitments, + decimal exceedingAmountOfPower, + int index) + { + while (index >= 0 && exceedingAmountOfPower > 0) + { + var powerPlant = sortedPowerPlants[index]; + var matchingUnitCommitment = unitCommitments[index]; + if (matchingUnitCommitment.Power > powerPlant.Pmin) + { + var powerAvailableToBeRemoved = matchingUnitCommitment.Power - powerPlant.Pmin; + var powerToBalance = Math.Min(powerAvailableToBeRemoved, exceedingAmountOfPower); + matchingUnitCommitment.AdjustPower(matchingUnitCommitment.Power - powerToBalance); + exceedingAmountOfPower -= powerToBalance; + } + index--; + } + return exceedingAmountOfPower == 0; + } +} \ No newline at end of file diff --git a/PowerPlantCodingChallenge.sln b/PowerPlantCodingChallenge.sln new file mode 100644 index 000000000..93ee381f9 --- /dev/null +++ b/PowerPlantCodingChallenge.sln @@ -0,0 +1,58 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlant.Api", "PowerPlant.Api\PowerPlant.Api.csproj", "{56961E57-86EE-4418-B877-7AD220F18A11}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{8FB65584-E4E7-44C6-AA41-AEB7E130B18C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PowerPlant", "PowerPlant", "{1383E084-1205-40AC-9ACD-B591E7EB575B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlant.Application", "PowerPlant.Application\PowerPlant.Application.csproj", "{1C38EB66-CC4E-4BBB-975B-481BA7B68F48}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlant.Domain", "PowerPlant.Domain\PowerPlant.Domain.csproj", "{E91B9869-DF5B-4878-9B75-166B34068376}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlant.Domain.UnitTests", "PowerPlant.Domain.UnitTests\PowerPlant.Domain.UnitTests.csproj", "{03D7900C-F24C-4EA9-9570-39C426DFEE8C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlant.Application.UniTests", "PowerPlant.Application.UniTests\PowerPlant.Application.UniTests.csproj", "{2AC54609-A42B-417E-8BDE-B26D17D82762}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerPlant.Api.IntegrationTests", "PowerPlant.Api.IntegrationTests\PowerPlant.Api.IntegrationTests.csproj", "{EF51697D-2206-4B26-930C-E0286FCC66F0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {56961E57-86EE-4418-B877-7AD220F18A11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56961E57-86EE-4418-B877-7AD220F18A11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56961E57-86EE-4418-B877-7AD220F18A11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56961E57-86EE-4418-B877-7AD220F18A11}.Release|Any CPU.Build.0 = Release|Any CPU + {1C38EB66-CC4E-4BBB-975B-481BA7B68F48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C38EB66-CC4E-4BBB-975B-481BA7B68F48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C38EB66-CC4E-4BBB-975B-481BA7B68F48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C38EB66-CC4E-4BBB-975B-481BA7B68F48}.Release|Any CPU.Build.0 = Release|Any CPU + {E91B9869-DF5B-4878-9B75-166B34068376}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E91B9869-DF5B-4878-9B75-166B34068376}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E91B9869-DF5B-4878-9B75-166B34068376}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E91B9869-DF5B-4878-9B75-166B34068376}.Release|Any CPU.Build.0 = Release|Any CPU + {03D7900C-F24C-4EA9-9570-39C426DFEE8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03D7900C-F24C-4EA9-9570-39C426DFEE8C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03D7900C-F24C-4EA9-9570-39C426DFEE8C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03D7900C-F24C-4EA9-9570-39C426DFEE8C}.Release|Any CPU.Build.0 = Release|Any CPU + {2AC54609-A42B-417E-8BDE-B26D17D82762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2AC54609-A42B-417E-8BDE-B26D17D82762}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2AC54609-A42B-417E-8BDE-B26D17D82762}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2AC54609-A42B-417E-8BDE-B26D17D82762}.Release|Any CPU.Build.0 = Release|Any CPU + {EF51697D-2206-4B26-930C-E0286FCC66F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF51697D-2206-4B26-930C-E0286FCC66F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF51697D-2206-4B26-930C-E0286FCC66F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF51697D-2206-4B26-930C-E0286FCC66F0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {56961E57-86EE-4418-B877-7AD220F18A11} = {1383E084-1205-40AC-9ACD-B591E7EB575B} + {1C38EB66-CC4E-4BBB-975B-481BA7B68F48} = {1383E084-1205-40AC-9ACD-B591E7EB575B} + {E91B9869-DF5B-4878-9B75-166B34068376} = {1383E084-1205-40AC-9ACD-B591E7EB575B} + {03D7900C-F24C-4EA9-9570-39C426DFEE8C} = {8FB65584-E4E7-44C6-AA41-AEB7E130B18C} + {2AC54609-A42B-417E-8BDE-B26D17D82762} = {8FB65584-E4E7-44C6-AA41-AEB7E130B18C} + {EF51697D-2206-4B26-930C-E0286FCC66F0} = {8FB65584-E4E7-44C6-AA41-AEB7E130B18C} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 44c93d608..bda3e2827 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,28 @@ -# powerplant-coding-challenge +# Powerplant Coding Challenge +This project contains an API written in .NET8 with the Clean Architecture principles alongside DDD, MediaR for mapping objects betweens layers. +The project also contain unit tests and integration tests using xUnit for the integration tests and nUnit for unit tests. -## Welcome ! +## Dependencies -Below you can find the description of a coding challenge that we ask people to perform when applying for a job in our team. +The project uses the following dependencies: -The goal of this coding challenge is to provide the applicant some insight into the business we're in and as such provide the applicant an indication about the challenges she/he will be confronted with. Next, during the first interview we will use the applicant's implementation as a seed to discuss all kinds of interesting software engineering topics. +- **MediaR**: A lightweight library that provides a mediator pattern implementation for .NET. +- **FluentValidation & FluentAssertion**: A validation library that provides a fluent API for validating objects. +- **Moq**: A library that help to mock external dependencies. +- **.NET8 SDK** +- A working IDE like Rider or Visual Studio -Time is scarce, we know. Therefore we ask you not to spend more than 4 hours on this challenge. We know it is not possible to deliver a finished implementation of the challenge in only four hours. Even though your submission will not be complete, it will provide us plenty of information and topics to discuss later on during the talks. +## Getting Started -This coding-challenge is part of a formal process and is used in collaboration with the recruiting companies we work with. Submitting a pull-request will not automatically trigger the recruitement process. -## Who are we +1. Clone this repository +2. Open the solution file `PowerPlantCodingChallenge.sln` in your preferred IDE. +3. Build the project in order to get the Nuget packages (ctrl+shift+b for the shortcut). +4. Select the configuration `PowerPlant.Api: http` or `PowerPlant.Api: https` and run the API project. +5. A swagger UI will be trigerred in your browser on the following url `http://localhost:8888/swagger/index.html`. +6. Use the `POST /ProductionPlan` to send the payload that you want to the following url exposed `http://localhost:8888/ProductionPlan` -We are the IS team of the 'Short-term Power as-a-Service' (a.k.a. SPaaS) team within [GEM](https://gems.engie.com/). - -[GEM](https://gems.engie.com/), which stands for 'Global Energy Management', is the energy management arm of [ENGIE](https://www.engie.com/), one of the largest global energy players, -with access to local markets all over the world. - -SPaaS is a team consisting of around 100 people with experience in energy markets, IT and modeling. In smaller teams consisting of a mix of people with different experiences, we are active on the [day-ahead](https://en.wikipedia.org/wiki/European_Power_Exchange#Day-ahead_markets) market, [intraday markets](https://en.wikipedia.org/wiki/European_Power_Exchange#Intraday_markets) and [collaborate with the TSO to balance the grid continuously](https://en.wikipedia.org/wiki/Transmission_system_operator#Electricity_market_operations). - -## The challenge - -### In short -Calculate how much power each of a multitude of different [powerplants](https://en.wikipedia.org/wiki/Power_station) need to produce (a.k.a. the production-plan) when the [load](https://en.wikipedia.org/wiki/Load_profile) is given and taking into account the cost of the underlying energy sources (gas, kerosine) and the Pmin and Pmax of each powerplant. - -### More in detail - -The load is the continuous demand of power. The total load at each moment in time is forecasted. For instance for Belgium you can see the load forecasted by the grid operator [here](https://www.elia.be/en/grid-data/load-and-load-forecasts). - -At any moment in time, all available powerplants need to generate the power to exactly match the load. The cost of generating power can be different for every powerplant and is dependent on external factors: The cost of producing power using a [turbojet](https://en.wikipedia.org/wiki/Gas_turbine#Industrial_gas_turbines_for_power_generation), that runs on kerosine, is higher compared to the cost of generating power using a gas-fired powerplant because of gas being cheaper compared to kerosine and because of the [thermal efficiency](https://en.wikipedia.org/wiki/Thermal_efficiency) of a gas-fired powerplant being around 50% (2 units of gas will generate 1 unit of electricity) while that of a turbojet is only around 30%. The cost of generating power using windmills however is zero. Thus deciding which powerplants to activate is dependent on the [merit-order](https://en.wikipedia.org/wiki/Merit_order). - -When deciding which powerplants in the merit-order to activate (a.k.a. [unit-commitment problem](https://en.wikipedia.org/wiki/Unit_commitment_problem_in_electrical_power_production)) the maximum amount of power each powerplant can produce (Pmax) obviously needs to be taken into account. Additionally gas-fired powerplants generate a certain minimum amount of power when switched on, called the Pmin. - - -### Performing the challenge - -Build a REST API exposing an endpoint `/productionplan` that accepts a POST of which the body contains a payload as you can find in the `example_payloads` directory and that returns a json with the same structure as in `example_response.json` and that manages and logs run-time errors. - -For calculating the unit-commitment, we prefer you not to rely on an existing (linear-programming) solver but instead write an algorithm yourself. - -Implementations can be submitted in either C# (on .Net 5 or higher) or Python (3.8 or higher) as these are (currently) the main languages we use in SPaaS. Along with the implementation should be a README that describes how to compile (if applicable) and launch the application. - -- C# implementations should contain a project file to compile the application. -- Python implementations should contain a `requirements.txt` or a `pyproject.toml` (for use with poetry) to install all needed dependencies. - -#### Payload +## Payload The payload contains 3 types of data: - load: The load is the amount of energy (MWh) that need to be generated during one hour. @@ -60,40 +38,41 @@ The payload contains 3 types of data: - pmax: the maximum amount of power the powerplant can generate. - pmin: the minimum amount of power the powerplant generates when switched on. -#### response - -The response should be a json as in `example_payloads/response3.json`, which is the expected answer for `example_payloads/payload3.json`, specifying for each powerplant how much power each powerplant should deliver. The power produced by each powerplant has to be a multiple of 0.1 Mw and the sum of the power produced by all the powerplants together should equal the load. - -### Want more challenge? - -Having fun with this challenge and want to make it more realistic. Optionally, do one of the extra's below: - -#### Docker - -Provide a Dockerfile along with the implementation to allow deploying your solution quickly. - -#### CO2 - -Taken into account that a gas-fired powerplant also emits CO2, the cost of running the powerplant should also take into account the cost of the [emission allowances](https://en.wikipedia.org/wiki/Carbon_emission_trading). For this challenge, you may take into account that each MWh generated creates 0.3 ton of CO2. - -## Acceptance criteria - -For a submission to be reviewed as part of an application for a position in the team, the project needs to: - - contain a README.md explaining how to build and launch the API - - expose the API on port `8888` - -Failing to comply with any of these criteria will automatically disqualify the submission. - -## More info - -For more info on energy management, check out: - - - [Global Energy Management Solutions](https://www.youtube.com/watch?v=SAop0RSGdHM) - - [COO hydroelectric power station](https://www.youtube.com/watch?v=edamsBppnlg) - - [Management of supply](https://www.youtube.com/watch?v=eh6IIQeeX3c) - video made during winter 2018-2019 - -## FAQ - -##### Can an existing solver be used to calculate the unit-commitment -Implementations should not rely on an external solver and thus contain an algorithm written from scratch (clarified in the text as of version v1.1.0) - + Exemple value of the payload : + +```json +{ + "load": 20, + "fuels": { + "gas(euro/MWh)": 1.0, + "kerosine(euro/MWh)": 1.0, + "co2(euro/ton)": 1.0, + "wind(%)": 60 + }, + "powerplants": [ + { + "name": "windpark", + "type": "windturbine", + "efficiency": 1, + "pmin": 0, + "pmax": 100 + } + ] +} +``` + +## Response + +Each powerplant will specify how much power they should deliver. +The power produced by each powerplant is a multiple of 0.1 Mw and the sum of the power produced by all the powerplants together is equal the load. + +Exemple value of the Response : + +```json +[ + { + "name": "windpark", + "p": 20.0 + } +] +``` \ No newline at end of file diff --git a/example_payloads/payload1.json b/example_payloads/payload1.json deleted file mode 100644 index b377475fb..000000000 --- a/example_payloads/payload1.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "load": 480, - "fuels": - { - "gas(euro/MWh)": 13.4, - "kerosine(euro/MWh)": 50.8, - "co2(euro/ton)": 20, - "wind(%)": 60 - }, - "powerplants": [ - { - "name": "gasfiredbig1", - "type": "gasfired", - "efficiency": 0.53, - "pmin": 100, - "pmax": 460 - }, - { - "name": "gasfiredbig2", - "type": "gasfired", - "efficiency": 0.53, - "pmin": 100, - "pmax": 460 - }, - { - "name": "gasfiredsomewhatsmaller", - "type": "gasfired", - "efficiency": 0.37, - "pmin": 40, - "pmax": 210 - }, - { - "name": "tj1", - "type": "turbojet", - "efficiency": 0.3, - "pmin": 0, - "pmax": 16 - }, - { - "name": "windpark1", - "type": "windturbine", - "efficiency": 1, - "pmin": 0, - "pmax": 150 - }, - { - "name": "windpark2", - "type": "windturbine", - "efficiency": 1, - "pmin": 0, - "pmax": 36 - } - ] -} diff --git a/example_payloads/payload2.json b/example_payloads/payload2.json deleted file mode 100644 index f3c7525db..000000000 --- a/example_payloads/payload2.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "load": 480, - "fuels": - { - "gas(euro/MWh)": 13.4, - "kerosine(euro/MWh)": 50.8, - "co2(euro/ton)": 20, - "wind(%)": 0 - }, - "powerplants": [ - { - "name": "gasfiredbig1", - "type": "gasfired", - "efficiency": 0.53, - "pmin": 100, - "pmax": 460 - }, - { - "name": "gasfiredbig2", - "type": "gasfired", - "efficiency": 0.53, - "pmin": 100, - "pmax": 460 - }, - { - "name": "gasfiredsomewhatsmaller", - "type": "gasfired", - "efficiency": 0.37, - "pmin": 40, - "pmax": 210 - }, - { - "name": "tj1", - "type": "turbojet", - "efficiency": 0.3, - "pmin": 0, - "pmax": 16 - }, - { - "name": "windpark1", - "type": "windturbine", - "efficiency": 1, - "pmin": 0, - "pmax": 150 - }, - { - "name": "windpark2", - "type": "windturbine", - "efficiency": 1, - "pmin": 0, - "pmax": 36 - } - ] -} diff --git a/example_payloads/payload3.json b/example_payloads/payload3.json deleted file mode 100644 index bd28884ce..000000000 --- a/example_payloads/payload3.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "load": 910, - "fuels": - { - "gas(euro/MWh)": 13.4, - "kerosine(euro/MWh)": 50.8, - "co2(euro/ton)": 20, - "wind(%)": 60 - }, - "powerplants": [ - { - "name": "gasfiredbig1", - "type": "gasfired", - "efficiency": 0.53, - "pmin": 100, - "pmax": 460 - }, - { - "name": "gasfiredbig2", - "type": "gasfired", - "efficiency": 0.53, - "pmin": 100, - "pmax": 460 - }, - { - "name": "gasfiredsomewhatsmaller", - "type": "gasfired", - "efficiency": 0.37, - "pmin": 40, - "pmax": 210 - }, - { - "name": "tj1", - "type": "turbojet", - "efficiency": 0.3, - "pmin": 0, - "pmax": 16 - }, - { - "name": "windpark1", - "type": "windturbine", - "efficiency": 1, - "pmin": 0, - "pmax": 150 - }, - { - "name": "windpark2", - "type": "windturbine", - "efficiency": 1, - "pmin": 0, - "pmax": 36 - } - ] -} diff --git a/example_payloads/response3.json b/example_payloads/response3.json deleted file mode 100644 index 1dd9ed852..000000000 --- a/example_payloads/response3.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "name": "windpark1", - "p": 90.0 - }, - { - "name": "windpark2", - "p": 21.6 - }, - { - "name": "gasfiredbig1", - "p": 460.0 - }, - { - "name": "gasfiredbig2", - "p": 338.4 - }, - { - "name": "gasfiredsomewhatsmaller", - "p": 0.0 - }, - { - "name": "tj1", - "p": 0.0 - } -] \ No newline at end of file