24 MathematicalModelSolverPluginBase
26 private const string ExplicitExecutableEnvironmentVariable =
27 "LOTSIZING_GUROBI_EXECUTABLE";
29 private readonly ExternalSolverProcessRunner _processRunner =
32 private string _cachedExecutablePath =
35 private string _cachedVersion =
44 SolverCapability.LinearProgramming,
45 SolverCapability.MixedIntegerLinearProgramming,
46 SolverCapability.Interruption,
47 SolverCapability.LpExport,
48 SolverCapability.OptimalityGapReporting,
49 SolverCapability.SearchStatistics
68 "LotSizingDataModel.Solver.Gurobi";
72 "LotSizingDataModel Gurobi Adapter";
83 public override async ValueTask<SolverAvailabilityInfo>
85 CancellationToken cancellationToken =
default)
87 cancellationToken.ThrowIfCancellationRequested();
89 string executablePath =
90 ResolveExecutablePath();
92 if (
string.IsNullOrWhiteSpace(executablePath))
95 new SolverAvailabilityInfo(
97 SolverAvailabilityStatus.NotInstalled)
102 notInstalled.AddDiagnostic(
103 "gurobi_cl was not found. Install Gurobi, put gurobi_cl " +
104 "on PATH, define GUROBI_HOME, or set " +
105 $
"{ExplicitExecutableEnvironmentVariable}.");
112 string workingDirectory =
113 Path.GetDirectoryName(executablePath) ??
114 Environment.CurrentDirectory;
116 ExternalSolverProcessResult processResult =
117 await _processRunner.RunAsync(
124 if (processResult.WasCancelled)
126 cancellationToken.ThrowIfCancellationRequested();
130 JoinOutput(processResult);
132 if (processResult.ExitCode != 0)
135 new SolverAvailabilityInfo(
137 SolverAvailabilityStatus.LoadFailure)
142 failed.AddDiagnostic(
143 FirstMeaningfulLine(text) ??
144 $
"gurobi_cl exited with code {processResult.ExitCode}.");
149 ExternalSolverProcessResult licenseResult =
150 await _processRunner.RunAsync(
157 if (licenseResult.WasCancelled)
159 cancellationToken.ThrowIfCancellationRequested();
163 JoinOutput(licenseResult);
165 if (licenseResult.ExitCode != 0)
168 new SolverAvailabilityInfo(
170 SolverAvailabilityStatus.LoadFailure)
176 failed.AddDiagnostic(
177 FirstMeaningfulLine(licenseText) ??
178 "gurobi_cl could be executed, but its license " +
179 "information could not be resolved.");
184 _cachedExecutablePath =
191 new SolverAvailabilityInfo(
193 SolverAvailabilityStatus.Available)
199 available.AddDiagnostic(
200 $
"Gurobi command-line executable detected at " +
201 $
"'{executablePath}'.");
203 if (!
string.IsNullOrWhiteSpace(text))
205 available.AddDiagnostic(
206 FirstMeaningfulLine(text) ?? text.Trim());
209 if (!
string.IsNullOrWhiteSpace(licenseText))
211 available.AddDiagnostic(
212 FirstMeaningfulLine(licenseText) ?? licenseText.Trim());
217 catch (OperationCanceledException)
221 catch (Exception exception)
224 new SolverAvailabilityInfo(
226 SolverAvailabilityStatus.LoadFailure)
231 failed.AddDiagnostic(exception.Message);
237 protected override async ValueTask<MathematicalModelSolveResult>
239 MathematicalModelSolveRequest request,
240 CancellationToken cancellationToken)
242 ArgumentNullException.ThrowIfNull(request);
243 ArgumentNullException.ThrowIfNull(request.Model);
245 request.Parameters.EnsureValid();
247 string executablePath =
248 !
string.IsNullOrWhiteSpace(_cachedExecutablePath) &&
249 File.Exists(_cachedExecutablePath)
250 ? _cachedExecutablePath
251 : ResolveExecutablePath();
253 if (
string.IsNullOrWhiteSpace(executablePath))
255 throw new InvalidOperationException(
256 "Gurobi is not available. Call solver discovery/availability " +
257 "before attempting to solve.");
260 string temporaryDirectory =
261 ExternalSolverResultUtilities.CreateTemporaryDirectory(
269 string solutionPath =
275 Stopwatch.StartNew();
279 await PublishProgressAsync(
281 new SolverProgressSnapshot
283 Stage = SolverProgressStage.BuildingModel,
284 ElapsedSeconds = stopwatch.Elapsed.TotalSeconds,
285 Message =
"Writing the portable LP model for Gurobi."
289 new PortableLpModelWriter().Write(
293 if (request.Parameters.ExportModel)
295 ExternalSolverResultUtilities.ExportPortableModel(
297 request.Parameters.ExportModelPath);
300 IReadOnlyList<string> parameterDiagnostics;
301 IReadOnlyList<string> arguments =
306 out parameterDiagnostics);
308 await PublishProgressAsync(
310 new SolverProgressSnapshot
312 Stage = ResolveOptimizationProgressStage(),
313 ElapsedSeconds = stopwatch.Elapsed.TotalSeconds,
314 Message =
"Gurobi optimization started."
318 ExternalSolverProcessResult processResult =
319 await _processRunner.RunAsync(
329 JoinOutput(processResult);
331 IReadOnlyDictionary<int, double> values =
332 NamedSolutionValueParser.ParseFile(
337 (request.Model.VariableCount == 0 &&
338 File.Exists(solutionPath));
341 new MathematicalModelSolveResult
346 !
string.IsNullOrWhiteSpace(_cachedVersion)
348 : ParseVersion(output),
349 SolveDuration = stopwatch.Elapsed,
350 HasFeasibleSolution = hasSolution,
352 MapTerminationReason(
355 processResult.WasCancelled),
356 IsOptimal = IsOptimal(output)
359 ExternalSolverResultUtilities.PopulateVariableValues(
370 foreach (
string diagnostic
in parameterDiagnostics)
372 result.AddDiagnostic(diagnostic);
375 result.AddDiagnostic(
376 $
"gurobi_cl exit code: {processResult.ExitCode}.");
379 LastMeaningfulLine(output);
381 if (!
string.IsNullOrWhiteSpace(finalLine))
383 result.AddDiagnostic(
384 $
"Gurobi final log: {finalLine}");
387 await PublishProgressAsync(
389 new SolverProgressSnapshot
391 Stage = SolverProgressStage.Completed,
392 ElapsedSeconds = stopwatch.Elapsed.TotalSeconds,
393 IncumbentObjective = result.ObjectiveValue,
394 BestBound = result.BestBound,
395 AbsoluteGap = result.AbsoluteGap,
396 RelativeGap = result.RelativeGap,
397 ExploredNodeCount = result.ExploredNodeCount,
398 IterationCount = result.IterationCount,
400 $
"Gurobi terminated with reason " +
401 $
"'{result.TerminationReason}'."
403 CancellationToken.None);
409 ExternalSolverResultUtilities.TryDeleteDirectory(
417 _processRunner.Stop();
420 private static IReadOnlyList<string> BuildArguments(
421 SolverParameters parameters,
424 out IReadOnlyList<string> diagnostics)
429 $
"ResultFile={solutionPath}"
438 parameters.TimeLimitSeconds);
442 parameters.ThreadCount);
446 parameters.RelativeMipGap);
450 parameters.AbsoluteMipGap);
454 parameters.NodeLimit);
458 parameters.SolutionLimit);
462 parameters.IterationLimit);
466 parameters.RandomSeed);
468 if (parameters.EnablePresolve ==
false)
470 arguments.Add(
"Presolve=0");
473 if (parameters.EnableCuts ==
false)
475 arguments.Add(
"Cuts=0");
478 if (parameters.EnableHeuristics ==
false)
480 arguments.Add(
"Heuristics=0");
483 if (parameters.MemoryLimitMegabytes.HasValue)
486 "Generic parameter 'MemoryLimitMegabytes' is valid but is " +
487 "not translated by the command-line Gurobi adapter.");
490 if (parameters.DeterministicMode)
493 "Generic parameter 'DeterministicMode' is valid but is not " +
494 "translated by the command-line Gurobi adapter.");
497 foreach (KeyValuePair<string, string> parameter
in
498 EnumerateNativeParameters(parameters.NativeParameters))
500 if (
string.IsNullOrWhiteSpace(parameter.Key) ||
501 parameter.Key.Contains(
'=') ||
502 parameter.Key.Any(
char.IsWhiteSpace))
505 $
"Ignored invalid Gurobi native parameter name " +
506 $
"'{parameter.Key}'.");
511 $
"{parameter.Key}={parameter.Value}");
514 arguments.Add(modelPath);
515 diagnostics = messages;
519 private static void AddParameter<T>(
520 ICollection<string> arguments,
523 where T :
struct, IFormattable
531 $
"{name}={value.Value.ToString(null, CultureInfo.InvariantCulture)}");
534 private static void PopulateStatistics(
535 MathematicalModelSolveResult result,
536 MathematicalModel model,
537 IReadOnlyDictionary<int, double> values,
540 if (TryParseBestObjectiveAndBound(
542 out
double incumbent,
545 result.ObjectiveValue = incumbent;
546 result.BestBound = bound;
548 else if (TryParseOptimalObjective(
550 out
double optimalObjective))
552 result.ObjectiveValue = optimalObjective;
553 result.BestBound = optimalObjective;
555 else if (result.HasFeasibleSolution &&
556 ExternalSolverResultUtilities.TryEvaluateObjective(
559 out
double recomputedObjective))
561 result.ObjectiveValue = recomputedObjective;
562 result.AddDiagnostic(
563 "Gurobi objective was recomputed from the returned solution " +
564 "because no objective value could be parsed from the log.");
568 NodeCountRegex().Match(output);
570 if (nodeMatch.Success &&
572 nodeMatch.Groups[1].Value,
573 NumberStyles.Integer,
574 CultureInfo.InvariantCulture,
577 result.ExploredNodeCount = nodes;
580 Match iterationMatch =
581 IterationCountRegex().Match(output);
583 if (iterationMatch.Success &&
585 iterationMatch.Groups[1].Value,
586 NumberStyles.Integer,
587 CultureInfo.InvariantCulture,
588 out
long iterations))
590 result.IterationCount = iterations;
593 ExternalSolverResultUtilities.PopulateGapStatistics(
597 private static SolverTerminationReason MapTerminationReason(
604 return SolverTerminationReason.UserInterrupted;
607 if (IsOptimal(output))
609 return SolverTerminationReason.Optimal;
613 "infeasible or unbounded",
614 StringComparison.OrdinalIgnoreCase))
616 return SolverTerminationReason.InfeasibleOrUnbounded;
620 "model is infeasible",
621 StringComparison.OrdinalIgnoreCase))
623 return SolverTerminationReason.Infeasible;
627 "model is unbounded",
628 StringComparison.OrdinalIgnoreCase))
630 return SolverTerminationReason.Unbounded;
634 "time limit reached",
635 StringComparison.OrdinalIgnoreCase))
637 return SolverTerminationReason.TimeLimit;
641 "node limit reached",
642 StringComparison.OrdinalIgnoreCase))
644 return SolverTerminationReason.NodeLimit;
648 "iteration limit reached",
649 StringComparison.OrdinalIgnoreCase))
651 return SolverTerminationReason.IterationLimit;
655 "solution limit reached",
656 StringComparison.OrdinalIgnoreCase))
658 return SolverTerminationReason.SolutionLimit;
662 ? SolverTerminationReason.Feasible
663 : SolverTerminationReason.Unknown;
666 private static bool IsOptimal(
669 return output.Contains(
670 "optimal solution found",
671 StringComparison.OrdinalIgnoreCase) ||
674 StringComparison.OrdinalIgnoreCase);
677 private static bool TryParseBestObjectiveAndBound(
679 out
double objective,
686 BestObjectiveRegex().Match(output);
688 return match.Success &&
689 TryParseFinite(match.Groups[1].Value, out objective) &&
690 TryParseFinite(match.Groups[2].Value, out bound);
693 private static bool TryParseOptimalObjective(
695 out
double objective)
700 OptimalObjectiveRegex().Match(output);
702 return match.Success &&
703 TryParseFinite(match.Groups[1].Value, out objective);
706 private string ResolveExecutablePath()
708 return ExternalSolverExecutableLocator.Resolve(
709 ExplicitExecutableEnvironmentVariable,
712 Path.Combine(
"bin",
"gurobi_cl.exe"),
713 Path.Combine(
"bin",
"gurobi_cl")
715 [
"gurobi_cl.exe",
"gurobi_cl"]);
718 private static string JoinOutput(
719 ExternalSolverProcessResult processResult)
725 processResult.StandardOutput,
726 processResult.StandardError
727 }.Where(text => !
string.IsNullOrWhiteSpace(text)));
730 private static string ParseVersion(
734 VersionRegex().Match(text ??
string.Empty);
737 ? match.Groups[1].Value
741 private static bool TryParseFinite(
745 return double.TryParse(
748 CultureInfo.InvariantCulture,
750 double.IsFinite(value);
753 private static string? FirstMeaningfulLine(
756 return SplitLines(text).FirstOrDefault();
759 private static string? LastMeaningfulLine(
762 return SplitLines(text).LastOrDefault();
765 private static IEnumerable<string> SplitLines(
768 return (text ??
string.Empty)
771 StringSplitOptions.RemoveEmptyEntries |
772 StringSplitOptions.TrimEntries)
773 .Where(line => !
string.IsNullOrWhiteSpace(line));
777 @"Gurobi Optimizer version\s+([0-9]+(?:\.[0-9]+)+)",
778 RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
779 private static partial Regex VersionRegex();
782 @"Best objective\s+([+\-0-9.eE]+),\s*best bound\s+([+\-0-9.eE]+)",
783 RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
784 private static partial Regex BestObjectiveRegex();
787 @"Optimal objective\s+([+\-0-9.eE]+)",
788 RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
789 private static partial Regex OptimalObjectiveRegex();
792 @"Explored\s+([0-9]+)\s+nodes",
793 RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
794 private static partial Regex NodeCountRegex();
797 @"([0-9]+)\s+simplex iterations",
798 RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
799 private static partial Regex IterationCountRegex();
800 private static SolverProgressStage ResolveOptimizationProgressStage()
802 foreach (
string candidate
in
803 new[] {
"Optimizing",
"Solving",
"Running" })
808 out SolverProgressStage stage))
818 return SolverProgressStage.BuildingModel;
821 private static IEnumerable<KeyValuePair<string, string>>
822 EnumerateNativeParameters(
823 System.Collections.IEnumerable? nativeParameters)
825 if (nativeParameters is
null)
830 foreach (
object? item
in nativeParameters)
837 if (item is KeyValuePair<string, string> pair)
844 ReadNativeParameterMember(
851 object? parameterValue =
852 ReadNativeParameterMember(
860 CultureInfo.InvariantCulture) ??
866 CultureInfo.InvariantCulture) ??
869 if (!
string.IsNullOrWhiteSpace(name))
871 yield
return new KeyValuePair<string, string>(
878 private static object? ReadNativeParameterMember(
880 params
string[] candidateNames)
882 Type type = instance.GetType();
884 foreach (
string candidateName
in candidateNames)
894 StringComparison.OrdinalIgnoreCase));
896 if (property is not
null)
898 return property.GetValue(instance);
908 StringComparison.OrdinalIgnoreCase));
910 if (field is not
null)
912 return field.GetValue(instance);