From 07b8369568eaf843e9d7506aec82244fd7a5743d Mon Sep 17 00:00:00 2001 From: Manuel57 Date: Fri, 17 Oct 2025 11:35:26 +0200 Subject: [PATCH] SIANXKE-473: implement solver that directly uses the Gurobi API --- .github/workflows/publish.yml | 10 +- CHANGELOG.md | 7 +- README.md | 41 ++- ...Anexia.MathematicalProgram.Examples.csproj | 2 +- .../Anexia.MathematicalProgram.csproj | 3 +- .../Model/Expression/Constraint.cs | 13 +- .../Model/Expression/IConstraint.cs | 5 + .../Result/ResultHandling.cs | 66 ++++ .../Result/SolverResultStatus.cs | 4 +- .../Solve/GurobiNativeSolver.cs | 110 +++++++ .../Solve/IlpSolver.cs | 42 ++- .../Solve/SolverFactory.cs | 2 +- .../SolverConfiguration/ILPSolverType.cs | 6 + .../SolverConfiguration/SolverParameter.cs | 27 +- .../Anexia.MathematicalProgram.Tests.csproj | 2 +- .../Extensions/EnumExtensionTest.cs | 33 ++ .../OptimizationSolverExtensionTest.cs | 68 +++++ .../Model/ConstraintBuilderTest.cs | 37 ++- .../Model/ConstraintTest.cs | 117 ++++++++ .../Model/IntervalTest.cs | 55 +++- .../Model/ScalarTest.cs | 57 ++++ .../Result/ResultHandlingTest.cs | 202 +++++++++++++ .../Result/SolutionValuesTest.cs | 64 ++++ .../Solve/GurobiNativeSolverTest.cs | 283 ++++++++++++++++++ .../Solve/IlpSolverTest.cs | 113 ++++++- .../SolverParameterTest.cs | 60 ++++ 26 files changed, 1392 insertions(+), 37 deletions(-) create mode 100644 src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Extensions/EnumExtensionTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Extensions/OptimizationSolverExtensionTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Model/ScalarTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Result/ResultHandlingTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs create mode 100644 test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 08fa58f..7bec446 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,7 @@ on: push: tags: - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+" jobs: deploy: @@ -10,20 +11,15 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Setup .NET SDKs - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v4 with: dotnet-version: | 6.0.x 8.0.x - - name: Verify commit exists in origin/main - run: | - git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* - git branch --remote --contains | grep origin/main - - name: Set VERSION variable from tag run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a75cd2..e3d59ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.8.0] - 2026-07-10 +## [2.9.0] - 2026-07-10 ### Added -- Update Version of Google OR Tools to 9.15. +- Added possibility to use Gurobi Solver directly via the API, not using Google OR-Tools. + +### Changed +- Usage of AdditionalSolverParmateters. Changed list of string to list of key-value pairs. ## [2.7.0] - 2025-11-10 diff --git a/README.md b/README.md index a238174..52d2750 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![](https://img.shields.io/nuget/v/Anexia.MathematicalProgram "NuGet version badge")](https://www.nuget.org/packages/Anexia.MathematicalProgram) [![](https://github.com/anexia/dotnetcore-mathematical-program/actions/workflows/test.yml/badge.svg?branch=main "Test status")](https://github.com/anexia/dotnetcore-mathematical-program/actions/workflows/test.yml) -[![codecov.io](https://codecov.io/github/Anexia/dotnetcore-mathematical-program/coverage.svg?branch=main "Code coverage")](https://codecov.io/github/Anexia/dotnetcore-mathematical-program/coverage.svg?branch=main) +[![codecov.io](https://codecov.io/github/Anexia/dotnetcore-mathematical-program/coverage.svg?branch=main "Code coverage")](https://codecov.io/github/anexia/dotnetcore-mathematical-program/coverage.svg?branch=main) This library allows you to build and solve linear programs and integer linear programs in a very handy way. For linear programs, either [SCIP](https://www.scipopt.org/) or Google's [GLOP](https://developers.google.com/optimization/lp/lp_example) solver can be used. For integer linear programs, SCIP, Gurobi and the Coin-OR CBC branch and cut @@ -66,6 +66,42 @@ var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel Further detailed examples can be found in the [examples folder](examples). +## Solver parameters (SolverParameter) + +You can control solver behavior using the SolverParameter record in Anexia.MathematicalProgram.SolverConfiguration. Common fields: + +- EnableSolverOutput: toggles solver console logs. +- TimeLimitInMilliseconds: overall time limit. +- NumberOfThreads: caps thread usage when supported by the solver. +- RelativeGap: early stopping gap (when supported by the solver). +- AdditionalSolverSpecificParameters: extra key/value pairs passed straight to the underlying solver. +- ExportModelFilePath: path to export the model (MPS or solver-specific format depending on backend). + +Examples: + +Use with native Gurobi API (GurobiNativeSolver): +``` +var native = new GurobiNativeSolver(); +var result = native.Solve(optimizationModel, + new SolverParameter( + new EnableSolverOutput(true), + NumberOfThreads: new NumberOfThreads(8), + RelativeGap: RelativeGap.EMinus7, + AdditionalSolverSpecificParameters: new[] + { + ("MIPFocus", "1"), + ("Heuristics", "0.05") + }, + ExportModelFilePath: "model.mps" + ) +); +``` + +Notes: +- For Gurobi parameters, see https://docs.gurobi.com/projects/optimizer/en/current/reference/parameters.html#secparameterreference +- The AdditionalSolverSpecificParameters are forwarded as-is. +- NumberOfThreads, TimeLimitInMilliseconds, and RelativeGap is mapped to the solver’s native time limit. + ## Contributing Contributions are welcomed! Read the [Contributing Guide](CONTRIBUTING.md) for more information. @@ -73,6 +109,3 @@ Contributions are welcomed! Read the [Contributing Guide](CONTRIBUTING.md) for m ## Licensing This project is licensed under MIT License. See [LICENSE](LICENSE) for more information. - - - diff --git a/examples/Anexia.MathematicalProgram.Examples/Anexia.MathematicalProgram.Examples.csproj b/examples/Anexia.MathematicalProgram.Examples/Anexia.MathematicalProgram.Examples.csproj index d5238f3..dfd29d8 100644 --- a/examples/Anexia.MathematicalProgram.Examples/Anexia.MathematicalProgram.Examples.csproj +++ b/examples/Anexia.MathematicalProgram.Examples/Anexia.MathematicalProgram.Examples.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Anexia.MathematicalProgram/Anexia.MathematicalProgram.csproj b/src/Anexia.MathematicalProgram/Anexia.MathematicalProgram.csproj index f7aca4f..733f992 100644 --- a/src/Anexia.MathematicalProgram/Anexia.MathematicalProgram.csproj +++ b/src/Anexia.MathematicalProgram/Anexia.MathematicalProgram.csproj @@ -22,7 +22,8 @@ - + + diff --git a/src/Anexia.MathematicalProgram/Model/Expression/Constraint.cs b/src/Anexia.MathematicalProgram/Model/Expression/Constraint.cs index 918479b..18ddb85 100644 --- a/src/Anexia.MathematicalProgram/Model/Expression/Constraint.cs +++ b/src/Anexia.MathematicalProgram/Model/Expression/Constraint.cs @@ -22,13 +22,14 @@ public readonly record struct IConstraint where TVariable : IVariable where TInterval : IAddableScalar - where TVariableCoefficient : IAddableScalar + where TVariableCoefficient : IAddableScalar { internal Constraint(IWeightedSum weightedSum, - IInterval interval) + IInterval interval, string? name = null) { WeightedSum = weightedSum; Interval = interval; + Name = name; } /// @@ -41,7 +42,13 @@ internal Constraint(IWeightedSum wei /// public IInterval Interval { get; } + /// + /// The constraint's name. + /// + public string? Name { get; } + /// [ExcludeFromCodeCoverage] - public override string ToString() => $"{Interval.LowerBound} <= {WeightedSum} <= {Interval.UpperBound}"; + public override string ToString() => + $"{Name ?? ""}: {Interval.LowerBound} <= {WeightedSum} <= {Interval.UpperBound}"; } \ No newline at end of file diff --git a/src/Anexia.MathematicalProgram/Model/Expression/IConstraint.cs b/src/Anexia.MathematicalProgram/Model/Expression/IConstraint.cs index 018a97a..0bb0c75 100644 --- a/src/Anexia.MathematicalProgram/Model/Expression/IConstraint.cs +++ b/src/Anexia.MathematicalProgram/Model/Expression/IConstraint.cs @@ -23,4 +23,9 @@ public interface IConstraint whe /// The constraint's interval. /// public IInterval Interval { get; } + + /// + /// The constraint's name. + /// + public string? Name { get; } } \ No newline at end of file diff --git a/src/Anexia.MathematicalProgram/Result/ResultHandling.cs b/src/Anexia.MathematicalProgram/Result/ResultHandling.cs index f51d495..57b796f 100644 --- a/src/Anexia.MathematicalProgram/Result/ResultHandling.cs +++ b/src/Anexia.MathematicalProgram/Result/ResultHandling.cs @@ -11,6 +11,7 @@ using Anexia.MathematicalProgram.Solve; using Google.OrTools.ModelBuilder; using Google.OrTools.Sat; +using Gurobi; namespace Anexia.MathematicalProgram.Result; @@ -60,6 +61,34 @@ internal static ISolverResult }; } + internal static ISolverResult + Handle(int resultStatus, + bool switchedToDefaultSolver, + ISolutionValues? solutionValues = null, + double? objectiveValue = null, + double? bestBound = null) where TVariable : IVariable + where TVariableInterval : IAddableScalar + { + return resultStatus switch + { + GRB.Status.OPTIMAL => objectiveValue is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.Optimal, switchedToDefaultSolver, solutionValues, objectiveValue, + bestBound, true, true), + GRB.Status.INFEASIBLE => SolverResult( + SolverResultStatus.Infeasible, switchedToDefaultSolver), + GRB.Status.UNBOUNDED => SolverResult( + SolverResultStatus.Unbounded, switchedToDefaultSolver), + GRB.Status.INTERRUPTED => SolverResult( + SolverResultStatus.CancelledByUser, switchedToDefaultSolver), + GRB.Status.INF_OR_UNBD => SolverResult( + SolverResultStatus.InfOrUnbound, switchedToDefaultSolver), + GRB.Status.TIME_LIMIT => SolverResult( + SolverResultStatus.Timelimit, switchedToDefaultSolver), + _ => throw new MathematicalProgramException($"Unknown result status in solver. {resultStatus}") + }; + } + internal static ISolverResult Handle(CpSolverStatus resultStatus, ISolutionValues? solutionValues = null, @@ -90,6 +119,43 @@ internal static ISolverResult }; } + internal static ISolverResult HandleGurobi(int resultStatus, + ISolutionValues? solutionValues = null, + double? objectiveValue = null, + double? bestBound = null) where TVariableInterval : IAddableScalar + where TVariable : IVariable + { + return resultStatus switch + { + GRB.Status.OPTIMAL => objectiveValue is null || bestBound is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.Optimal, false, solutionValues, objectiveValue, + bestBound, true, true), + GRB.Status.SUBOPTIMAL => objectiveValue is null || bestBound is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.Feasible, false, solutionValues, objectiveValue, + bestBound, true), + GRB.Status.TIME_LIMIT => objectiveValue is null || bestBound is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.Timelimit, false, solutionValues, objectiveValue, + bestBound, true), + GRB.Status.INTERRUPTED => objectiveValue is null || bestBound is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.CancelledByUser, false, solutionValues, objectiveValue, + bestBound, true), + GRB.Status.MEM_LIMIT => objectiveValue is null || bestBound is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.UnknownStatus, false, solutionValues, objectiveValue, + bestBound, true), + GRB.Status.UNBOUNDED => objectiveValue is null || bestBound is null + ? throw new MathematicalProgramException("Mathematical program could not be solved.") + : SolverResult(SolverResultStatus.Unbounded, false), + + _ => throw new MathematicalProgramException($"Unknown result status in solver. {resultStatus}") + }; + } + private static ISolverResult SolverResult(SolverResultStatus resultStatus, bool switchedToDefaultSolver, diff --git a/src/Anexia.MathematicalProgram/Result/SolverResultStatus.cs b/src/Anexia.MathematicalProgram/Result/SolverResultStatus.cs index fc7b57a..d0e765c 100644 --- a/src/Anexia.MathematicalProgram/Result/SolverResultStatus.cs +++ b/src/Anexia.MathematicalProgram/Result/SolverResultStatus.cs @@ -24,5 +24,7 @@ public enum SolverResultStatus ModelInvalid, InvalidSolverParameters, SolverTypeUnavailable, - IncompatibleOptions + IncompatibleOptions, + InfOrUnbound, + Timelimit } \ No newline at end of file diff --git a/src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs b/src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs new file mode 100644 index 0000000..02bd9cd --- /dev/null +++ b/src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA® Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.Model; +using Anexia.MathematicalProgram.Model.Scalar; +using Anexia.MathematicalProgram.Model.Variable; +using Anexia.MathematicalProgram.Result; +using Anexia.MathematicalProgram.SolverConfiguration; +using Gurobi; +using Microsoft.Extensions.Logging; + +namespace Anexia.MathematicalProgram.Solve; + +public sealed class GurobiNativeSolver( + ILogger? logger = null) + : MemberwiseEquatable, + IOptimizationSolver, IRealScalar, IRealScalar, RealScalar> +{ + public ISolverResult, RealScalar, IRealScalar> Solve( + ICompletedOptimizationModel, IRealScalar, IRealScalar> model, + SolverParameter solverParameter) + { + try + { + var env = new GRBEnv(true); + foreach (var (key, value) in solverParameter.ToSolverSpecificParametersList(IlpSolverType + .GurobiIntegerProgramming)) + { + env.Set(key, value); + } + + if (solverParameter.TimeLimitInMilliseconds is not null) + env.TimeLimit = solverParameter.TimeLimitInMilliseconds.AsSeconds; + + env.LogToConsole = solverParameter.EnableSolverOutput.Value ? 1 : 0; + + env.Start(); + + var gurobiModel = new GRBModel(env); + + var variables = model.Variables.ToDictionary( + item => item, item => item switch + { + IntegerVariable or IntegerVariable or + IntegerVariable or IntegerVariable => gurobiModel.AddVar( + item.Interval.LowerBound.Value, + item.Interval.UpperBound.Value, 0, GRB.INTEGER, item.Name), + BinaryVariable or IntegerVariable => gurobiModel.AddVar( + item.Interval.LowerBound.Value, + item.Interval.UpperBound.Value, 0, GRB.BINARY, item.Name), + _ => throw new ArgumentOutOfRangeException(nameof(item), item, "Variable type not supported.") + }); + + var constraintNumber = 1; + foreach (var constraint in model.Constraints) + { + var termsExpression = constraint.WeightedSum + .Aggregate( + new GRBLinExpr(), (expression, term) => + { + expression.AddTerm(term.Coefficient.Value, variables[term.Variable]); + return expression; + }); + + gurobiModel.AddConstr(constraint.Interval.LowerBound.Value, GRB.LESS_EQUAL, termsExpression, + constraint.Name ?? $"{constraintNumber++}"); + gurobiModel.AddConstr(termsExpression, GRB.LESS_EQUAL, constraint.Interval.UpperBound.Value, + constraint.Name ?? $"{constraintNumber++}"); + } + + gurobiModel.SetObjective(model.ObjectiveFunction.WeightedSum + .Aggregate( + new GRBLinExpr(model.ObjectiveFunction.Offset?.Value ?? 0), (expression, term) => + { + expression.AddTerm(term.Coefficient.Value, variables[term.Variable]); + return expression; + }), model.ObjectiveFunction.Maximize ? GRB.MAXIMIZE : GRB.MINIMIZE); + + gurobiModel.Optimize(); + + if (solverParameter.ExportModelFilePath is not null) + { + logger?.LogInformation("Exporting model to {ExportModelFilePath}", solverParameter.ExportModelFilePath); + + gurobiModel.Write(solverParameter.ExportModelFilePath); + } + + if (gurobiModel.SolCount == 0) + return ResultHandling.Handle, RealScalar, IRealScalar>(gurobiModel.Status, + false); + + var solutionValues = new SolutionValues, RealScalar, IRealScalar>( + variables.ToDictionary( + variable => variable.Key, + variable => new RealScalar(gurobiModel.GetVarByName(variable.Key.Name).X)).AsReadOnly()); + + return ResultHandling.HandleGurobi(gurobiModel.Status, + solutionValues, gurobiModel.ObjVal, + gurobiModel.ObjBound); + } + catch (Exception exception) + { + logger?.LogError(exception, "An error occurred during solving the model: {EMessage}", exception.Message); + throw new MathematicalProgramException(exception); + } + } +} \ No newline at end of file diff --git a/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs b/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs index f24eeda..c7b37b6 100644 --- a/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs +++ b/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs @@ -7,7 +7,9 @@ using Anexia.MathematicalProgram.Result; using Anexia.MathematicalProgram.SolverConfiguration; using Google.OrTools.ModelBuilder; +using Gurobi; using Microsoft.Extensions.Logging; +using static Google.OrTools.Init.operations_research_init; namespace Anexia.MathematicalProgram.Solve; @@ -26,7 +28,7 @@ public sealed class IlpSolver( private ILogger? Logger { get; } = logger; /// - /// Solves the given optimization model. Switches solver to SCIP, then the given type is not available. + /// Solves the given optimization model. Switches solver to SCIP, when the given type is not available. /// /// The model to be solved. /// Parameters to be passed to the underlying solver. @@ -36,6 +38,26 @@ public ISolverResult, RealScalar, IRealScalar> Sol completedOptimizationModel, SolverParameter solverParameter) { + if (SolverType == IlpSolverType.GurobiNativeIntegerProgramming) + { + try + { + return new GurobiNativeSolver().Solve(completedOptimizationModel, + solverParameter); + } + catch (MathematicalProgramException exception) when (exception.InnerException is GRBException) + { + if (exception.Message.Contains("No Gurobi license found")) + { + Logger.LogInformation("No Gurobi licence found. Original Exception {Exception}", exception); + return new IlpSolver(FallbackSolver, FallbackSolver, Logger).Solve(completedOptimizationModel, + solverParameter); + } + + throw; + } + } + var (configuredSolver, solverWasSwitched) = InitializeSolver(solverParameter); if (configuredSolver is null) @@ -88,6 +110,24 @@ IntegerVariable or IntegerVariable or configuredSolver.BestObjectiveBound); } + /// + /// Solves the given optimization model directly using the specified solver API. Switches solver to specified default solver, when the given type is not available. + /// + /// The model to be solved. + /// Parameters to be passed to the underlying solver. + /// Solver result containing solution information. + public ISolverResult, RealScalar, IRealScalar> SolveWithoutORTools( + ICompletedOptimizationModel, IRealScalar, IRealScalar> + completedOptimizationModel, + SolverParameter solverParameter) => + SolverType switch + { + IlpSolverType.GurobiIntegerProgramming => new GurobiNativeSolver().Solve(completedOptimizationModel, + solverParameter), + _ => throw new NotImplementedException( + "The specified type is not yet implemented. Use OR Tools for solving") + }; + /// /// Solves the given model by minimizing the objective function. /// diff --git a/src/Anexia.MathematicalProgram/Solve/SolverFactory.cs b/src/Anexia.MathematicalProgram/Solve/SolverFactory.cs index f61bb9d..1d7ad65 100644 --- a/src/Anexia.MathematicalProgram/Solve/SolverFactory.cs +++ b/src/Anexia.MathematicalProgram/Solve/SolverFactory.cs @@ -26,7 +26,7 @@ public static solverType switch { IlpSolverType.CbcIntegerProgramming => new IlpCbcSolver(), - IlpSolverType.GurobiIntegerProgramming or + IlpSolverType.GurobiNativeIntegerProgramming or IlpSolverType.GurobiIntegerProgramming or IlpSolverType.Scip or IlpSolverType.HiGhs => new IlpSolver(solverType, fallbackSolverType, logger), _ => throw new ArgumentOutOfRangeException(nameof(solverType), solverType, null) }; diff --git a/src/Anexia.MathematicalProgram/SolverConfiguration/ILPSolverType.cs b/src/Anexia.MathematicalProgram/SolverConfiguration/ILPSolverType.cs index e5448bd..45e9519 100644 --- a/src/Anexia.MathematicalProgram/SolverConfiguration/ILPSolverType.cs +++ b/src/Anexia.MathematicalProgram/SolverConfiguration/ILPSolverType.cs @@ -27,6 +27,12 @@ public enum IlpSolverType /// [EnumMember(Value = "GUROBI_MIXED_INTEGER_PROGRAMMING")] GurobiIntegerProgramming, + + /// + /// Gurobi solver. A licence is needed for usage. + /// + [EnumMember(Value = "GUROBI_NATIVE_MIXED_INTEGER_PROGRAMMING")] + GurobiNativeIntegerProgramming, /// /// SCIP solver. diff --git a/src/Anexia.MathematicalProgram/SolverConfiguration/SolverParameter.cs b/src/Anexia.MathematicalProgram/SolverConfiguration/SolverParameter.cs index 7731586..c834ed4 100644 --- a/src/Anexia.MathematicalProgram/SolverConfiguration/SolverParameter.cs +++ b/src/Anexia.MathematicalProgram/SolverConfiguration/SolverParameter.cs @@ -16,15 +16,15 @@ namespace Anexia.MathematicalProgram.SolverConfiguration; /// Time limit of the solving process. /// The number of threads that should be used by the solver. /// The relative gap when the solver should terminate. -/// Additional solver specific parameters (key-value string, use key:value for GLOP, key=value for other supported solvers) to pass to the solver. The correct format for the desired solver -/// must be used. Check corresponding solver documentations to be sure. +/// Additional solver specific parameters (key-value string pairs) to pass to the solver. The correct format for the desired solver +/// must be used. Check corresponding solver documentations to be sure. E.g., Gurobi parameters can be found here: https://docs.gurobi.com/projects/optimizer/en/current/reference/parameters.html#secparameterreference /// The file path and name of a file where the model should be written to. public record SolverParameter( EnableSolverOutput EnableSolverOutput, RelativeGap? RelativeGap = null, TimeLimitInMilliseconds? TimeLimitInMilliseconds = null, NumberOfThreads? NumberOfThreads = null, - IReadOnlyCollection? AdditionalSolverSpecificParameters = null, + IReadOnlyCollection<(string Key, string Value)>? AdditionalSolverSpecificParameters = null, string? ExportModelFilePath = null) { private const string RelativeGapKey = "RELATIVE_GAP"; @@ -109,20 +109,23 @@ public SolverParameter() { } - internal string ToSolverSpecificParameters(IlpSolverType solverType) + internal List<(string Key, string Value)> ToSolverSpecificParametersList(IlpSolverType solverType) { - var parameters = new List(); + var parameters = new List<(string Key, string Value)>(); if (NumberOfThreads is not null) - parameters.Add( - $"{IlpParameterKeyMapping[(solverType, NumberOfThreadsKey)]}={NumberOfThreads.Value}"); + parameters.Add((IlpParameterKeyMapping[(solverType, NumberOfThreadsKey)], + NumberOfThreads.Value.ToString(CultureInfo.InvariantCulture))); if (RelativeGap is not null) - parameters.Add( - $"{IlpParameterKeyMapping[(solverType, RelativeGapKey)]}={RelativeGap.Value.ToString(CultureInfo.InvariantCulture)}"); + parameters.Add((IlpParameterKeyMapping[(solverType, RelativeGapKey)], + RelativeGap.Value.ToString(CultureInfo.InvariantCulture))); if (AdditionalSolverSpecificParameters is not null) parameters.AddRange(AdditionalSolverSpecificParameters); - return string.Join(',', parameters); + return parameters; } + internal string ToSolverSpecificParameters(IlpSolverType solverType) => string.Join(',', + ToSolverSpecificParametersList(solverType).Select(parameter => $"{parameter.Key}={parameter.Value}")); + internal string ToSolverSpecificParameters(LpSolverType solverType) { var parameters = new List(); @@ -130,7 +133,9 @@ internal string ToSolverSpecificParameters(LpSolverType solverType) parameters.Add( $"{LpParameterKeyMapping[(solverType, NumberOfThreadsKey)]}{LpKeyValueSeparators[solverType]}{NumberOfThreads.Value}"); - if (AdditionalSolverSpecificParameters is not null) parameters.AddRange(AdditionalSolverSpecificParameters); + if (AdditionalSolverSpecificParameters is not null) + parameters.AddRange(AdditionalSolverSpecificParameters.Select(parameter => + $"{parameter.Key}{LpKeyValueSeparators[solverType]}{parameter.Value}")); return string.Join(',', parameters); } diff --git a/test/Anexia.MathematicalProgram.Tests/Anexia.MathematicalProgram.Tests.csproj b/test/Anexia.MathematicalProgram.Tests/Anexia.MathematicalProgram.Tests.csproj index 727d890..cb975a5 100644 --- a/test/Anexia.MathematicalProgram.Tests/Anexia.MathematicalProgram.Tests.csproj +++ b/test/Anexia.MathematicalProgram.Tests/Anexia.MathematicalProgram.Tests.csproj @@ -15,7 +15,7 @@ - + diff --git a/test/Anexia.MathematicalProgram.Tests/Extensions/EnumExtensionTest.cs b/test/Anexia.MathematicalProgram.Tests/Extensions/EnumExtensionTest.cs new file mode 100644 index 0000000..279e353 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Extensions/EnumExtensionTest.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.Extensions; +using Anexia.MathematicalProgram.Result; +using Anexia.MathematicalProgram.SolverConfiguration; + +namespace Anexia.MathematicalProgram.Tests.Extensions; + +public sealed class EnumExtensionTest +{ + [Fact] + public void ToEnumStringReturnsEnumMemberValue() + { + Assert.Equal("GLOP", LpSolverType.Glop.ToEnumString()); + Assert.Equal("HIGHS_MIXED_INTEGER_PROGRAMMING", IlpSolverType.HiGhs.ToEnumString()); + } + + [Fact] + public void ToEnumStringReturnsEmptyStringWhenEnumMemberAttributeIsMissing() + { + Assert.Equal(string.Empty, SolverResultStatus.Optimal.ToEnumString()); + } + + [Fact] + public void ToEnumStringReturnsEmptyStringForInvalidEnumValue() + { + Assert.Equal(string.Empty, ((LpSolverType)int.MaxValue).ToEnumString()); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Extensions/OptimizationSolverExtensionTest.cs b/test/Anexia.MathematicalProgram.Tests/Extensions/OptimizationSolverExtensionTest.cs new file mode 100644 index 0000000..e1b95b0 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Extensions/OptimizationSolverExtensionTest.cs @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using System.Collections.ObjectModel; +using Anexia.MathematicalProgram.Extensions; +using Anexia.MathematicalProgram.Model; +using Anexia.MathematicalProgram.Model.Expression; +using Anexia.MathematicalProgram.Model.Interval; +using Anexia.MathematicalProgram.Model.Scalar; +using Anexia.MathematicalProgram.Model.Variable; +using Anexia.MathematicalProgram.Result; +using Anexia.MathematicalProgram.Solve; +using Anexia.MathematicalProgram.SolverConfiguration; + +namespace Anexia.MathematicalProgram.Tests.Extensions; + +public sealed class OptimizationSolverExtensionTest +{ + [Fact] + public void SolveWithoutSolverParameterUsesDefaultSolverParameter() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + var variable = model.NewVariable>(new RealInterval(0, 1), "v1"); + var completedModel = model.SetObjective( + new ObjectiveFunction, RealScalar, IRealScalar>( + null, + new WeightedSum, RealScalar, IRealScalar>().Add(variable, new RealScalar(1)), + maximize: true)); + var solver = new FakeSolver(); + + var result = solver.Solve(completedModel); + + Assert.Same(solver.Result, result); + Assert.NotNull(solver.LastSolverParameter); + Assert.Equal(EnableSolverOutput.False, solver.LastSolverParameter.EnableSolverOutput); + Assert.Null(solver.LastSolverParameter.RelativeGap); + Assert.Null(solver.LastSolverParameter.TimeLimitInMilliseconds); + Assert.Null(solver.LastSolverParameter.NumberOfThreads); + } + + private sealed class FakeSolver : + IOptimizationSolver, RealScalar, IRealScalar, RealScalar> + { + internal readonly ISolverResult, RealScalar, IRealScalar> Result = + new SolverResult, RealScalar, IRealScalar>( + new SolutionValues, RealScalar, IRealScalar>( + ReadOnlyDictionary, RealScalar>.Empty), + null, + new IsFeasible(false), + new IsOptimal(false), + null, + SolverResultStatus.NotSolved, + false); + + internal SolverParameter? LastSolverParameter { get; private set; } + + public ISolverResult, RealScalar, IRealScalar> Solve( + ICompletedOptimizationModel, RealScalar, IRealScalar> model, + SolverParameter solverParameter) + { + LastSolverParameter = solverParameter; + return Result; + } + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs index b368cd0..f5ae45c 100644 --- a/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs @@ -58,6 +58,41 @@ public void ConstraintBuilderAddWeightedSumReturnsCorrectResult() constraint); } + [Fact] + public void ConstraintBuilderAddExistingWeightedSumReturnsCorrectResult() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + + var v1 = model.NewVariable>(new RealInterval(0, 1), "v1"); + var v2 = model.NewVariable>(new RealInterval(0, 2), "v2"); + var weightedSum = model.CreateWeightedSumBuilder() + .AddTermToSum(1, v1) + .AddTermToSum(2, v2) + .Build(); + + var constraint = model.CreateConstraintBuilder() + .AddTermToSum(3, v1) + .AddWeightedSum(weightedSum) + .Build(new IntegralInterval(-10, 20)); + + Assert.Equal( + Constraint(WeightedSum((v1, 4), (v2, 2)), Interval(-10, 20)), + constraint); + } + + [Fact] + public void ConstraintBuilderBuildsEmptyConstraintWhenNoTermsWereAdded() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + + var constraint = model.CreateConstraintBuilder() + .Build(new IntegralInterval(0, 0)); + + Assert.Empty(constraint.WeightedSum); + Assert.Equal(new IntegralInterval(0, 0), constraint.Interval); + Assert.Null(constraint.Name); + } + [Fact] public void AddWeightedSumThrowsCorrectException() { @@ -76,4 +111,4 @@ public void AddWeightedSumThrowsCorrectException() "Number of weights must match number of variables. Weights: 2, Variables: 3", exception.Message); } -} \ No newline at end of file +} diff --git a/test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs new file mode 100644 index 0000000..f317cf2 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using System.Collections; +using Anexia.MathematicalProgram.Model; +using Anexia.MathematicalProgram.Model.Expression; +using Anexia.MathematicalProgram.Model.Interval; +using Anexia.MathematicalProgram.Model.Scalar; +using Anexia.MathematicalProgram.Model.Variable; +using static Anexia.MathematicalProgram.Tests.Factory.WeightedSumFactory; + +namespace Anexia.MathematicalProgram.Tests.Model; + +public sealed class ConstraintTest +{ + [Fact] + public void ConstraintKeepsWeightedSumIntervalAndName() + { + var (weightedSum, interval) = CreateWeightedSumAndInterval(); + + var constraint = new Constraint, RealScalar, IRealScalar>( + weightedSum, + interval, + "capacity"); + + Assert.Same(weightedSum, constraint.WeightedSum); + Assert.Same(interval, constraint.Interval); + Assert.Equal("capacity", constraint.Name); + } + + [Fact] + public void ConstraintsWithEquivalentMembersAreEqual() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + var variable = model.NewVariable>(new RealInterval(0, 10), "x"); + + var first = new Constraint, RealScalar, IRealScalar>( + WeightedSum((variable, 2)), + new RealInterval(-4, 8), + "limit"); + var second = new Constraint, RealScalar, IRealScalar>( + WeightedSum((variable, 2)), + new RealInterval(-4, 8), + "limit"); + var differentName = new Constraint, RealScalar, IRealScalar>( + WeightedSum((variable, 2)), + new RealInterval(-4, 8), + "other-limit"); + + Assert.Equal(first, second); + Assert.NotEqual(first, differentName); + } + + [Fact] + public void DefaultConstraintsCollectionIsEmpty() + { + var constraints = new Constraints, RealScalar, IRealScalar>(); + + Assert.True(constraints.Count is 0); + Assert.Empty(constraints); + Assert.Empty(((IEnumerable)constraints).Cast()); + } + + [Fact] + public void ConstraintsConstructorMaterializesEnumerable() + { + var constraint = CreateConstraint("c1"); + var source = new List, RealScalar, IRealScalar>> { constraint }; + var constraints = new Constraints, RealScalar, IRealScalar>( + source.Where(_ => true)); + + source.Clear(); + + Assert.True(constraints.Count is 1); + Assert.Equal(constraint, Assert.Single(constraints)); + } + + [Fact] + public void AddReturnsNewConstraintsCollectionWithoutChangingOriginal() + { + var first = CreateConstraint("c1"); + var second = CreateConstraint("c2"); + var constraints = new Constraints, RealScalar, IRealScalar>(first); + + var updated = constraints.Add(second); + + Assert.True(constraints.Count is 1); + Assert.Equal(first, Assert.Single(constraints)); + Assert.True(updated.Count is 2); + Assert.Collection( + updated, + constraint => Assert.Equal(first, constraint), + constraint => Assert.Equal(second, constraint)); + } + + private static Constraint, RealScalar, IRealScalar> CreateConstraint(string name) + { + var (weightedSum, interval) = CreateWeightedSumAndInterval(); + + return new Constraint, RealScalar, IRealScalar>( + weightedSum, + interval, + name); + } + + private static (IWeightedSum, RealScalar, IRealScalar> WeightedSum, + IInterval Interval) CreateWeightedSumAndInterval() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + var variable = model.NewVariable>(new RealInterval(0, 10), "x"); + + return (WeightedSum((variable, 2)), new RealInterval(-4, 8)); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs index 7f4f849..e202160 100644 --- a/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs @@ -13,6 +13,15 @@ namespace Anexia.MathematicalProgram.Tests.Model; public sealed class IntervalTest { + [Fact] + public void RealIntervalKeepsInclusiveBounds() + { + var interval = new RealInterval(new RealScalar(-1.5), new RealScalar(3.25)); + + Assert.Equal(new RealScalar(-1.5), interval.LowerBound); + Assert.Equal(new RealScalar(3.25), interval.UpperBound); + } + [Theory] [InlineData(6, 5)] [InlineData(0.1, 0)] @@ -21,7 +30,16 @@ public sealed class IntervalTest public void IntervalInitializingThrowsExpectedException(double left, double right) => Assert.Throws>(() => new RealInterval(new RealScalar(left), new RealScalar(right))); - + + [Fact] + public void IntegralIntervalKeepsInclusiveBounds() + { + var interval = new IntegralInterval(new IntegerScalar(-3), new IntegerScalar(7)); + + Assert.Equal(new IntegerScalar(-3), interval.LowerBound); + Assert.Equal(new IntegerScalar(7), interval.UpperBound); + } + [Theory] [InlineData(6, 5)] [InlineData(-1, -21)] @@ -29,4 +47,37 @@ public void IntervalInitializingThrowsExpectedException(double left, double righ public void IntegralIntervalInitializingThrowsExpectedException(int left, int right) => Assert.Throws>(() => new IntegralInterval(new IntegerScalar(left), new IntegerScalar(right))); -} \ No newline at end of file + + [Fact] + public void BinaryIntervalAlwaysUsesZeroAndOneBounds() + { + var interval = new BinaryInterval(); + + Assert.Equal(BinaryScalar.Zero, interval.LowerBound); + Assert.Equal(BinaryScalar.One, interval.UpperBound); + } + + [Theory] + [InlineData(-2.5)] + [InlineData(0)] + [InlineData(4.75)] + public void PointUsesSameValueForLowerAndUpperBounds(double value) + { + var point = new Point(value); + + Assert.Equal(new RealScalar(value), point.LowerBound); + Assert.Equal(new RealScalar(value), point.UpperBound); + } + + [Theory] + [InlineData(-2)] + [InlineData(0)] + [InlineData(4)] + public void IntegralPointUsesSameValueForLowerAndUpperBounds(int value) + { + var point = new IntegralPoint(value); + + Assert.Equal(new IntegerScalar(value), point.LowerBound); + Assert.Equal(new IntegerScalar(value), point.UpperBound); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Model/ScalarTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/ScalarTest.cs new file mode 100644 index 0000000..b178144 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Model/ScalarTest.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.Model.Scalar; + +namespace Anexia.MathematicalProgram.Tests.Model; + +public sealed class ScalarTest +{ + [Fact] + public void RealScalarSupportsConcreteAndInterfaceArithmetic() + { + var left = new RealScalar(7.5); + var right = new RealScalar(2); + IRealScalar interfaceRight = right; + + Assert.Equal(new RealScalar(9.5), left.Add(right)); + Assert.Equal(new RealScalar(5.5), left.Subtract(right)); + Assert.Equal(9.5, left.Add(interfaceRight).Value); + Assert.Equal(5.5, left.Subtract(interfaceRight).Value); + Assert.Equal(new RealScalar(9.5), left + right); + Assert.Equal(new RealScalar(5.5), left - right); + } + + [Fact] + public void IntegerScalarSupportsConcreteAndInterfaceArithmetic() + { + var left = new IntegerScalar(7); + var right = new IntegerScalar(2); + IIntegerScalar integerRight = right; + IRealScalar realRight = new RealScalar(2.5); + + Assert.Equal(new IntegerScalar(9), left.Add(right)); + Assert.Equal(new IntegerScalar(5), left.Subtract(right)); + Assert.Equal(9, left.Add(integerRight).Value); + Assert.Equal(5, left.Subtract(integerRight).Value); + Assert.Equal(9.5, left.Add(realRight).Value); + Assert.Equal(4.5, left.Subtract(realRight).Value); + Assert.Equal(new IntegerScalar(9), left + right); + Assert.Equal(new IntegerScalar(5), left - right); + } + + [Fact] + public void BinaryScalarSupportsIntegerScalarArithmetic() + { + IIntegerScalar even = new IntegerScalar(4); + IIntegerScalar odd = new IntegerScalar(3); + + Assert.Equal(1, BinaryScalar.One.Add(even).Value); + Assert.Equal(0, BinaryScalar.One.Add(odd).Value); + Assert.Equal(1, BinaryScalar.One.Subtract(even).Value); + Assert.Equal(1, BinaryScalar.Zero.Subtract(odd).Value); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Result/ResultHandlingTest.cs b/test/Anexia.MathematicalProgram.Tests/Result/ResultHandlingTest.cs new file mode 100644 index 0000000..f47f2c1 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Result/ResultHandlingTest.cs @@ -0,0 +1,202 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.Model.Scalar; +using Anexia.MathematicalProgram.Model.Variable; +using Anexia.MathematicalProgram.Result; +using Anexia.MathematicalProgram.Solve; +using Google.OrTools.ModelBuilder; +using Google.OrTools.Sat; +using Gurobi; + +namespace Anexia.MathematicalProgram.Tests.Result; + +public sealed class ResultHandlingTest +{ + public static IEnumerable OrToolsModelBuilderStatusMappings() + { + yield return [SolveStatus.INFEASIBLE, SolverResultStatus.Infeasible]; + yield return [SolveStatus.UNBOUNDED, SolverResultStatus.Unbounded]; + yield return [SolveStatus.ABNORMAL, SolverResultStatus.Abnormal]; + yield return [SolveStatus.NOT_SOLVED, SolverResultStatus.NotSolved]; + yield return [SolveStatus.MODEL_INVALID, SolverResultStatus.ModelInvalid]; + yield return [SolveStatus.MODEL_IS_VALID, SolverResultStatus.ModelIsValid]; + yield return [SolveStatus.CANCELLED_BY_USER, SolverResultStatus.CancelledByUser]; + yield return [SolveStatus.UNKNOWN_STATUS, SolverResultStatus.UnknownStatus]; + yield return [SolveStatus.INVALID_SOLVER_PARAMETERS, SolverResultStatus.InvalidSolverParameters]; + yield return [SolveStatus.SOLVER_TYPE_UNAVAILABLE, SolverResultStatus.SolverTypeUnavailable]; + yield return [SolveStatus.INCOMPATIBLE_OPTIONS, SolverResultStatus.IncompatibleOptions]; + } + + public static IEnumerable GurobiNoSolutionStatusMappings() + { + yield return [GRB.Status.INFEASIBLE, SolverResultStatus.Infeasible]; + yield return [GRB.Status.UNBOUNDED, SolverResultStatus.Unbounded]; + yield return [GRB.Status.INTERRUPTED, SolverResultStatus.CancelledByUser]; + yield return [GRB.Status.INF_OR_UNBD, SolverResultStatus.InfOrUnbound]; + yield return [GRB.Status.TIME_LIMIT, SolverResultStatus.Timelimit]; + } + + public static IEnumerable GurobiSolutionStatusMappings() + { + yield return [GRB.Status.OPTIMAL, SolverResultStatus.Optimal, true]; + yield return [GRB.Status.SUBOPTIMAL, SolverResultStatus.Feasible, false]; + yield return [GRB.Status.TIME_LIMIT, SolverResultStatus.Timelimit, false]; + yield return [GRB.Status.INTERRUPTED, SolverResultStatus.CancelledByUser, false]; + yield return [GRB.Status.MEM_LIMIT, SolverResultStatus.UnknownStatus, false]; + } + + [Theory] + [MemberData(nameof(OrToolsModelBuilderStatusMappings))] + public void HandleOrToolsModelBuilderStatusMapsNonSolutionStatuses( + SolveStatus solveStatus, + SolverResultStatus expectedStatus) + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + solveStatus, + switchedToDefaultSolver: true); + + Assert.Equal(expectedStatus, result.SolverResultStatus); + Assert.True(result.SolutionValues.Empty); + Assert.Null(result.ObjectiveValue); + Assert.False(result.IsFeasible.Value); + Assert.False(result.IsOptimal.Value); + Assert.Null(result.OptimalityGap); + Assert.True(result.SwitchedToDefaultSolver); + } + + [Theory] + [MemberData(nameof(GurobiNoSolutionStatusMappings))] + public void HandleGurobiStatusMapsNonSolutionStatuses(int gurobiStatus, SolverResultStatus expectedStatus) + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + gurobiStatus, + switchedToDefaultSolver: true); + + Assert.Equal(expectedStatus, result.SolverResultStatus); + Assert.True(result.SolutionValues.Empty); + Assert.Null(result.ObjectiveValue); + Assert.False(result.IsFeasible.Value); + Assert.False(result.IsOptimal.Value); + Assert.Null(result.OptimalityGap); + Assert.True(result.SwitchedToDefaultSolver); + } + + [Fact] + public void HandleOrToolsModelBuilderOptimalStatusBuildsResultWithGap() + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + SolveStatus.OPTIMAL, + switchedToDefaultSolver: true, + objectiveValue: 12, + bestBound: 9); + + Assert.Equal(SolverResultStatus.Optimal, result.SolverResultStatus); + Assert.Equal(new ObjectiveValue(12), result.ObjectiveValue); + Assert.Equal(new OptimalityGap(0.25), result.OptimalityGap); + Assert.True(result.IsFeasible.Value); + Assert.True(result.IsOptimal.Value); + Assert.True(result.SwitchedToDefaultSolver); + } + + [Fact] + public void HandleCpOptimalStatusReturnsZeroGapWhenObjectiveAndBestBoundAreZero() + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + CpSolverStatus.Optimal, + objectiveValue: 0, + bestBound: 0); + + Assert.Equal(SolverResultStatus.Optimal, result.SolverResultStatus); + Assert.Equal(new ObjectiveValue(0), result.ObjectiveValue); + Assert.Equal(new OptimalityGap(0), result.OptimalityGap); + Assert.True(result.IsFeasible.Value); + Assert.True(result.IsOptimal.Value); + Assert.False(result.SwitchedToDefaultSolver); + } + + [Theory] + [MemberData(nameof(GurobiSolutionStatusMappings))] + public void HandleGurobiSolutionStatusBuildsExpectedResult( + int gurobiStatus, + SolverResultStatus expectedStatus, + bool expectedOptimal) + { + var result = ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + gurobiStatus, + objectiveValue: 20, + bestBound: 15); + + Assert.Equal(expectedStatus, result.SolverResultStatus); + Assert.Equal(new ObjectiveValue(20), result.ObjectiveValue); + Assert.Equal(new OptimalityGap(0.25), result.OptimalityGap); + Assert.True(result.IsFeasible.Value); + Assert.Equal(expectedOptimal, result.IsOptimal.Value); + Assert.False(result.SwitchedToDefaultSolver); + } + + [Fact] + public void HandleGurobiUnboundedStatusBuildsNonFeasibleResultWhenBoundsArePresent() + { + var result = ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + GRB.Status.UNBOUNDED, + objectiveValue: 20, + bestBound: 15); + + Assert.Equal(SolverResultStatus.Unbounded, result.SolverResultStatus); + Assert.True(result.SolutionValues.Empty); + Assert.Null(result.ObjectiveValue); + Assert.False(result.IsFeasible.Value); + Assert.False(result.IsOptimal.Value); + Assert.Null(result.OptimalityGap); + Assert.False(result.SwitchedToDefaultSolver); + } + + [Fact] + public void HandleOptimalStatusWithoutObjectiveValueThrows() + { + var exception = Assert.Throws(() => + ResultHandling.Handle, RealScalar, IRealScalar>( + SolveStatus.OPTIMAL, + switchedToDefaultSolver: false)); + + Assert.Equal("Mathematical program could not be solved.", exception.Message); + } + + [Fact] + public void HandleGurobiSolutionStatusWithoutBestBoundThrows() + { + var exception = Assert.Throws(() => + ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + GRB.Status.OPTIMAL, + objectiveValue: 1)); + + Assert.Equal("Mathematical program could not be solved.", exception.Message); + } + + [Fact] + public void HandleUnknownStatusThrows() + { + var exception = Assert.Throws(() => + ResultHandling.Handle, RealScalar, IRealScalar>( + (SolveStatus)int.MaxValue, + switchedToDefaultSolver: false)); + + Assert.Equal("Unknown result status in solver. 2147483647", exception.Message); + } + + [Fact] + public void HandleGurobiUnknownStatusThrows() + { + var exception = Assert.Throws(() => + ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + int.MaxValue, + objectiveValue: 1, + bestBound: 1)); + + Assert.Equal("Unknown result status in solver. 2147483647", exception.Message); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs b/test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs new file mode 100644 index 0000000..bf693b2 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using System.Collections.ObjectModel; +using Anexia.MathematicalProgram.Model; +using Anexia.MathematicalProgram.Model.Interval; +using Anexia.MathematicalProgram.Model.Scalar; +using Anexia.MathematicalProgram.Model.Variable; +using Anexia.MathematicalProgram.Result; + +namespace Anexia.MathematicalProgram.Tests.Result; + +public sealed class SolutionValuesTest +{ + [Fact] + public void EmptySolutionValuesReturnsDefaultForMissingVariables() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + var variable = model.NewVariable>(new RealInterval(0, 1), "v1"); + var values = new SolutionValues, RealScalar, IRealScalar>( + ReadOnlyDictionary, RealScalar>.Empty); + + Assert.True(values.Empty); + Assert.Null(values.GetSolutionValueOrDefault(variable)); + Assert.False(values.TryGetSolutionValue(variable, out var value)); + Assert.Null(value); + Assert.Empty(values); + } + + [Fact] + public void SolutionValuesReturnsAndEnumeratesStoredValues() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + var firstVariable = model.NewVariable>(new RealInterval(0, 10), "v1"); + var secondVariable = model.NewVariable>(new RealInterval(-10, 10), "v2"); + var values = new SolutionValues, RealScalar, IRealScalar>( + new ReadOnlyDictionary, RealScalar>( + new Dictionary, RealScalar> + { + [firstVariable] = new(2.5), + [secondVariable] = new(-3) + })); + + Assert.False(values.Empty); + Assert.Equal(new RealScalar(2.5), values.GetSolutionValueOrDefault(firstVariable)); + Assert.True(values.TryGetSolutionValue(secondVariable, out var secondValue)); + Assert.Equal(new RealScalar(-3), secondValue); + Assert.Collection( + values.OrderBy(pair => pair.Key.Name), + first => + { + Assert.Equal("v1", first.Key.Name); + Assert.Equal(new RealScalar(2.5), first.Value); + }, + second => + { + Assert.Equal("v2", second.Key.Name); + Assert.Equal(new RealScalar(-3), second.Value); + }); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs b/test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs new file mode 100644 index 0000000..721f204 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs @@ -0,0 +1,283 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA® Internetdienstleistungs GmbH.All rights reserved. +// +// ------------------------------------------------------------------------------------------ + + +using System.Collections.ObjectModel; +using Anexia.MathematicalProgram.Model; +using Anexia.MathematicalProgram.Model.Interval; +using Anexia.MathematicalProgram.Model.Scalar; +using Anexia.MathematicalProgram.Model.Variable; +using Anexia.MathematicalProgram.Result; +using Anexia.MathematicalProgram.Solve; +using Anexia.MathematicalProgram.SolverConfiguration; +using Microsoft.Extensions.Logging; +using static Anexia.MathematicalProgram.Tests.Factory.IntervalFactory; +using static Anexia.MathematicalProgram.Tests.Factory.SolutionValuesFactory; +using static Anexia.MathematicalProgram.Tests.Factory.SolverResultFactory; + + +namespace Anexia.MathematicalProgram.Tests.Solve; + +public sealed class GurobiNativeSolverTest +{ + [Fact] + public void SolveWrapsGurobiSetupExceptionAndLogsError() + { + var logger = new FakeLogger(); + + var exception = Assert.Throws(() => + new GurobiNativeSolver(logger).Solve( + CreateSimpleModel(), + CreateInvalidGurobiParameter())); + + Assert.Contains("Error in solver:", exception.Message); + Assert.NotNull(exception.InnerException); + Assert.Equal(LogLevel.Error, logger.LastLogLevel); + Assert.Same(exception.InnerException, logger.LastException); + } + + [Fact] + public void SolveWrapsGurobiSetupExceptionWithoutLogger() + { + var exception = Assert.Throws(() => + new GurobiNativeSolver().Solve( + CreateSimpleModel(), + CreateInvalidGurobiParameter())); + + Assert.Contains("Error in solver:", exception.Message); + Assert.NotNull(exception.InnerException); + } + + [Fact(Skip = "Licence needed")] + public void SolverWithSimpleFeasibleIlpModelReturnsCorrectResult() + { + /* + * min 2x, s.t. x=1, x binary + */ + + var model = + new OptimizationModel, IRealScalar, IRealScalar>(); + var v1 = model.NewVariable>(Interval(1, 1), "TestVariable"); + + + var optimizationModel = + model.SetObjective( + model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), v1).Build(false)); + + var result = new IlpSolver(IlpSolverType.GurobiIntegerProgramming).SolveWithoutORTools(optimizationModel, + new SolverParameter(new EnableSolverOutput(true))); + + Assert.Equal( + SolverResult( + SolutionValues, RealScalar, IRealScalar>( + (v1, new RealScalar(1))), new ObjectiveValue(2), new IsFeasible(true), + new IsOptimal(true), new OptimalityGap(0), + SolverResultStatus.Optimal, false), result); + } + + [Fact] + public void SolverSwitchesToFallbackWhenNoLicenceAvailable() + { + /* + * min 2x, s.t. x=1, x binary + */ + var logger = new FakeLogger(); + var model = + new OptimizationModel, IRealScalar, IRealScalar>(); + var v1 = model.NewVariable>(Interval(1, 1), "TestVariable"); + + + var optimizationModel = + model.SetObjective( + model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), v1).Build(false)); + + var result = + new IlpSolver(IlpSolverType.GurobiNativeIntegerProgramming, IlpSolverType.Scip, logger) + .Solve( + optimizationModel, + new SolverParameter(new EnableSolverOutput(true))); + + Assert.Contains(logger.Messages, message => message.Contains("Scip")); + Assert.Contains(logger.Messages, message => message.Contains("No Gurobi licence found")); + + Assert.Equal( + SolverResult( + SolutionValues, RealScalar, IRealScalar>( + (v1, new RealScalar(1))), new ObjectiveValue(2), new IsFeasible(true), + new IsOptimal(true), new OptimalityGap(0), + SolverResultStatus.Optimal, false), result); + } + + [Fact(Skip = "Licence needed")] + public void SolverWithSimpleFeasibleBinaryIlpModelReturnsCorrectResult() + { + /* + * max 2x, x binary + */ + + var model = + new OptimizationModel, IRealScalar, IRealScalar>(); + var v1 = model.NewBinaryVariable("TestVariable"); + + var optimizationModel = + model.SetObjective( + model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), v1).Build()); + + var result = new IlpSolver(IlpSolverType.GurobiIntegerProgramming).SolveWithoutORTools(optimizationModel, + new SolverParameter(new EnableSolverOutput(true))); + + Assert.Equal( + SolverResult( + SolutionValues, RealScalar, IRealScalar>( + (v1, new RealScalar(1))), new ObjectiveValue(2), new IsFeasible(true), + new IsOptimal(true), new OptimalityGap(0), + SolverResultStatus.Optimal, false), result); + } + + [Fact(Skip = "Licence needed")] + public void SolverWithInfeasibleIlModelReturnsCorrectResult() + { + /* + * max 2x, s.t. x=3, x binary + */ + + var model = + new OptimizationModel, IRealScalar, IRealScalar>(); + var x = model.NewVariable>(Interval(0, 1), "c"); + + + model.AddConstraint(model.CreateConstraintBuilder() + .AddTermToSum(new IntegerScalar(1), x).Build(Point(3))); + + + var optimizationModel = + model.SetObjective(model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), x) + .Build()); + + + var result = new IlpSolver(IlpSolverType.GurobiIntegerProgramming).SolveWithoutORTools(optimizationModel, + new SolverParameter( + EnableSolverOutput.True, + RelativeGap.EMinus7, + null, + new NumberOfThreads(2))); + + + Assert.Equal( + SolverResult( + new SolutionValues, RealScalar, IRealScalar>( + ReadOnlyDictionary, RealScalar>.Empty), null, new IsFeasible(false), + new IsOptimal(false), null, + SolverResultStatus.Infeasible, false), result); + } + + [Fact(Skip = "Licence needed")] + public void SolverWithUnboundedIlModelReturnsCorrectResult() + { + /* + * max 2x, x positive + */ + + var model = new OptimizationModel, IRealScalar, IRealScalar>(); + var x = model.NewVariable>(Interval(0, double.PositiveInfinity), "x"); + + var optimizationModel = + model.SetObjective(model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), x) + .Build()); + + var result = new IlpSolver(IlpSolverType.GurobiIntegerProgramming).SolveWithoutORTools(optimizationModel, + new SolverParameter()); + + Assert.Equal( + SolverResult( + new SolutionValues, RealScalar, IRealScalar>( + ReadOnlyDictionary, RealScalar>.Empty), null, new IsFeasible(false), + new IsOptimal(false), null, + SolverResultStatus.Unbounded, false), result); + } + + [Fact(Skip = "Licence needed")] + public void GurobiWithoutORToolsGivesSameResultAsWithORTools() + { + var model = + new OptimizationModel, RealScalar, IRealScalar>(); + + var x = model.NewVariable>( + new IntegralInterval(new IntegerScalar(1), new IntegerScalar(3)), "x"); + var y = model.NewVariable>( + new IntegralInterval(0, 1), "y"); + var xMinusY = model.CreateWeightedSumBuilder() + .AddWeightedSum([x, y], [1, -1]).Build(); + + var constraint = model.CreateConstraintBuilder() + .AddWeightedSum(xMinusY) + .Build(new RealInterval(0, double.PositiveInfinity)); + + model.AddConstraint(constraint); + + var objFunction = model.CreateObjectiveFunctionBuilder().AddTermToSum(2, x) + .AddTermToSum(2, y).Build(false); + + var optimizationModel = model.SetObjective(objFunction); + + var resultORTools = SolverFactory.SolverFor(IlpSolverType.GurobiIntegerProgramming).Solve(optimizationModel, + new SolverParameter(new EnableSolverOutput(false), RelativeGap.EMinus7, + new TimeLimitInMilliseconds(10000), new NumberOfThreads(2), AdditionalSolverSpecificParameters: + [ + ("ResultFile", "resultOR.sol") + ])); + + + var resultGurobiAPI = new IlpSolver(IlpSolverType.GurobiIntegerProgramming).SolveWithoutORTools( + optimizationModel, + new SolverParameter(new EnableSolverOutput(false), RelativeGap.EMinus7, + new TimeLimitInMilliseconds(10000), new NumberOfThreads(2), + AdditionalSolverSpecificParameters: [("ResultFile", "resultGRB.sol")])); + + Assert.Equal(resultORTools, resultGurobiAPI); + } + + private sealed class FakeLogger : ILogger + { + internal LogLevel? LastLogLevel { get; private set; } + + internal Exception? LastException { get; private set; } + + internal List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + LastLogLevel = logLevel; + LastException = exception; + Messages.Add(formatter(state, exception)); + } + } + + private static ICompletedOptimizationModel, IRealScalar, IRealScalar> + CreateSimpleModel() + { + var model = + new OptimizationModel, IRealScalar, IRealScalar>(); + var v1 = model.NewVariable>(Interval(0, 1), "TestVariable"); + + return model.SetObjective( + model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(1), v1).Build(false)); + } + + private static SolverParameter CreateInvalidGurobiParameter() => + new( + EnableSolverOutput.False, + AdditionalSolverSpecificParameters: [("__invalid_gurobi_parameter__", "1")]); +} \ No newline at end of file diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs b/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs index 0994fc0..af2976e 100644 --- a/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs @@ -26,6 +26,87 @@ namespace Anexia.MathematicalProgram.Tests.Solve; public sealed class IlpSolverTest { + [Fact] + public void SolveWithoutOrToolsThrowsForUnsupportedNativeSolver() + { + var optimizationModel = CreateSimpleOptimizationModel(out _); + + var exception = Assert.Throws(() => + new IlpSolver(IlpSolverType.Scip).SolveWithoutORTools( + optimizationModel, + new SolverParameter())); + + Assert.Equal("The specified type is not yet implemented. Use OR Tools for solving", exception.Message); + } + + [Fact] + public void SolveWithUnsupportedSolverSwitchesToFallbackSolver() + { + var optimizationModel = CreateSimpleOptimizationModel(out var variable); + var logger = new FakeLogger(); + + var result = new IlpSolver((IlpSolverType)int.MaxValue, IlpSolverType.Scip, logger) + .Solve(optimizationModel, new SolverParameter(EnableSolverOutput.False)); + + Assert.Equal( + SolverResult( + SolutionValues, RealScalar, IRealScalar>( + (variable, new RealScalar(1))), + new ObjectiveValue(2), + new IsFeasible(true), + new IsOptimal(true), + new OptimalityGap(0), + SolverResultStatus.Optimal, + true), + result); + Assert.Contains(logger.Messages, message => message.Contains("switching to fallback solver", StringComparison.Ordinal)); + } + + [Fact] + public void SolveModelAsMpsFormatThrowsWhenSolverAndFallbackAreUnsupported() + { + var exception = Assert.Throws(() => + new IlpSolver((IlpSolverType)int.MaxValue, (IlpSolverType)int.MaxValue) + .Solve(new ModelAsMpsFormat(string.Empty), new SolverParameter())); + + Assert.Equal( + "Neither the expected solver 2147483647 nor fallback solver 2147483647 could be initialized.", + exception.Message); + } + + [Fact] + public void SolveModelAsMpsFormatDefaultOverloadSolvesExportedModel() + { + var exportFilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.mps"); + var optimizationModel = CreateSimpleOptimizationModel(out var variable); + var logger = new FakeLogger(); + + try + { + var exportedResult = new IlpSolver(IlpSolverType.Scip, logger: logger) + .Solve(optimizationModel, new SolverParameter( + EnableSolverOutput.False, + ExportModelFilePath: exportFilePath)); + + var resultFromMps = new IlpSolver(IlpSolverType.Scip) + .Solve(new ModelAsMpsFormat(File.ReadAllText(exportFilePath))); + + Assert.True(File.Exists(exportFilePath)); + Assert.Contains(logger.Messages, message => message.Contains("Exporting model to", StringComparison.Ordinal)); + Assert.Equal(exportedResult.ObjectiveValue, resultFromMps.ObjectiveValue); + var solutionValue = Assert.IsAssignableFrom< + IEnumerable, RealScalar>>>( + resultFromMps.SolutionValues) + .Single(); + Assert.Equal(variable.Name, solutionValue.Key.Name); + Assert.Equal(new RealScalar(1), solutionValue.Value); + } + finally + { + if (File.Exists(exportFilePath)) File.Delete(exportFilePath); + } + } + [Fact] public void SolverWithSimpleFeasibleIlpModelReturnsCorrectResult() { @@ -226,4 +307,34 @@ public void SolverAdditionalLoggingWorks() new IsOptimal(false), null, SolverResultStatus.Unbounded, false), result); } -} \ No newline at end of file + + private static ICompletedOptimizationModel, IRealScalar, IRealScalar> + CreateSimpleOptimizationModel(out IIntegerVariable variable) + { + var model = + new OptimizationModel, IRealScalar, IRealScalar>(); + variable = model.NewVariable>(Interval(1, 1), "TestVariable"); + + return model.SetObjective( + model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), variable).Build(false)); + } + + private sealed class FakeLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs b/test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs new file mode 100644 index 0000000..79614d8 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.SolverConfiguration; + +namespace Anexia.MathematicalProgram.Tests.SolverConfiguration; + +public sealed class SolverParameterTest +{ + [Fact] + public void TimeLimitConstructorSetsDefaultSolverParameters() + { + var timeLimit = new TimeLimitInMilliseconds(2_500); + var parameters = new SolverParameter(timeLimit); + + Assert.Equal(EnableSolverOutput.False, parameters.EnableSolverOutput); + Assert.Null(parameters.RelativeGap); + Assert.Equal(timeLimit, parameters.TimeLimitInMilliseconds); + Assert.Equal(2u, parameters.TimeLimitInMilliseconds?.AsSeconds); + Assert.Null(parameters.NumberOfThreads); + } + + [Fact] + public void RelativeGapFromEMinusReturnsExpectedPowerOfTen() + { + var gap = RelativeGap.FromEMinus(4); + + Assert.Equal(0.0001, gap.Value); + } + + [Fact] + public void ToSolverSpecificParametersForIlpIncludesMappedAndAdditionalParameters() + { + var parameters = new SolverParameter( + EnableSolverOutput.True, + RelativeGap: new RelativeGap(0.05), + NumberOfThreads: new NumberOfThreads(8), + AdditionalSolverSpecificParameters: [("custom", "value")]); + + var result = parameters.ToSolverSpecificParameters(IlpSolverType.HiGhs); + + Assert.Equal("threads=8,mip_rel_gap=0.05,custom=value", result); + } + + [Fact] + public void ToSolverSpecificParametersForLpUsesSolverSpecificSeparators() + { + var parameters = new SolverParameter( + EnableSolverOutput.False, + RelativeGap: new RelativeGap(0.05), + NumberOfThreads: new NumberOfThreads(3), + AdditionalSolverSpecificParameters: [("custom", "value")]); + + Assert.Equal("num_omp_threads:3,custom:value", parameters.ToSolverSpecificParameters(LpSolverType.Glop)); + Assert.Equal("parallel/maxnthreads=3,custom=value", parameters.ToSolverSpecificParameters(LpSolverType.Scip)); + } +}