ortools.math_opt.python.result
The output from solving a mathematical optimization problem from model.py.
1#!/usr/bin/env python3 2# Copyright 2010-2025 Google LLC 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""The output from solving a mathematical optimization problem from model.py.""" 16import dataclasses 17import datetime 18import enum 19from typing import Dict, Iterable, List, Optional, overload 20 21from ortools.math_opt import result_pb2 22from ortools.math_opt.python import linear_constraints as linear_constraints_mod 23from ortools.math_opt.python import model 24from ortools.math_opt.python import solution 25from ortools.math_opt.python import variables as variables_mod 26from ortools.math_opt.solvers import osqp_pb2 27from ortools.math_opt.solvers.gscip import gscip_pb2 28 29_NO_DUAL_SOLUTION_ERROR = ( 30 "Best solution does not have an associated dual feasible solution." 31) 32_NO_BASIS_ERROR = "Best solution does not have an associated basis." 33 34 35@enum.unique 36class FeasibilityStatus(enum.Enum): 37 """Problem feasibility status as claimed by the solver. 38 39 (solver is not required to return a certificate for the claim.) 40 41 Attributes: 42 UNDETERMINED: Solver does not claim a status. 43 FEASIBLE: Solver claims the problem is feasible. 44 INFEASIBLE: Solver claims the problem is infeasible. 45 """ 46 47 UNDETERMINED = result_pb2.FEASIBILITY_STATUS_UNDETERMINED 48 FEASIBLE = result_pb2.FEASIBILITY_STATUS_FEASIBLE 49 INFEASIBLE = result_pb2.FEASIBILITY_STATUS_INFEASIBLE 50 51 52@dataclasses.dataclass(frozen=True) 53class ProblemStatus: 54 """Feasibility status of the primal problem and its dual (or dual relaxation). 55 56 Statuses are as claimed by the solver and a dual relaxation is the dual of a 57 continuous relaxation for the original problem (e.g. the LP relaxation of a 58 MIP). The solver is not required to return a certificate for the feasibility 59 or infeasibility claims (e.g. the solver may claim primal feasibility without 60 returning a primal feasible solutuion). This combined status gives a 61 comprehensive description of a solver's claims about feasibility and 62 unboundedness of the solved problem. For instance, 63 * a feasible status for primal and dual problems indicates the primal is 64 feasible and bounded and likely has an optimal solution (guaranteed for 65 problems without non-linear constraints). 66 * a primal feasible and a dual infeasible status indicates the primal 67 problem is unbounded (i.e. has arbitrarily good solutions). 68 Note that a dual infeasible status by itself (i.e. accompanied by an 69 undetermined primal status) does not imply the primal problem is unbounded as 70 we could have both problems be infeasible. Also, while a primal and dual 71 feasible status may imply the existence of an optimal solution, it does not 72 guarantee the solver has actually found such optimal solution. 73 74 Attributes: 75 primal_status: Status for the primal problem. 76 dual_status: Status for the dual problem (or for the dual of a continuous 77 relaxation). 78 primal_or_dual_infeasible: If true, the solver claims the primal or dual 79 problem is infeasible, but it does not know which (or if both are 80 infeasible). Can be true only when primal_problem_status = 81 dual_problem_status = kUndetermined. This extra information is often 82 needed when preprocessing determines there is no optimal solution to the 83 problem (but can't determine if it is due to infeasibility, unboundedness, 84 or both). 85 """ 86 87 primal_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED 88 dual_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED 89 primal_or_dual_infeasible: bool = False 90 91 def to_proto(self) -> result_pb2.ProblemStatusProto: 92 """Returns an equivalent proto for a problem status.""" 93 return result_pb2.ProblemStatusProto( 94 primal_status=self.primal_status.value, 95 dual_status=self.dual_status.value, 96 primal_or_dual_infeasible=self.primal_or_dual_infeasible, 97 ) 98 99 100def parse_problem_status(proto: result_pb2.ProblemStatusProto) -> ProblemStatus: 101 """Returns an equivalent ProblemStatus from the input proto.""" 102 primal_status_proto = proto.primal_status 103 if primal_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED: 104 raise ValueError("Primal feasibility status should not be UNSPECIFIED") 105 dual_status_proto = proto.dual_status 106 if dual_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED: 107 raise ValueError("Dual feasibility status should not be UNSPECIFIED") 108 return ProblemStatus( 109 primal_status=FeasibilityStatus(primal_status_proto), 110 dual_status=FeasibilityStatus(dual_status_proto), 111 primal_or_dual_infeasible=proto.primal_or_dual_infeasible, 112 ) 113 114 115@dataclasses.dataclass(frozen=True) 116class ObjectiveBounds: 117 """Bounds on the optimal objective value. 118 119 MOE:begin_intracomment_strip 120 See go/mathopt-objective-bounds for more details. 121 MOE:end_intracomment_strip 122 123 Attributes: 124 primal_bound: Solver claims there exists a primal solution that is 125 numerically feasible (i.e. feasible up to the solvers tolerance), and 126 whose objective value is primal_bound. 127 128 The optimal value is equal or better (smaller for min objectives and 129 larger for max objectives) than primal_bound, but only up to 130 solver-tolerances. 131 132 MOE:begin_intracomment_strip 133 See go/mathopt-objective-bounds for more details. 134 MOE:end_intracomment_strip 135 dual_bound: Solver claims there exists a dual solution that is numerically 136 feasible (i.e. feasible up to the solvers tolerance), and whose objective 137 value is dual_bound. 138 139 For MIP solvers, the associated dual problem may be some continuous 140 relaxation (e.g. LP relaxation), but it is often an implicitly defined 141 problem that is a complex consequence of the solvers execution. For both 142 continuous and MIP solvers, the optimal value is equal or worse (larger 143 for min objective and smaller for max objectives) than dual_bound, but 144 only up to solver-tolerances. Some continuous solvers provide a 145 numerically safer dual bound through solver's specific output (e.g. for 146 PDLP, pdlp_output.convergence_information.corrected_dual_objective). 147 148 MOE:begin_intracomment_strip 149 See go/mathopt-objective-bounds for more details. 150 MOE:end_intracomment_strip 151 """ # fmt: skip 152 153 primal_bound: float = 0.0 154 dual_bound: float = 0.0 155 156 def to_proto(self) -> result_pb2.ObjectiveBoundsProto: 157 """Returns an equivalent proto for objective bounds.""" 158 return result_pb2.ObjectiveBoundsProto( 159 primal_bound=self.primal_bound, dual_bound=self.dual_bound 160 ) 161 162 163def parse_objective_bounds( 164 proto: result_pb2.ObjectiveBoundsProto, 165) -> ObjectiveBounds: 166 """Returns an equivalent ObjectiveBounds from the input proto.""" 167 return ObjectiveBounds(primal_bound=proto.primal_bound, dual_bound=proto.dual_bound) 168 169 170@dataclasses.dataclass 171class SolveStats: 172 """Problem statuses and solve statistics returned by the solver. 173 174 Attributes: 175 solve_time: Elapsed wall clock time as measured by math_opt, roughly the 176 time inside solve(). Note: this does not include work done building the 177 model. 178 simplex_iterations: Simplex iterations. 179 barrier_iterations: Barrier iterations. 180 first_order_iterations: First order iterations. 181 node_count: Node count. 182 """ 183 184 solve_time: datetime.timedelta = datetime.timedelta() 185 simplex_iterations: int = 0 186 barrier_iterations: int = 0 187 first_order_iterations: int = 0 188 node_count: int = 0 189 190 def to_proto(self) -> result_pb2.SolveStatsProto: 191 """Returns an equivalent proto for a solve stats.""" 192 result = result_pb2.SolveStatsProto( 193 simplex_iterations=self.simplex_iterations, 194 barrier_iterations=self.barrier_iterations, 195 first_order_iterations=self.first_order_iterations, 196 node_count=self.node_count, 197 ) 198 result.solve_time.FromTimedelta(self.solve_time) 199 return result 200 201 202def parse_solve_stats(proto: result_pb2.SolveStatsProto) -> SolveStats: 203 """Returns an equivalent SolveStats from the input proto.""" 204 result = SolveStats() 205 result.solve_time = proto.solve_time.ToTimedelta() 206 result.simplex_iterations = proto.simplex_iterations 207 result.barrier_iterations = proto.barrier_iterations 208 result.first_order_iterations = proto.first_order_iterations 209 result.node_count = proto.node_count 210 return result 211 212 213@enum.unique 214class TerminationReason(enum.Enum): 215 """The reason a solve of a model terminated. 216 217 These reasons are typically as reported by the underlying solver, e.g. we do 218 not attempt to verify the precision of the solution returned. 219 220 The values are: 221 * OPTIMAL: A provably optimal solution (up to numerical tolerances) has 222 been found. 223 * INFEASIBLE: The primal problem has no feasible solutions. 224 * UNBOUNDED: The primal problem is feasible and arbitrarily good solutions 225 can be found along a primal ray. 226 * INFEASIBLE_OR_UNBOUNDED: The primal problem is either infeasible or 227 unbounded. More details on the problem status may be available in 228 solve_stats.problem_status. Note that Gurobi's unbounded status may be 229 mapped here as explained in 230 go/mathopt-solver-specific#gurobi-inf-or-unb. 231 * IMPRECISE: The problem was solved to one of the criteria above (Optimal, 232 Infeasible, Unbounded, or InfeasibleOrUnbounded), but one or more 233 tolerances was not met. Some primal/dual solutions/rays may be present, 234 but either they will be slightly infeasible, or (if the problem was 235 nearly optimal) their may be a gap between the best solution objective 236 and best objective bound. 237 238 Users can still query primal/dual solutions/rays and solution stats, 239 but they are responsible for dealing with the numerical imprecision. 240 * FEASIBLE: The optimizer reached some kind of limit and a primal feasible 241 solution is returned. See SolveResultProto.limit_detail for detailed 242 description of the kind of limit that was reached. 243 * NO_SOLUTION_FOUND: The optimizer reached some kind of limit and it did 244 not find a primal feasible solution. See SolveResultProto.limit_detail 245 for detailed description of the kind of limit that was reached. 246 * NUMERICAL_ERROR: The algorithm stopped because it encountered 247 unrecoverable numerical error. No solution information is present. 248 * OTHER_ERROR: The algorithm stopped because of an error not covered by one 249 of the statuses defined above. No solution information is present. 250 """ 251 252 OPTIMAL = result_pb2.TERMINATION_REASON_OPTIMAL 253 INFEASIBLE = result_pb2.TERMINATION_REASON_INFEASIBLE 254 UNBOUNDED = result_pb2.TERMINATION_REASON_UNBOUNDED 255 INFEASIBLE_OR_UNBOUNDED = result_pb2.TERMINATION_REASON_INFEASIBLE_OR_UNBOUNDED 256 IMPRECISE = result_pb2.TERMINATION_REASON_IMPRECISE 257 FEASIBLE = result_pb2.TERMINATION_REASON_FEASIBLE 258 NO_SOLUTION_FOUND = result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND 259 NUMERICAL_ERROR = result_pb2.TERMINATION_REASON_NUMERICAL_ERROR 260 OTHER_ERROR = result_pb2.TERMINATION_REASON_OTHER_ERROR 261 262 263@enum.unique 264class Limit(enum.Enum): 265 """The optimizer reached a limit, partial solution information may be present. 266 267 Values are: 268 * UNDETERMINED: The underlying solver does not expose which limit was 269 reached. 270 * ITERATION: An iterative algorithm stopped after conducting the 271 maximum number of iterations (e.g. simplex or barrier iterations). 272 * TIME: The algorithm stopped after a user-specified amount of 273 computation time. 274 * NODE: A branch-and-bound algorithm stopped because it explored a 275 maximum number of nodes in the branch-and-bound tree. 276 * SOLUTION: The algorithm stopped because it found the required 277 number of solutions. This is often used in MIPs to get the solver to 278 return the first feasible solution it encounters. 279 * MEMORY: The algorithm stopped because it ran out of memory. 280 * OBJECTIVE: The algorithm stopped because it found a solution better 281 than a minimum limit set by the user. 282 * NORM: The algorithm stopped because the norm of an iterate became 283 too large. 284 * INTERRUPTED: The algorithm stopped because of an interrupt signal or a 285 user interrupt request. 286 * SLOW_PROGRESS: The algorithm stopped because it was unable to continue 287 making progress towards the solution. 288 * OTHER: The algorithm stopped due to a limit not covered by one of the 289 above. Note that UNDETERMINED is used when the reason cannot be 290 determined, and OTHER is used when the reason is known but does not fit 291 into any of the above alternatives. 292 """ 293 294 UNDETERMINED = result_pb2.LIMIT_UNDETERMINED 295 ITERATION = result_pb2.LIMIT_ITERATION 296 TIME = result_pb2.LIMIT_TIME 297 NODE = result_pb2.LIMIT_NODE 298 SOLUTION = result_pb2.LIMIT_SOLUTION 299 MEMORY = result_pb2.LIMIT_MEMORY 300 OBJECTIVE = result_pb2.LIMIT_OBJECTIVE 301 NORM = result_pb2.LIMIT_NORM 302 INTERRUPTED = result_pb2.LIMIT_INTERRUPTED 303 SLOW_PROGRESS = result_pb2.LIMIT_SLOW_PROGRESS 304 OTHER = result_pb2.LIMIT_OTHER 305 306 307@dataclasses.dataclass 308class Termination: 309 """An explanation of why the solver stopped. 310 311 Attributes: 312 reason: Why the solver stopped, e.g. it found a provably optimal solution. 313 Additional information in `limit` when value is FEASIBLE or 314 NO_SOLUTION_FOUND, see `limit` for details. 315 limit: If the solver stopped early, what caused it to stop. Have value 316 UNSPECIFIED when reason is not NO_SOLUTION_FOUND or FEASIBLE. May still be 317 UNSPECIFIED when reason is NO_SOLUTION_FOUND or FEASIBLE, some solvers 318 cannot fill this in. 319 detail: Additional, information beyond reason about why the solver stopped, 320 typically solver specific. 321 problem_status: Feasibility statuses for primal and dual problems. 322 objective_bounds: Bounds on the optimal objective value. 323 """ 324 325 reason: TerminationReason = TerminationReason.OPTIMAL 326 limit: Optional[Limit] = None 327 detail: str = "" 328 problem_status: ProblemStatus = ProblemStatus() 329 objective_bounds: ObjectiveBounds = ObjectiveBounds() 330 331 def to_proto(self) -> result_pb2.TerminationProto: 332 """Returns an equivalent protocol buffer to this Termination.""" 333 return result_pb2.TerminationProto( 334 reason=self.reason.value, 335 limit=( 336 result_pb2.LIMIT_UNSPECIFIED if self.limit is None else self.limit.value 337 ), 338 detail=self.detail, 339 problem_status=self.problem_status.to_proto(), 340 objective_bounds=self.objective_bounds.to_proto(), 341 ) 342 343 344def parse_termination( 345 termination_proto: result_pb2.TerminationProto, 346) -> Termination: 347 """Returns a Termination that is equivalent to termination_proto.""" 348 reason_proto = termination_proto.reason 349 limit_proto = termination_proto.limit 350 if reason_proto == result_pb2.TERMINATION_REASON_UNSPECIFIED: 351 raise ValueError("Termination reason should not be UNSPECIFIED") 352 reason_is_limit = ( 353 reason_proto == result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND 354 ) or (reason_proto == result_pb2.TERMINATION_REASON_FEASIBLE) 355 limit_set = limit_proto != result_pb2.LIMIT_UNSPECIFIED 356 if reason_is_limit != limit_set: 357 raise ValueError( 358 f"Termination limit (={limit_proto})) should take value other than " 359 f"UNSPECIFIED if and only if termination reason (={reason_proto}) is " 360 "FEASIBLE or NO_SOLUTION_FOUND" 361 ) 362 termination = Termination() 363 termination.reason = TerminationReason(reason_proto) 364 termination.limit = Limit(limit_proto) if limit_set else None 365 termination.detail = termination_proto.detail 366 termination.problem_status = parse_problem_status(termination_proto.problem_status) 367 termination.objective_bounds = parse_objective_bounds( 368 termination_proto.objective_bounds 369 ) 370 return termination 371 372 373@dataclasses.dataclass 374class SolveResult: 375 """The result of solving an optimization problem defined by a Model. 376 377 We attempt to return as much solution information (primal_solutions, 378 primal_rays, dual_solutions, dual_rays) as each underlying solver will provide 379 given its return status. Differences in the underlying solvers result in a 380 weak contract on what fields will be populated for a given termination 381 reason. This is discussed in detail in termination_reasons.md, and the most 382 important points are summarized below: 383 * When the termination reason is optimal, there will be at least one primal 384 solution provided that will be feasible up to the underlying solver's 385 tolerances. 386 * Dual solutions are only given for convex optimization problems (e.g. 387 linear programs, not integer programs). 388 * A basis is only given for linear programs when solved by the simplex 389 method (e.g., not with PDLP). 390 * Solvers have widely varying support for returning primal and dual rays. 391 E.g. a termination_reason of unbounded does not ensure that a feasible 392 solution or a primal ray is returned, check termination_reasons.md for 393 solver specific guarantees if this is needed. Further, many solvers will 394 provide the ray but not the feasible solution when returning an unbounded 395 status. 396 * When the termination reason is that a limit was reached or that the result 397 is imprecise, a solution may or may not be present. Further, for some 398 solvers (generally, convex optimization solvers, not MIP solvers), the 399 primal or dual solution may not be feasible. 400 401 Solver specific output is also returned for some solvers (and only information 402 for the solver used will be populated). 403 404 Attributes: 405 termination: The reason the solver stopped. 406 solve_stats: Statistics on the solve process, e.g. running time, iterations. 407 solutions: Lexicographically by primal feasibility status, dual feasibility 408 status, (basic dual feasibility for simplex solvers), primal objective 409 value and dual objective value. 410 primal_rays: Directions of unbounded primal improvement, or equivalently, 411 dual infeasibility certificates. Typically provided for terminal reasons 412 UNBOUNDED and DUAL_INFEASIBLE. 413 dual_rays: Directions of unbounded dual improvement, or equivalently, primal 414 infeasibility certificates. Typically provided for termination reason 415 INFEASIBLE. 416 gscip_specific_output: statistics returned by the gSCIP solver, if used. 417 osqp_specific_output: statistics returned by the OSQP solver, if used. 418 pdlp_specific_output: statistics returned by the PDLP solver, if used. 419 """ 420 421 termination: Termination = dataclasses.field(default_factory=Termination) 422 solve_stats: SolveStats = dataclasses.field(default_factory=SolveStats) 423 solutions: List[solution.Solution] = dataclasses.field(default_factory=list) 424 primal_rays: List[solution.PrimalRay] = dataclasses.field(default_factory=list) 425 dual_rays: List[solution.DualRay] = dataclasses.field(default_factory=list) 426 # At most one of the below will be set 427 gscip_specific_output: Optional[gscip_pb2.GScipOutput] = None 428 osqp_specific_output: Optional[osqp_pb2.OsqpOutput] = None 429 pdlp_specific_output: Optional[result_pb2.SolveResultProto.PdlpOutput] = None 430 431 def solve_time(self) -> datetime.timedelta: 432 """Shortcut for SolveResult.solve_stats.solve_time.""" 433 return self.solve_stats.solve_time 434 435 def primal_bound(self) -> float: 436 """Returns a primal bound on the optimal objective value as described in ObjectiveBounds. 437 438 Will return a valid (possibly infinite) bound even if no primal feasible 439 solutions are available. 440 """ 441 return self.termination.objective_bounds.primal_bound 442 443 def dual_bound(self) -> float: 444 """Returns a dual bound on the optimal objective value as described in ObjectiveBounds. 445 446 Will return a valid (possibly infinite) bound even if no dual feasible 447 solutions are available. 448 """ 449 return self.termination.objective_bounds.dual_bound 450 451 def has_primal_feasible_solution(self) -> bool: 452 """Indicates if at least one primal feasible solution is available. 453 454 When termination.reason is TerminationReason.OPTIMAL or 455 TerminationReason.FEASIBLE, this is guaranteed to be true and need not be 456 checked. 457 458 Returns: 459 True if there is at least one primal feasible solution is available, 460 False, otherwise. 461 """ 462 if not self.solutions: 463 return False 464 sol = self.solutions[0] 465 return ( 466 sol.primal_solution is not None 467 and sol.primal_solution.feasibility_status 468 == solution.SolutionStatus.FEASIBLE 469 ) 470 471 def objective_value(self) -> float: 472 """Returns the objective value of the best primal feasible solution. 473 474 An error will be raised if there are no primal feasible solutions. 475 primal_bound() above is guaranteed to be at least as good (larger or equal 476 for max problems and smaller or equal for min problems) as objective_value() 477 and will never raise an error, so it may be preferable in some cases. Note 478 that primal_bound() could be better than objective_value() even for optimal 479 terminations, but on such optimal termination, both should satisfy the 480 optimality tolerances. 481 482 Returns: 483 The objective value of the best primal feasible solution. 484 485 Raises: 486 ValueError: There are no primal feasible solutions. 487 """ 488 if not self.has_primal_feasible_solution(): 489 raise ValueError("No primal feasible solution available.") 490 sol = self.solutions[0] 491 assert sol.primal_solution is not None 492 return sol.primal_solution.objective_value 493 494 def best_objective_bound(self) -> float: 495 """Returns a bound on the best possible objective value. 496 497 best_objective_bound() is always equal to dual_bound(), so they can be 498 used interchangeably. 499 """ 500 return self.termination.objective_bounds.dual_bound 501 502 @overload 503 def variable_values( 504 self, variables: None = ... 505 ) -> Dict[variables_mod.Variable, float]: ... 506 507 @overload 508 def variable_values(self, variables: variables_mod.Variable) -> float: ... 509 510 @overload 511 def variable_values( 512 self, variables: Iterable[variables_mod.Variable] 513 ) -> List[float]: ... 514 515 def variable_values(self, variables=None): 516 """The variable values from the best primal feasible solution. 517 518 An error will be raised if there are no primal feasible solutions. 519 520 Args: 521 variables: an optional Variable or iterator of Variables indicating what 522 variable values to return. If not provided, variable_values returns a 523 dictionary with all the variable values for all variables. 524 525 Returns: 526 The variable values from the best primal feasible solution. 527 528 Raises: 529 ValueError: There are no primal feasible solutions. 530 TypeError: Argument is not None, a Variable or an iterable of Variables. 531 KeyError: Variable values requested for an invalid variable (e.g. is not a 532 Variable or is a variable for another model). 533 """ 534 if not self.has_primal_feasible_solution(): 535 raise ValueError("No primal feasible solution available.") 536 sol = self.solutions[0] 537 assert sol.primal_solution is not None 538 if variables is None: 539 return sol.primal_solution.variable_values 540 if isinstance(variables, variables_mod.Variable): 541 return sol.primal_solution.variable_values[variables] 542 if isinstance(variables, Iterable): 543 return [sol.primal_solution.variable_values[v] for v in variables] 544 raise TypeError( 545 "unsupported type in argument for " 546 f"variable_values: {type(variables).__name__!r}" 547 ) 548 549 def bounded(self) -> bool: 550 """Returns true only if the problem has been shown to be feasible and bounded.""" 551 return ( 552 self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE 553 and self.termination.problem_status.dual_status 554 == FeasibilityStatus.FEASIBLE 555 ) 556 557 def has_ray(self) -> bool: 558 """Indicates if at least one primal ray is available. 559 560 This is NOT guaranteed to be true when termination.reason is 561 TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded. 562 563 Returns: 564 True if at least one primal ray is available. 565 """ 566 return bool(self.primal_rays) 567 568 @overload 569 def ray_variable_values( 570 self, variables: None = ... 571 ) -> Dict[variables_mod.Variable, float]: ... 572 573 @overload 574 def ray_variable_values(self, variables: variables_mod.Variable) -> float: ... 575 576 @overload 577 def ray_variable_values( 578 self, variables: Iterable[variables_mod.Variable] 579 ) -> List[float]: ... 580 581 def ray_variable_values(self, variables=None): 582 """The variable values from the first primal ray. 583 584 An error will be raised if there are no primal rays. 585 586 Args: 587 variables: an optional Variable or iterator of Variables indicating what 588 variable values to return. If not provided, variable_values() returns a 589 dictionary with the variable values for all variables. 590 591 Returns: 592 The variable values from the first primal ray. 593 594 Raises: 595 ValueError: There are no primal rays. 596 TypeError: Argument is not None, a Variable or an iterable of Variables. 597 KeyError: Variable values requested for an invalid variable (e.g. is not a 598 Variable or is a variable for another model). 599 """ 600 if not self.has_ray(): 601 raise ValueError("No primal ray available.") 602 if variables is None: 603 return self.primal_rays[0].variable_values 604 if isinstance(variables, variables_mod.Variable): 605 return self.primal_rays[0].variable_values[variables] 606 if isinstance(variables, Iterable): 607 return [self.primal_rays[0].variable_values[v] for v in variables] 608 raise TypeError( 609 "unsupported type in argument for " 610 f"ray_variable_values: {type(variables).__name__!r}" 611 ) 612 613 def has_dual_feasible_solution(self) -> bool: 614 """Indicates if the best solution has an associated dual feasible solution. 615 616 This is NOT guaranteed to be true when termination.reason is 617 TerminationReason.Optimal. It also may be true even when the best solution 618 does not have an associated primal feasible solution. 619 620 Returns: 621 True if the best solution has an associated dual feasible solution. 622 """ 623 if not self.solutions: 624 return False 625 sol = self.solutions[0] 626 return ( 627 sol.dual_solution is not None 628 and sol.dual_solution.feasibility_status == solution.SolutionStatus.FEASIBLE 629 ) 630 631 @overload 632 def dual_values( 633 self, linear_constraints: None = ... 634 ) -> Dict[linear_constraints_mod.LinearConstraint, float]: ... 635 636 @overload 637 def dual_values( 638 self, linear_constraints: linear_constraints_mod.LinearConstraint 639 ) -> float: ... 640 641 @overload 642 def dual_values( 643 self, 644 linear_constraints: Iterable[linear_constraints_mod.LinearConstraint], 645 ) -> List[float]: ... 646 647 def dual_values(self, linear_constraints=None): 648 """The dual values associated to the best solution. 649 650 If there is at least one primal feasible solution, this corresponds to the 651 dual values associated to the best primal feasible solution. An error will 652 be raised if the best solution does not have an associated dual feasible 653 solution. 654 655 Args: 656 linear_constraints: an optional LinearConstraint or iterator of 657 LinearConstraint indicating what dual values to return. If not provided, 658 dual_values() returns a dictionary with the dual values for all linear 659 constraints. 660 661 Returns: 662 The dual values associated to the best solution. 663 664 Raises: 665 ValueError: The best solution does not have an associated dual feasible 666 solution. 667 TypeError: Argument is not None, a LinearConstraint or an iterable of 668 LinearConstraint. 669 KeyError: LinearConstraint values requested for an invalid 670 linear constraint (e.g. is not a LinearConstraint or is a linear 671 constraint for another model). 672 """ 673 if not self.has_dual_feasible_solution(): 674 raise ValueError(_NO_DUAL_SOLUTION_ERROR) 675 sol = self.solutions[0] 676 assert sol.dual_solution is not None 677 if linear_constraints is None: 678 return sol.dual_solution.dual_values 679 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 680 return sol.dual_solution.dual_values[linear_constraints] 681 if isinstance(linear_constraints, Iterable): 682 return [sol.dual_solution.dual_values[c] for c in linear_constraints] 683 raise TypeError( 684 "unsupported type in argument for " 685 f"dual_values: {type(linear_constraints).__name__!r}" 686 ) 687 688 @overload 689 def reduced_costs( 690 self, variables: None = ... 691 ) -> Dict[variables_mod.Variable, float]: ... 692 693 @overload 694 def reduced_costs(self, variables: variables_mod.Variable) -> float: ... 695 696 @overload 697 def reduced_costs( 698 self, variables: Iterable[variables_mod.Variable] 699 ) -> List[float]: ... 700 701 def reduced_costs(self, variables=None): 702 """The reduced costs associated to the best solution. 703 704 If there is at least one primal feasible solution, this corresponds to the 705 reduced costs associated to the best primal feasible solution. An error will 706 be raised if the best solution does not have an associated dual feasible 707 solution. 708 709 Args: 710 variables: an optional Variable or iterator of Variables indicating what 711 reduced costs to return. If not provided, reduced_costs() returns a 712 dictionary with the reduced costs for all variables. 713 714 Returns: 715 The reduced costs associated to the best solution. 716 717 Raises: 718 ValueError: The best solution does not have an associated dual feasible 719 solution. 720 TypeError: Argument is not None, a Variable or an iterable of Variables. 721 KeyError: Variable values requested for an invalid variable (e.g. is not a 722 Variable or is a variable for another model). 723 """ 724 if not self.has_dual_feasible_solution(): 725 raise ValueError(_NO_DUAL_SOLUTION_ERROR) 726 sol = self.solutions[0] 727 assert sol.dual_solution is not None 728 if variables is None: 729 return sol.dual_solution.reduced_costs 730 if isinstance(variables, variables_mod.Variable): 731 return sol.dual_solution.reduced_costs[variables] 732 if isinstance(variables, Iterable): 733 return [sol.dual_solution.reduced_costs[v] for v in variables] 734 raise TypeError( 735 "unsupported type in argument for " 736 f"reduced_costs: {type(variables).__name__!r}" 737 ) 738 739 def has_dual_ray(self) -> bool: 740 """Indicates if at least one dual ray is available. 741 742 This is NOT guaranteed to be true when termination.reason is 743 TerminationReason.Infeasible. 744 745 Returns: 746 True if at least one dual ray is available. 747 """ 748 return bool(self.dual_rays) 749 750 @overload 751 def ray_dual_values( 752 self, linear_constraints: None = ... 753 ) -> Dict[linear_constraints_mod.LinearConstraint, float]: ... 754 755 @overload 756 def ray_dual_values( 757 self, linear_constraints: linear_constraints_mod.LinearConstraint 758 ) -> float: ... 759 760 @overload 761 def ray_dual_values( 762 self, 763 linear_constraints: Iterable[linear_constraints_mod.LinearConstraint], 764 ) -> List[float]: ... 765 766 def ray_dual_values(self, linear_constraints=None): 767 """The dual values from the first dual ray. 768 769 An error will be raised if there are no dual rays. 770 771 Args: 772 linear_constraints: an optional LinearConstraint or iterator of 773 LinearConstraint indicating what dual values to return. If not provided, 774 ray_dual_values() returns a dictionary with the dual values for all 775 linear constraints. 776 777 Returns: 778 The dual values from the first dual ray. 779 780 Raises: 781 ValueError: There are no dual rays. 782 TypeError: Argument is not None, a LinearConstraint or an iterable of 783 LinearConstraint. 784 KeyError: LinearConstraint values requested for an invalid 785 linear constraint (e.g. is not a LinearConstraint or is a linear 786 constraint for another model). 787 """ 788 if not self.has_dual_ray(): 789 raise ValueError("No dual ray available.") 790 ray = self.dual_rays[0] 791 if linear_constraints is None: 792 return ray.dual_values 793 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 794 return ray.dual_values[linear_constraints] 795 if isinstance(linear_constraints, Iterable): 796 return [ray.dual_values[v] for v in linear_constraints] 797 raise TypeError( 798 "unsupported type in argument for " 799 f"ray_dual_values: {type(linear_constraints).__name__!r}" 800 ) 801 802 @overload 803 def ray_reduced_costs( 804 self, variables: None = ... 805 ) -> Dict[variables_mod.Variable, float]: ... 806 807 @overload 808 def ray_reduced_costs(self, variables: variables_mod.Variable) -> float: ... 809 810 @overload 811 def ray_reduced_costs( 812 self, variables: Iterable[variables_mod.Variable] 813 ) -> List[float]: ... 814 815 def ray_reduced_costs(self, variables=None): 816 """The reduced costs from the first dual ray. 817 818 An error will be raised if there are no dual rays. 819 820 Args: 821 variables: an optional Variable or iterator of Variables indicating what 822 reduced costs to return. If not provided, ray_reduced_costs() returns a 823 dictionary with the reduced costs for all variables. 824 825 Returns: 826 The reduced costs from the first dual ray. 827 828 Raises: 829 ValueError: There are no dual rays. 830 TypeError: Argument is not None, a Variable or an iterable of Variables. 831 KeyError: Variable values requested for an invalid variable (e.g. is not a 832 Variable or is a variable for another model). 833 """ 834 if not self.has_dual_ray(): 835 raise ValueError("No dual ray available.") 836 ray = self.dual_rays[0] 837 if variables is None: 838 return ray.reduced_costs 839 if isinstance(variables, variables_mod.Variable): 840 return ray.reduced_costs[variables] 841 if isinstance(variables, Iterable): 842 return [ray.reduced_costs[v] for v in variables] 843 raise TypeError( 844 "unsupported type in argument for " 845 f"ray_reduced_costs: {type(variables).__name__!r}" 846 ) 847 848 def has_basis(self) -> bool: 849 """Indicates if the best solution has an associated basis. 850 851 This is NOT guaranteed to be true when termination.reason is 852 TerminationReason.Optimal. It also may be true even when the best solution 853 does not have an associated primal feasible solution. 854 855 Returns: 856 True if the best solution has an associated basis. 857 """ 858 if not self.solutions: 859 return False 860 return self.solutions[0].basis is not None 861 862 @overload 863 def constraint_status( 864 self, linear_constraints: None = ... 865 ) -> Dict[linear_constraints_mod.LinearConstraint, solution.BasisStatus]: ... 866 867 @overload 868 def constraint_status( 869 self, linear_constraints: linear_constraints_mod.LinearConstraint 870 ) -> solution.BasisStatus: ... 871 872 @overload 873 def constraint_status( 874 self, 875 linear_constraints: Iterable[linear_constraints_mod.LinearConstraint], 876 ) -> List[solution.BasisStatus]: ... 877 878 def constraint_status(self, linear_constraints=None): 879 """The constraint basis status associated to the best solution. 880 881 If there is at least one primal feasible solution, this corresponds to the 882 basis associated to the best primal feasible solution. An error will 883 be raised if the best solution does not have an associated basis. 884 885 886 Args: 887 linear_constraints: an optional LinearConstraint or iterator of 888 LinearConstraint indicating what constraint statuses to return. If not 889 provided, returns a dictionary with the constraint statuses for all 890 linear constraints. 891 892 Returns: 893 The constraint basis status associated to the best solution. 894 895 Raises: 896 ValueError: The best solution does not have an associated basis. 897 TypeError: Argument is not None, a LinearConstraint or an iterable of 898 LinearConstraint. 899 KeyError: LinearConstraint values requested for an invalid 900 linear constraint (e.g. is not a LinearConstraint or is a linear 901 constraint for another model). 902 """ 903 if not self.has_basis(): 904 raise ValueError(_NO_BASIS_ERROR) 905 basis = self.solutions[0].basis 906 assert basis is not None 907 if linear_constraints is None: 908 return basis.constraint_status 909 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 910 return basis.constraint_status[linear_constraints] 911 if isinstance(linear_constraints, Iterable): 912 return [basis.constraint_status[c] for c in linear_constraints] 913 raise TypeError( 914 "unsupported type in argument for " 915 f"constraint_status: {type(linear_constraints).__name__!r}" 916 ) 917 918 @overload 919 def variable_status( 920 self, variables: None = ... 921 ) -> Dict[variables_mod.Variable, solution.BasisStatus]: ... 922 923 @overload 924 def variable_status( 925 self, variables: variables_mod.Variable 926 ) -> solution.BasisStatus: ... 927 928 @overload 929 def variable_status( 930 self, variables: Iterable[variables_mod.Variable] 931 ) -> List[solution.BasisStatus]: ... 932 933 def variable_status(self, variables=None): 934 """The variable basis status associated to the best solution. 935 936 If there is at least one primal feasible solution, this corresponds to the 937 basis associated to the best primal feasible solution. An error will 938 be raised if the best solution does not have an associated basis. 939 940 Args: 941 variables: an optional Variable or iterator of Variables indicating what 942 reduced costs to return. If not provided, variable_status() returns a 943 dictionary with the reduced costs for all variables. 944 945 Returns: 946 The variable basis status associated to the best solution. 947 948 Raises: 949 ValueError: The best solution does not have an associated basis. 950 TypeError: Argument is not None, a Variable or an iterable of Variables. 951 KeyError: Variable values requested for an invalid variable (e.g. is not a 952 Variable or is a variable for another model). 953 """ 954 if not self.has_basis(): 955 raise ValueError(_NO_BASIS_ERROR) 956 basis = self.solutions[0].basis 957 assert basis is not None 958 if variables is None: 959 return basis.variable_status 960 if isinstance(variables, variables_mod.Variable): 961 return basis.variable_status[variables] 962 if isinstance(variables, Iterable): 963 return [basis.variable_status[v] for v in variables] 964 raise TypeError( 965 "unsupported type in argument for " 966 f"variable_status: {type(variables).__name__!r}" 967 ) 968 969 def to_proto(self) -> result_pb2.SolveResultProto: 970 """Returns an equivalent protocol buffer for a SolveResult.""" 971 proto = result_pb2.SolveResultProto( 972 termination=self.termination.to_proto(), 973 solutions=[s.to_proto() for s in self.solutions], 974 primal_rays=[r.to_proto() for r in self.primal_rays], 975 dual_rays=[r.to_proto() for r in self.dual_rays], 976 solve_stats=self.solve_stats.to_proto(), 977 ) 978 979 # Ensure that at most solver has solver specific output. 980 existing_solver_specific_output = None 981 982 def has_solver_specific_output(solver_name: str) -> None: 983 nonlocal existing_solver_specific_output 984 if existing_solver_specific_output is not None: 985 raise ValueError( 986 "found solver specific output for both" 987 f" {existing_solver_specific_output} and {solver_name}" 988 ) 989 existing_solver_specific_output = solver_name 990 991 if self.gscip_specific_output is not None: 992 has_solver_specific_output("gscip") 993 proto.gscip_output.CopyFrom(self.gscip_specific_output) 994 if self.osqp_specific_output is not None: 995 has_solver_specific_output("osqp") 996 proto.osqp_output.CopyFrom(self.osqp_specific_output) 997 if self.pdlp_specific_output is not None: 998 has_solver_specific_output("pdlp") 999 proto.pdlp_output.CopyFrom(self.pdlp_specific_output) 1000 return proto 1001 1002 1003def _get_problem_status( 1004 result_proto: result_pb2.SolveResultProto, 1005) -> result_pb2.ProblemStatusProto: 1006 if result_proto.termination.HasField("problem_status"): 1007 return result_proto.termination.problem_status 1008 return result_proto.solve_stats.problem_status 1009 1010 1011def _get_objective_bounds( 1012 result_proto: result_pb2.SolveResultProto, 1013) -> result_pb2.ObjectiveBoundsProto: 1014 if result_proto.termination.HasField("objective_bounds"): 1015 return result_proto.termination.objective_bounds 1016 return result_pb2.ObjectiveBoundsProto( 1017 primal_bound=result_proto.solve_stats.best_primal_bound, 1018 dual_bound=result_proto.solve_stats.best_dual_bound, 1019 ) 1020 1021 1022def _upgrade_termination( 1023 result_proto: result_pb2.SolveResultProto, 1024) -> result_pb2.TerminationProto: 1025 return result_pb2.TerminationProto( 1026 reason=result_proto.termination.reason, 1027 limit=result_proto.termination.limit, 1028 detail=result_proto.termination.detail, 1029 problem_status=_get_problem_status(result_proto), 1030 objective_bounds=_get_objective_bounds(result_proto), 1031 ) 1032 1033 1034def parse_solve_result( 1035 proto: result_pb2.SolveResultProto, 1036 mod: model.Model, 1037 *, 1038 validate: bool = True, 1039) -> SolveResult: 1040 """Returns a SolveResult equivalent to the input proto.""" 1041 result = SolveResult() 1042 # TODO(b/290091715): change to parse_termination(proto.termination) 1043 # once solve_stats proto no longer has best_primal/dual_bound/problem_status 1044 # and problem_status/objective_bounds are guaranteed to be present in 1045 # termination proto. 1046 result.termination = parse_termination(_upgrade_termination(proto)) 1047 result.solve_stats = parse_solve_stats(proto.solve_stats) 1048 for solution_proto in proto.solutions: 1049 result.solutions.append( 1050 solution.parse_solution(solution_proto, mod, validate=validate) 1051 ) 1052 for primal_ray_proto in proto.primal_rays: 1053 result.primal_rays.append( 1054 solution.parse_primal_ray(primal_ray_proto, mod, validate=validate) 1055 ) 1056 for dual_ray_proto in proto.dual_rays: 1057 result.dual_rays.append( 1058 solution.parse_dual_ray(dual_ray_proto, mod, validate=validate) 1059 ) 1060 if proto.HasField("gscip_output"): 1061 result.gscip_specific_output = proto.gscip_output 1062 elif proto.HasField("osqp_output"): 1063 result.osqp_specific_output = proto.osqp_output 1064 elif proto.HasField("pdlp_output"): 1065 result.pdlp_specific_output = proto.pdlp_output 1066 return result
36@enum.unique 37class FeasibilityStatus(enum.Enum): 38 """Problem feasibility status as claimed by the solver. 39 40 (solver is not required to return a certificate for the claim.) 41 42 Attributes: 43 UNDETERMINED: Solver does not claim a status. 44 FEASIBLE: Solver claims the problem is feasible. 45 INFEASIBLE: Solver claims the problem is infeasible. 46 """ 47 48 UNDETERMINED = result_pb2.FEASIBILITY_STATUS_UNDETERMINED 49 FEASIBLE = result_pb2.FEASIBILITY_STATUS_FEASIBLE 50 INFEASIBLE = result_pb2.FEASIBILITY_STATUS_INFEASIBLE
Problem feasibility status as claimed by the solver.
(solver is not required to return a certificate for the claim.)
Attributes:
- UNDETERMINED: Solver does not claim a status.
- FEASIBLE: Solver claims the problem is feasible.
- INFEASIBLE: Solver claims the problem is infeasible.
53@dataclasses.dataclass(frozen=True) 54class ProblemStatus: 55 """Feasibility status of the primal problem and its dual (or dual relaxation). 56 57 Statuses are as claimed by the solver and a dual relaxation is the dual of a 58 continuous relaxation for the original problem (e.g. the LP relaxation of a 59 MIP). The solver is not required to return a certificate for the feasibility 60 or infeasibility claims (e.g. the solver may claim primal feasibility without 61 returning a primal feasible solutuion). This combined status gives a 62 comprehensive description of a solver's claims about feasibility and 63 unboundedness of the solved problem. For instance, 64 * a feasible status for primal and dual problems indicates the primal is 65 feasible and bounded and likely has an optimal solution (guaranteed for 66 problems without non-linear constraints). 67 * a primal feasible and a dual infeasible status indicates the primal 68 problem is unbounded (i.e. has arbitrarily good solutions). 69 Note that a dual infeasible status by itself (i.e. accompanied by an 70 undetermined primal status) does not imply the primal problem is unbounded as 71 we could have both problems be infeasible. Also, while a primal and dual 72 feasible status may imply the existence of an optimal solution, it does not 73 guarantee the solver has actually found such optimal solution. 74 75 Attributes: 76 primal_status: Status for the primal problem. 77 dual_status: Status for the dual problem (or for the dual of a continuous 78 relaxation). 79 primal_or_dual_infeasible: If true, the solver claims the primal or dual 80 problem is infeasible, but it does not know which (or if both are 81 infeasible). Can be true only when primal_problem_status = 82 dual_problem_status = kUndetermined. This extra information is often 83 needed when preprocessing determines there is no optimal solution to the 84 problem (but can't determine if it is due to infeasibility, unboundedness, 85 or both). 86 """ 87 88 primal_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED 89 dual_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED 90 primal_or_dual_infeasible: bool = False 91 92 def to_proto(self) -> result_pb2.ProblemStatusProto: 93 """Returns an equivalent proto for a problem status.""" 94 return result_pb2.ProblemStatusProto( 95 primal_status=self.primal_status.value, 96 dual_status=self.dual_status.value, 97 primal_or_dual_infeasible=self.primal_or_dual_infeasible, 98 )
Feasibility status of the primal problem and its dual (or dual relaxation).
Statuses are as claimed by the solver and a dual relaxation is the dual of a continuous relaxation for the original problem (e.g. the LP relaxation of a MIP). The solver is not required to return a certificate for the feasibility or infeasibility claims (e.g. the solver may claim primal feasibility without returning a primal feasible solutuion). This combined status gives a comprehensive description of a solver's claims about feasibility and unboundedness of the solved problem. For instance,
- a feasible status for primal and dual problems indicates the primal is feasible and bounded and likely has an optimal solution (guaranteed for problems without non-linear constraints).
- a primal feasible and a dual infeasible status indicates the primal problem is unbounded (i.e. has arbitrarily good solutions). Note that a dual infeasible status by itself (i.e. accompanied by an undetermined primal status) does not imply the primal problem is unbounded as we could have both problems be infeasible. Also, while a primal and dual feasible status may imply the existence of an optimal solution, it does not guarantee the solver has actually found such optimal solution.
Attributes:
- primal_status: Status for the primal problem.
- dual_status: Status for the dual problem (or for the dual of a continuous relaxation).
- primal_or_dual_infeasible: If true, the solver claims the primal or dual problem is infeasible, but it does not know which (or if both are infeasible). Can be true only when primal_problem_status = dual_problem_status = kUndetermined. This extra information is often needed when preprocessing determines there is no optimal solution to the problem (but can't determine if it is due to infeasibility, unboundedness, or both).
92 def to_proto(self) -> result_pb2.ProblemStatusProto: 93 """Returns an equivalent proto for a problem status.""" 94 return result_pb2.ProblemStatusProto( 95 primal_status=self.primal_status.value, 96 dual_status=self.dual_status.value, 97 primal_or_dual_infeasible=self.primal_or_dual_infeasible, 98 )
Returns an equivalent proto for a problem status.
101def parse_problem_status(proto: result_pb2.ProblemStatusProto) -> ProblemStatus: 102 """Returns an equivalent ProblemStatus from the input proto.""" 103 primal_status_proto = proto.primal_status 104 if primal_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED: 105 raise ValueError("Primal feasibility status should not be UNSPECIFIED") 106 dual_status_proto = proto.dual_status 107 if dual_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED: 108 raise ValueError("Dual feasibility status should not be UNSPECIFIED") 109 return ProblemStatus( 110 primal_status=FeasibilityStatus(primal_status_proto), 111 dual_status=FeasibilityStatus(dual_status_proto), 112 primal_or_dual_infeasible=proto.primal_or_dual_infeasible, 113 )
Returns an equivalent ProblemStatus from the input proto.
116@dataclasses.dataclass(frozen=True) 117class ObjectiveBounds: 118 """Bounds on the optimal objective value. 119 120 MOE:begin_intracomment_strip 121 See go/mathopt-objective-bounds for more details. 122 MOE:end_intracomment_strip 123 124 Attributes: 125 primal_bound: Solver claims there exists a primal solution that is 126 numerically feasible (i.e. feasible up to the solvers tolerance), and 127 whose objective value is primal_bound. 128 129 The optimal value is equal or better (smaller for min objectives and 130 larger for max objectives) than primal_bound, but only up to 131 solver-tolerances. 132 133 MOE:begin_intracomment_strip 134 See go/mathopt-objective-bounds for more details. 135 MOE:end_intracomment_strip 136 dual_bound: Solver claims there exists a dual solution that is numerically 137 feasible (i.e. feasible up to the solvers tolerance), and whose objective 138 value is dual_bound. 139 140 For MIP solvers, the associated dual problem may be some continuous 141 relaxation (e.g. LP relaxation), but it is often an implicitly defined 142 problem that is a complex consequence of the solvers execution. For both 143 continuous and MIP solvers, the optimal value is equal or worse (larger 144 for min objective and smaller for max objectives) than dual_bound, but 145 only up to solver-tolerances. Some continuous solvers provide a 146 numerically safer dual bound through solver's specific output (e.g. for 147 PDLP, pdlp_output.convergence_information.corrected_dual_objective). 148 149 MOE:begin_intracomment_strip 150 See go/mathopt-objective-bounds for more details. 151 MOE:end_intracomment_strip 152 """ # fmt: skip 153 154 primal_bound: float = 0.0 155 dual_bound: float = 0.0 156 157 def to_proto(self) -> result_pb2.ObjectiveBoundsProto: 158 """Returns an equivalent proto for objective bounds.""" 159 return result_pb2.ObjectiveBoundsProto( 160 primal_bound=self.primal_bound, dual_bound=self.dual_bound 161 )
Bounds on the optimal objective value.
MOE:begin_intracomment_strip See go/mathopt-objective-bounds for more details. MOE:end_intracomment_strip
Attributes:
primal_bound: Solver claims there exists a primal solution that is numerically feasible (i.e. feasible up to the solvers tolerance), and whose objective value is primal_bound.
The optimal value is equal or better (smaller for min objectives and larger for max objectives) than primal_bound, but only up to solver-tolerances.
MOE:begin_intracomment_strip See go/mathopt-objective-bounds for more details. MOE:end_intracomment_strip
dual_bound: Solver claims there exists a dual solution that is numerically feasible (i.e. feasible up to the solvers tolerance), and whose objective value is dual_bound.
For MIP solvers, the associated dual problem may be some continuous relaxation (e.g. LP relaxation), but it is often an implicitly defined problem that is a complex consequence of the solvers execution. For both continuous and MIP solvers, the optimal value is equal or worse (larger for min objective and smaller for max objectives) than dual_bound, but only up to solver-tolerances. Some continuous solvers provide a numerically safer dual bound through solver's specific output (e.g. for PDLP, pdlp_output.convergence_information.corrected_dual_objective).
MOE:begin_intracomment_strip See go/mathopt-objective-bounds for more details. MOE:end_intracomment_strip
164def parse_objective_bounds( 165 proto: result_pb2.ObjectiveBoundsProto, 166) -> ObjectiveBounds: 167 """Returns an equivalent ObjectiveBounds from the input proto.""" 168 return ObjectiveBounds(primal_bound=proto.primal_bound, dual_bound=proto.dual_bound)
Returns an equivalent ObjectiveBounds from the input proto.
171@dataclasses.dataclass 172class SolveStats: 173 """Problem statuses and solve statistics returned by the solver. 174 175 Attributes: 176 solve_time: Elapsed wall clock time as measured by math_opt, roughly the 177 time inside solve(). Note: this does not include work done building the 178 model. 179 simplex_iterations: Simplex iterations. 180 barrier_iterations: Barrier iterations. 181 first_order_iterations: First order iterations. 182 node_count: Node count. 183 """ 184 185 solve_time: datetime.timedelta = datetime.timedelta() 186 simplex_iterations: int = 0 187 barrier_iterations: int = 0 188 first_order_iterations: int = 0 189 node_count: int = 0 190 191 def to_proto(self) -> result_pb2.SolveStatsProto: 192 """Returns an equivalent proto for a solve stats.""" 193 result = result_pb2.SolveStatsProto( 194 simplex_iterations=self.simplex_iterations, 195 barrier_iterations=self.barrier_iterations, 196 first_order_iterations=self.first_order_iterations, 197 node_count=self.node_count, 198 ) 199 result.solve_time.FromTimedelta(self.solve_time) 200 return result
Problem statuses and solve statistics returned by the solver.
Attributes:
- solve_time: Elapsed wall clock time as measured by math_opt, roughly the time inside solve(). Note: this does not include work done building the model.
- simplex_iterations: Simplex iterations.
- barrier_iterations: Barrier iterations.
- first_order_iterations: First order iterations.
- node_count: Node count.
191 def to_proto(self) -> result_pb2.SolveStatsProto: 192 """Returns an equivalent proto for a solve stats.""" 193 result = result_pb2.SolveStatsProto( 194 simplex_iterations=self.simplex_iterations, 195 barrier_iterations=self.barrier_iterations, 196 first_order_iterations=self.first_order_iterations, 197 node_count=self.node_count, 198 ) 199 result.solve_time.FromTimedelta(self.solve_time) 200 return result
Returns an equivalent proto for a solve stats.
203def parse_solve_stats(proto: result_pb2.SolveStatsProto) -> SolveStats: 204 """Returns an equivalent SolveStats from the input proto.""" 205 result = SolveStats() 206 result.solve_time = proto.solve_time.ToTimedelta() 207 result.simplex_iterations = proto.simplex_iterations 208 result.barrier_iterations = proto.barrier_iterations 209 result.first_order_iterations = proto.first_order_iterations 210 result.node_count = proto.node_count 211 return result
Returns an equivalent SolveStats from the input proto.
214@enum.unique 215class TerminationReason(enum.Enum): 216 """The reason a solve of a model terminated. 217 218 These reasons are typically as reported by the underlying solver, e.g. we do 219 not attempt to verify the precision of the solution returned. 220 221 The values are: 222 * OPTIMAL: A provably optimal solution (up to numerical tolerances) has 223 been found. 224 * INFEASIBLE: The primal problem has no feasible solutions. 225 * UNBOUNDED: The primal problem is feasible and arbitrarily good solutions 226 can be found along a primal ray. 227 * INFEASIBLE_OR_UNBOUNDED: The primal problem is either infeasible or 228 unbounded. More details on the problem status may be available in 229 solve_stats.problem_status. Note that Gurobi's unbounded status may be 230 mapped here as explained in 231 go/mathopt-solver-specific#gurobi-inf-or-unb. 232 * IMPRECISE: The problem was solved to one of the criteria above (Optimal, 233 Infeasible, Unbounded, or InfeasibleOrUnbounded), but one or more 234 tolerances was not met. Some primal/dual solutions/rays may be present, 235 but either they will be slightly infeasible, or (if the problem was 236 nearly optimal) their may be a gap between the best solution objective 237 and best objective bound. 238 239 Users can still query primal/dual solutions/rays and solution stats, 240 but they are responsible for dealing with the numerical imprecision. 241 * FEASIBLE: The optimizer reached some kind of limit and a primal feasible 242 solution is returned. See SolveResultProto.limit_detail for detailed 243 description of the kind of limit that was reached. 244 * NO_SOLUTION_FOUND: The optimizer reached some kind of limit and it did 245 not find a primal feasible solution. See SolveResultProto.limit_detail 246 for detailed description of the kind of limit that was reached. 247 * NUMERICAL_ERROR: The algorithm stopped because it encountered 248 unrecoverable numerical error. No solution information is present. 249 * OTHER_ERROR: The algorithm stopped because of an error not covered by one 250 of the statuses defined above. No solution information is present. 251 """ 252 253 OPTIMAL = result_pb2.TERMINATION_REASON_OPTIMAL 254 INFEASIBLE = result_pb2.TERMINATION_REASON_INFEASIBLE 255 UNBOUNDED = result_pb2.TERMINATION_REASON_UNBOUNDED 256 INFEASIBLE_OR_UNBOUNDED = result_pb2.TERMINATION_REASON_INFEASIBLE_OR_UNBOUNDED 257 IMPRECISE = result_pb2.TERMINATION_REASON_IMPRECISE 258 FEASIBLE = result_pb2.TERMINATION_REASON_FEASIBLE 259 NO_SOLUTION_FOUND = result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND 260 NUMERICAL_ERROR = result_pb2.TERMINATION_REASON_NUMERICAL_ERROR 261 OTHER_ERROR = result_pb2.TERMINATION_REASON_OTHER_ERROR
The reason a solve of a model terminated.
These reasons are typically as reported by the underlying solver, e.g. we do not attempt to verify the precision of the solution returned.
The values are:
- OPTIMAL: A provably optimal solution (up to numerical tolerances) has been found.
- INFEASIBLE: The primal problem has no feasible solutions.
- UNBOUNDED: The primal problem is feasible and arbitrarily good solutions can be found along a primal ray.
- INFEASIBLE_OR_UNBOUNDED: The primal problem is either infeasible or unbounded. More details on the problem status may be available in solve_stats.problem_status. Note that Gurobi's unbounded status may be mapped here as explained in go/mathopt-solver-specific#gurobi-inf-or-unb.
IMPRECISE: The problem was solved to one of the criteria above (Optimal, Infeasible, Unbounded, or InfeasibleOrUnbounded), but one or more tolerances was not met. Some primal/dual solutions/rays may be present, but either they will be slightly infeasible, or (if the problem was nearly optimal) their may be a gap between the best solution objective and best objective bound.
Users can still query primal/dual solutions/rays and solution stats, but they are responsible for dealing with the numerical imprecision.
- FEASIBLE: The optimizer reached some kind of limit and a primal feasible solution is returned. See SolveResultProto.limit_detail for detailed description of the kind of limit that was reached.
- NO_SOLUTION_FOUND: The optimizer reached some kind of limit and it did not find a primal feasible solution. See SolveResultProto.limit_detail for detailed description of the kind of limit that was reached.
- NUMERICAL_ERROR: The algorithm stopped because it encountered unrecoverable numerical error. No solution information is present.
- OTHER_ERROR: The algorithm stopped because of an error not covered by one of the statuses defined above. No solution information is present.
264@enum.unique 265class Limit(enum.Enum): 266 """The optimizer reached a limit, partial solution information may be present. 267 268 Values are: 269 * UNDETERMINED: The underlying solver does not expose which limit was 270 reached. 271 * ITERATION: An iterative algorithm stopped after conducting the 272 maximum number of iterations (e.g. simplex or barrier iterations). 273 * TIME: The algorithm stopped after a user-specified amount of 274 computation time. 275 * NODE: A branch-and-bound algorithm stopped because it explored a 276 maximum number of nodes in the branch-and-bound tree. 277 * SOLUTION: The algorithm stopped because it found the required 278 number of solutions. This is often used in MIPs to get the solver to 279 return the first feasible solution it encounters. 280 * MEMORY: The algorithm stopped because it ran out of memory. 281 * OBJECTIVE: The algorithm stopped because it found a solution better 282 than a minimum limit set by the user. 283 * NORM: The algorithm stopped because the norm of an iterate became 284 too large. 285 * INTERRUPTED: The algorithm stopped because of an interrupt signal or a 286 user interrupt request. 287 * SLOW_PROGRESS: The algorithm stopped because it was unable to continue 288 making progress towards the solution. 289 * OTHER: The algorithm stopped due to a limit not covered by one of the 290 above. Note that UNDETERMINED is used when the reason cannot be 291 determined, and OTHER is used when the reason is known but does not fit 292 into any of the above alternatives. 293 """ 294 295 UNDETERMINED = result_pb2.LIMIT_UNDETERMINED 296 ITERATION = result_pb2.LIMIT_ITERATION 297 TIME = result_pb2.LIMIT_TIME 298 NODE = result_pb2.LIMIT_NODE 299 SOLUTION = result_pb2.LIMIT_SOLUTION 300 MEMORY = result_pb2.LIMIT_MEMORY 301 OBJECTIVE = result_pb2.LIMIT_OBJECTIVE 302 NORM = result_pb2.LIMIT_NORM 303 INTERRUPTED = result_pb2.LIMIT_INTERRUPTED 304 SLOW_PROGRESS = result_pb2.LIMIT_SLOW_PROGRESS 305 OTHER = result_pb2.LIMIT_OTHER
The optimizer reached a limit, partial solution information may be present.
Values are:
- UNDETERMINED: The underlying solver does not expose which limit was reached.
- ITERATION: An iterative algorithm stopped after conducting the maximum number of iterations (e.g. simplex or barrier iterations).
- TIME: The algorithm stopped after a user-specified amount of computation time.
- NODE: A branch-and-bound algorithm stopped because it explored a maximum number of nodes in the branch-and-bound tree.
- SOLUTION: The algorithm stopped because it found the required number of solutions. This is often used in MIPs to get the solver to return the first feasible solution it encounters.
- MEMORY: The algorithm stopped because it ran out of memory.
- OBJECTIVE: The algorithm stopped because it found a solution better than a minimum limit set by the user.
- NORM: The algorithm stopped because the norm of an iterate became too large.
- INTERRUPTED: The algorithm stopped because of an interrupt signal or a user interrupt request.
- SLOW_PROGRESS: The algorithm stopped because it was unable to continue making progress towards the solution.
- OTHER: The algorithm stopped due to a limit not covered by one of the above. Note that UNDETERMINED is used when the reason cannot be determined, and OTHER is used when the reason is known but does not fit into any of the above alternatives.
308@dataclasses.dataclass 309class Termination: 310 """An explanation of why the solver stopped. 311 312 Attributes: 313 reason: Why the solver stopped, e.g. it found a provably optimal solution. 314 Additional information in `limit` when value is FEASIBLE or 315 NO_SOLUTION_FOUND, see `limit` for details. 316 limit: If the solver stopped early, what caused it to stop. Have value 317 UNSPECIFIED when reason is not NO_SOLUTION_FOUND or FEASIBLE. May still be 318 UNSPECIFIED when reason is NO_SOLUTION_FOUND or FEASIBLE, some solvers 319 cannot fill this in. 320 detail: Additional, information beyond reason about why the solver stopped, 321 typically solver specific. 322 problem_status: Feasibility statuses for primal and dual problems. 323 objective_bounds: Bounds on the optimal objective value. 324 """ 325 326 reason: TerminationReason = TerminationReason.OPTIMAL 327 limit: Optional[Limit] = None 328 detail: str = "" 329 problem_status: ProblemStatus = ProblemStatus() 330 objective_bounds: ObjectiveBounds = ObjectiveBounds() 331 332 def to_proto(self) -> result_pb2.TerminationProto: 333 """Returns an equivalent protocol buffer to this Termination.""" 334 return result_pb2.TerminationProto( 335 reason=self.reason.value, 336 limit=( 337 result_pb2.LIMIT_UNSPECIFIED if self.limit is None else self.limit.value 338 ), 339 detail=self.detail, 340 problem_status=self.problem_status.to_proto(), 341 objective_bounds=self.objective_bounds.to_proto(), 342 )
An explanation of why the solver stopped.
Attributes:
- reason: Why the solver stopped, e.g. it found a provably optimal solution.
Additional information in
limitwhen value is FEASIBLE or NO_SOLUTION_FOUND, seelimitfor details. - limit: If the solver stopped early, what caused it to stop. Have value UNSPECIFIED when reason is not NO_SOLUTION_FOUND or FEASIBLE. May still be UNSPECIFIED when reason is NO_SOLUTION_FOUND or FEASIBLE, some solvers cannot fill this in.
- detail: Additional, information beyond reason about why the solver stopped, typically solver specific.
- problem_status: Feasibility statuses for primal and dual problems.
- objective_bounds: Bounds on the optimal objective value.
332 def to_proto(self) -> result_pb2.TerminationProto: 333 """Returns an equivalent protocol buffer to this Termination.""" 334 return result_pb2.TerminationProto( 335 reason=self.reason.value, 336 limit=( 337 result_pb2.LIMIT_UNSPECIFIED if self.limit is None else self.limit.value 338 ), 339 detail=self.detail, 340 problem_status=self.problem_status.to_proto(), 341 objective_bounds=self.objective_bounds.to_proto(), 342 )
Returns an equivalent protocol buffer to this Termination.
345def parse_termination( 346 termination_proto: result_pb2.TerminationProto, 347) -> Termination: 348 """Returns a Termination that is equivalent to termination_proto.""" 349 reason_proto = termination_proto.reason 350 limit_proto = termination_proto.limit 351 if reason_proto == result_pb2.TERMINATION_REASON_UNSPECIFIED: 352 raise ValueError("Termination reason should not be UNSPECIFIED") 353 reason_is_limit = ( 354 reason_proto == result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND 355 ) or (reason_proto == result_pb2.TERMINATION_REASON_FEASIBLE) 356 limit_set = limit_proto != result_pb2.LIMIT_UNSPECIFIED 357 if reason_is_limit != limit_set: 358 raise ValueError( 359 f"Termination limit (={limit_proto})) should take value other than " 360 f"UNSPECIFIED if and only if termination reason (={reason_proto}) is " 361 "FEASIBLE or NO_SOLUTION_FOUND" 362 ) 363 termination = Termination() 364 termination.reason = TerminationReason(reason_proto) 365 termination.limit = Limit(limit_proto) if limit_set else None 366 termination.detail = termination_proto.detail 367 termination.problem_status = parse_problem_status(termination_proto.problem_status) 368 termination.objective_bounds = parse_objective_bounds( 369 termination_proto.objective_bounds 370 ) 371 return termination
Returns a Termination that is equivalent to termination_proto.
374@dataclasses.dataclass 375class SolveResult: 376 """The result of solving an optimization problem defined by a Model. 377 378 We attempt to return as much solution information (primal_solutions, 379 primal_rays, dual_solutions, dual_rays) as each underlying solver will provide 380 given its return status. Differences in the underlying solvers result in a 381 weak contract on what fields will be populated for a given termination 382 reason. This is discussed in detail in termination_reasons.md, and the most 383 important points are summarized below: 384 * When the termination reason is optimal, there will be at least one primal 385 solution provided that will be feasible up to the underlying solver's 386 tolerances. 387 * Dual solutions are only given for convex optimization problems (e.g. 388 linear programs, not integer programs). 389 * A basis is only given for linear programs when solved by the simplex 390 method (e.g., not with PDLP). 391 * Solvers have widely varying support for returning primal and dual rays. 392 E.g. a termination_reason of unbounded does not ensure that a feasible 393 solution or a primal ray is returned, check termination_reasons.md for 394 solver specific guarantees if this is needed. Further, many solvers will 395 provide the ray but not the feasible solution when returning an unbounded 396 status. 397 * When the termination reason is that a limit was reached or that the result 398 is imprecise, a solution may or may not be present. Further, for some 399 solvers (generally, convex optimization solvers, not MIP solvers), the 400 primal or dual solution may not be feasible. 401 402 Solver specific output is also returned for some solvers (and only information 403 for the solver used will be populated). 404 405 Attributes: 406 termination: The reason the solver stopped. 407 solve_stats: Statistics on the solve process, e.g. running time, iterations. 408 solutions: Lexicographically by primal feasibility status, dual feasibility 409 status, (basic dual feasibility for simplex solvers), primal objective 410 value and dual objective value. 411 primal_rays: Directions of unbounded primal improvement, or equivalently, 412 dual infeasibility certificates. Typically provided for terminal reasons 413 UNBOUNDED and DUAL_INFEASIBLE. 414 dual_rays: Directions of unbounded dual improvement, or equivalently, primal 415 infeasibility certificates. Typically provided for termination reason 416 INFEASIBLE. 417 gscip_specific_output: statistics returned by the gSCIP solver, if used. 418 osqp_specific_output: statistics returned by the OSQP solver, if used. 419 pdlp_specific_output: statistics returned by the PDLP solver, if used. 420 """ 421 422 termination: Termination = dataclasses.field(default_factory=Termination) 423 solve_stats: SolveStats = dataclasses.field(default_factory=SolveStats) 424 solutions: List[solution.Solution] = dataclasses.field(default_factory=list) 425 primal_rays: List[solution.PrimalRay] = dataclasses.field(default_factory=list) 426 dual_rays: List[solution.DualRay] = dataclasses.field(default_factory=list) 427 # At most one of the below will be set 428 gscip_specific_output: Optional[gscip_pb2.GScipOutput] = None 429 osqp_specific_output: Optional[osqp_pb2.OsqpOutput] = None 430 pdlp_specific_output: Optional[result_pb2.SolveResultProto.PdlpOutput] = None 431 432 def solve_time(self) -> datetime.timedelta: 433 """Shortcut for SolveResult.solve_stats.solve_time.""" 434 return self.solve_stats.solve_time 435 436 def primal_bound(self) -> float: 437 """Returns a primal bound on the optimal objective value as described in ObjectiveBounds. 438 439 Will return a valid (possibly infinite) bound even if no primal feasible 440 solutions are available. 441 """ 442 return self.termination.objective_bounds.primal_bound 443 444 def dual_bound(self) -> float: 445 """Returns a dual bound on the optimal objective value as described in ObjectiveBounds. 446 447 Will return a valid (possibly infinite) bound even if no dual feasible 448 solutions are available. 449 """ 450 return self.termination.objective_bounds.dual_bound 451 452 def has_primal_feasible_solution(self) -> bool: 453 """Indicates if at least one primal feasible solution is available. 454 455 When termination.reason is TerminationReason.OPTIMAL or 456 TerminationReason.FEASIBLE, this is guaranteed to be true and need not be 457 checked. 458 459 Returns: 460 True if there is at least one primal feasible solution is available, 461 False, otherwise. 462 """ 463 if not self.solutions: 464 return False 465 sol = self.solutions[0] 466 return ( 467 sol.primal_solution is not None 468 and sol.primal_solution.feasibility_status 469 == solution.SolutionStatus.FEASIBLE 470 ) 471 472 def objective_value(self) -> float: 473 """Returns the objective value of the best primal feasible solution. 474 475 An error will be raised if there are no primal feasible solutions. 476 primal_bound() above is guaranteed to be at least as good (larger or equal 477 for max problems and smaller or equal for min problems) as objective_value() 478 and will never raise an error, so it may be preferable in some cases. Note 479 that primal_bound() could be better than objective_value() even for optimal 480 terminations, but on such optimal termination, both should satisfy the 481 optimality tolerances. 482 483 Returns: 484 The objective value of the best primal feasible solution. 485 486 Raises: 487 ValueError: There are no primal feasible solutions. 488 """ 489 if not self.has_primal_feasible_solution(): 490 raise ValueError("No primal feasible solution available.") 491 sol = self.solutions[0] 492 assert sol.primal_solution is not None 493 return sol.primal_solution.objective_value 494 495 def best_objective_bound(self) -> float: 496 """Returns a bound on the best possible objective value. 497 498 best_objective_bound() is always equal to dual_bound(), so they can be 499 used interchangeably. 500 """ 501 return self.termination.objective_bounds.dual_bound 502 503 @overload 504 def variable_values( 505 self, variables: None = ... 506 ) -> Dict[variables_mod.Variable, float]: ... 507 508 @overload 509 def variable_values(self, variables: variables_mod.Variable) -> float: ... 510 511 @overload 512 def variable_values( 513 self, variables: Iterable[variables_mod.Variable] 514 ) -> List[float]: ... 515 516 def variable_values(self, variables=None): 517 """The variable values from the best primal feasible solution. 518 519 An error will be raised if there are no primal feasible solutions. 520 521 Args: 522 variables: an optional Variable or iterator of Variables indicating what 523 variable values to return. If not provided, variable_values returns a 524 dictionary with all the variable values for all variables. 525 526 Returns: 527 The variable values from the best primal feasible solution. 528 529 Raises: 530 ValueError: There are no primal feasible solutions. 531 TypeError: Argument is not None, a Variable or an iterable of Variables. 532 KeyError: Variable values requested for an invalid variable (e.g. is not a 533 Variable or is a variable for another model). 534 """ 535 if not self.has_primal_feasible_solution(): 536 raise ValueError("No primal feasible solution available.") 537 sol = self.solutions[0] 538 assert sol.primal_solution is not None 539 if variables is None: 540 return sol.primal_solution.variable_values 541 if isinstance(variables, variables_mod.Variable): 542 return sol.primal_solution.variable_values[variables] 543 if isinstance(variables, Iterable): 544 return [sol.primal_solution.variable_values[v] for v in variables] 545 raise TypeError( 546 "unsupported type in argument for " 547 f"variable_values: {type(variables).__name__!r}" 548 ) 549 550 def bounded(self) -> bool: 551 """Returns true only if the problem has been shown to be feasible and bounded.""" 552 return ( 553 self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE 554 and self.termination.problem_status.dual_status 555 == FeasibilityStatus.FEASIBLE 556 ) 557 558 def has_ray(self) -> bool: 559 """Indicates if at least one primal ray is available. 560 561 This is NOT guaranteed to be true when termination.reason is 562 TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded. 563 564 Returns: 565 True if at least one primal ray is available. 566 """ 567 return bool(self.primal_rays) 568 569 @overload 570 def ray_variable_values( 571 self, variables: None = ... 572 ) -> Dict[variables_mod.Variable, float]: ... 573 574 @overload 575 def ray_variable_values(self, variables: variables_mod.Variable) -> float: ... 576 577 @overload 578 def ray_variable_values( 579 self, variables: Iterable[variables_mod.Variable] 580 ) -> List[float]: ... 581 582 def ray_variable_values(self, variables=None): 583 """The variable values from the first primal ray. 584 585 An error will be raised if there are no primal rays. 586 587 Args: 588 variables: an optional Variable or iterator of Variables indicating what 589 variable values to return. If not provided, variable_values() returns a 590 dictionary with the variable values for all variables. 591 592 Returns: 593 The variable values from the first primal ray. 594 595 Raises: 596 ValueError: There are no primal rays. 597 TypeError: Argument is not None, a Variable or an iterable of Variables. 598 KeyError: Variable values requested for an invalid variable (e.g. is not a 599 Variable or is a variable for another model). 600 """ 601 if not self.has_ray(): 602 raise ValueError("No primal ray available.") 603 if variables is None: 604 return self.primal_rays[0].variable_values 605 if isinstance(variables, variables_mod.Variable): 606 return self.primal_rays[0].variable_values[variables] 607 if isinstance(variables, Iterable): 608 return [self.primal_rays[0].variable_values[v] for v in variables] 609 raise TypeError( 610 "unsupported type in argument for " 611 f"ray_variable_values: {type(variables).__name__!r}" 612 ) 613 614 def has_dual_feasible_solution(self) -> bool: 615 """Indicates if the best solution has an associated dual feasible solution. 616 617 This is NOT guaranteed to be true when termination.reason is 618 TerminationReason.Optimal. It also may be true even when the best solution 619 does not have an associated primal feasible solution. 620 621 Returns: 622 True if the best solution has an associated dual feasible solution. 623 """ 624 if not self.solutions: 625 return False 626 sol = self.solutions[0] 627 return ( 628 sol.dual_solution is not None 629 and sol.dual_solution.feasibility_status == solution.SolutionStatus.FEASIBLE 630 ) 631 632 @overload 633 def dual_values( 634 self, linear_constraints: None = ... 635 ) -> Dict[linear_constraints_mod.LinearConstraint, float]: ... 636 637 @overload 638 def dual_values( 639 self, linear_constraints: linear_constraints_mod.LinearConstraint 640 ) -> float: ... 641 642 @overload 643 def dual_values( 644 self, 645 linear_constraints: Iterable[linear_constraints_mod.LinearConstraint], 646 ) -> List[float]: ... 647 648 def dual_values(self, linear_constraints=None): 649 """The dual values associated to the best solution. 650 651 If there is at least one primal feasible solution, this corresponds to the 652 dual values associated to the best primal feasible solution. An error will 653 be raised if the best solution does not have an associated dual feasible 654 solution. 655 656 Args: 657 linear_constraints: an optional LinearConstraint or iterator of 658 LinearConstraint indicating what dual values to return. If not provided, 659 dual_values() returns a dictionary with the dual values for all linear 660 constraints. 661 662 Returns: 663 The dual values associated to the best solution. 664 665 Raises: 666 ValueError: The best solution does not have an associated dual feasible 667 solution. 668 TypeError: Argument is not None, a LinearConstraint or an iterable of 669 LinearConstraint. 670 KeyError: LinearConstraint values requested for an invalid 671 linear constraint (e.g. is not a LinearConstraint or is a linear 672 constraint for another model). 673 """ 674 if not self.has_dual_feasible_solution(): 675 raise ValueError(_NO_DUAL_SOLUTION_ERROR) 676 sol = self.solutions[0] 677 assert sol.dual_solution is not None 678 if linear_constraints is None: 679 return sol.dual_solution.dual_values 680 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 681 return sol.dual_solution.dual_values[linear_constraints] 682 if isinstance(linear_constraints, Iterable): 683 return [sol.dual_solution.dual_values[c] for c in linear_constraints] 684 raise TypeError( 685 "unsupported type in argument for " 686 f"dual_values: {type(linear_constraints).__name__!r}" 687 ) 688 689 @overload 690 def reduced_costs( 691 self, variables: None = ... 692 ) -> Dict[variables_mod.Variable, float]: ... 693 694 @overload 695 def reduced_costs(self, variables: variables_mod.Variable) -> float: ... 696 697 @overload 698 def reduced_costs( 699 self, variables: Iterable[variables_mod.Variable] 700 ) -> List[float]: ... 701 702 def reduced_costs(self, variables=None): 703 """The reduced costs associated to the best solution. 704 705 If there is at least one primal feasible solution, this corresponds to the 706 reduced costs associated to the best primal feasible solution. An error will 707 be raised if the best solution does not have an associated dual feasible 708 solution. 709 710 Args: 711 variables: an optional Variable or iterator of Variables indicating what 712 reduced costs to return. If not provided, reduced_costs() returns a 713 dictionary with the reduced costs for all variables. 714 715 Returns: 716 The reduced costs associated to the best solution. 717 718 Raises: 719 ValueError: The best solution does not have an associated dual feasible 720 solution. 721 TypeError: Argument is not None, a Variable or an iterable of Variables. 722 KeyError: Variable values requested for an invalid variable (e.g. is not a 723 Variable or is a variable for another model). 724 """ 725 if not self.has_dual_feasible_solution(): 726 raise ValueError(_NO_DUAL_SOLUTION_ERROR) 727 sol = self.solutions[0] 728 assert sol.dual_solution is not None 729 if variables is None: 730 return sol.dual_solution.reduced_costs 731 if isinstance(variables, variables_mod.Variable): 732 return sol.dual_solution.reduced_costs[variables] 733 if isinstance(variables, Iterable): 734 return [sol.dual_solution.reduced_costs[v] for v in variables] 735 raise TypeError( 736 "unsupported type in argument for " 737 f"reduced_costs: {type(variables).__name__!r}" 738 ) 739 740 def has_dual_ray(self) -> bool: 741 """Indicates if at least one dual ray is available. 742 743 This is NOT guaranteed to be true when termination.reason is 744 TerminationReason.Infeasible. 745 746 Returns: 747 True if at least one dual ray is available. 748 """ 749 return bool(self.dual_rays) 750 751 @overload 752 def ray_dual_values( 753 self, linear_constraints: None = ... 754 ) -> Dict[linear_constraints_mod.LinearConstraint, float]: ... 755 756 @overload 757 def ray_dual_values( 758 self, linear_constraints: linear_constraints_mod.LinearConstraint 759 ) -> float: ... 760 761 @overload 762 def ray_dual_values( 763 self, 764 linear_constraints: Iterable[linear_constraints_mod.LinearConstraint], 765 ) -> List[float]: ... 766 767 def ray_dual_values(self, linear_constraints=None): 768 """The dual values from the first dual ray. 769 770 An error will be raised if there are no dual rays. 771 772 Args: 773 linear_constraints: an optional LinearConstraint or iterator of 774 LinearConstraint indicating what dual values to return. If not provided, 775 ray_dual_values() returns a dictionary with the dual values for all 776 linear constraints. 777 778 Returns: 779 The dual values from the first dual ray. 780 781 Raises: 782 ValueError: There are no dual rays. 783 TypeError: Argument is not None, a LinearConstraint or an iterable of 784 LinearConstraint. 785 KeyError: LinearConstraint values requested for an invalid 786 linear constraint (e.g. is not a LinearConstraint or is a linear 787 constraint for another model). 788 """ 789 if not self.has_dual_ray(): 790 raise ValueError("No dual ray available.") 791 ray = self.dual_rays[0] 792 if linear_constraints is None: 793 return ray.dual_values 794 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 795 return ray.dual_values[linear_constraints] 796 if isinstance(linear_constraints, Iterable): 797 return [ray.dual_values[v] for v in linear_constraints] 798 raise TypeError( 799 "unsupported type in argument for " 800 f"ray_dual_values: {type(linear_constraints).__name__!r}" 801 ) 802 803 @overload 804 def ray_reduced_costs( 805 self, variables: None = ... 806 ) -> Dict[variables_mod.Variable, float]: ... 807 808 @overload 809 def ray_reduced_costs(self, variables: variables_mod.Variable) -> float: ... 810 811 @overload 812 def ray_reduced_costs( 813 self, variables: Iterable[variables_mod.Variable] 814 ) -> List[float]: ... 815 816 def ray_reduced_costs(self, variables=None): 817 """The reduced costs from the first dual ray. 818 819 An error will be raised if there are no dual rays. 820 821 Args: 822 variables: an optional Variable or iterator of Variables indicating what 823 reduced costs to return. If not provided, ray_reduced_costs() returns a 824 dictionary with the reduced costs for all variables. 825 826 Returns: 827 The reduced costs from the first dual ray. 828 829 Raises: 830 ValueError: There are no dual rays. 831 TypeError: Argument is not None, a Variable or an iterable of Variables. 832 KeyError: Variable values requested for an invalid variable (e.g. is not a 833 Variable or is a variable for another model). 834 """ 835 if not self.has_dual_ray(): 836 raise ValueError("No dual ray available.") 837 ray = self.dual_rays[0] 838 if variables is None: 839 return ray.reduced_costs 840 if isinstance(variables, variables_mod.Variable): 841 return ray.reduced_costs[variables] 842 if isinstance(variables, Iterable): 843 return [ray.reduced_costs[v] for v in variables] 844 raise TypeError( 845 "unsupported type in argument for " 846 f"ray_reduced_costs: {type(variables).__name__!r}" 847 ) 848 849 def has_basis(self) -> bool: 850 """Indicates if the best solution has an associated basis. 851 852 This is NOT guaranteed to be true when termination.reason is 853 TerminationReason.Optimal. It also may be true even when the best solution 854 does not have an associated primal feasible solution. 855 856 Returns: 857 True if the best solution has an associated basis. 858 """ 859 if not self.solutions: 860 return False 861 return self.solutions[0].basis is not None 862 863 @overload 864 def constraint_status( 865 self, linear_constraints: None = ... 866 ) -> Dict[linear_constraints_mod.LinearConstraint, solution.BasisStatus]: ... 867 868 @overload 869 def constraint_status( 870 self, linear_constraints: linear_constraints_mod.LinearConstraint 871 ) -> solution.BasisStatus: ... 872 873 @overload 874 def constraint_status( 875 self, 876 linear_constraints: Iterable[linear_constraints_mod.LinearConstraint], 877 ) -> List[solution.BasisStatus]: ... 878 879 def constraint_status(self, linear_constraints=None): 880 """The constraint basis status associated to the best solution. 881 882 If there is at least one primal feasible solution, this corresponds to the 883 basis associated to the best primal feasible solution. An error will 884 be raised if the best solution does not have an associated basis. 885 886 887 Args: 888 linear_constraints: an optional LinearConstraint or iterator of 889 LinearConstraint indicating what constraint statuses to return. If not 890 provided, returns a dictionary with the constraint statuses for all 891 linear constraints. 892 893 Returns: 894 The constraint basis status associated to the best solution. 895 896 Raises: 897 ValueError: The best solution does not have an associated basis. 898 TypeError: Argument is not None, a LinearConstraint or an iterable of 899 LinearConstraint. 900 KeyError: LinearConstraint values requested for an invalid 901 linear constraint (e.g. is not a LinearConstraint or is a linear 902 constraint for another model). 903 """ 904 if not self.has_basis(): 905 raise ValueError(_NO_BASIS_ERROR) 906 basis = self.solutions[0].basis 907 assert basis is not None 908 if linear_constraints is None: 909 return basis.constraint_status 910 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 911 return basis.constraint_status[linear_constraints] 912 if isinstance(linear_constraints, Iterable): 913 return [basis.constraint_status[c] for c in linear_constraints] 914 raise TypeError( 915 "unsupported type in argument for " 916 f"constraint_status: {type(linear_constraints).__name__!r}" 917 ) 918 919 @overload 920 def variable_status( 921 self, variables: None = ... 922 ) -> Dict[variables_mod.Variable, solution.BasisStatus]: ... 923 924 @overload 925 def variable_status( 926 self, variables: variables_mod.Variable 927 ) -> solution.BasisStatus: ... 928 929 @overload 930 def variable_status( 931 self, variables: Iterable[variables_mod.Variable] 932 ) -> List[solution.BasisStatus]: ... 933 934 def variable_status(self, variables=None): 935 """The variable basis status associated to the best solution. 936 937 If there is at least one primal feasible solution, this corresponds to the 938 basis associated to the best primal feasible solution. An error will 939 be raised if the best solution does not have an associated basis. 940 941 Args: 942 variables: an optional Variable or iterator of Variables indicating what 943 reduced costs to return. If not provided, variable_status() returns a 944 dictionary with the reduced costs for all variables. 945 946 Returns: 947 The variable basis status associated to the best solution. 948 949 Raises: 950 ValueError: The best solution does not have an associated basis. 951 TypeError: Argument is not None, a Variable or an iterable of Variables. 952 KeyError: Variable values requested for an invalid variable (e.g. is not a 953 Variable or is a variable for another model). 954 """ 955 if not self.has_basis(): 956 raise ValueError(_NO_BASIS_ERROR) 957 basis = self.solutions[0].basis 958 assert basis is not None 959 if variables is None: 960 return basis.variable_status 961 if isinstance(variables, variables_mod.Variable): 962 return basis.variable_status[variables] 963 if isinstance(variables, Iterable): 964 return [basis.variable_status[v] for v in variables] 965 raise TypeError( 966 "unsupported type in argument for " 967 f"variable_status: {type(variables).__name__!r}" 968 ) 969 970 def to_proto(self) -> result_pb2.SolveResultProto: 971 """Returns an equivalent protocol buffer for a SolveResult.""" 972 proto = result_pb2.SolveResultProto( 973 termination=self.termination.to_proto(), 974 solutions=[s.to_proto() for s in self.solutions], 975 primal_rays=[r.to_proto() for r in self.primal_rays], 976 dual_rays=[r.to_proto() for r in self.dual_rays], 977 solve_stats=self.solve_stats.to_proto(), 978 ) 979 980 # Ensure that at most solver has solver specific output. 981 existing_solver_specific_output = None 982 983 def has_solver_specific_output(solver_name: str) -> None: 984 nonlocal existing_solver_specific_output 985 if existing_solver_specific_output is not None: 986 raise ValueError( 987 "found solver specific output for both" 988 f" {existing_solver_specific_output} and {solver_name}" 989 ) 990 existing_solver_specific_output = solver_name 991 992 if self.gscip_specific_output is not None: 993 has_solver_specific_output("gscip") 994 proto.gscip_output.CopyFrom(self.gscip_specific_output) 995 if self.osqp_specific_output is not None: 996 has_solver_specific_output("osqp") 997 proto.osqp_output.CopyFrom(self.osqp_specific_output) 998 if self.pdlp_specific_output is not None: 999 has_solver_specific_output("pdlp") 1000 proto.pdlp_output.CopyFrom(self.pdlp_specific_output) 1001 return proto
The result of solving an optimization problem defined by a Model.
We attempt to return as much solution information (primal_solutions, primal_rays, dual_solutions, dual_rays) as each underlying solver will provide given its return status. Differences in the underlying solvers result in a weak contract on what fields will be populated for a given termination reason. This is discussed in detail in termination_reasons.md, and the most important points are summarized below:
- When the termination reason is optimal, there will be at least one primal solution provided that will be feasible up to the underlying solver's tolerances.
- Dual solutions are only given for convex optimization problems (e.g. linear programs, not integer programs).
- A basis is only given for linear programs when solved by the simplex method (e.g., not with PDLP).
- Solvers have widely varying support for returning primal and dual rays. E.g. a termination_reason of unbounded does not ensure that a feasible solution or a primal ray is returned, check termination_reasons.md for solver specific guarantees if this is needed. Further, many solvers will provide the ray but not the feasible solution when returning an unbounded status.
- When the termination reason is that a limit was reached or that the result is imprecise, a solution may or may not be present. Further, for some solvers (generally, convex optimization solvers, not MIP solvers), the primal or dual solution may not be feasible.
Solver specific output is also returned for some solvers (and only information for the solver used will be populated).
Attributes:
- termination: The reason the solver stopped.
- solve_stats: Statistics on the solve process, e.g. running time, iterations.
- solutions: Lexicographically by primal feasibility status, dual feasibility status, (basic dual feasibility for simplex solvers), primal objective value and dual objective value.
- primal_rays: Directions of unbounded primal improvement, or equivalently, dual infeasibility certificates. Typically provided for terminal reasons UNBOUNDED and DUAL_INFEASIBLE.
- dual_rays: Directions of unbounded dual improvement, or equivalently, primal infeasibility certificates. Typically provided for termination reason INFEASIBLE.
- gscip_specific_output: statistics returned by the gSCIP solver, if used.
- osqp_specific_output: statistics returned by the OSQP solver, if used.
- pdlp_specific_output: statistics returned by the PDLP solver, if used.
432 def solve_time(self) -> datetime.timedelta: 433 """Shortcut for SolveResult.solve_stats.solve_time.""" 434 return self.solve_stats.solve_time
Shortcut for SolveResult.solve_stats.solve_time.
436 def primal_bound(self) -> float: 437 """Returns a primal bound on the optimal objective value as described in ObjectiveBounds. 438 439 Will return a valid (possibly infinite) bound even if no primal feasible 440 solutions are available. 441 """ 442 return self.termination.objective_bounds.primal_bound
Returns a primal bound on the optimal objective value as described in ObjectiveBounds.
Will return a valid (possibly infinite) bound even if no primal feasible solutions are available.
444 def dual_bound(self) -> float: 445 """Returns a dual bound on the optimal objective value as described in ObjectiveBounds. 446 447 Will return a valid (possibly infinite) bound even if no dual feasible 448 solutions are available. 449 """ 450 return self.termination.objective_bounds.dual_bound
Returns a dual bound on the optimal objective value as described in ObjectiveBounds.
Will return a valid (possibly infinite) bound even if no dual feasible solutions are available.
452 def has_primal_feasible_solution(self) -> bool: 453 """Indicates if at least one primal feasible solution is available. 454 455 When termination.reason is TerminationReason.OPTIMAL or 456 TerminationReason.FEASIBLE, this is guaranteed to be true and need not be 457 checked. 458 459 Returns: 460 True if there is at least one primal feasible solution is available, 461 False, otherwise. 462 """ 463 if not self.solutions: 464 return False 465 sol = self.solutions[0] 466 return ( 467 sol.primal_solution is not None 468 and sol.primal_solution.feasibility_status 469 == solution.SolutionStatus.FEASIBLE 470 )
Indicates if at least one primal feasible solution is available.
When termination.reason is TerminationReason.OPTIMAL or TerminationReason.FEASIBLE, this is guaranteed to be true and need not be checked.
Returns:
True if there is at least one primal feasible solution is available, False, otherwise.
472 def objective_value(self) -> float: 473 """Returns the objective value of the best primal feasible solution. 474 475 An error will be raised if there are no primal feasible solutions. 476 primal_bound() above is guaranteed to be at least as good (larger or equal 477 for max problems and smaller or equal for min problems) as objective_value() 478 and will never raise an error, so it may be preferable in some cases. Note 479 that primal_bound() could be better than objective_value() even for optimal 480 terminations, but on such optimal termination, both should satisfy the 481 optimality tolerances. 482 483 Returns: 484 The objective value of the best primal feasible solution. 485 486 Raises: 487 ValueError: There are no primal feasible solutions. 488 """ 489 if not self.has_primal_feasible_solution(): 490 raise ValueError("No primal feasible solution available.") 491 sol = self.solutions[0] 492 assert sol.primal_solution is not None 493 return sol.primal_solution.objective_value
Returns the objective value of the best primal feasible solution.
An error will be raised if there are no primal feasible solutions. primal_bound() above is guaranteed to be at least as good (larger or equal for max problems and smaller or equal for min problems) as objective_value() and will never raise an error, so it may be preferable in some cases. Note that primal_bound() could be better than objective_value() even for optimal terminations, but on such optimal termination, both should satisfy the optimality tolerances.
Returns: The objective value of the best primal feasible solution.
Raises: ValueError: There are no primal feasible solutions.
495 def best_objective_bound(self) -> float: 496 """Returns a bound on the best possible objective value. 497 498 best_objective_bound() is always equal to dual_bound(), so they can be 499 used interchangeably. 500 """ 501 return self.termination.objective_bounds.dual_bound
Returns a bound on the best possible objective value.
best_objective_bound() is always equal to dual_bound(), so they can be used interchangeably.
516 def variable_values(self, variables=None): 517 """The variable values from the best primal feasible solution. 518 519 An error will be raised if there are no primal feasible solutions. 520 521 Args: 522 variables: an optional Variable or iterator of Variables indicating what 523 variable values to return. If not provided, variable_values returns a 524 dictionary with all the variable values for all variables. 525 526 Returns: 527 The variable values from the best primal feasible solution. 528 529 Raises: 530 ValueError: There are no primal feasible solutions. 531 TypeError: Argument is not None, a Variable or an iterable of Variables. 532 KeyError: Variable values requested for an invalid variable (e.g. is not a 533 Variable or is a variable for another model). 534 """ 535 if not self.has_primal_feasible_solution(): 536 raise ValueError("No primal feasible solution available.") 537 sol = self.solutions[0] 538 assert sol.primal_solution is not None 539 if variables is None: 540 return sol.primal_solution.variable_values 541 if isinstance(variables, variables_mod.Variable): 542 return sol.primal_solution.variable_values[variables] 543 if isinstance(variables, Iterable): 544 return [sol.primal_solution.variable_values[v] for v in variables] 545 raise TypeError( 546 "unsupported type in argument for " 547 f"variable_values: {type(variables).__name__!r}" 548 )
The variable values from the best primal feasible solution.
An error will be raised if there are no primal feasible solutions.
Arguments:
- variables: an optional Variable or iterator of Variables indicating what variable values to return. If not provided, variable_values returns a dictionary with all the variable values for all variables.
Returns:
The variable values from the best primal feasible solution.
Raises:
- ValueError: There are no primal feasible solutions.
- TypeError: Argument is not None, a Variable or an iterable of Variables.
- KeyError: Variable values requested for an invalid variable (e.g. is not a Variable or is a variable for another model).
550 def bounded(self) -> bool: 551 """Returns true only if the problem has been shown to be feasible and bounded.""" 552 return ( 553 self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE 554 and self.termination.problem_status.dual_status 555 == FeasibilityStatus.FEASIBLE 556 )
Returns true only if the problem has been shown to be feasible and bounded.
558 def has_ray(self) -> bool: 559 """Indicates if at least one primal ray is available. 560 561 This is NOT guaranteed to be true when termination.reason is 562 TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded. 563 564 Returns: 565 True if at least one primal ray is available. 566 """ 567 return bool(self.primal_rays)
Indicates if at least one primal ray is available.
This is NOT guaranteed to be true when termination.reason is TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded.
Returns:
True if at least one primal ray is available.
582 def ray_variable_values(self, variables=None): 583 """The variable values from the first primal ray. 584 585 An error will be raised if there are no primal rays. 586 587 Args: 588 variables: an optional Variable or iterator of Variables indicating what 589 variable values to return. If not provided, variable_values() returns a 590 dictionary with the variable values for all variables. 591 592 Returns: 593 The variable values from the first primal ray. 594 595 Raises: 596 ValueError: There are no primal rays. 597 TypeError: Argument is not None, a Variable or an iterable of Variables. 598 KeyError: Variable values requested for an invalid variable (e.g. is not a 599 Variable or is a variable for another model). 600 """ 601 if not self.has_ray(): 602 raise ValueError("No primal ray available.") 603 if variables is None: 604 return self.primal_rays[0].variable_values 605 if isinstance(variables, variables_mod.Variable): 606 return self.primal_rays[0].variable_values[variables] 607 if isinstance(variables, Iterable): 608 return [self.primal_rays[0].variable_values[v] for v in variables] 609 raise TypeError( 610 "unsupported type in argument for " 611 f"ray_variable_values: {type(variables).__name__!r}" 612 )
The variable values from the first primal ray.
An error will be raised if there are no primal rays.
Arguments:
- variables: an optional Variable or iterator of Variables indicating what variable values to return. If not provided, variable_values() returns a dictionary with the variable values for all variables.
Returns:
The variable values from the first primal ray.
Raises:
- ValueError: There are no primal rays.
- TypeError: Argument is not None, a Variable or an iterable of Variables.
- KeyError: Variable values requested for an invalid variable (e.g. is not a Variable or is a variable for another model).
614 def has_dual_feasible_solution(self) -> bool: 615 """Indicates if the best solution has an associated dual feasible solution. 616 617 This is NOT guaranteed to be true when termination.reason is 618 TerminationReason.Optimal. It also may be true even when the best solution 619 does not have an associated primal feasible solution. 620 621 Returns: 622 True if the best solution has an associated dual feasible solution. 623 """ 624 if not self.solutions: 625 return False 626 sol = self.solutions[0] 627 return ( 628 sol.dual_solution is not None 629 and sol.dual_solution.feasibility_status == solution.SolutionStatus.FEASIBLE 630 )
Indicates if the best solution has an associated dual feasible solution.
This is NOT guaranteed to be true when termination.reason is TerminationReason.Optimal. It also may be true even when the best solution does not have an associated primal feasible solution.
Returns:
True if the best solution has an associated dual feasible solution.
648 def dual_values(self, linear_constraints=None): 649 """The dual values associated to the best solution. 650 651 If there is at least one primal feasible solution, this corresponds to the 652 dual values associated to the best primal feasible solution. An error will 653 be raised if the best solution does not have an associated dual feasible 654 solution. 655 656 Args: 657 linear_constraints: an optional LinearConstraint or iterator of 658 LinearConstraint indicating what dual values to return. If not provided, 659 dual_values() returns a dictionary with the dual values for all linear 660 constraints. 661 662 Returns: 663 The dual values associated to the best solution. 664 665 Raises: 666 ValueError: The best solution does not have an associated dual feasible 667 solution. 668 TypeError: Argument is not None, a LinearConstraint or an iterable of 669 LinearConstraint. 670 KeyError: LinearConstraint values requested for an invalid 671 linear constraint (e.g. is not a LinearConstraint or is a linear 672 constraint for another model). 673 """ 674 if not self.has_dual_feasible_solution(): 675 raise ValueError(_NO_DUAL_SOLUTION_ERROR) 676 sol = self.solutions[0] 677 assert sol.dual_solution is not None 678 if linear_constraints is None: 679 return sol.dual_solution.dual_values 680 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 681 return sol.dual_solution.dual_values[linear_constraints] 682 if isinstance(linear_constraints, Iterable): 683 return [sol.dual_solution.dual_values[c] for c in linear_constraints] 684 raise TypeError( 685 "unsupported type in argument for " 686 f"dual_values: {type(linear_constraints).__name__!r}" 687 )
The dual values associated to the best solution.
If there is at least one primal feasible solution, this corresponds to the dual values associated to the best primal feasible solution. An error will be raised if the best solution does not have an associated dual feasible solution.
Arguments:
- linear_constraints: an optional LinearConstraint or iterator of LinearConstraint indicating what dual values to return. If not provided, dual_values() returns a dictionary with the dual values for all linear constraints.
Returns:
The dual values associated to the best solution.
Raises:
- ValueError: The best solution does not have an associated dual feasible solution.
- TypeError: Argument is not None, a LinearConstraint or an iterable of LinearConstraint.
- KeyError: LinearConstraint values requested for an invalid linear constraint (e.g. is not a LinearConstraint or is a linear constraint for another model).
702 def reduced_costs(self, variables=None): 703 """The reduced costs associated to the best solution. 704 705 If there is at least one primal feasible solution, this corresponds to the 706 reduced costs associated to the best primal feasible solution. An error will 707 be raised if the best solution does not have an associated dual feasible 708 solution. 709 710 Args: 711 variables: an optional Variable or iterator of Variables indicating what 712 reduced costs to return. If not provided, reduced_costs() returns a 713 dictionary with the reduced costs for all variables. 714 715 Returns: 716 The reduced costs associated to the best solution. 717 718 Raises: 719 ValueError: The best solution does not have an associated dual feasible 720 solution. 721 TypeError: Argument is not None, a Variable or an iterable of Variables. 722 KeyError: Variable values requested for an invalid variable (e.g. is not a 723 Variable or is a variable for another model). 724 """ 725 if not self.has_dual_feasible_solution(): 726 raise ValueError(_NO_DUAL_SOLUTION_ERROR) 727 sol = self.solutions[0] 728 assert sol.dual_solution is not None 729 if variables is None: 730 return sol.dual_solution.reduced_costs 731 if isinstance(variables, variables_mod.Variable): 732 return sol.dual_solution.reduced_costs[variables] 733 if isinstance(variables, Iterable): 734 return [sol.dual_solution.reduced_costs[v] for v in variables] 735 raise TypeError( 736 "unsupported type in argument for " 737 f"reduced_costs: {type(variables).__name__!r}" 738 )
The reduced costs associated to the best solution.
If there is at least one primal feasible solution, this corresponds to the reduced costs associated to the best primal feasible solution. An error will be raised if the best solution does not have an associated dual feasible solution.
Arguments:
- variables: an optional Variable or iterator of Variables indicating what reduced costs to return. If not provided, reduced_costs() returns a dictionary with the reduced costs for all variables.
Returns:
The reduced costs associated to the best solution.
Raises:
- ValueError: The best solution does not have an associated dual feasible solution.
- TypeError: Argument is not None, a Variable or an iterable of Variables.
- KeyError: Variable values requested for an invalid variable (e.g. is not a Variable or is a variable for another model).
740 def has_dual_ray(self) -> bool: 741 """Indicates if at least one dual ray is available. 742 743 This is NOT guaranteed to be true when termination.reason is 744 TerminationReason.Infeasible. 745 746 Returns: 747 True if at least one dual ray is available. 748 """ 749 return bool(self.dual_rays)
Indicates if at least one dual ray is available.
This is NOT guaranteed to be true when termination.reason is TerminationReason.Infeasible.
Returns:
True if at least one dual ray is available.
767 def ray_dual_values(self, linear_constraints=None): 768 """The dual values from the first dual ray. 769 770 An error will be raised if there are no dual rays. 771 772 Args: 773 linear_constraints: an optional LinearConstraint or iterator of 774 LinearConstraint indicating what dual values to return. If not provided, 775 ray_dual_values() returns a dictionary with the dual values for all 776 linear constraints. 777 778 Returns: 779 The dual values from the first dual ray. 780 781 Raises: 782 ValueError: There are no dual rays. 783 TypeError: Argument is not None, a LinearConstraint or an iterable of 784 LinearConstraint. 785 KeyError: LinearConstraint values requested for an invalid 786 linear constraint (e.g. is not a LinearConstraint or is a linear 787 constraint for another model). 788 """ 789 if not self.has_dual_ray(): 790 raise ValueError("No dual ray available.") 791 ray = self.dual_rays[0] 792 if linear_constraints is None: 793 return ray.dual_values 794 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 795 return ray.dual_values[linear_constraints] 796 if isinstance(linear_constraints, Iterable): 797 return [ray.dual_values[v] for v in linear_constraints] 798 raise TypeError( 799 "unsupported type in argument for " 800 f"ray_dual_values: {type(linear_constraints).__name__!r}" 801 )
The dual values from the first dual ray.
An error will be raised if there are no dual rays.
Arguments:
- linear_constraints: an optional LinearConstraint or iterator of LinearConstraint indicating what dual values to return. If not provided, ray_dual_values() returns a dictionary with the dual values for all linear constraints.
Returns:
The dual values from the first dual ray.
Raises:
- ValueError: There are no dual rays.
- TypeError: Argument is not None, a LinearConstraint or an iterable of LinearConstraint.
- KeyError: LinearConstraint values requested for an invalid linear constraint (e.g. is not a LinearConstraint or is a linear constraint for another model).
816 def ray_reduced_costs(self, variables=None): 817 """The reduced costs from the first dual ray. 818 819 An error will be raised if there are no dual rays. 820 821 Args: 822 variables: an optional Variable or iterator of Variables indicating what 823 reduced costs to return. If not provided, ray_reduced_costs() returns a 824 dictionary with the reduced costs for all variables. 825 826 Returns: 827 The reduced costs from the first dual ray. 828 829 Raises: 830 ValueError: There are no dual rays. 831 TypeError: Argument is not None, a Variable or an iterable of Variables. 832 KeyError: Variable values requested for an invalid variable (e.g. is not a 833 Variable or is a variable for another model). 834 """ 835 if not self.has_dual_ray(): 836 raise ValueError("No dual ray available.") 837 ray = self.dual_rays[0] 838 if variables is None: 839 return ray.reduced_costs 840 if isinstance(variables, variables_mod.Variable): 841 return ray.reduced_costs[variables] 842 if isinstance(variables, Iterable): 843 return [ray.reduced_costs[v] for v in variables] 844 raise TypeError( 845 "unsupported type in argument for " 846 f"ray_reduced_costs: {type(variables).__name__!r}" 847 )
The reduced costs from the first dual ray.
An error will be raised if there are no dual rays.
Arguments:
- variables: an optional Variable or iterator of Variables indicating what reduced costs to return. If not provided, ray_reduced_costs() returns a dictionary with the reduced costs for all variables.
Returns:
The reduced costs from the first dual ray.
Raises:
- ValueError: There are no dual rays.
- TypeError: Argument is not None, a Variable or an iterable of Variables.
- KeyError: Variable values requested for an invalid variable (e.g. is not a Variable or is a variable for another model).
849 def has_basis(self) -> bool: 850 """Indicates if the best solution has an associated basis. 851 852 This is NOT guaranteed to be true when termination.reason is 853 TerminationReason.Optimal. It also may be true even when the best solution 854 does not have an associated primal feasible solution. 855 856 Returns: 857 True if the best solution has an associated basis. 858 """ 859 if not self.solutions: 860 return False 861 return self.solutions[0].basis is not None
Indicates if the best solution has an associated basis.
This is NOT guaranteed to be true when termination.reason is TerminationReason.Optimal. It also may be true even when the best solution does not have an associated primal feasible solution.
Returns:
True if the best solution has an associated basis.
879 def constraint_status(self, linear_constraints=None): 880 """The constraint basis status associated to the best solution. 881 882 If there is at least one primal feasible solution, this corresponds to the 883 basis associated to the best primal feasible solution. An error will 884 be raised if the best solution does not have an associated basis. 885 886 887 Args: 888 linear_constraints: an optional LinearConstraint or iterator of 889 LinearConstraint indicating what constraint statuses to return. If not 890 provided, returns a dictionary with the constraint statuses for all 891 linear constraints. 892 893 Returns: 894 The constraint basis status associated to the best solution. 895 896 Raises: 897 ValueError: The best solution does not have an associated basis. 898 TypeError: Argument is not None, a LinearConstraint or an iterable of 899 LinearConstraint. 900 KeyError: LinearConstraint values requested for an invalid 901 linear constraint (e.g. is not a LinearConstraint or is a linear 902 constraint for another model). 903 """ 904 if not self.has_basis(): 905 raise ValueError(_NO_BASIS_ERROR) 906 basis = self.solutions[0].basis 907 assert basis is not None 908 if linear_constraints is None: 909 return basis.constraint_status 910 if isinstance(linear_constraints, linear_constraints_mod.LinearConstraint): 911 return basis.constraint_status[linear_constraints] 912 if isinstance(linear_constraints, Iterable): 913 return [basis.constraint_status[c] for c in linear_constraints] 914 raise TypeError( 915 "unsupported type in argument for " 916 f"constraint_status: {type(linear_constraints).__name__!r}" 917 )
The constraint basis status associated to the best solution.
If there is at least one primal feasible solution, this corresponds to the basis associated to the best primal feasible solution. An error will be raised if the best solution does not have an associated basis.
Arguments:
- linear_constraints: an optional LinearConstraint or iterator of LinearConstraint indicating what constraint statuses to return. If not provided, returns a dictionary with the constraint statuses for all linear constraints.
Returns:
The constraint basis status associated to the best solution.
Raises:
- ValueError: The best solution does not have an associated basis.
- TypeError: Argument is not None, a LinearConstraint or an iterable of LinearConstraint.
- KeyError: LinearConstraint values requested for an invalid linear constraint (e.g. is not a LinearConstraint or is a linear constraint for another model).
934 def variable_status(self, variables=None): 935 """The variable basis status associated to the best solution. 936 937 If there is at least one primal feasible solution, this corresponds to the 938 basis associated to the best primal feasible solution. An error will 939 be raised if the best solution does not have an associated basis. 940 941 Args: 942 variables: an optional Variable or iterator of Variables indicating what 943 reduced costs to return. If not provided, variable_status() returns a 944 dictionary with the reduced costs for all variables. 945 946 Returns: 947 The variable basis status associated to the best solution. 948 949 Raises: 950 ValueError: The best solution does not have an associated basis. 951 TypeError: Argument is not None, a Variable or an iterable of Variables. 952 KeyError: Variable values requested for an invalid variable (e.g. is not a 953 Variable or is a variable for another model). 954 """ 955 if not self.has_basis(): 956 raise ValueError(_NO_BASIS_ERROR) 957 basis = self.solutions[0].basis 958 assert basis is not None 959 if variables is None: 960 return basis.variable_status 961 if isinstance(variables, variables_mod.Variable): 962 return basis.variable_status[variables] 963 if isinstance(variables, Iterable): 964 return [basis.variable_status[v] for v in variables] 965 raise TypeError( 966 "unsupported type in argument for " 967 f"variable_status: {type(variables).__name__!r}" 968 )
The variable basis status associated to the best solution.
If there is at least one primal feasible solution, this corresponds to the basis associated to the best primal feasible solution. An error will be raised if the best solution does not have an associated basis.
Arguments:
- variables: an optional Variable or iterator of Variables indicating what reduced costs to return. If not provided, variable_status() returns a dictionary with the reduced costs for all variables.
Returns:
The variable basis status associated to the best solution.
Raises:
- ValueError: The best solution does not have an associated basis.
- TypeError: Argument is not None, a Variable or an iterable of Variables.
- KeyError: Variable values requested for an invalid variable (e.g. is not a Variable or is a variable for another model).
970 def to_proto(self) -> result_pb2.SolveResultProto: 971 """Returns an equivalent protocol buffer for a SolveResult.""" 972 proto = result_pb2.SolveResultProto( 973 termination=self.termination.to_proto(), 974 solutions=[s.to_proto() for s in self.solutions], 975 primal_rays=[r.to_proto() for r in self.primal_rays], 976 dual_rays=[r.to_proto() for r in self.dual_rays], 977 solve_stats=self.solve_stats.to_proto(), 978 ) 979 980 # Ensure that at most solver has solver specific output. 981 existing_solver_specific_output = None 982 983 def has_solver_specific_output(solver_name: str) -> None: 984 nonlocal existing_solver_specific_output 985 if existing_solver_specific_output is not None: 986 raise ValueError( 987 "found solver specific output for both" 988 f" {existing_solver_specific_output} and {solver_name}" 989 ) 990 existing_solver_specific_output = solver_name 991 992 if self.gscip_specific_output is not None: 993 has_solver_specific_output("gscip") 994 proto.gscip_output.CopyFrom(self.gscip_specific_output) 995 if self.osqp_specific_output is not None: 996 has_solver_specific_output("osqp") 997 proto.osqp_output.CopyFrom(self.osqp_specific_output) 998 if self.pdlp_specific_output is not None: 999 has_solver_specific_output("pdlp") 1000 proto.pdlp_output.CopyFrom(self.pdlp_specific_output) 1001 return proto
Returns an equivalent protocol buffer for a SolveResult.
1035def parse_solve_result( 1036 proto: result_pb2.SolveResultProto, 1037 mod: model.Model, 1038 *, 1039 validate: bool = True, 1040) -> SolveResult: 1041 """Returns a SolveResult equivalent to the input proto.""" 1042 result = SolveResult() 1043 # TODO(b/290091715): change to parse_termination(proto.termination) 1044 # once solve_stats proto no longer has best_primal/dual_bound/problem_status 1045 # and problem_status/objective_bounds are guaranteed to be present in 1046 # termination proto. 1047 result.termination = parse_termination(_upgrade_termination(proto)) 1048 result.solve_stats = parse_solve_stats(proto.solve_stats) 1049 for solution_proto in proto.solutions: 1050 result.solutions.append( 1051 solution.parse_solution(solution_proto, mod, validate=validate) 1052 ) 1053 for primal_ray_proto in proto.primal_rays: 1054 result.primal_rays.append( 1055 solution.parse_primal_ray(primal_ray_proto, mod, validate=validate) 1056 ) 1057 for dual_ray_proto in proto.dual_rays: 1058 result.dual_rays.append( 1059 solution.parse_dual_ray(dual_ray_proto, mod, validate=validate) 1060 ) 1061 if proto.HasField("gscip_output"): 1062 result.gscip_specific_output = proto.gscip_output 1063 elif proto.HasField("osqp_output"): 1064 result.osqp_specific_output = proto.osqp_output 1065 elif proto.HasField("pdlp_output"): 1066 result.pdlp_specific_output = proto.pdlp_output 1067 return result
Returns a SolveResult equivalent to the input proto.