ortools.math_opt.python.result

The output from solving a mathematical optimization problem from model.py.

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

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).
ProblemStatus( primal_status: FeasibilityStatus = <FeasibilityStatus.UNDETERMINED: 1>, dual_status: FeasibilityStatus = <FeasibilityStatus.UNDETERMINED: 1>, primal_or_dual_infeasible: bool = False)
primal_or_dual_infeasible: bool = False
def to_proto(self) -> ortools.math_opt.result_pb2.ProblemStatusProto:
89    def to_proto(self) -> result_pb2.ProblemStatusProto:
90        """Returns an equivalent proto for a problem status."""
91        return result_pb2.ProblemStatusProto(
92            primal_status=self.primal_status.value,
93            dual_status=self.dual_status.value,
94            primal_or_dual_infeasible=self.primal_or_dual_infeasible,
95        )

Returns an equivalent proto for a problem status.

def parse_problem_status( proto: ortools.math_opt.result_pb2.ProblemStatusProto) -> ProblemStatus:
 98def parse_problem_status(proto: result_pb2.ProblemStatusProto) -> ProblemStatus:
 99    """Returns an equivalent ProblemStatus from the input proto."""
100    primal_status_proto = proto.primal_status
101    if primal_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED:
102        raise ValueError("Primal feasibility status should not be UNSPECIFIED")
103    dual_status_proto = proto.dual_status
104    if dual_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED:
105        raise ValueError("Dual feasibility status should not be UNSPECIFIED")
106    return ProblemStatus(
107        primal_status=FeasibilityStatus(primal_status_proto),
108        dual_status=FeasibilityStatus(dual_status_proto),
109        primal_or_dual_infeasible=proto.primal_or_dual_infeasible,
110    )

Returns an equivalent ProblemStatus from the input proto.

@dataclasses.dataclass(frozen=True)
class ObjectiveBounds:
113@dataclasses.dataclass(frozen=True)
114class ObjectiveBounds:
115    """Bounds on the optimal objective value.
116
117  MOE:begin_intracomment_strip
118  See go/mathopt-objective-bounds for more details.
119  MOE:end_intracomment_strip
120
121  Attributes:
122    primal_bound: Solver claims there exists a primal solution that is
123      numerically feasible (i.e. feasible up to the solvers tolerance), and
124      whose objective value is primal_bound.
125
126      The optimal value is equal or better (smaller for min objectives and
127      larger for max objectives) than primal_bound, but only up to
128      solver-tolerances.
129
130      MOE:begin_intracomment_strip
131      See go/mathopt-objective-bounds for more details.
132      MOE:end_intracomment_strip
133    dual_bound: Solver claims there exists a dual solution that is numerically
134      feasible (i.e. feasible up to the solvers tolerance), and whose objective
135      value is dual_bound.
136
137      For MIP solvers, the associated dual problem may be some continuous
138      relaxation (e.g. LP relaxation), but it is often an implicitly defined
139      problem that is a complex consequence of the solvers execution. For both
140      continuous and MIP solvers, the optimal value is equal or worse (larger
141      for min objective and smaller for max objectives) than dual_bound, but
142      only up to solver-tolerances. Some continuous solvers provide a
143      numerically safer dual bound through solver's specific output (e.g. for
144      PDLP, pdlp_output.convergence_information.corrected_dual_objective).
145
146      MOE:begin_intracomment_strip
147      See go/mathopt-objective-bounds for more details.
148      MOE:end_intracomment_strip
149  """  # fmt: skip
150
151    primal_bound: float = 0.0
152    dual_bound: float = 0.0
153
154    def to_proto(self) -> result_pb2.ObjectiveBoundsProto:
155        """Returns an equivalent proto for objective bounds."""
156        return result_pb2.ObjectiveBoundsProto(
157            primal_bound=self.primal_bound, dual_bound=self.dual_bound
158        )

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

ObjectiveBounds(primal_bound: float = 0.0, dual_bound: float = 0.0)
primal_bound: float = 0.0
dual_bound: float = 0.0
def to_proto(self) -> ortools.math_opt.result_pb2.ObjectiveBoundsProto:
154    def to_proto(self) -> result_pb2.ObjectiveBoundsProto:
155        """Returns an equivalent proto for objective bounds."""
156        return result_pb2.ObjectiveBoundsProto(
157            primal_bound=self.primal_bound, dual_bound=self.dual_bound
158        )

Returns an equivalent proto for objective bounds.

def parse_objective_bounds( proto: ortools.math_opt.result_pb2.ObjectiveBoundsProto) -> ObjectiveBounds:
161def parse_objective_bounds(
162    proto: result_pb2.ObjectiveBoundsProto,
163) -> ObjectiveBounds:
164    """Returns an equivalent ObjectiveBounds from the input proto."""
165    return ObjectiveBounds(primal_bound=proto.primal_bound, dual_bound=proto.dual_bound)

Returns an equivalent ObjectiveBounds from the input proto.

@dataclasses.dataclass
class SolveStats:
168@dataclasses.dataclass
169class SolveStats:
170    """Problem statuses and solve statistics returned by the solver.
171
172    Attributes:
173      solve_time: Elapsed wall clock time as measured by math_opt, roughly the
174        time inside solve(). Note: this does not include work done building the
175        model.
176      simplex_iterations: Simplex iterations.
177      barrier_iterations: Barrier iterations.
178      first_order_iterations: First order iterations.
179      node_count: Node count.
180    """
181
182    solve_time: datetime.timedelta = datetime.timedelta()
183    simplex_iterations: int = 0
184    barrier_iterations: int = 0
185    first_order_iterations: int = 0
186    node_count: int = 0
187
188    def to_proto(self) -> result_pb2.SolveStatsProto:
189        """Returns an equivalent proto for a solve stats."""
190        result = result_pb2.SolveStatsProto(
191            simplex_iterations=self.simplex_iterations,
192            barrier_iterations=self.barrier_iterations,
193            first_order_iterations=self.first_order_iterations,
194            node_count=self.node_count,
195        )
196        result.solve_time.FromTimedelta(self.solve_time)
197        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.
SolveStats( solve_time: datetime.timedelta = datetime.timedelta(0), simplex_iterations: int = 0, barrier_iterations: int = 0, first_order_iterations: int = 0, node_count: int = 0)
solve_time: datetime.timedelta = datetime.timedelta(0)
simplex_iterations: int = 0
barrier_iterations: int = 0
first_order_iterations: int = 0
node_count: int = 0
def to_proto(self) -> ortools.math_opt.result_pb2.SolveStatsProto:
188    def to_proto(self) -> result_pb2.SolveStatsProto:
189        """Returns an equivalent proto for a solve stats."""
190        result = result_pb2.SolveStatsProto(
191            simplex_iterations=self.simplex_iterations,
192            barrier_iterations=self.barrier_iterations,
193            first_order_iterations=self.first_order_iterations,
194            node_count=self.node_count,
195        )
196        result.solve_time.FromTimedelta(self.solve_time)
197        return result

Returns an equivalent proto for a solve stats.

def parse_solve_stats( proto: ortools.math_opt.result_pb2.SolveStatsProto) -> SolveStats:
200def parse_solve_stats(proto: result_pb2.SolveStatsProto) -> SolveStats:
201    """Returns an equivalent SolveStats from the input proto."""
202    result = SolveStats()
203    result.solve_time = proto.solve_time.ToTimedelta()
204    result.simplex_iterations = proto.simplex_iterations
205    result.barrier_iterations = proto.barrier_iterations
206    result.first_order_iterations = proto.first_order_iterations
207    result.node_count = proto.node_count
208    return result

Returns an equivalent SolveStats from the input proto.

@enum.unique
class TerminationReason(enum.Enum):
211@enum.unique
212class TerminationReason(enum.Enum):
213    """The reason a solve of a model terminated.
214
215    These reasons are typically as reported by the underlying solver, e.g. we do
216    not attempt to verify the precision of the solution returned.
217
218    The values are:
219       * OPTIMAL: A provably optimal solution (up to numerical tolerances) has
220           been found.
221       * INFEASIBLE: The primal problem has no feasible solutions.
222       * UNBOUNDED: The primal problem is feasible and arbitrarily good solutions
223           can be found along a primal ray.
224       * INFEASIBLE_OR_UNBOUNDED: The primal problem is either infeasible or
225           unbounded. More details on the problem status may be available in
226           solve_stats.problem_status. Note that Gurobi's unbounded status may be
227           mapped here as explained in
228           go/mathopt-solver-specific#gurobi-inf-or-unb.
229       * IMPRECISE: The problem was solved to one of the criteria above (Optimal,
230           Infeasible, Unbounded, or InfeasibleOrUnbounded), but one or more
231           tolerances was not met. Some primal/dual solutions/rays may be present,
232           but either they will be slightly infeasible, or (if the problem was
233           nearly optimal) their may be a gap between the best solution objective
234           and best objective bound.
235
236           Users can still query primal/dual solutions/rays and solution stats,
237           but they are responsible for dealing with the numerical imprecision.
238       * FEASIBLE: The optimizer reached some kind of limit and a primal feasible
239           solution is returned. See SolveResultProto.limit_detail for detailed
240           description of the kind of limit that was reached.
241       * NO_SOLUTION_FOUND: The optimizer reached some kind of limit and it did
242           not find a primal feasible solution. See SolveResultProto.limit_detail
243           for detailed description of the kind of limit that was reached.
244       * NUMERICAL_ERROR: The algorithm stopped because it encountered
245           unrecoverable numerical error. No solution information is present.
246       * OTHER_ERROR: The algorithm stopped because of an error not covered by one
247           of the statuses defined above. No solution information is present.
248    """
249
250    OPTIMAL = result_pb2.TERMINATION_REASON_OPTIMAL
251    INFEASIBLE = result_pb2.TERMINATION_REASON_INFEASIBLE
252    UNBOUNDED = result_pb2.TERMINATION_REASON_UNBOUNDED
253    INFEASIBLE_OR_UNBOUNDED = result_pb2.TERMINATION_REASON_INFEASIBLE_OR_UNBOUNDED
254    IMPRECISE = result_pb2.TERMINATION_REASON_IMPRECISE
255    FEASIBLE = result_pb2.TERMINATION_REASON_FEASIBLE
256    NO_SOLUTION_FOUND = result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND
257    NUMERICAL_ERROR = result_pb2.TERMINATION_REASON_NUMERICAL_ERROR
258    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.
OPTIMAL = <TerminationReason.OPTIMAL: 1>
INFEASIBLE = <TerminationReason.INFEASIBLE: 2>
UNBOUNDED = <TerminationReason.UNBOUNDED: 3>
INFEASIBLE_OR_UNBOUNDED = <TerminationReason.INFEASIBLE_OR_UNBOUNDED: 4>
IMPRECISE = <TerminationReason.IMPRECISE: 5>
FEASIBLE = <TerminationReason.FEASIBLE: 9>
NO_SOLUTION_FOUND = <TerminationReason.NO_SOLUTION_FOUND: 6>
NUMERICAL_ERROR = <TerminationReason.NUMERICAL_ERROR: 7>
OTHER_ERROR = <TerminationReason.OTHER_ERROR: 8>
Inherited Members
enum.Enum
name
value
@enum.unique
class Limit(enum.Enum):
261@enum.unique
262class Limit(enum.Enum):
263    """The optimizer reached a limit, partial solution information may be present.
264
265    Values are:
266       * UNDETERMINED: The underlying solver does not expose which limit was
267           reached.
268       * ITERATION: An iterative algorithm stopped after conducting the
269           maximum number of iterations (e.g. simplex or barrier iterations).
270       * TIME: The algorithm stopped after a user-specified amount of
271           computation time.
272       * NODE: A branch-and-bound algorithm stopped because it explored a
273           maximum number of nodes in the branch-and-bound tree.
274       * SOLUTION: The algorithm stopped because it found the required
275           number of solutions. This is often used in MIPs to get the solver to
276           return the first feasible solution it encounters.
277       * MEMORY: The algorithm stopped because it ran out of memory.
278       * OBJECTIVE: The algorithm stopped because it found a solution better
279           than a minimum limit set by the user.
280       * NORM: The algorithm stopped because the norm of an iterate became
281           too large.
282       * INTERRUPTED: The algorithm stopped because of an interrupt signal or a
283           user interrupt request.
284       * SLOW_PROGRESS: The algorithm stopped because it was unable to continue
285           making progress towards the solution.
286       * OTHER: The algorithm stopped due to a limit not covered by one of the
287           above. Note that UNDETERMINED is used when the reason cannot be
288           determined, and OTHER is used when the reason is known but does not fit
289           into any of the above alternatives.
290    """
291
292    UNDETERMINED = result_pb2.LIMIT_UNDETERMINED
293    ITERATION = result_pb2.LIMIT_ITERATION
294    TIME = result_pb2.LIMIT_TIME
295    NODE = result_pb2.LIMIT_NODE
296    SOLUTION = result_pb2.LIMIT_SOLUTION
297    MEMORY = result_pb2.LIMIT_MEMORY
298    OBJECTIVE = result_pb2.LIMIT_OBJECTIVE
299    NORM = result_pb2.LIMIT_NORM
300    INTERRUPTED = result_pb2.LIMIT_INTERRUPTED
301    SLOW_PROGRESS = result_pb2.LIMIT_SLOW_PROGRESS
302    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.
UNDETERMINED = <Limit.UNDETERMINED: 1>
ITERATION = <Limit.ITERATION: 2>
TIME = <Limit.TIME: 3>
NODE = <Limit.NODE: 4>
SOLUTION = <Limit.SOLUTION: 5>
MEMORY = <Limit.MEMORY: 6>
OBJECTIVE = <Limit.OBJECTIVE: 7>
NORM = <Limit.NORM: 8>
INTERRUPTED = <Limit.INTERRUPTED: 9>
SLOW_PROGRESS = <Limit.SLOW_PROGRESS: 10>
OTHER = <Limit.OTHER: 11>
Inherited Members
enum.Enum
name
value
@dataclasses.dataclass
class Termination:
305@dataclasses.dataclass
306class Termination:
307    """An explanation of why the solver stopped.
308
309    Attributes:
310      reason: Why the solver stopped, e.g. it found a provably optimal solution.
311        Additional information in `limit` when value is FEASIBLE or
312        NO_SOLUTION_FOUND, see `limit` for details.
313      limit: If the solver stopped early, what caused it to stop. Have value
314        UNSPECIFIED when reason is not NO_SOLUTION_FOUND or FEASIBLE. May still be
315        UNSPECIFIED when reason is NO_SOLUTION_FOUND or FEASIBLE, some solvers
316        cannot fill this in.
317      detail: Additional, information beyond reason about why the solver stopped,
318        typically solver specific.
319      problem_status: Feasibility statuses for primal and dual problems.
320      objective_bounds: Bounds on the optimal objective value.
321    """
322
323    reason: TerminationReason = TerminationReason.OPTIMAL
324    limit: Optional[Limit] = None
325    detail: str = ""
326    problem_status: ProblemStatus = ProblemStatus()
327    objective_bounds: ObjectiveBounds = ObjectiveBounds()

An explanation of why the solver stopped.

Attributes:
  • reason: Why the solver stopped, e.g. it found a provably optimal solution. Additional information in limit when value is FEASIBLE or NO_SOLUTION_FOUND, see limit for 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.
Termination( reason: TerminationReason = <TerminationReason.OPTIMAL: 1>, limit: Optional[Limit] = None, detail: str = '', problem_status: ProblemStatus = ProblemStatus(primal_status=<FeasibilityStatus.UNDETERMINED: 1>, dual_status=<FeasibilityStatus.UNDETERMINED: 1>, primal_or_dual_infeasible=False), objective_bounds: ObjectiveBounds = ObjectiveBounds(primal_bound=0.0, dual_bound=0.0))
limit: Optional[Limit] = None
detail: str = ''
problem_status: ProblemStatus = ProblemStatus(primal_status=<FeasibilityStatus.UNDETERMINED: 1>, dual_status=<FeasibilityStatus.UNDETERMINED: 1>, primal_or_dual_infeasible=False)
objective_bounds: ObjectiveBounds = ObjectiveBounds(primal_bound=0.0, dual_bound=0.0)
def parse_termination( termination_proto: ortools.math_opt.result_pb2.TerminationProto) -> Termination:
330def parse_termination(
331    termination_proto: result_pb2.TerminationProto,
332) -> Termination:
333    """Returns a Termination that is equivalent to termination_proto."""
334    reason_proto = termination_proto.reason
335    limit_proto = termination_proto.limit
336    if reason_proto == result_pb2.TERMINATION_REASON_UNSPECIFIED:
337        raise ValueError("Termination reason should not be UNSPECIFIED")
338    reason_is_limit = (
339        reason_proto == result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND
340    ) or (reason_proto == result_pb2.TERMINATION_REASON_FEASIBLE)
341    limit_set = limit_proto != result_pb2.LIMIT_UNSPECIFIED
342    if reason_is_limit != limit_set:
343        raise ValueError(
344            f"Termination limit (={limit_proto})) should take value other than "
345            f"UNSPECIFIED if and only if termination reason (={reason_proto}) is "
346            "FEASIBLE or NO_SOLUTION_FOUND"
347        )
348    termination = Termination()
349    termination.reason = TerminationReason(reason_proto)
350    termination.limit = Limit(limit_proto) if limit_set else None
351    termination.detail = termination_proto.detail
352    termination.problem_status = parse_problem_status(termination_proto.problem_status)
353    termination.objective_bounds = parse_objective_bounds(
354        termination_proto.objective_bounds
355    )
356    return termination

Returns a Termination that is equivalent to termination_proto.

@dataclasses.dataclass
class SolveResult:
359@dataclasses.dataclass
360class SolveResult:
361    """The result of solving an optimization problem defined by a Model.
362
363    We attempt to return as much solution information (primal_solutions,
364    primal_rays, dual_solutions, dual_rays) as each underlying solver will provide
365    given its return status. Differences in the underlying solvers result in a
366    weak contract on what fields will be populated for a given termination
367    reason. This is discussed in detail in termination_reasons.md, and the most
368    important points are summarized below:
369      * When the termination reason is optimal, there will be at least one primal
370        solution provided that will be feasible up to the underlying solver's
371        tolerances.
372      * Dual solutions are only given for convex optimization problems (e.g.
373        linear programs, not integer programs).
374      * A basis is only given for linear programs when solved by the simplex
375        method (e.g., not with PDLP).
376      * Solvers have widely varying support for returning primal and dual rays.
377        E.g. a termination_reason of unbounded does not ensure that a feasible
378        solution or a primal ray is returned, check termination_reasons.md for
379        solver specific guarantees if this is needed. Further, many solvers will
380        provide the ray but not the feasible solution when returning an unbounded
381        status.
382      * When the termination reason is that a limit was reached or that the result
383        is imprecise, a solution may or may not be present. Further, for some
384        solvers (generally, convex optimization solvers, not MIP solvers), the
385        primal or dual solution may not be feasible.
386
387    Solver specific output is also returned for some solvers (and only information
388    for the solver used will be populated).
389
390    Attributes:
391      termination: The reason the solver stopped.
392      solve_stats: Statistics on the solve process, e.g. running time, iterations.
393      solutions: Lexicographically by primal feasibility status, dual feasibility
394        status, (basic dual feasibility for simplex solvers), primal objective
395        value and dual objective value.
396      primal_rays: Directions of unbounded primal improvement, or equivalently,
397        dual infeasibility certificates. Typically provided for terminal reasons
398        UNBOUNDED and DUAL_INFEASIBLE.
399      dual_rays: Directions of unbounded dual improvement, or equivalently, primal
400        infeasibility certificates. Typically provided for termination reason
401        INFEASIBLE.
402      gscip_specific_output: statistics returned by the gSCIP solver, if used.
403      osqp_specific_output: statistics returned by the OSQP solver, if used.
404      pdlp_specific_output: statistics returned by the PDLP solver, if used.
405    """
406
407    termination: Termination = dataclasses.field(default_factory=Termination)
408    solve_stats: SolveStats = dataclasses.field(default_factory=SolveStats)
409    solutions: List[solution.Solution] = dataclasses.field(default_factory=list)
410    primal_rays: List[solution.PrimalRay] = dataclasses.field(default_factory=list)
411    dual_rays: List[solution.DualRay] = dataclasses.field(default_factory=list)
412    # At most one of the below will be set
413    gscip_specific_output: Optional[gscip_pb2.GScipOutput] = None
414    osqp_specific_output: Optional[osqp_pb2.OsqpOutput] = None
415    pdlp_specific_output: Optional[result_pb2.SolveResultProto.PdlpOutput] = None
416
417    def solve_time(self) -> datetime.timedelta:
418        """Shortcut for SolveResult.solve_stats.solve_time."""
419        return self.solve_stats.solve_time
420
421    def primal_bound(self) -> float:
422        """Returns a primal bound on the optimal objective value as described in ObjectiveBounds.
423
424        Will return a valid (possibly infinite) bound even if no primal feasible
425        solutions are available.
426        """
427        return self.termination.objective_bounds.primal_bound
428
429    def dual_bound(self) -> float:
430        """Returns a dual bound on the optimal objective value as described in ObjectiveBounds.
431
432        Will return a valid (possibly infinite) bound even if no dual feasible
433        solutions are available.
434        """
435        return self.termination.objective_bounds.dual_bound
436
437    def has_primal_feasible_solution(self) -> bool:
438        """Indicates if at least one primal feasible solution is available.
439
440        When termination.reason is TerminationReason.OPTIMAL or
441        TerminationReason.FEASIBLE, this is guaranteed to be true and need not be
442        checked.
443
444        Returns:
445          True if there is at least one primal feasible solution is available,
446          False, otherwise.
447        """
448        if not self.solutions:
449            return False
450        return (
451            self.solutions[0].primal_solution is not None
452            and self.solutions[0].primal_solution.feasibility_status
453            == solution.SolutionStatus.FEASIBLE
454        )
455
456    def objective_value(self) -> float:
457        """Returns the objective value of the best primal feasible solution.
458
459        An error will be raised if there are no primal feasible solutions.
460        primal_bound() above is guaranteed to be at least as good (larger or equal
461        for max problems and smaller or equal for min problems) as objective_value()
462        and will never raise an error, so it may be preferable in some cases. Note
463        that primal_bound() could be better than objective_value() even for optimal
464        terminations, but on such optimal termination, both should satisfy the
465        optimality tolerances.
466
467         Returns:
468           The objective value of the best primal feasible solution.
469
470         Raises:
471           ValueError: There are no primal feasible solutions.
472        """
473        if not self.has_primal_feasible_solution():
474            raise ValueError("No primal feasible solution available.")
475        assert self.solutions[0].primal_solution is not None
476        return self.solutions[0].primal_solution.objective_value
477
478    def best_objective_bound(self) -> float:
479        """Returns a bound on the best possible objective value.
480
481        best_objective_bound() is always equal to dual_bound(), so they can be
482        used interchangeably.
483        """
484        return self.termination.objective_bounds.dual_bound
485
486    @overload
487    def variable_values(self, variables: None = ...) -> Dict[model.Variable, float]: ...
488
489    @overload
490    def variable_values(self, variables: model.Variable) -> float: ...
491
492    @overload
493    def variable_values(self, variables: Iterable[model.Variable]) -> List[float]: ...
494
495    def variable_values(self, variables=None):
496        """The variable values from the best primal feasible solution.
497
498        An error will be raised if there are no primal feasible solutions.
499
500        Args:
501          variables: an optional Variable or iterator of Variables indicating what
502            variable values to return. If not provided, variable_values returns a
503            dictionary with all the variable values for all variables.
504
505        Returns:
506          The variable values from the best primal feasible solution.
507
508        Raises:
509          ValueError: There are no primal feasible solutions.
510          TypeError: Argument is not None, a Variable or an iterable of Variables.
511          KeyError: Variable values requested for an invalid variable (e.g. is not a
512            Variable or is a variable for another model).
513        """
514        if not self.has_primal_feasible_solution():
515            raise ValueError("No primal feasible solution available.")
516        assert self.solutions[0].primal_solution is not None
517        if variables is None:
518            return self.solutions[0].primal_solution.variable_values
519        if isinstance(variables, model.Variable):
520            return self.solutions[0].primal_solution.variable_values[variables]
521        if isinstance(variables, Iterable):
522            return [
523                self.solutions[0].primal_solution.variable_values[v] for v in variables
524            ]
525        raise TypeError(
526            "unsupported type in argument for "
527            f"variable_values: {type(variables).__name__!r}"
528        )
529
530    def bounded(self) -> bool:
531        """Returns true only if the problem has been shown to be feasible and bounded."""
532        return (
533            self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE
534            and self.termination.problem_status.dual_status
535            == FeasibilityStatus.FEASIBLE
536        )
537
538    def has_ray(self) -> bool:
539        """Indicates if at least one primal ray is available.
540
541        This is NOT guaranteed to be true when termination.reason is
542        TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded.
543
544        Returns:
545          True if at least one primal ray is available.
546        """
547        return bool(self.primal_rays)
548
549    @overload
550    def ray_variable_values(
551        self, variables: None = ...
552    ) -> Dict[model.Variable, float]: ...
553
554    @overload
555    def ray_variable_values(self, variables: model.Variable) -> float: ...
556
557    @overload
558    def ray_variable_values(
559        self, variables: Iterable[model.Variable]
560    ) -> List[float]: ...
561
562    def ray_variable_values(self, variables=None):
563        """The variable values from the first primal ray.
564
565        An error will be raised if there are no primal rays.
566
567        Args:
568          variables: an optional Variable or iterator of Variables indicating what
569            variable values to return. If not provided, variable_values() returns a
570            dictionary with the variable values for all variables.
571
572        Returns:
573          The variable values from the first primal ray.
574
575        Raises:
576          ValueError: There are no primal rays.
577          TypeError: Argument is not None, a Variable or an iterable of Variables.
578          KeyError: Variable values requested for an invalid variable (e.g. is not a
579            Variable or is a variable for another model).
580        """
581        if not self.has_ray():
582            raise ValueError("No primal ray available.")
583        if variables is None:
584            return self.primal_rays[0].variable_values
585        if isinstance(variables, model.Variable):
586            return self.primal_rays[0].variable_values[variables]
587        if isinstance(variables, Iterable):
588            return [self.primal_rays[0].variable_values[v] for v in variables]
589        raise TypeError(
590            "unsupported type in argument for "
591            f"ray_variable_values: {type(variables).__name__!r}"
592        )
593
594    def has_dual_feasible_solution(self) -> bool:
595        """Indicates if the best solution has an associated dual feasible solution.
596
597        This is NOT guaranteed to be true when termination.reason is
598        TerminationReason.Optimal. It also may be true even when the best solution
599        does not have an associated primal feasible solution.
600
601        Returns:
602          True if the best solution has an associated dual feasible solution.
603        """
604        if not self.solutions:
605            return False
606        return (
607            self.solutions[0].dual_solution is not None
608            and self.solutions[0].dual_solution.feasibility_status
609            == solution.SolutionStatus.FEASIBLE
610        )
611
612    @overload
613    def dual_values(
614        self, linear_constraints: None = ...
615    ) -> Dict[model.LinearConstraint, float]: ...
616
617    @overload
618    def dual_values(self, linear_constraints: model.LinearConstraint) -> float: ...
619
620    @overload
621    def dual_values(
622        self, linear_constraints: Iterable[model.LinearConstraint]
623    ) -> List[float]: ...
624
625    def dual_values(self, linear_constraints=None):
626        """The dual values associated to the best solution.
627
628        If there is at least one primal feasible solution, this corresponds to the
629        dual values associated to the best primal feasible solution. An error will
630        be raised if the best solution does not have an associated dual feasible
631        solution.
632
633        Args:
634          linear_constraints: an optional LinearConstraint or iterator of
635            LinearConstraint indicating what dual values to return. If not provided,
636            dual_values() returns a dictionary with the dual values for all linear
637            constraints.
638
639        Returns:
640          The dual values associated to the best solution.
641
642        Raises:
643          ValueError: The best solution does not have an associated dual feasible
644            solution.
645          TypeError: Argument is not None, a LinearConstraint or an iterable of
646            LinearConstraint.
647          KeyError: LinearConstraint values requested for an invalid
648            linear constraint (e.g. is not a LinearConstraint or is a linear
649            constraint for another model).
650        """
651        if not self.has_dual_feasible_solution():
652            raise ValueError(_NO_DUAL_SOLUTION_ERROR)
653        assert self.solutions[0].dual_solution is not None
654        if linear_constraints is None:
655            return self.solutions[0].dual_solution.dual_values
656        if isinstance(linear_constraints, model.LinearConstraint):
657            return self.solutions[0].dual_solution.dual_values[linear_constraints]
658        if isinstance(linear_constraints, Iterable):
659            return [
660                self.solutions[0].dual_solution.dual_values[c]
661                for c in linear_constraints
662            ]
663        raise TypeError(
664            "unsupported type in argument for "
665            f"dual_values: {type(linear_constraints).__name__!r}"
666        )
667
668    @overload
669    def reduced_costs(self, variables: None = ...) -> Dict[model.Variable, float]: ...
670
671    @overload
672    def reduced_costs(self, variables: model.Variable) -> float: ...
673
674    @overload
675    def reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]: ...
676
677    def reduced_costs(self, variables=None):
678        """The reduced costs associated to the best solution.
679
680        If there is at least one primal feasible solution, this corresponds to the
681        reduced costs associated to the best primal feasible solution. An error will
682        be raised if the best solution does not have an associated dual feasible
683        solution.
684
685        Args:
686          variables: an optional Variable or iterator of Variables indicating what
687            reduced costs to return. If not provided, reduced_costs() returns a
688            dictionary with the reduced costs for all variables.
689
690        Returns:
691          The reduced costs associated to the best solution.
692
693        Raises:
694          ValueError: The best solution does not have an associated dual feasible
695            solution.
696          TypeError: Argument is not None, a Variable or an iterable of Variables.
697          KeyError: Variable values requested for an invalid variable (e.g. is not a
698            Variable or is a variable for another model).
699        """
700        if not self.has_dual_feasible_solution():
701            raise ValueError(_NO_DUAL_SOLUTION_ERROR)
702        assert self.solutions[0].dual_solution is not None
703        if variables is None:
704            return self.solutions[0].dual_solution.reduced_costs
705        if isinstance(variables, model.Variable):
706            return self.solutions[0].dual_solution.reduced_costs[variables]
707        if isinstance(variables, Iterable):
708            return [self.solutions[0].dual_solution.reduced_costs[v] for v in variables]
709        raise TypeError(
710            "unsupported type in argument for "
711            f"reduced_costs: {type(variables).__name__!r}"
712        )
713
714    def has_dual_ray(self) -> bool:
715        """Indicates if at least one dual ray is available.
716
717        This is NOT guaranteed to be true when termination.reason is
718        TerminationReason.Infeasible.
719
720        Returns:
721          True if at least one dual ray is available.
722        """
723        return bool(self.dual_rays)
724
725    @overload
726    def ray_dual_values(
727        self, linear_constraints: None = ...
728    ) -> Dict[model.LinearConstraint, float]: ...
729
730    @overload
731    def ray_dual_values(self, linear_constraints: model.LinearConstraint) -> float: ...
732
733    @overload
734    def ray_dual_values(
735        self, linear_constraints: Iterable[model.LinearConstraint]
736    ) -> List[float]: ...
737
738    def ray_dual_values(self, linear_constraints=None):
739        """The dual values from the first dual ray.
740
741        An error will be raised if there are no dual rays.
742
743        Args:
744          linear_constraints: an optional LinearConstraint or iterator of
745            LinearConstraint indicating what dual values to return. If not provided,
746            ray_dual_values() returns a dictionary with the dual values for all
747            linear constraints.
748
749        Returns:
750          The dual values from the first dual ray.
751
752        Raises:
753          ValueError: There are no dual rays.
754          TypeError: Argument is not None, a LinearConstraint or an iterable of
755            LinearConstraint.
756          KeyError: LinearConstraint values requested for an invalid
757            linear constraint (e.g. is not a LinearConstraint or is a linear
758            constraint for another model).
759        """
760        if not self.has_dual_ray():
761            raise ValueError("No dual ray available.")
762        if linear_constraints is None:
763            return self.dual_rays[0].dual_values
764        if isinstance(linear_constraints, model.LinearConstraint):
765            return self.dual_rays[0].dual_values[linear_constraints]
766        if isinstance(linear_constraints, Iterable):
767            return [self.dual_rays[0].dual_values[v] for v in linear_constraints]
768        raise TypeError(
769            "unsupported type in argument for "
770            f"ray_dual_values: {type(linear_constraints).__name__!r}"
771        )
772
773    @overload
774    def ray_reduced_costs(
775        self, variables: None = ...
776    ) -> Dict[model.Variable, float]: ...
777
778    @overload
779    def ray_reduced_costs(self, variables: model.Variable) -> float: ...
780
781    @overload
782    def ray_reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]: ...
783
784    def ray_reduced_costs(self, variables=None):
785        """The reduced costs from the first dual ray.
786
787        An error will be raised if there are no dual rays.
788
789        Args:
790          variables: an optional Variable or iterator of Variables indicating what
791            reduced costs to return. If not provided, ray_reduced_costs() returns a
792            dictionary with the reduced costs for all variables.
793
794        Returns:
795          The reduced costs from the first dual ray.
796
797        Raises:
798          ValueError: There are no dual rays.
799          TypeError: Argument is not None, a Variable or an iterable of Variables.
800          KeyError: Variable values requested for an invalid variable (e.g. is not a
801            Variable or is a variable for another model).
802        """
803        if not self.has_dual_ray():
804            raise ValueError("No dual ray available.")
805        if variables is None:
806            return self.dual_rays[0].reduced_costs
807        if isinstance(variables, model.Variable):
808            return self.dual_rays[0].reduced_costs[variables]
809        if isinstance(variables, Iterable):
810            return [self.dual_rays[0].reduced_costs[v] for v in variables]
811        raise TypeError(
812            "unsupported type in argument for "
813            f"ray_reduced_costs: {type(variables).__name__!r}"
814        )
815
816    def has_basis(self) -> bool:
817        """Indicates if the best solution has an associated basis.
818
819        This is NOT guaranteed to be true when termination.reason is
820        TerminationReason.Optimal. It also may be true even when the best solution
821        does not have an associated primal feasible solution.
822
823        Returns:
824          True if the best solution has an associated basis.
825        """
826        if not self.solutions:
827            return False
828        return self.solutions[0].basis is not None
829
830    @overload
831    def constraint_status(
832        self, linear_constraints: None = ...
833    ) -> Dict[model.LinearConstraint, solution.BasisStatus]: ...
834
835    @overload
836    def constraint_status(
837        self, linear_constraints: model.LinearConstraint
838    ) -> solution.BasisStatus: ...
839
840    @overload
841    def constraint_status(
842        self, linear_constraints: Iterable[model.LinearConstraint]
843    ) -> List[solution.BasisStatus]: ...
844
845    def constraint_status(self, linear_constraints=None):
846        """The constraint basis status associated to the best solution.
847
848        If there is at least one primal feasible solution, this corresponds to the
849        basis associated to the best primal feasible solution. An error will
850        be raised if the best solution does not have an associated basis.
851
852
853        Args:
854          linear_constraints: an optional LinearConstraint or iterator of
855            LinearConstraint indicating what constraint statuses to return. If not
856            provided, returns a dictionary with the constraint statuses for all
857            linear constraints.
858
859        Returns:
860          The constraint basis status associated to the best solution.
861
862        Raises:
863          ValueError: The best solution does not have an associated basis.
864          TypeError: Argument is not None, a LinearConstraint or an iterable of
865            LinearConstraint.
866          KeyError: LinearConstraint values requested for an invalid
867            linear constraint (e.g. is not a LinearConstraint or is a linear
868            constraint for another model).
869        """
870        if not self.has_basis():
871            raise ValueError(_NO_BASIS_ERROR)
872        assert self.solutions[0].basis is not None
873        if linear_constraints is None:
874            return self.solutions[0].basis.constraint_status
875        if isinstance(linear_constraints, model.LinearConstraint):
876            return self.solutions[0].basis.constraint_status[linear_constraints]
877        if isinstance(linear_constraints, Iterable):
878            return [
879                self.solutions[0].basis.constraint_status[c] for c in linear_constraints
880            ]
881        raise TypeError(
882            "unsupported type in argument for "
883            f"constraint_status: {type(linear_constraints).__name__!r}"
884        )
885
886    @overload
887    def variable_status(
888        self, variables: None = ...
889    ) -> Dict[model.Variable, solution.BasisStatus]: ...
890
891    @overload
892    def variable_status(self, variables: model.Variable) -> solution.BasisStatus: ...
893
894    @overload
895    def variable_status(
896        self, variables: Iterable[model.Variable]
897    ) -> List[solution.BasisStatus]: ...
898
899    def variable_status(self, variables=None):
900        """The variable basis status associated to the best solution.
901
902        If there is at least one primal feasible solution, this corresponds to the
903        basis associated to the best primal feasible solution. An error will
904        be raised if the best solution does not have an associated basis.
905
906        Args:
907          variables: an optional Variable or iterator of Variables indicating what
908            reduced costs to return. If not provided, variable_status() returns a
909            dictionary with the reduced costs for all variables.
910
911        Returns:
912          The variable basis status associated to the best solution.
913
914        Raises:
915          ValueError: The best solution does not have an associated basis.
916          TypeError: Argument is not None, a Variable or an iterable of Variables.
917          KeyError: Variable values requested for an invalid variable (e.g. is not a
918            Variable or is a variable for another model).
919        """
920        if not self.has_basis():
921            raise ValueError(_NO_BASIS_ERROR)
922        assert self.solutions[0].basis is not None
923        if variables is None:
924            return self.solutions[0].basis.variable_status
925        if isinstance(variables, model.Variable):
926            return self.solutions[0].basis.variable_status[variables]
927        if isinstance(variables, Iterable):
928            return [self.solutions[0].basis.variable_status[v] for v in variables]
929        raise TypeError(
930            "unsupported type in argument for "
931            f"variable_status: {type(variables).__name__!r}"
932        )

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.
SolveResult( termination: Termination = <factory>, solve_stats: SolveStats = <factory>, solutions: List[ortools.math_opt.python.solution.Solution] = <factory>, primal_rays: List[ortools.math_opt.python.solution.PrimalRay] = <factory>, dual_rays: List[ortools.math_opt.python.solution.DualRay] = <factory>, gscip_specific_output: Optional[ortools.gscip.gscip_pb2.GScipOutput] = None, osqp_specific_output: Optional[ortools.math_opt.solvers.osqp_pb2.OsqpOutput] = None, pdlp_specific_output: Optional[ortools.math_opt.result_pb2.PdlpOutput] = None)
termination: Termination
solve_stats: SolveStats
gscip_specific_output: Optional[ortools.gscip.gscip_pb2.GScipOutput] = None
osqp_specific_output: Optional[ortools.math_opt.solvers.osqp_pb2.OsqpOutput] = None
pdlp_specific_output: Optional[ortools.math_opt.result_pb2.PdlpOutput] = None
def solve_time(self) -> datetime.timedelta:
417    def solve_time(self) -> datetime.timedelta:
418        """Shortcut for SolveResult.solve_stats.solve_time."""
419        return self.solve_stats.solve_time

Shortcut for SolveResult.solve_stats.solve_time.

def primal_bound(self) -> float:
421    def primal_bound(self) -> float:
422        """Returns a primal bound on the optimal objective value as described in ObjectiveBounds.
423
424        Will return a valid (possibly infinite) bound even if no primal feasible
425        solutions are available.
426        """
427        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.

def dual_bound(self) -> float:
429    def dual_bound(self) -> float:
430        """Returns a dual bound on the optimal objective value as described in ObjectiveBounds.
431
432        Will return a valid (possibly infinite) bound even if no dual feasible
433        solutions are available.
434        """
435        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.

def has_primal_feasible_solution(self) -> bool:
437    def has_primal_feasible_solution(self) -> bool:
438        """Indicates if at least one primal feasible solution is available.
439
440        When termination.reason is TerminationReason.OPTIMAL or
441        TerminationReason.FEASIBLE, this is guaranteed to be true and need not be
442        checked.
443
444        Returns:
445          True if there is at least one primal feasible solution is available,
446          False, otherwise.
447        """
448        if not self.solutions:
449            return False
450        return (
451            self.solutions[0].primal_solution is not None
452            and self.solutions[0].primal_solution.feasibility_status
453            == solution.SolutionStatus.FEASIBLE
454        )

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.

def objective_value(self) -> float:
456    def objective_value(self) -> float:
457        """Returns the objective value of the best primal feasible solution.
458
459        An error will be raised if there are no primal feasible solutions.
460        primal_bound() above is guaranteed to be at least as good (larger or equal
461        for max problems and smaller or equal for min problems) as objective_value()
462        and will never raise an error, so it may be preferable in some cases. Note
463        that primal_bound() could be better than objective_value() even for optimal
464        terminations, but on such optimal termination, both should satisfy the
465        optimality tolerances.
466
467         Returns:
468           The objective value of the best primal feasible solution.
469
470         Raises:
471           ValueError: There are no primal feasible solutions.
472        """
473        if not self.has_primal_feasible_solution():
474            raise ValueError("No primal feasible solution available.")
475        assert self.solutions[0].primal_solution is not None
476        return self.solutions[0].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.

def best_objective_bound(self) -> float:
478    def best_objective_bound(self) -> float:
479        """Returns a bound on the best possible objective value.
480
481        best_objective_bound() is always equal to dual_bound(), so they can be
482        used interchangeably.
483        """
484        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.

def variable_values(self, variables=None):
495    def variable_values(self, variables=None):
496        """The variable values from the best primal feasible solution.
497
498        An error will be raised if there are no primal feasible solutions.
499
500        Args:
501          variables: an optional Variable or iterator of Variables indicating what
502            variable values to return. If not provided, variable_values returns a
503            dictionary with all the variable values for all variables.
504
505        Returns:
506          The variable values from the best primal feasible solution.
507
508        Raises:
509          ValueError: There are no primal feasible solutions.
510          TypeError: Argument is not None, a Variable or an iterable of Variables.
511          KeyError: Variable values requested for an invalid variable (e.g. is not a
512            Variable or is a variable for another model).
513        """
514        if not self.has_primal_feasible_solution():
515            raise ValueError("No primal feasible solution available.")
516        assert self.solutions[0].primal_solution is not None
517        if variables is None:
518            return self.solutions[0].primal_solution.variable_values
519        if isinstance(variables, model.Variable):
520            return self.solutions[0].primal_solution.variable_values[variables]
521        if isinstance(variables, Iterable):
522            return [
523                self.solutions[0].primal_solution.variable_values[v] for v in variables
524            ]
525        raise TypeError(
526            "unsupported type in argument for "
527            f"variable_values: {type(variables).__name__!r}"
528        )

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).
def bounded(self) -> bool:
530    def bounded(self) -> bool:
531        """Returns true only if the problem has been shown to be feasible and bounded."""
532        return (
533            self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE
534            and self.termination.problem_status.dual_status
535            == FeasibilityStatus.FEASIBLE
536        )

Returns true only if the problem has been shown to be feasible and bounded.

def has_ray(self) -> bool:
538    def has_ray(self) -> bool:
539        """Indicates if at least one primal ray is available.
540
541        This is NOT guaranteed to be true when termination.reason is
542        TerminationReason.kUnbounded or TerminationReason.kInfeasibleOrUnbounded.
543
544        Returns:
545          True if at least one primal ray is available.
546        """
547        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.

def ray_variable_values(self, variables=None):
562    def ray_variable_values(self, variables=None):
563        """The variable values from the first primal ray.
564
565        An error will be raised if there are no primal rays.
566
567        Args:
568          variables: an optional Variable or iterator of Variables indicating what
569            variable values to return. If not provided, variable_values() returns a
570            dictionary with the variable values for all variables.
571
572        Returns:
573          The variable values from the first primal ray.
574
575        Raises:
576          ValueError: There are no primal rays.
577          TypeError: Argument is not None, a Variable or an iterable of Variables.
578          KeyError: Variable values requested for an invalid variable (e.g. is not a
579            Variable or is a variable for another model).
580        """
581        if not self.has_ray():
582            raise ValueError("No primal ray available.")
583        if variables is None:
584            return self.primal_rays[0].variable_values
585        if isinstance(variables, model.Variable):
586            return self.primal_rays[0].variable_values[variables]
587        if isinstance(variables, Iterable):
588            return [self.primal_rays[0].variable_values[v] for v in variables]
589        raise TypeError(
590            "unsupported type in argument for "
591            f"ray_variable_values: {type(variables).__name__!r}"
592        )

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).
def has_dual_feasible_solution(self) -> bool:
594    def has_dual_feasible_solution(self) -> bool:
595        """Indicates if the best solution has an associated dual feasible solution.
596
597        This is NOT guaranteed to be true when termination.reason is
598        TerminationReason.Optimal. It also may be true even when the best solution
599        does not have an associated primal feasible solution.
600
601        Returns:
602          True if the best solution has an associated dual feasible solution.
603        """
604        if not self.solutions:
605            return False
606        return (
607            self.solutions[0].dual_solution is not None
608            and self.solutions[0].dual_solution.feasibility_status
609            == solution.SolutionStatus.FEASIBLE
610        )

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.

def dual_values(self, linear_constraints=None):
625    def dual_values(self, linear_constraints=None):
626        """The dual values associated to the best solution.
627
628        If there is at least one primal feasible solution, this corresponds to the
629        dual values associated to the best primal feasible solution. An error will
630        be raised if the best solution does not have an associated dual feasible
631        solution.
632
633        Args:
634          linear_constraints: an optional LinearConstraint or iterator of
635            LinearConstraint indicating what dual values to return. If not provided,
636            dual_values() returns a dictionary with the dual values for all linear
637            constraints.
638
639        Returns:
640          The dual values associated to the best solution.
641
642        Raises:
643          ValueError: The best solution does not have an associated dual feasible
644            solution.
645          TypeError: Argument is not None, a LinearConstraint or an iterable of
646            LinearConstraint.
647          KeyError: LinearConstraint values requested for an invalid
648            linear constraint (e.g. is not a LinearConstraint or is a linear
649            constraint for another model).
650        """
651        if not self.has_dual_feasible_solution():
652            raise ValueError(_NO_DUAL_SOLUTION_ERROR)
653        assert self.solutions[0].dual_solution is not None
654        if linear_constraints is None:
655            return self.solutions[0].dual_solution.dual_values
656        if isinstance(linear_constraints, model.LinearConstraint):
657            return self.solutions[0].dual_solution.dual_values[linear_constraints]
658        if isinstance(linear_constraints, Iterable):
659            return [
660                self.solutions[0].dual_solution.dual_values[c]
661                for c in linear_constraints
662            ]
663        raise TypeError(
664            "unsupported type in argument for "
665            f"dual_values: {type(linear_constraints).__name__!r}"
666        )

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).
def reduced_costs(self, variables=None):
677    def reduced_costs(self, variables=None):
678        """The reduced costs associated to the best solution.
679
680        If there is at least one primal feasible solution, this corresponds to the
681        reduced costs associated to the best primal feasible solution. An error will
682        be raised if the best solution does not have an associated dual feasible
683        solution.
684
685        Args:
686          variables: an optional Variable or iterator of Variables indicating what
687            reduced costs to return. If not provided, reduced_costs() returns a
688            dictionary with the reduced costs for all variables.
689
690        Returns:
691          The reduced costs associated to the best solution.
692
693        Raises:
694          ValueError: The best solution does not have an associated dual feasible
695            solution.
696          TypeError: Argument is not None, a Variable or an iterable of Variables.
697          KeyError: Variable values requested for an invalid variable (e.g. is not a
698            Variable or is a variable for another model).
699        """
700        if not self.has_dual_feasible_solution():
701            raise ValueError(_NO_DUAL_SOLUTION_ERROR)
702        assert self.solutions[0].dual_solution is not None
703        if variables is None:
704            return self.solutions[0].dual_solution.reduced_costs
705        if isinstance(variables, model.Variable):
706            return self.solutions[0].dual_solution.reduced_costs[variables]
707        if isinstance(variables, Iterable):
708            return [self.solutions[0].dual_solution.reduced_costs[v] for v in variables]
709        raise TypeError(
710            "unsupported type in argument for "
711            f"reduced_costs: {type(variables).__name__!r}"
712        )

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).
def has_dual_ray(self) -> bool:
714    def has_dual_ray(self) -> bool:
715        """Indicates if at least one dual ray is available.
716
717        This is NOT guaranteed to be true when termination.reason is
718        TerminationReason.Infeasible.
719
720        Returns:
721          True if at least one dual ray is available.
722        """
723        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.

def ray_dual_values(self, linear_constraints=None):
738    def ray_dual_values(self, linear_constraints=None):
739        """The dual values from the first dual ray.
740
741        An error will be raised if there are no dual rays.
742
743        Args:
744          linear_constraints: an optional LinearConstraint or iterator of
745            LinearConstraint indicating what dual values to return. If not provided,
746            ray_dual_values() returns a dictionary with the dual values for all
747            linear constraints.
748
749        Returns:
750          The dual values from the first dual ray.
751
752        Raises:
753          ValueError: There are no dual rays.
754          TypeError: Argument is not None, a LinearConstraint or an iterable of
755            LinearConstraint.
756          KeyError: LinearConstraint values requested for an invalid
757            linear constraint (e.g. is not a LinearConstraint or is a linear
758            constraint for another model).
759        """
760        if not self.has_dual_ray():
761            raise ValueError("No dual ray available.")
762        if linear_constraints is None:
763            return self.dual_rays[0].dual_values
764        if isinstance(linear_constraints, model.LinearConstraint):
765            return self.dual_rays[0].dual_values[linear_constraints]
766        if isinstance(linear_constraints, Iterable):
767            return [self.dual_rays[0].dual_values[v] for v in linear_constraints]
768        raise TypeError(
769            "unsupported type in argument for "
770            f"ray_dual_values: {type(linear_constraints).__name__!r}"
771        )

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).
def ray_reduced_costs(self, variables=None):
784    def ray_reduced_costs(self, variables=None):
785        """The reduced costs from the first dual ray.
786
787        An error will be raised if there are no dual rays.
788
789        Args:
790          variables: an optional Variable or iterator of Variables indicating what
791            reduced costs to return. If not provided, ray_reduced_costs() returns a
792            dictionary with the reduced costs for all variables.
793
794        Returns:
795          The reduced costs from the first dual ray.
796
797        Raises:
798          ValueError: There are no dual rays.
799          TypeError: Argument is not None, a Variable or an iterable of Variables.
800          KeyError: Variable values requested for an invalid variable (e.g. is not a
801            Variable or is a variable for another model).
802        """
803        if not self.has_dual_ray():
804            raise ValueError("No dual ray available.")
805        if variables is None:
806            return self.dual_rays[0].reduced_costs
807        if isinstance(variables, model.Variable):
808            return self.dual_rays[0].reduced_costs[variables]
809        if isinstance(variables, Iterable):
810            return [self.dual_rays[0].reduced_costs[v] for v in variables]
811        raise TypeError(
812            "unsupported type in argument for "
813            f"ray_reduced_costs: {type(variables).__name__!r}"
814        )

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).
def has_basis(self) -> bool:
816    def has_basis(self) -> bool:
817        """Indicates if the best solution has an associated basis.
818
819        This is NOT guaranteed to be true when termination.reason is
820        TerminationReason.Optimal. It also may be true even when the best solution
821        does not have an associated primal feasible solution.
822
823        Returns:
824          True if the best solution has an associated basis.
825        """
826        if not self.solutions:
827            return False
828        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.

def constraint_status(self, linear_constraints=None):
845    def constraint_status(self, linear_constraints=None):
846        """The constraint basis status associated to the best solution.
847
848        If there is at least one primal feasible solution, this corresponds to the
849        basis associated to the best primal feasible solution. An error will
850        be raised if the best solution does not have an associated basis.
851
852
853        Args:
854          linear_constraints: an optional LinearConstraint or iterator of
855            LinearConstraint indicating what constraint statuses to return. If not
856            provided, returns a dictionary with the constraint statuses for all
857            linear constraints.
858
859        Returns:
860          The constraint basis status associated to the best solution.
861
862        Raises:
863          ValueError: The best solution does not have an associated basis.
864          TypeError: Argument is not None, a LinearConstraint or an iterable of
865            LinearConstraint.
866          KeyError: LinearConstraint values requested for an invalid
867            linear constraint (e.g. is not a LinearConstraint or is a linear
868            constraint for another model).
869        """
870        if not self.has_basis():
871            raise ValueError(_NO_BASIS_ERROR)
872        assert self.solutions[0].basis is not None
873        if linear_constraints is None:
874            return self.solutions[0].basis.constraint_status
875        if isinstance(linear_constraints, model.LinearConstraint):
876            return self.solutions[0].basis.constraint_status[linear_constraints]
877        if isinstance(linear_constraints, Iterable):
878            return [
879                self.solutions[0].basis.constraint_status[c] for c in linear_constraints
880            ]
881        raise TypeError(
882            "unsupported type in argument for "
883            f"constraint_status: {type(linear_constraints).__name__!r}"
884        )

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).
def variable_status(self, variables=None):
899    def variable_status(self, variables=None):
900        """The variable basis status associated to the best solution.
901
902        If there is at least one primal feasible solution, this corresponds to the
903        basis associated to the best primal feasible solution. An error will
904        be raised if the best solution does not have an associated basis.
905
906        Args:
907          variables: an optional Variable or iterator of Variables indicating what
908            reduced costs to return. If not provided, variable_status() returns a
909            dictionary with the reduced costs for all variables.
910
911        Returns:
912          The variable basis status associated to the best solution.
913
914        Raises:
915          ValueError: The best solution does not have an associated basis.
916          TypeError: Argument is not None, a Variable or an iterable of Variables.
917          KeyError: Variable values requested for an invalid variable (e.g. is not a
918            Variable or is a variable for another model).
919        """
920        if not self.has_basis():
921            raise ValueError(_NO_BASIS_ERROR)
922        assert self.solutions[0].basis is not None
923        if variables is None:
924            return self.solutions[0].basis.variable_status
925        if isinstance(variables, model.Variable):
926            return self.solutions[0].basis.variable_status[variables]
927        if isinstance(variables, Iterable):
928            return [self.solutions[0].basis.variable_status[v] for v in variables]
929        raise TypeError(
930            "unsupported type in argument for "
931            f"variable_status: {type(variables).__name__!r}"
932        )

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).
966def parse_solve_result(
967    proto: result_pb2.SolveResultProto, mod: model.Model
968) -> SolveResult:
969    """Returns a SolveResult equivalent to the input proto."""
970    result = SolveResult()
971    # TODO(b/290091715): change to parse_termination(proto.termination)
972    # once solve_stats proto no longer has best_primal/dual_bound/problem_status
973    # and problem_status/objective_bounds are guaranteed to be present in
974    # termination proto.
975    result.termination = parse_termination(_upgrade_termination(proto))
976    result.solve_stats = parse_solve_stats(proto.solve_stats)
977    for solution_proto in proto.solutions:
978        result.solutions.append(solution.parse_solution(solution_proto, mod))
979    for primal_ray_proto in proto.primal_rays:
980        result.primal_rays.append(solution.parse_primal_ray(primal_ray_proto, mod))
981    for dual_ray_proto in proto.dual_rays:
982        result.dual_rays.append(solution.parse_dual_ray(dual_ray_proto, mod))
983    if proto.HasField("gscip_output"):
984        result.gscip_specific_output = proto.gscip_output
985    elif proto.HasField("osqp_output"):
986        result.osqp_specific_output = proto.osqp_output
987    elif proto.HasField("pdlp_output"):
988        result.pdlp_specific_output = proto.pdlp_output
989    return result

Returns a SolveResult equivalent to the input proto.