diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2a84e79..154bad1 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: @@ -23,7 +24,7 @@ jobs: 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/.github/workflows/test.yml b/.github/workflows/test.yml index 3ae50b8..3e23e7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,10 +6,10 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup .NET SDKs - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 6.0.x 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/examples/Anexia.MathematicalProgram.Examples/IlpExample.cs b/examples/Anexia.MathematicalProgram.Examples/IlpExample.cs index 80db527..2bea47a 100644 --- a/examples/Anexia.MathematicalProgram.Examples/IlpExample.cs +++ b/examples/Anexia.MathematicalProgram.Examples/IlpExample.cs @@ -49,7 +49,7 @@ public static void Main() // Create SCIP Solver and solve model. Different settings can be set via out parameters. var result = SolverFactory.SolverFor(IlpSolverType.HiGhs).Solve(optimizationModel, new SolverParameter(new EnableSolverOutput(false), RelativeGap.EMinus7, - new TimeLimitInMilliseconds(10000), new NumberOfThreads(2), ExportModelFilePath: "model.txt")); + new TimeLimitInMilliseconds(10000), new NumberOfThreads(2), ExportModelFilePaths: "model.txt")); Console.WriteLine(result); // Output: ObjectiveValue: 2, IsFeasible: True, IsOptimal: True, OptimalityGap: 0 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/ISolverResult.cs b/src/Anexia.MathematicalProgram/Result/ISolverResult.cs index c5b1b3e..9a5374f 100644 --- a/src/Anexia.MathematicalProgram/Result/ISolverResult.cs +++ b/src/Anexia.MathematicalProgram/Result/ISolverResult.cs @@ -25,7 +25,7 @@ public interface ISolverResult /// /// Indicates whether a solution to the optimization problem is feasible. /// - IsFeasible IsFeasible { get; } + IsFeasible? IsFeasible { get; } /// /// Indicates whether the solution to the optimization problem is optimal. diff --git a/src/Anexia.MathematicalProgram/Result/ResultHandling.cs b/src/Anexia.MathematicalProgram/Result/ResultHandling.cs index f51d495..7096eec 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; @@ -35,7 +36,7 @@ internal static ISolverResult : SolverResult(SolverResultStatus.Feasible, switchedToDefaultSolver, solutionValues, objectiveValue, bestBound, true), SolveStatus.INFEASIBLE => SolverResult( - SolverResultStatus.Infeasible, switchedToDefaultSolver), + SolverResultStatus.Infeasible, switchedToDefaultSolver, isFeasible: false), SolveStatus.UNBOUNDED => SolverResult( SolverResultStatus.Unbounded, switchedToDefaultSolver), SolveStatus.ABNORMAL => SolverResult( @@ -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, isFeasible: false), + 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, @@ -79,7 +108,7 @@ internal static ISolverResult bestBound, true), CpSolverStatus.Infeasible => SolverResult(SolverResultStatus.Infeasible, - false), + false, isFeasible: false), CpSolverStatus.Unknown => SolverResult( SolverResultStatus.UnknownStatus, false), @@ -90,11 +119,50 @@ internal static ISolverResult }; } + internal static ISolverResult HandleGurobi(int resultStatus, + ISolutionValues? solutionValues = null, + double? objectiveValue = null, + double? bestBound = null) where TVariableInterval : IAddableScalar + where TVariable : IVariable + { + if (resultStatus == GRB.Status.INFEASIBLE) + { + return SolverResult( + SolverResultStatus.Infeasible, false, isFeasible: false); + } + + if (objectiveValue is null || bestBound is null) + { + throw new MathematicalProgramException("Mathematical program could not be solved."); + } + + return resultStatus switch + { + GRB.Status.OPTIMAL => SolverResult(SolverResultStatus.Optimal, false, solutionValues, objectiveValue, + bestBound, true, true), + GRB.Status.SUBOPTIMAL => SolverResult(SolverResultStatus.Feasible, false, solutionValues, objectiveValue, + bestBound, true), + GRB.Status.TIME_LIMIT => SolverResult(SolverResultStatus.Timelimit, false, solutionValues, objectiveValue, + bestBound, true), + GRB.Status.INTERRUPTED => SolverResult(SolverResultStatus.CancelledByUser, false, solutionValues, + objectiveValue, + bestBound, true), + GRB.Status.MEM_LIMIT => SolverResult(SolverResultStatus.UnknownStatus, false, solutionValues, + objectiveValue, + bestBound, true), + GRB.Status.UNBOUNDED => SolverResult( + SolverResultStatus.Unbounded, false), + + _ => throw new MathematicalProgramException($"Unknown result status in solver. {resultStatus}") + }; + } + private static ISolverResult SolverResult(SolverResultStatus resultStatus, bool switchedToDefaultSolver, ISolutionValues? solutionValues = null, - double? objectiveValue = null, double? bestBound = null, bool isFeasible = false, bool isOptimal = false) + double? objectiveValue = null, double? bestBound = null, bool? isFeasible = null, bool isOptimal = false) where TVariable : IVariable where TVariableInterval : IAddableScalar { @@ -103,7 +171,7 @@ private static ISolverResult new SolutionValues(ReadOnlyDictionary .Empty), objectiveValue is null ? null : new ObjectiveValue(objectiveValue.Value), - new IsFeasible(isFeasible), + isFeasible is null ? null : new IsFeasible(isFeasible.Value), new IsOptimal(isOptimal), objectiveValue is null || bestBound is null ? null : CalculateGap(objectiveValue.Value, bestBound.Value), resultStatus, diff --git a/src/Anexia.MathematicalProgram/Result/SolverResult.cs b/src/Anexia.MathematicalProgram/Result/SolverResult.cs index 298b224..16cb10f 100644 --- a/src/Anexia.MathematicalProgram/Result/SolverResult.cs +++ b/src/Anexia.MathematicalProgram/Result/SolverResult.cs @@ -26,7 +26,7 @@ namespace Anexia.MathematicalProgram.Result; public readonly record struct SolverResult( ISolutionValues SolutionValues, ObjectiveValue? ObjectiveValue, - IsFeasible IsFeasible, + IsFeasible? IsFeasible, IsOptimal IsOptimal, OptimalityGap? OptimalityGap, SolverResultStatus SolverResultStatus, 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/ConstraintProgrammingSolver.cs b/src/Anexia.MathematicalProgram/Solve/ConstraintProgrammingSolver.cs index 0858779..0e0432d 100644 --- a/src/Anexia.MathematicalProgram/Solve/ConstraintProgrammingSolver.cs +++ b/src/Anexia.MathematicalProgram/Solve/ConstraintProgrammingSolver.cs @@ -68,7 +68,14 @@ public ISolverResult, IntegerScalar, IIntegerSc else model.Minimize(expr); } - if (solverParameter.ExportModelFilePath is not null) model.ExportToFile(solverParameter.ExportModelFilePath); + if (solverParameter.ExportModelFilePaths.Any()) + { + + foreach (var file in solverParameter.ExportModelFilePaths) + { + model.ExportToFile(file); + } + } var solver = new CpSolver(); if (solverParameter.TimeLimitInMilliseconds is not null) diff --git a/src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs b/src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs new file mode 100644 index 0000000..05bd004 --- /dev/null +++ b/src/Anexia.MathematicalProgram/Solve/GurobiNativeSolver.cs @@ -0,0 +1,115 @@ +// ------------------------------------------------------------------------------------------ +// +// 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 + { + using 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(); + + using 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); + + + if (solverParameter.ExportModelFilePaths.Any()) + { + logger?.LogInformation("Exporting model to {ExportModelFilePath}", + string.Join(", ", solverParameter.ExportModelFilePaths)); + + foreach (var file in solverParameter.ExportModelFilePaths) + { + gurobiModel.Write(file); + } + } + + gurobiModel.Optimize(); + + 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/IlpCbcSolver.cs b/src/Anexia.MathematicalProgram/Solve/IlpCbcSolver.cs index 0e669fa..97ebb8d 100644 --- a/src/Anexia.MathematicalProgram/Solve/IlpCbcSolver.cs +++ b/src/Anexia.MathematicalProgram/Solve/IlpCbcSolver.cs @@ -73,8 +73,17 @@ IntegerVariable or IntegerVariable or if (solverParameter.TimeLimitInMilliseconds is not null) Solver.SetTimeLimit(solverParameter.TimeLimitInMilliseconds.Value); - if (solverParameter.ExportModelFilePath is not null) - File.WriteAllText(solverParameter.ExportModelFilePath, Solver.ExportModelAsMpsFormat(true, false)); + if (solverParameter.ExportModelFilePaths.SingleOrDefault(item => item.EndsWith(".mps")) is not null) + { + File.WriteAllText(solverParameter.ExportModelFilePaths.Single(item => item.EndsWith(".mps")), + Solver.ExportModelAsMpsFormat(true, false)); + } + + if (solverParameter.ExportModelFilePaths.SingleOrDefault(item => item.EndsWith(".lp")) is not null) + { + File.WriteAllText(solverParameter.ExportModelFilePaths.Single(item => item.EndsWith(".lp")), + Solver.ExportModelAsLpFormat(false)); + } if (solverParameter.EnableSolverOutput.Value) Solver.EnableOutput(); using var parameter = new MPSolverParameters(); diff --git a/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs b/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs index f24eeda..9682e03 100644 --- a/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs +++ b/src/Anexia.MathematicalProgram/Solve/IlpSolver.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using Anexia.MathematicalProgram.Extensions; using Anexia.MathematicalProgram.Model; using Anexia.MathematicalProgram.Model.Interval; @@ -7,6 +6,7 @@ using Anexia.MathematicalProgram.Result; using Anexia.MathematicalProgram.SolverConfiguration; using Google.OrTools.ModelBuilder; +using Gurobi; using Microsoft.Extensions.Logging; namespace Anexia.MathematicalProgram.Solve; @@ -26,7 +26,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 +36,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) @@ -186,8 +206,19 @@ solverParameter.TimeLimitInMilliseconds is null private void ExportModelIfRequested(SolverParameter solverParameter, Google.OrTools.ModelBuilder.Model model) { - if (solverParameter.ExportModelFilePath is null) return; - Logger?.LogInformation("Exporting model to {ExportModelFilePath}", solverParameter.ExportModelFilePath); - model.WriteToMpsFile(solverParameter.ExportModelFilePath, false); + if (!solverParameter.ExportModelFilePaths.Any()) return; + Logger?.LogInformation("Exporting model to {ExportModelFilePath}", + string.Join(", ", solverParameter.ExportModelFilePaths)); + + if (solverParameter.ExportModelFilePaths.SingleOrDefault(item => item.EndsWith(".mps")) is not null) + { + model.WriteToMpsFile(solverParameter.ExportModelFilePaths.SingleOrDefault(item => item.EndsWith(".mps")), + false); + } + + foreach (var modelFilePath in solverParameter.ExportModelFilePaths.Where(item => !item.EndsWith(".mps"))) + { + model.ExportToFile(modelFilePath); + } } } \ No newline at end of file diff --git a/src/Anexia.MathematicalProgram/Solve/LPSolver.cs b/src/Anexia.MathematicalProgram/Solve/LPSolver.cs index f13faa3..a923646 100644 --- a/src/Anexia.MathematicalProgram/Solve/LPSolver.cs +++ b/src/Anexia.MathematicalProgram/Solve/LPSolver.cs @@ -139,10 +139,16 @@ public ISolverResult, RealScalar, IRealScalar> S private void ExportModelIfRequested(SolverParameter solverParameter, Google.OrTools.ModelBuilder.Model model) { - if (solverParameter.ExportModelFilePath is not null) + if (solverParameter.ExportModelFilePaths.SingleOrDefault(item => item.EndsWith(".mps")) is not null) { - File.WriteAllText(solverParameter.ExportModelFilePath, model.ExportToMpsString(false)); - File.WriteAllText(solverParameter.ExportModelFilePath.Replace(".", "_lp."), model.ExportToLpString(false)); + File.WriteAllText(solverParameter.ExportModelFilePaths.Single(item => item.EndsWith(".mps")), + model.ExportToMpsString(false)); + } + + if (solverParameter.ExportModelFilePaths.SingleOrDefault(item => item.EndsWith(".lp")) is not null) + { + File.WriteAllText(solverParameter.ExportModelFilePaths.Single(item => item.EndsWith(".lp")), + model.ExportToLpString(false)); } } } \ No newline at end of file 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..7b29ed0 100644 --- a/src/Anexia.MathematicalProgram/SolverConfiguration/SolverParameter.cs +++ b/src/Anexia.MathematicalProgram/SolverConfiguration/SolverParameter.cs @@ -16,16 +16,16 @@ 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. -/// The file path and name of a file where the model should be written to. +/// 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, - string? ExportModelFilePath = null) + IReadOnlyCollection<(string Key, string Value)>? AdditionalSolverSpecificParameters = null, + params string[] ExportModelFilePaths) { private const string RelativeGapKey = "RELATIVE_GAP"; private const string NumberOfThreadsKey = "NUMBER_OF_THREADS_KEY"; @@ -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); } @@ -138,5 +143,5 @@ internal string ToSolverSpecificParameters(LpSolverType solverType) /// [ExcludeFromCodeCoverage] public override string ToString() => - $"{nameof(EnableSolverOutput)}: {EnableSolverOutput}, {nameof(RelativeGap)}: {RelativeGap}, {nameof(TimeLimitInMilliseconds)}: {TimeLimitInMilliseconds}, {nameof(NumberOfThreads)}: {NumberOfThreads}, {nameof(ExportModelFilePath)}: {ExportModelFilePath}"; + $"{nameof(EnableSolverOutput)}: {EnableSolverOutput}, {nameof(RelativeGap)}: {RelativeGap}, {nameof(TimeLimitInMilliseconds)}: {TimeLimitInMilliseconds}, {nameof(NumberOfThreads)}: {NumberOfThreads}, {nameof(ExportModelFilePaths)}: {ExportModelFilePaths}"; } \ No newline at end of file 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..4bace64 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Extensions/EnumExtensionTest.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------ +// +// 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 ToEnumStringReturnsEnumMemberValueForLpSolverType() + { + Assert.Equal("GLOP", LpSolverType.Glop.ToEnumString()); + } + + [Fact] + public void ToEnumStringReturnsEnumMemberValueForIlpSolverType() + { + 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..cc6018a --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Extensions/OptimizationSolverExtensionTest.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------------------------ +// +// 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.Equal(solver.Result, result); + } + + 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); + + + public ISolverResult, RealScalar, IRealScalar> Solve( + ICompletedOptimizationModel, RealScalar, IRealScalar> model, + SolverParameter solverParameter) + { + return Result; + } + } +} \ No newline at end of file diff --git a/test/Anexia.MathematicalProgram.Tests/Factory/SolverResultFactory.cs b/test/Anexia.MathematicalProgram.Tests/Factory/SolverResultFactory.cs index 6813793..bc484db 100644 --- a/test/Anexia.MathematicalProgram.Tests/Factory/SolverResultFactory.cs +++ b/test/Anexia.MathematicalProgram.Tests/Factory/SolverResultFactory.cs @@ -16,7 +16,7 @@ public static SolverResult SolverResult( ISolutionValues solutionValues, ObjectiveValue? objectiveValue, - IsFeasible isFeasible, + IsFeasible? isFeasible, IsOptimal isOptimal, OptimalityGap? optimalityGap, SolverResultStatus solverResultStatus, diff --git a/test/Anexia.MathematicalProgram.Tests/Model/BinaryScalarTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/BinaryScalarTest.cs index 449aa1f..e5c3fd6 100644 --- a/test/Anexia.MathematicalProgram.Tests/Model/BinaryScalarTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Model/BinaryScalarTest.cs @@ -32,8 +32,7 @@ public void AddWithBinaryScalarReturnsExpected(BinaryScalar left, BinaryScalar r { var result = left.Add(right); - Assert.Equal(expected.IsOne, result.IsOne); - Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected, result); } [Theory] @@ -42,8 +41,7 @@ public void SubtractWithBinaryScalarReturnsExpected(BinaryScalar left, BinarySca { var result = left.Subtract(right); - Assert.Equal(expected.IsOne, result.IsOne); - Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected, result); } [Theory] @@ -53,8 +51,7 @@ public void AddWithIBinaryScalarReturnsExpected(BinaryScalar left, BinaryScalar IBinaryScalar r = right; var result = left.Add(r); - Assert.Equal(expected.IsOne, result.IsOne); - Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected, result); } [Theory] @@ -73,8 +70,7 @@ public void SubtractWithIBinaryScalarReturnsExpected(BinaryScalar left, BinarySc IBinaryScalar r = right; var result = left.Subtract(r); - Assert.Equal(expected.IsOne, result.IsOne); - Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected, result); } [Theory] @@ -92,8 +88,7 @@ public void OperatorPlusReturnsExpected(BinaryScalar left, BinaryScalar right, B { var result = left + right; - Assert.Equal(expected.IsOne, result.IsOne); - Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected, result); } [Theory] @@ -102,7 +97,6 @@ public void OperatorMinusReturnsExpected(BinaryScalar left, BinaryScalar right, { var result = left - right; - Assert.Equal(expected.IsOne, result.IsOne); - Assert.Equal(expected.Value, result.Value); + Assert.Equal(expected, result); } -} \ No newline at end of file +} diff --git a/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs index b368cd0..c0e415d 100644 --- a/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintBuilderTest.cs @@ -26,7 +26,7 @@ public void ConstraintBuilderAddTermReturnsCorrectResult() var v1 = model.NewVariable>(new RealInterval(0, 1), "v1"); var v2 = model.NewVariable>(new RealInterval(0, 2), "v2"); var v3 = model.NewVariable>(new RealInterval(0, 3), "v3"); - + var constraint = model.CreateConstraintBuilder() .AddTermToSum(1, v1) .AddTermToSum(2, v2) @@ -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.Equal(new Constraint, RealScalar, IRealScalar>( + new WeightedSum, RealScalar, IRealScalar>(), new IntegralInterval(0, 0)), + constraint); + } + [Fact] public void AddWeightedSumThrowsCorrectException() { diff --git a/test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs new file mode 100644 index 0000000..0ccbab2 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Model/ConstraintTest.cs @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +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.Equal( + new Constraint, RealScalar, IRealScalar>(weightedSum, interval, "capacity"), + constraint); + } + + [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.Equal(new[] { constraint as IConstraint, RealScalar, IRealScalar> }, + [.. 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.Equal( + new[] + { + first as IConstraint, RealScalar, IRealScalar>, + first as IConstraint, RealScalar, IRealScalar>, + second as IConstraint, RealScalar, IRealScalar> + }, + [.. constraints, ..updated]); + } + + 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)); + } +} \ No newline at end of file diff --git a/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs b/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs index 7f4f849..de9db14 100644 --- a/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Model/IntervalTest.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------------------ // // Copyright (c) ANEXIA® Internetdienstleistungs GmbH.All rights reserved. // @@ -13,6 +13,22 @@ namespace Anexia.MathematicalProgram.Tests.Model; public sealed class IntervalTest { + [Fact] + public void RealIntervalKeepsInclusiveLowerBound() + { + var interval = new RealInterval(new RealScalar(-1.5), new RealScalar(3.25)); + + Assert.Equal(new RealScalar(-1.5), interval.LowerBound); + } + + [Fact] + public void RealIntervalKeepsInclusiveUpperBound() + { + var interval = new RealInterval(new RealScalar(-1.5), new RealScalar(3.25)); + + Assert.Equal(new RealScalar(3.25), interval.UpperBound); + } + [Theory] [InlineData(6, 5)] [InlineData(0.1, 0)] @@ -21,7 +37,23 @@ public sealed class IntervalTest public void IntervalInitializingThrowsExpectedException(double left, double right) => Assert.Throws>(() => new RealInterval(new RealScalar(left), new RealScalar(right))); - + + [Fact] + public void IntegralIntervalKeepsInclusiveLowerBound() + { + var interval = new IntegralInterval(new IntegerScalar(-3), new IntegerScalar(7)); + + Assert.Equal(new IntegerScalar(-3), interval.LowerBound); + } + + [Fact] + public void IntegralIntervalKeepsInclusiveUpperBound() + { + var interval = new IntegralInterval(new IntegerScalar(-3), new IntegerScalar(7)); + + Assert.Equal(new IntegerScalar(7), interval.UpperBound); + } + [Theory] [InlineData(6, 5)] [InlineData(-1, -21)] @@ -29,4 +61,64 @@ 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 BinaryIntervalUsesZeroAsLowerBound() + { + var interval = new BinaryInterval(); + + Assert.Equal(BinaryScalar.Zero, interval.LowerBound); + } + + [Fact] + public void BinaryIntervalUsesOneAsUpperBound() + { + var interval = new BinaryInterval(); + + Assert.Equal(BinaryScalar.One, interval.UpperBound); + } + + [Theory] + [InlineData(-2.5)] + [InlineData(0)] + [InlineData(4.75)] + public void PointUsesValueAsLowerBound(double value) + { + var point = new Point(value); + + Assert.Equal(new RealScalar(value), point.LowerBound); + } + + [Theory] + [InlineData(-2.5)] + [InlineData(0)] + [InlineData(4.75)] + public void PointUsesValueAsUpperBound(double value) + { + var point = new Point(value); + + Assert.Equal(new RealScalar(value), point.UpperBound); + } + + [Theory] + [InlineData(-2)] + [InlineData(0)] + [InlineData(4)] + public void IntegralPointUsesValueAsLowerBound(int value) + { + var point = new IntegralPoint(value); + + Assert.Equal(new IntegerScalar(value), point.LowerBound); + } + + [Theory] + [InlineData(-2)] + [InlineData(0)] + [InlineData(4)] + public void IntegralPointUsesValueAsUpperBound(int value) + { + var point = new IntegralPoint(value); + + 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..8630a33 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Model/ScalarTest.cs @@ -0,0 +1,140 @@ +// ------------------------------------------------------------------------------------------ +// +// 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 RealScalarAddReturnsSum() + { + Assert.Equal(new RealScalar(9.5), new RealScalar(7.5).Add(new RealScalar(2))); + } + + [Fact] + public void RealScalarSubtractReturnsDifference() + { + Assert.Equal(new RealScalar(5.5), new RealScalar(7.5).Subtract(new RealScalar(2))); + } + + [Fact] + public void RealScalarAddViaInterfaceReturnsSum() + { + IRealScalar right = new RealScalar(2); + + Assert.Equal(9.5, new RealScalar(7.5).Add(right).Value); + } + + [Fact] + public void RealScalarSubtractViaInterfaceReturnsDifference() + { + IRealScalar right = new RealScalar(2); + + Assert.Equal(5.5, new RealScalar(7.5).Subtract(right).Value); + } + + [Fact] + public void RealScalarPlusOperatorReturnsSum() + { + Assert.Equal(new RealScalar(9.5), new RealScalar(7.5) + new RealScalar(2)); + } + + [Fact] + public void RealScalarMinusOperatorReturnsDifference() + { + Assert.Equal(new RealScalar(5.5), new RealScalar(7.5) - new RealScalar(2)); + } + + [Fact] + public void IntegerScalarAddReturnsSum() + { + Assert.Equal(new IntegerScalar(9), new IntegerScalar(7).Add(new IntegerScalar(2))); + } + + [Fact] + public void IntegerScalarSubtractReturnsDifference() + { + Assert.Equal(new IntegerScalar(5), new IntegerScalar(7).Subtract(new IntegerScalar(2))); + } + + [Fact] + public void IntegerScalarAddViaIntegerInterfaceReturnsSum() + { + IIntegerScalar right = new IntegerScalar(2); + + Assert.Equal(9, new IntegerScalar(7).Add(right).Value); + } + + [Fact] + public void IntegerScalarSubtractViaIntegerInterfaceReturnsDifference() + { + IIntegerScalar right = new IntegerScalar(2); + + Assert.Equal(5, new IntegerScalar(7).Subtract(right).Value); + } + + [Fact] + public void IntegerScalarAddRealScalarReturnsRealSum() + { + IRealScalar right = new RealScalar(2.5); + + Assert.Equal(9.5, new IntegerScalar(7).Add(right).Value); + } + + [Fact] + public void IntegerScalarSubtractRealScalarReturnsRealDifference() + { + IRealScalar right = new RealScalar(2.5); + + Assert.Equal(4.5, new IntegerScalar(7).Subtract(right).Value); + } + + [Fact] + public void IntegerScalarPlusOperatorReturnsSum() + { + Assert.Equal(new IntegerScalar(9), new IntegerScalar(7) + new IntegerScalar(2)); + } + + [Fact] + public void IntegerScalarMinusOperatorReturnsDifference() + { + Assert.Equal(new IntegerScalar(5), new IntegerScalar(7) - new IntegerScalar(2)); + } + + [Fact] + public void BinaryScalarAddEvenIntegerScalarKeepsValue() + { + IIntegerScalar even = new IntegerScalar(4); + + Assert.Equal(1, BinaryScalar.One.Add(even).Value); + } + + [Fact] + public void BinaryScalarAddOddIntegerScalarFlipsValue() + { + IIntegerScalar odd = new IntegerScalar(3); + + Assert.Equal(0, BinaryScalar.One.Add(odd).Value); + } + + [Fact] + public void BinaryScalarSubtractEvenIntegerScalarKeepsValue() + { + IIntegerScalar even = new IntegerScalar(4); + + Assert.Equal(1, BinaryScalar.One.Subtract(even).Value); + } + + [Fact] + public void BinaryScalarSubtractOddIntegerScalarFlipsValue() + { + IIntegerScalar odd = new IntegerScalar(3); + + 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..d74b9a2 --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Result/ResultHandlingTest.cs @@ -0,0 +1,289 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using System.Collections.ObjectModel; +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, false]; + yield return [SolveStatus.UNBOUNDED, SolverResultStatus.Unbounded, null]; + yield return [SolveStatus.ABNORMAL, SolverResultStatus.Abnormal, null]; + yield return [SolveStatus.NOT_SOLVED, SolverResultStatus.NotSolved, null]; + yield return [SolveStatus.MODEL_INVALID, SolverResultStatus.ModelInvalid, null]; + yield return [SolveStatus.MODEL_IS_VALID, SolverResultStatus.ModelIsValid, null]; + yield return [SolveStatus.CANCELLED_BY_USER, SolverResultStatus.CancelledByUser, null]; + yield return [SolveStatus.UNKNOWN_STATUS, SolverResultStatus.UnknownStatus, null]; + yield return [SolveStatus.INVALID_SOLVER_PARAMETERS, SolverResultStatus.InvalidSolverParameters, null]; + yield return [SolveStatus.SOLVER_TYPE_UNAVAILABLE, SolverResultStatus.SolverTypeUnavailable, null]; + yield return [SolveStatus.INCOMPATIBLE_OPTIONS, SolverResultStatus.IncompatibleOptions, null]; + } + + public static IEnumerable GurobiNoSolutionStatusMappings() + { + yield return [GRB.Status.INFEASIBLE, SolverResultStatus.Infeasible, false]; + yield return [GRB.Status.UNBOUNDED, SolverResultStatus.Unbounded, null]; + yield return [GRB.Status.INTERRUPTED, SolverResultStatus.CancelledByUser, null]; + yield return [GRB.Status.INF_OR_UNBD, SolverResultStatus.InfOrUnbound, null]; + yield return [GRB.Status.TIME_LIMIT, SolverResultStatus.Timelimit, null]; + } + + 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, + bool? expectedFeasible) + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + solveStatus, + switchedToDefaultSolver: true); + + Assert.Equal( + EmptySolverResult(expectedStatus, switchedToDefaultSolver: true, expectedFeasible), + result); + } + + [Theory] + [MemberData(nameof(GurobiNoSolutionStatusMappings))] + public void HandleGurobiStatusMapsNonSolutionStatuses( + int gurobiStatus, + SolverResultStatus expectedStatus, + bool? expectedFeasible) + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + gurobiStatus, + switchedToDefaultSolver: true); + + Assert.Equal( + EmptySolverResult(expectedStatus, switchedToDefaultSolver: true, expectedFeasible), + result); + } + + [Fact] + public void HandleOrToolsModelBuilderOptimalStatusBuildsResultWithGap() + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + SolveStatus.OPTIMAL, + switchedToDefaultSolver: true, + objectiveValue: 12, + bestBound: 9); + + Assert.Equal( + new SolverResult, RealScalar, IRealScalar>( + EmptySolutionValues, + new ObjectiveValue(12), + new IsFeasible(true), + new IsOptimal(true), + new OptimalityGap(0.25), + SolverResultStatus.Optimal, + SwitchedToDefaultSolver: true), + result); + } + + [Fact] + public void HandleCpOptimalStatusReturnsZeroGapWhenObjectiveAndBestBoundAreZero() + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + CpSolverStatus.Optimal, + objectiveValue: 0, + bestBound: 0); + + Assert.Equal( + new SolverResult, RealScalar, IRealScalar>( + EmptySolutionValues, + new ObjectiveValue(0), + new IsFeasible(true), + new IsOptimal(true), + new OptimalityGap(0), + SolverResultStatus.Optimal, + SwitchedToDefaultSolver: false), + result); + } + + [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( + new SolverResult, RealScalar, IRealScalar>( + EmptySolutionValues, + new ObjectiveValue(20), + new IsFeasible(true), + new IsOptimal(expectedOptimal), + new OptimalityGap(0.25), + expectedStatus, + SwitchedToDefaultSolver: false), + result); + } + + [Fact] + public void HandleGurobiInfeasibleStatusBuildsInfeasibleResult() + { + var result = ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + GRB.Status.INFEASIBLE, + objectiveValue: 20, + bestBound: 15); + + Assert.Equal( + EmptySolverResult(SolverResultStatus.Infeasible, switchedToDefaultSolver: false, isFeasible: false), + result); + } + + [Fact] + public void HandleGurobiUnboundedStatusBuildsNonFeasibleResultWhenBoundsArePresent() + { + var result = ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + GRB.Status.UNBOUNDED, + objectiveValue: 20, + bestBound: 15); + + Assert.Equal( + EmptySolverResult(SolverResultStatus.Unbounded, switchedToDefaultSolver: false, isFeasible: null), + result); + } + + [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); + } + + public static IEnumerable GurobiSolutionStatusesRequiringObjectiveAndBound() + { + yield return [GRB.Status.SUBOPTIMAL]; + yield return [GRB.Status.TIME_LIMIT]; + yield return [GRB.Status.INTERRUPTED]; + yield return [GRB.Status.MEM_LIMIT]; + yield return [GRB.Status.UNBOUNDED]; + } + + [Theory] + [MemberData(nameof(GurobiSolutionStatusesRequiringObjectiveAndBound))] + public void HandleGurobiSolutionStatusWithoutObjectiveValueThrows(int gurobiStatus) + { + var exception = Assert.Throws(() => + ResultHandling.HandleGurobi, RealScalar, IRealScalar>( + gurobiStatus, + bestBound: 1)); + + Assert.Equal("Mathematical program could not be solved.", exception.Message); + } + + [Fact] + public void HandleGurobiIntStatusOptimalWithObjectiveBuildsOptimalResultWithGap() + { + var result = ResultHandling.Handle, RealScalar, IRealScalar>( + GRB.Status.OPTIMAL, + switchedToDefaultSolver: false, + objectiveValue: 12, + bestBound: 9); + + Assert.Equal( + new SolverResult, RealScalar, IRealScalar>( + EmptySolutionValues, + new ObjectiveValue(12), + new IsFeasible(true), + new IsOptimal(true), + new OptimalityGap(0.25), + SolverResultStatus.Optimal, + SwitchedToDefaultSolver: false), + result); + } + + [Fact] + public void HandleGurobiIntStatusOptimalWithoutObjectiveValueThrows() + { + var exception = Assert.Throws(() => + ResultHandling.Handle, RealScalar, IRealScalar>( + GRB.Status.OPTIMAL, + switchedToDefaultSolver: false)); + + 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); + } + + private static SolutionValues, RealScalar, IRealScalar> EmptySolutionValues => + new(ReadOnlyDictionary, RealScalar>.Empty); + + private static SolverResult, RealScalar, IRealScalar> EmptySolverResult( + SolverResultStatus status, + bool switchedToDefaultSolver, + bool? isFeasible) => + new( + EmptySolutionValues, + ObjectiveValue: null, + isFeasible is null ? null : new IsFeasible(isFeasible.Value), + new IsOptimal(false), + OptimalityGap: null, + status, + switchedToDefaultSolver); +} diff --git a/test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs b/test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs new file mode 100644 index 0000000..b9ea05b --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Result/SolutionValuesTest.cs @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------------------------------ +// +// 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 EmptySolutionValuesIsEmpty() + { + var values = EmptySolutionValues(); + + Assert.True(values.Empty); + } + + [Fact] + public void EmptySolutionValuesReturnsDefaultForMissingVariable() + { + var values = EmptySolutionValues(); + + Assert.Null(values.GetSolutionValueOrDefault(CreateVariable())); + } + + [Fact] + public void EmptySolutionValuesTryGetReturnsFalseForMissingVariable() + { + var values = EmptySolutionValues(); + + Assert.False(values.TryGetSolutionValue(CreateVariable(), out _)); + } + + [Fact] + public void EmptySolutionValuesTryGetOutputsNullForMissingVariable() + { + var values = EmptySolutionValues(); + + values.TryGetSolutionValue(CreateVariable(), out var value); + + Assert.Null(value); + } + + [Fact] + public void EmptySolutionValuesEnumeratesNoElements() + { + var values = EmptySolutionValues(); + + Assert.Empty(values); + } + + [Fact] + public void StoredSolutionValuesAreNotEmpty() + { + var values = StoredSolutionValues(out _, out _); + + Assert.False(values.Empty); + } + + [Fact] + public void GetSolutionValueOrDefaultReturnsStoredValue() + { + var values = StoredSolutionValues(out var firstVariable, out _); + + Assert.Equal(new RealScalar(2.5), values.GetSolutionValueOrDefault(firstVariable)); + } + + [Fact] + public void TryGetSolutionValueReturnsTrueForStoredVariable() + { + var values = StoredSolutionValues(out _, out var secondVariable); + + Assert.True(values.TryGetSolutionValue(secondVariable, out _)); + } + + [Fact] + public void TryGetSolutionValueOutputsStoredValue() + { + var values = StoredSolutionValues(out _, out var secondVariable); + + values.TryGetSolutionValue(secondVariable, out var secondValue); + + Assert.Equal(new RealScalar(-3), secondValue); + } + + [Fact] + public void SolutionValuesEnumerateAllStoredValues() + { + var values = StoredSolutionValues(out var firstVariable, out var secondVariable); + + Assert.Equal( + [ + KeyValuePair.Create(firstVariable, new RealScalar(2.5)), + KeyValuePair.Create(secondVariable, new RealScalar(-3)) + ], + values.OrderBy(pair => pair.Key.Name)); + } + + private static SolutionValues, RealScalar, IRealScalar> EmptySolutionValues() => + new(ReadOnlyDictionary, RealScalar>.Empty); + + private static IIntegerVariable CreateVariable() + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + + return model.NewVariable>(new RealInterval(0, 1), "v1"); + } + + private static SolutionValues, RealScalar, IRealScalar> StoredSolutionValues( + out IIntegerVariable firstVariable, + out IIntegerVariable secondVariable) + { + var model = new OptimizationModel, RealScalar, IRealScalar>(); + firstVariable = model.NewVariable>(new RealInterval(0, 10), "v1"); + secondVariable = model.NewVariable>(new RealInterval(-10, 10), "v2"); + + return new SolutionValues, RealScalar, IRealScalar>( + new ReadOnlyDictionary, RealScalar>( + new Dictionary, RealScalar> + { + [firstVariable] = new(2.5), + [secondVariable] = new(-3) + })); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/GurobiLicense.cs b/test/Anexia.MathematicalProgram.Tests/Solve/GurobiLicense.cs new file mode 100644 index 0000000..056ad7d --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Solve/GurobiLicense.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Gurobi; + +namespace Anexia.MathematicalProgram.Tests.Solve; + +/// +/// Detects (once per test run) whether a usable Gurobi licence is available in the current environment. +/// +internal static class GurobiLicense +{ + internal static bool IsAvailable { get; } + + internal static bool IsMissing { get; } + + static GurobiLicense() + { + try + { + // The parameterless constructor creates and starts the environment immediately, + // which fails when no licence (or no native library) is available. + var env = new GRBEnv(); + env.Dispose(); + IsAvailable = true; + } + catch (Exception exception) + { + // Only a missing licence triggers the solver's fallback path; other failures + // (e.g. an expired licence) are rethrown and cannot exercise that behaviour. + IsMissing = exception.Message.Contains("No Gurobi license found"); + } + } +} + +/// +/// A that is skipped unless a Gurobi licence is available. +/// +public sealed class RequiresGurobiLicenceFactAttribute : FactAttribute +{ + public RequiresGurobiLicenceFactAttribute() + { + if (!GurobiLicense.IsAvailable) + Skip = "Requires a Gurobi licence; skipped because none was detected in this environment."; + } +} + +/// +/// A that is skipped when a Gurobi licence is available. Use for tests that +/// assert the no-licence fallback behaviour, which cannot hold on a licensed machine. +/// +public sealed class RequiresNoGurobiLicenceFactAttribute : FactAttribute +{ + public RequiresNoGurobiLicenceFactAttribute() + { + if (!GurobiLicense.IsMissing) + Skip = "Asserts no-licence fallback behaviour; skipped because the environment does not " + + "report a missing Gurobi licence."; + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs b/test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs new file mode 100644 index 0000000..e96c70c --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Solve/GurobiNativeSolverTest.cs @@ -0,0 +1,344 @@ +// ------------------------------------------------------------------------------------------ +// +// 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 SolveWrapsGurobiSetupExceptionMessage() + { + var (exception, _) = SolveWithInvalidGurobiParameterAndLogger(); + + Assert.Contains("Error in solver:", exception.Message); + } + + [Fact] + public void SolveWrapsGurobiSetupExceptionWithInnerException() + { + var (exception, _) = SolveWithInvalidGurobiParameterAndLogger(); + + Assert.NotNull(exception.InnerException); + } + + [Fact] + public void SolveLogsErrorWhenGurobiSetupFails() + { + var (_, logger) = SolveWithInvalidGurobiParameterAndLogger(); + + Assert.Equal(LogLevel.Error, logger.LastLogLevel); + } + + [Fact] + public void SolveLogsGurobiSetupExceptionAsLastException() + { + var (exception, logger) = SolveWithInvalidGurobiParameterAndLogger(); + + Assert.Same(exception.InnerException, logger.LastException); + } + + [Fact] + public void SolveWithoutLoggerWrapsGurobiSetupExceptionMessage() + { + var exception = SolveWithInvalidGurobiParameterWithoutLogger(); + + Assert.Contains("Error in solver:", exception.Message); + } + + [Fact] + public void SolveWithoutLoggerWrapsGurobiSetupExceptionWithInnerException() + { + var exception = SolveWithInvalidGurobiParameterWithoutLogger(); + + Assert.NotNull(exception.InnerException); + } + + [RequiresGurobiLicenceFact] + 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.GurobiNativeIntegerProgramming).Solve(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); + } + + [RequiresNoGurobiLicenceFact] + public void SolverWithoutLicenceLogsFallbackSolverName() + { + var (_, logger, _) = SolveSimpleModelWithScipFallback(); + + Assert.Contains(logger.Messages, message => message.Contains("Scip")); + } + + [RequiresNoGurobiLicenceFact] + public void SolverWithoutLicenceLogsMissingLicence() + { + var (_, logger, _) = SolveSimpleModelWithScipFallback(); + + Assert.Contains(logger.Messages, message => message.Contains("No Gurobi licence found")); + } + + [RequiresNoGurobiLicenceFact] + public void SolverWithoutLicenceReturnsResultFromFallbackSolver() + { + var (result, _, variable) = SolveSimpleModelWithScipFallback(); + + Assert.Equal( + SolverResult( + SolutionValues, RealScalar, IRealScalar>( + (variable, new RealScalar(1))), new ObjectiveValue(2), new IsFeasible(true), + new IsOptimal(true), new OptimalityGap(0), + SolverResultStatus.Optimal, false), result); + } + + [RequiresGurobiLicenceFact] + 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.GurobiNativeIntegerProgramming).Solve(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); + } + + [RequiresGurobiLicenceFact] + 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.GurobiNativeIntegerProgramming).Solve(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); + } + + [RequiresGurobiLicenceFact] + 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.GurobiNativeIntegerProgramming).Solve(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); + } + + [RequiresGurobiLicenceFact] + 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.GurobiNativeIntegerProgramming).Solve( + optimizationModel, + new SolverParameter(new EnableSolverOutput(false), RelativeGap.EMinus7, + new TimeLimitInMilliseconds(10000), new NumberOfThreads(2), + AdditionalSolverSpecificParameters: [("ResultFile", "resultGRB.sol")])); + + Assert.Equal(resultORTools, resultGurobiAPI); + } + + private static (MathematicalProgramException Exception, FakeLogger Logger) + SolveWithInvalidGurobiParameterAndLogger() + { + var logger = new FakeLogger(); + + var exception = Assert.Throws(() => + new GurobiNativeSolver(logger).Solve( + CreateSimpleModel(), + CreateInvalidGurobiParameter())); + + return (exception, logger); + } + + private static MathematicalProgramException SolveWithInvalidGurobiParameterWithoutLogger() => + Assert.Throws(() => + new GurobiNativeSolver().Solve( + CreateSimpleModel(), + CreateInvalidGurobiParameter())); + + private static (ISolverResult, RealScalar, IRealScalar> Result, + FakeLogger Logger, + IIntegerVariable Variable) SolveSimpleModelWithScipFallback() + { + /* + * 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))); + + return (result, logger, v1); + } + + 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")]); +} diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs b/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs index 0994fc0..19c8808 100644 --- a/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Solve/IlpSolverTest.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------------------------ +// ------------------------------------------------------------------------------------------ // // Copyright (c) ANEXIA® Internetdienstleistungs GmbH.All rights reserved. // @@ -14,6 +14,7 @@ using Anexia.MathematicalProgram.Result; using Anexia.MathematicalProgram.Solve; using Anexia.MathematicalProgram.SolverConfiguration; +using Gurobi; using Microsoft.Extensions.Logging; using Serilog; using Serilog.Extensions.Logging; @@ -26,6 +27,94 @@ namespace Anexia.MathematicalProgram.Tests.Solve; public sealed class IlpSolverTest { + [Fact] + public void SolveWithGurobiNativeWrapsNonLicenceGurobiExceptionMessage() + { + var (exception, _) = SolveWithInvalidGurobiParameter(); + + Assert.Contains("Error in solver:", exception.Message); + } + + [Fact] + public void SolveWithGurobiNativeKeepsGurobiExceptionAsInnerException() + { + var (exception, _) = SolveWithInvalidGurobiParameter(); + + Assert.IsType(exception.InnerException); + } + + [Fact] + public void SolveWithGurobiNativeDoesNotFallBackOnNonLicenceGurobiException() + { + var (_, logger) = SolveWithInvalidGurobiParameter(); + + Assert.DoesNotContain(logger.Messages, + message => message.Contains("No Gurobi licence found", StringComparison.Ordinal)); + } + + [Fact] + public void SolveWithUnsupportedSolverReturnsResultFromFallbackSolver() + { + var (result, _, variable) = SolveWithUnsupportedSolver(); + + 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); + } + + [Fact] + public void SolveWithUnsupportedSolverLogsSwitchToFallbackSolver() + { + var (_, logger, _) = SolveWithUnsupportedSolver(); + + 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 SolveWithExportModelFilePathWritesExportFile() + { + var roundTrip = SolveExportedModelWithDefaultOverload(); + + Assert.True(roundTrip.ExportFileExisted); + } + + [Fact] + public void SolveModelAsMpsFormatDefaultOverloadReturnsSameObjectiveValue() + { + var roundTrip = SolveExportedModelWithDefaultOverload(); + + Assert.Equal(roundTrip.ExportedResult.ObjectiveValue, roundTrip.ResultFromMps.ObjectiveValue); + } + + [Fact] + public void SolveModelAsMpsFormatDefaultOverloadReturnsSameSolutionValue() + { + var roundTrip = SolveExportedModelWithDefaultOverload(); + + Assert.Equal(new RealScalar(1), SingleSolutionValue(roundTrip.ResultFromMps).Value); + } + [Fact] public void SolverWithSimpleFeasibleIlpModelReturnsCorrectResult() { @@ -68,13 +157,23 @@ public void SolverFromModelWithIntegerIntervalReturnsCorrectResult() model.SetObjective( model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), x).Build(false)); - var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel, - new SolverParameter(new EnableSolverOutput(true), ExportModelFilePath: "model.txt")); + var exportFilePath = NewExportFilePath(); + + try + { + var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel, + new SolverParameter(new EnableSolverOutput(true), ExportModelFilePaths: exportFilePath)); - var resultFromModel = new IlpSolver(IlpSolverType.Scip).Solve( - new ModelAsMpsFormat(File.ReadAllText("model.txt")), new SolverParameter(new EnableSolverOutput(true))); + var resultFromModel = new IlpSolver(IlpSolverType.Scip).Solve( + new ModelAsMpsFormat(File.ReadAllText(exportFilePath)), + new SolverParameter(new EnableSolverOutput(true))); - Assert.Equal(result, resultFromModel); + Assert.Equal(result, resultFromModel); + } + finally + { + File.Delete(exportFilePath); + } } [Fact] @@ -92,13 +191,23 @@ public void SolverFromModelWithBinaryIntervalReturnsCorrectResult() model.SetObjective( model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), x).Build()); - var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel, - new SolverParameter(new EnableSolverOutput(true), ExportModelFilePath: "model.txt")); + var exportFilePath = NewExportFilePath(); - var resultFromModel = new IlpSolver(IlpSolverType.Scip).Solve( - new ModelAsMpsFormat(File.ReadAllText("model.txt")), new SolverParameter(new EnableSolverOutput(true))); + try + { + var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel, + new SolverParameter(new EnableSolverOutput(true), ExportModelFilePaths: exportFilePath)); + + var resultFromModel = new IlpSolver(IlpSolverType.Scip).Solve( + new ModelAsMpsFormat(File.ReadAllText(exportFilePath)), + new SolverParameter(new EnableSolverOutput(true))); - Assert.Equal(result.ObjectiveValue, resultFromModel.ObjectiveValue); + Assert.Equal(result.ObjectiveValue, resultFromModel.ObjectiveValue); + } + finally + { + File.Delete(exportFilePath); + } } [Fact] @@ -116,13 +225,23 @@ public void SolverFromModelWithBinaryVariableReturnsCorrectResult() model.SetObjective( model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), x).Build(false)); - var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel, - new SolverParameter(new EnableSolverOutput(true), ExportModelFilePath: "model.txt")); + var exportFilePath = NewExportFilePath(); + + try + { + var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(optimizationModel, + new SolverParameter(new EnableSolverOutput(true), ExportModelFilePaths: exportFilePath)); - var resultFromModel = new IlpSolver(IlpSolverType.Scip).Solve( - new ModelAsMpsFormat(File.ReadAllText("model.txt")), new SolverParameter(new EnableSolverOutput(true))); + var resultFromModel = new IlpSolver(IlpSolverType.Scip).Solve( + new ModelAsMpsFormat(File.ReadAllText(exportFilePath)), + new SolverParameter(new EnableSolverOutput(true))); - Assert.Equal(result, resultFromModel); + Assert.Equal(result, resultFromModel); + } + finally + { + File.Delete(exportFilePath); + } } [Fact] @@ -179,16 +298,84 @@ public void SolverWithUnboundedIlModelReturnsCorrectResult() var result = SolverFactory.SolverFor(IlpSolverType.Scip).Solve(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); + Assert.Equal(UnboundedSolverResult(), result); } [Fact] - public void SolverAdditionalLoggingWorks() + public void SolverAdditionalLoggingDoesNotChangeSolverResult() + { + var (result, _) = SolveUnboundedModelWithFileLogger(); + + Assert.Equal(UnboundedSolverResult(), result); + } + + private static string NewExportFilePath() => Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.mps"); + + // An invalid Gurobi parameter fails during environment setup with a GRBException that is + // unrelated to licensing. The solver must surface it instead of silently falling back. + private static (MathematicalProgramException Exception, FakeLogger Logger) SolveWithInvalidGurobiParameter() + { + var optimizationModel = CreateSimpleOptimizationModel(out _); + var logger = new FakeLogger(); + + var exception = Assert.Throws(() => + new IlpSolver(IlpSolverType.GurobiNativeIntegerProgramming, IlpSolverType.Scip, logger) + .Solve( + optimizationModel, + new SolverParameter( + EnableSolverOutput.False, + AdditionalSolverSpecificParameters: [("__invalid_gurobi_parameter__", "1")]))); + + return (exception, logger); + } + + private static (ISolverResult, RealScalar, IRealScalar> Result, + FakeLogger Logger, + IIntegerVariable Variable) SolveWithUnsupportedSolver() + { + 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)); + + return (result, logger, variable); + } + + private static (bool ExportFileExisted, + IReadOnlyList LoggerMessages, + ISolverResult, RealScalar, IRealScalar> ExportedResult, + ISolverResult, RealScalar, IRealScalar> ResultFromMps, + IIntegerVariable Variable) SolveExportedModelWithDefaultOverload() + { + var exportFilePath = NewExportFilePath(); + 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, + ExportModelFilePaths: exportFilePath)); + + var resultFromMps = new IlpSolver(IlpSolverType.Scip) + .Solve(new ModelAsMpsFormat(File.ReadAllText(exportFilePath))); + + return (File.Exists(exportFilePath), logger.Messages, exportedResult, resultFromMps, variable); + } + finally + { + if (File.Exists(exportFilePath)) File.Delete(exportFilePath); + } + } + + private static KeyValuePair, RealScalar> SingleSolutionValue( + ISolverResult, RealScalar, IRealScalar> result) => + ((IEnumerable, RealScalar>>)result.SolutionValues).Single(); + + private static (ISolverResult, RealScalar, IRealScalar> Result, string LogContent) + SolveUnboundedModelWithFileLogger() { /* * max 2x, x positive @@ -201,7 +388,7 @@ public void SolverAdditionalLoggingWorks() model.SetObjective(model.CreateObjectiveFunctionBuilder().AddTermToSum(new IntegerScalar(2), x) .Build()); - var logFile = "tmp.log"; + var logFile = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.log"); var result = SolverFactory.SolverFor(IlpSolverType.Scip, null, new SerilogLoggerFactory(new LoggerConfiguration().WriteTo @@ -210,20 +397,51 @@ public void SolverAdditionalLoggingWorks() optimizationModel, new SolverParameter()); + string logContent; using (var streamReader = new StreamReader(logFile)) { - Assert.Equal( - "[Anexia.MathematicalProgram.Solve.IlpSolver] [INFO] Initialized Solver Scip with TimeLimit: \"unbounded\" and solver specific parameters \"\"", - streamReader.ReadToEnd()); + logContent = streamReader.ReadToEnd(); } File.Delete(logFile); - Assert.Equal( - SolverResult( - new SolutionValues, RealScalar, IRealScalar>( - ReadOnlyDictionary, RealScalar>.Empty), null, new IsFeasible(false), - new IsOptimal(false), null, - SolverResultStatus.Unbounded, false), result); + return (result, logContent); + } + + private static SolverResult, RealScalar, IRealScalar> UnboundedSolverResult() => + SolverResult( + new SolutionValues, RealScalar, IRealScalar>( + ReadOnlyDictionary, RealScalar>.Empty), null, null, + new IsOptimal(false), null, + SolverResultStatus.Unbounded, false); + + 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)); + } } -} \ No newline at end of file +} diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/LinearSolverTest.cs b/test/Anexia.MathematicalProgram.Tests/Solve/LinearSolverTest.cs index d6d7ac5..2f324bd 100644 --- a/test/Anexia.MathematicalProgram.Tests/Solve/LinearSolverTest.cs +++ b/test/Anexia.MathematicalProgram.Tests/Solve/LinearSolverTest.cs @@ -94,13 +94,23 @@ public void SolverFromModelReturnsCorrectResult() model.SetObjective( model.CreateObjectiveFunctionBuilder().AddTermToSum(new RealScalar(2), v1).Build(false)); - var result = SolverFactory.SolverFor(LpSolverType.Scip).Solve(optimizationModel, - new SolverParameter(new EnableSolverOutput(true), ExportModelFilePath: "model.txt")); - - var resultFromModel = new LpSolver(LpSolverType.Scip).Solve( - new ModelAsMpsFormat(File.ReadAllText("model.txt")), new SolverParameter(new EnableSolverOutput(true))); - - Assert.Equal(result, resultFromModel); + var exportFilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.mps"); + + try + { + var result = SolverFactory.SolverFor(LpSolverType.Scip).Solve(optimizationModel, + new SolverParameter(new EnableSolverOutput(true), ExportModelFilePaths: exportFilePath)); + + var resultFromModel = new LpSolver(LpSolverType.Scip).Solve( + new ModelAsMpsFormat(File.ReadAllText(exportFilePath)), + new SolverParameter(new EnableSolverOutput(true))); + + Assert.Equal(result, resultFromModel); + } + finally + { + File.Delete(exportFilePath); + } } [Fact] diff --git a/test/Anexia.MathematicalProgram.Tests/Solve/SolverFactoryTest.cs b/test/Anexia.MathematicalProgram.Tests/Solve/SolverFactoryTest.cs new file mode 100644 index 0000000..9fceb0d --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/Solve/SolverFactoryTest.cs @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.Solve; +using Anexia.MathematicalProgram.SolverConfiguration; + +namespace Anexia.MathematicalProgram.Tests.Solve; + +public sealed class SolverFactoryTest +{ + [Theory] + [InlineData(IlpSolverType.GurobiNativeIntegerProgramming)] + [InlineData(IlpSolverType.GurobiIntegerProgramming)] + [InlineData(IlpSolverType.Scip)] + [InlineData(IlpSolverType.HiGhs)] + public void SolverForIlpTypeReturnsIlpSolver(IlpSolverType solverType) + { + var solver = SolverFactory.SolverFor(solverType); + + Assert.IsType(solver); + } + + [Fact] + public void SolverForCbcReturnsIlpCbcSolver() + { +#pragma warning disable CS0618 // CBC is obsolete but still resolved by the factory. + var solver = SolverFactory.SolverFor(IlpSolverType.CbcIntegerProgramming); + + Assert.IsType(solver); +#pragma warning restore CS0618 + } + + [Fact] + public void SolverForUnknownIlpTypeThrows() + { + var exception = Assert.Throws(() => + SolverFactory.SolverFor((IlpSolverType)int.MaxValue)); + + Assert.Equal("solverType", exception.ParamName); + } + + [Theory] + [InlineData(LpSolverType.Glop)] + [InlineData(LpSolverType.Scip)] + [InlineData(LpSolverType.GurobiMixedIntegerProgramming)] + public void SolverForLpTypeReturnsLpSolver(LpSolverType solverType) + { + var solver = SolverFactory.SolverFor(solverType); + + Assert.IsType(solver); + } + + [Fact] + public void SolverForUnknownLpTypeThrows() + { + var exception = Assert.Throws(() => + SolverFactory.SolverFor((LpSolverType)int.MaxValue)); + + Assert.Equal("solverType", exception.ParamName); + } + + [Fact] + public void NewCpSolverReturnsConstraintProgrammingSolver() + { + var solver = SolverFactory.NewCpSolver(); + + Assert.IsType(solver); + } +} diff --git a/test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs b/test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs new file mode 100644 index 0000000..0592d0a --- /dev/null +++ b/test/Anexia.MathematicalProgram.Tests/SolverConfiguration/SolverParameterTest.cs @@ -0,0 +1,92 @@ +// ------------------------------------------------------------------------------------------ +// +// Copyright (c) ANEXIA Internetdienstleistungs GmbH. All rights reserved. +// +// ------------------------------------------------------------------------------------------ + +using Anexia.MathematicalProgram.SolverConfiguration; + +namespace Anexia.MathematicalProgram.Tests.SolverConfiguration; + +public sealed class SolverParameterTest +{ + [Fact] + public void TimeLimitConstructorDisablesSolverOutputByDefault() + { + var parameters = new SolverParameter(new TimeLimitInMilliseconds(2_500)); + + Assert.Equal(EnableSolverOutput.False, parameters.EnableSolverOutput); + } + + [Fact] + public void TimeLimitConstructorLeavesRelativeGapUnset() + { + var parameters = new SolverParameter(new TimeLimitInMilliseconds(2_500)); + + Assert.Null(parameters.RelativeGap); + } + + [Fact] + public void TimeLimitConstructorKeepsGivenTimeLimit() + { + var timeLimit = new TimeLimitInMilliseconds(2_500); + + var parameters = new SolverParameter(timeLimit); + + Assert.Equal(timeLimit, parameters.TimeLimitInMilliseconds); + } + + [Fact] + public void TimeLimitConstructorLeavesNumberOfThreadsUnset() + { + var parameters = new SolverParameter(new TimeLimitInMilliseconds(2_500)); + + Assert.Null(parameters.NumberOfThreads); + } + + [Fact] + public void TimeLimitInMillisecondsConvertsToTruncatedSeconds() + { + var timeLimit = new TimeLimitInMilliseconds(2_500); + + Assert.Equal(2u, timeLimit.AsSeconds); + } + + [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); + } + + [Theory] + [InlineData(LpSolverType.Glop, "num_omp_threads:3,custom:value")] + [InlineData(LpSolverType.Scip, "parallel/maxnthreads=3,custom=value")] + public void ToSolverSpecificParametersForLpUsesSolverSpecificSeparators( + LpSolverType solverType, + string expected) + { + var parameters = new SolverParameter( + EnableSolverOutput.False, + RelativeGap: new RelativeGap(0.05), + NumberOfThreads: new NumberOfThreads(3), + AdditionalSolverSpecificParameters: [("custom", "value")]); + + Assert.Equal(expected, parameters.ToSolverSpecificParameters(solverType)); + } +}