1007 lines
42 KiB
Python
1007 lines
42 KiB
Python
|
|
# Copyright 2010-2022 Google LLC
|
||
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
# you may not use this file except in compliance with the License.
|
||
|
|
# You may obtain a copy of the License at
|
||
|
|
#
|
||
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
#
|
||
|
|
# Unless required by applicable law or agreed to in writing, software
|
||
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
# See the License for the specific language governing permissions and
|
||
|
|
# limitations under the License.
|
||
|
|
|
||
|
|
"""The output from solving a mathematical optimization problem from model.py."""
|
||
|
|
import dataclasses
|
||
|
|
import datetime
|
||
|
|
import enum
|
||
|
|
from typing import Dict, Iterable, List, Optional, overload
|
||
|
|
|
||
|
|
from ortools.gscip import gscip_pb2
|
||
|
|
from ortools.math_opt import result_pb2
|
||
|
|
from ortools.math_opt.python import model
|
||
|
|
from ortools.math_opt.python import solution
|
||
|
|
from ortools.math_opt.solvers import osqp_pb2
|
||
|
|
|
||
|
|
_NO_DUAL_SOLUTION_ERROR = (
|
||
|
|
"Best solution does not have an associated dual feasible solution."
|
||
|
|
)
|
||
|
|
_NO_BASIS_ERROR = "Best solution does not have an associated basis."
|
||
|
|
|
||
|
|
|
||
|
|
@enum.unique
|
||
|
|
class FeasibilityStatus(enum.Enum):
|
||
|
|
"""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 = result_pb2.FEASIBILITY_STATUS_UNDETERMINED
|
||
|
|
FEASIBLE = result_pb2.FEASIBILITY_STATUS_FEASIBLE
|
||
|
|
INFEASIBLE = result_pb2.FEASIBILITY_STATUS_INFEASIBLE
|
||
|
|
|
||
|
|
|
||
|
|
@dataclasses.dataclass(frozen=True)
|
||
|
|
class ProblemStatus:
|
||
|
|
"""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).
|
||
|
|
"""
|
||
|
|
|
||
|
|
primal_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED
|
||
|
|
dual_status: FeasibilityStatus = FeasibilityStatus.UNDETERMINED
|
||
|
|
primal_or_dual_infeasible: bool = False
|
||
|
|
|
||
|
|
def to_proto(self) -> result_pb2.ProblemStatusProto:
|
||
|
|
"""Returns an equivalent proto for a problem status."""
|
||
|
|
return result_pb2.ProblemStatusProto(
|
||
|
|
primal_status=self.primal_status.value,
|
||
|
|
dual_status=self.dual_status.value,
|
||
|
|
primal_or_dual_infeasible=self.primal_or_dual_infeasible,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_problem_status(proto: result_pb2.ProblemStatusProto) -> ProblemStatus:
|
||
|
|
"""Returns an equivalent ProblemStatus from the input proto."""
|
||
|
|
primal_status_proto = proto.primal_status
|
||
|
|
if primal_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED:
|
||
|
|
raise ValueError("Primal feasibility status should not be UNSPECIFIED")
|
||
|
|
dual_status_proto = proto.dual_status
|
||
|
|
if dual_status_proto == result_pb2.FEASIBILITY_STATUS_UNSPECIFIED:
|
||
|
|
raise ValueError("Dual feasibility status should not be UNSPECIFIED")
|
||
|
|
return ProblemStatus(
|
||
|
|
primal_status=FeasibilityStatus(primal_status_proto),
|
||
|
|
dual_status=FeasibilityStatus(dual_status_proto),
|
||
|
|
primal_or_dual_infeasible=proto.primal_or_dual_infeasible,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclasses.dataclass(frozen=True)
|
||
|
|
class ObjectiveBounds:
|
||
|
|
"""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
|
||
|
|
""" # fmt: skip
|
||
|
|
|
||
|
|
primal_bound: float = 0.0
|
||
|
|
dual_bound: float = 0.0
|
||
|
|
|
||
|
|
def to_proto(self) -> result_pb2.ObjectiveBoundsProto:
|
||
|
|
"""Returns an equivalent proto for objective bounds."""
|
||
|
|
return result_pb2.ObjectiveBoundsProto(
|
||
|
|
primal_bound=self.primal_bound, dual_bound=self.dual_bound
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_objective_bounds(
|
||
|
|
proto: result_pb2.ObjectiveBoundsProto,
|
||
|
|
) -> ObjectiveBounds:
|
||
|
|
"""Returns an equivalent ObjectiveBounds from the input proto."""
|
||
|
|
return ObjectiveBounds(primal_bound=proto.primal_bound, dual_bound=proto.dual_bound)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclasses.dataclass
|
||
|
|
class SolveStats:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
|
||
|
|
solve_time: datetime.timedelta = datetime.timedelta()
|
||
|
|
simplex_iterations: int = 0
|
||
|
|
barrier_iterations: int = 0
|
||
|
|
first_order_iterations: int = 0
|
||
|
|
node_count: int = 0
|
||
|
|
|
||
|
|
def to_proto(self) -> result_pb2.SolveStatsProto:
|
||
|
|
"""Returns an equivalent proto for a solve stats."""
|
||
|
|
result = result_pb2.SolveStatsProto(
|
||
|
|
simplex_iterations=self.simplex_iterations,
|
||
|
|
barrier_iterations=self.barrier_iterations,
|
||
|
|
first_order_iterations=self.first_order_iterations,
|
||
|
|
node_count=self.node_count,
|
||
|
|
)
|
||
|
|
result.solve_time.FromTimedelta(self.solve_time)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def parse_solve_stats(proto: result_pb2.SolveStatsProto) -> SolveStats:
|
||
|
|
"""Returns an equivalent SolveStats from the input proto."""
|
||
|
|
result = SolveStats()
|
||
|
|
result.solve_time = proto.solve_time.ToTimedelta()
|
||
|
|
result.simplex_iterations = proto.simplex_iterations
|
||
|
|
result.barrier_iterations = proto.barrier_iterations
|
||
|
|
result.first_order_iterations = proto.first_order_iterations
|
||
|
|
result.node_count = proto.node_count
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
@enum.unique
|
||
|
|
class TerminationReason(enum.Enum):
|
||
|
|
"""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 = result_pb2.TERMINATION_REASON_OPTIMAL
|
||
|
|
INFEASIBLE = result_pb2.TERMINATION_REASON_INFEASIBLE
|
||
|
|
UNBOUNDED = result_pb2.TERMINATION_REASON_UNBOUNDED
|
||
|
|
INFEASIBLE_OR_UNBOUNDED = result_pb2.TERMINATION_REASON_INFEASIBLE_OR_UNBOUNDED
|
||
|
|
IMPRECISE = result_pb2.TERMINATION_REASON_IMPRECISE
|
||
|
|
FEASIBLE = result_pb2.TERMINATION_REASON_FEASIBLE
|
||
|
|
NO_SOLUTION_FOUND = result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND
|
||
|
|
NUMERICAL_ERROR = result_pb2.TERMINATION_REASON_NUMERICAL_ERROR
|
||
|
|
OTHER_ERROR = result_pb2.TERMINATION_REASON_OTHER_ERROR
|
||
|
|
|
||
|
|
|
||
|
|
@enum.unique
|
||
|
|
class Limit(enum.Enum):
|
||
|
|
"""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 = result_pb2.LIMIT_UNDETERMINED
|
||
|
|
ITERATION = result_pb2.LIMIT_ITERATION
|
||
|
|
TIME = result_pb2.LIMIT_TIME
|
||
|
|
NODE = result_pb2.LIMIT_NODE
|
||
|
|
SOLUTION = result_pb2.LIMIT_SOLUTION
|
||
|
|
MEMORY = result_pb2.LIMIT_MEMORY
|
||
|
|
OBJECTIVE = result_pb2.LIMIT_OBJECTIVE
|
||
|
|
NORM = result_pb2.LIMIT_NORM
|
||
|
|
INTERRUPTED = result_pb2.LIMIT_INTERRUPTED
|
||
|
|
SLOW_PROGRESS = result_pb2.LIMIT_SLOW_PROGRESS
|
||
|
|
OTHER = result_pb2.LIMIT_OTHER
|
||
|
|
|
||
|
|
|
||
|
|
@dataclasses.dataclass
|
||
|
|
class Termination:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
|
||
|
|
reason: TerminationReason = TerminationReason.OPTIMAL
|
||
|
|
limit: Optional[Limit] = None
|
||
|
|
detail: str = ""
|
||
|
|
problem_status: ProblemStatus = ProblemStatus()
|
||
|
|
objective_bounds: ObjectiveBounds = ObjectiveBounds()
|
||
|
|
|
||
|
|
|
||
|
|
def parse_termination(
|
||
|
|
termination_proto: result_pb2.TerminationProto,
|
||
|
|
) -> Termination:
|
||
|
|
"""Returns a Termination that is equivalent to termination_proto."""
|
||
|
|
reason_proto = termination_proto.reason
|
||
|
|
limit_proto = termination_proto.limit
|
||
|
|
if reason_proto == result_pb2.TERMINATION_REASON_UNSPECIFIED:
|
||
|
|
raise ValueError("Termination reason should not be UNSPECIFIED")
|
||
|
|
reason_is_limit = (
|
||
|
|
reason_proto == result_pb2.TERMINATION_REASON_NO_SOLUTION_FOUND
|
||
|
|
) or (reason_proto == result_pb2.TERMINATION_REASON_FEASIBLE)
|
||
|
|
limit_set = limit_proto != result_pb2.LIMIT_UNSPECIFIED
|
||
|
|
if reason_is_limit != limit_set:
|
||
|
|
raise ValueError(
|
||
|
|
f"Termination limit (={limit_proto})) should take value other than "
|
||
|
|
f"UNSPECIFIED if and only if termination reason (={reason_proto}) is "
|
||
|
|
"FEASIBLE or NO_SOLUTION_FOUND"
|
||
|
|
)
|
||
|
|
termination = Termination()
|
||
|
|
termination.reason = TerminationReason(reason_proto)
|
||
|
|
termination.limit = Limit(limit_proto) if limit_set else None
|
||
|
|
termination.detail = termination_proto.detail
|
||
|
|
termination.problem_status = parse_problem_status(termination_proto.problem_status)
|
||
|
|
termination.objective_bounds = parse_objective_bounds(
|
||
|
|
termination_proto.objective_bounds
|
||
|
|
)
|
||
|
|
return termination
|
||
|
|
|
||
|
|
|
||
|
|
@dataclasses.dataclass
|
||
|
|
class SolveResult:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
|
||
|
|
termination: Termination = dataclasses.field(default_factory=Termination)
|
||
|
|
solve_stats: SolveStats = dataclasses.field(default_factory=SolveStats)
|
||
|
|
solutions: List[solution.Solution] = dataclasses.field(default_factory=list)
|
||
|
|
primal_rays: List[solution.PrimalRay] = dataclasses.field(default_factory=list)
|
||
|
|
dual_rays: List[solution.DualRay] = dataclasses.field(default_factory=list)
|
||
|
|
# At most one of the below will be set
|
||
|
|
gscip_specific_output: Optional[gscip_pb2.GScipOutput] = None
|
||
|
|
osqp_specific_output: Optional[osqp_pb2.OsqpOutput] = None
|
||
|
|
pdlp_specific_output: Optional[result_pb2.SolveResultProto.PdlpOutput] = None
|
||
|
|
|
||
|
|
def solve_time(self) -> datetime.timedelta:
|
||
|
|
"""Shortcut for SolveResult.solve_stats.solve_time."""
|
||
|
|
return self.solve_stats.solve_time
|
||
|
|
|
||
|
|
def primal_bound(self) -> float:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
return self.termination.objective_bounds.primal_bound
|
||
|
|
|
||
|
|
def dual_bound(self) -> float:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
return self.termination.objective_bounds.dual_bound
|
||
|
|
|
||
|
|
def has_primal_feasible_solution(self) -> bool:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
if not self.solutions:
|
||
|
|
return False
|
||
|
|
return (
|
||
|
|
self.solutions[0].primal_solution is not None
|
||
|
|
and self.solutions[0].primal_solution.feasibility_status
|
||
|
|
== solution.SolutionStatus.FEASIBLE
|
||
|
|
)
|
||
|
|
|
||
|
|
def objective_value(self) -> float:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
if not self.has_primal_feasible_solution():
|
||
|
|
raise ValueError("No primal feasible solution available.")
|
||
|
|
assert self.solutions[0].primal_solution is not None
|
||
|
|
return self.solutions[0].primal_solution.objective_value
|
||
|
|
|
||
|
|
def best_objective_bound(self) -> float:
|
||
|
|
"""Returns a bound on the best possible objective value.
|
||
|
|
|
||
|
|
best_objective_bound() is always equal to dual_bound(), so they can be
|
||
|
|
used interchangeably.
|
||
|
|
"""
|
||
|
|
return self.termination.objective_bounds.dual_bound
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def variable_values(self, variables: None = ...) -> Dict[model.Variable, float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def variable_values(self, variables: model.Variable) -> float:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def variable_values(self, variables: Iterable[model.Variable]) -> List[float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def variable_values(self, variables=None):
|
||
|
|
"""The variable values from the best primal feasible solution.
|
||
|
|
|
||
|
|
An error will be raised if there are no primal feasible solutions.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_primal_feasible_solution():
|
||
|
|
raise ValueError("No primal feasible solution available.")
|
||
|
|
assert self.solutions[0].primal_solution is not None
|
||
|
|
if variables is None:
|
||
|
|
return self.solutions[0].primal_solution.variable_values
|
||
|
|
if isinstance(variables, model.Variable):
|
||
|
|
return self.solutions[0].primal_solution.variable_values[variables]
|
||
|
|
if isinstance(variables, Iterable):
|
||
|
|
return [
|
||
|
|
self.solutions[0].primal_solution.variable_values[v] for v in variables
|
||
|
|
]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"variable_values: {type(variables).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def bounded(self) -> bool:
|
||
|
|
"""Returns true only if the problem has been shown to be feasible and bounded."""
|
||
|
|
return (
|
||
|
|
self.termination.problem_status.primal_status == FeasibilityStatus.FEASIBLE
|
||
|
|
and self.termination.problem_status.dual_status
|
||
|
|
== FeasibilityStatus.FEASIBLE
|
||
|
|
)
|
||
|
|
|
||
|
|
def has_ray(self) -> bool:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
return bool(self.primal_rays)
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_variable_values(self, variables: None = ...) -> Dict[model.Variable, float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_variable_values(self, variables: model.Variable) -> float:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_variable_values(self, variables: Iterable[model.Variable]) -> List[float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def ray_variable_values(self, variables=None):
|
||
|
|
"""The variable values from the first primal ray.
|
||
|
|
|
||
|
|
An error will be raised if there are no primal rays.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_ray():
|
||
|
|
raise ValueError("No primal ray available.")
|
||
|
|
if variables is None:
|
||
|
|
return self.primal_rays[0].variable_values
|
||
|
|
if isinstance(variables, model.Variable):
|
||
|
|
return self.primal_rays[0].variable_values[variables]
|
||
|
|
if isinstance(variables, Iterable):
|
||
|
|
return [self.primal_rays[0].variable_values[v] for v in variables]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"ray_variable_values: {type(variables).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def has_dual_feasible_solution(self) -> bool:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
if not self.solutions:
|
||
|
|
return False
|
||
|
|
return (
|
||
|
|
self.solutions[0].dual_solution is not None
|
||
|
|
and self.solutions[0].dual_solution.feasibility_status
|
||
|
|
== solution.SolutionStatus.FEASIBLE
|
||
|
|
)
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def dual_values(
|
||
|
|
self, linear_constraints: None = ...
|
||
|
|
) -> Dict[model.LinearConstraint, float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def dual_values(self, linear_constraints: model.LinearConstraint) -> float:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def dual_values(
|
||
|
|
self, linear_constraints: Iterable[model.LinearConstraint]
|
||
|
|
) -> List[float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def dual_values(self, linear_constraints=None):
|
||
|
|
"""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.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_dual_feasible_solution():
|
||
|
|
raise ValueError(_NO_DUAL_SOLUTION_ERROR)
|
||
|
|
assert self.solutions[0].dual_solution is not None
|
||
|
|
if linear_constraints is None:
|
||
|
|
return self.solutions[0].dual_solution.dual_values
|
||
|
|
if isinstance(linear_constraints, model.LinearConstraint):
|
||
|
|
return self.solutions[0].dual_solution.dual_values[linear_constraints]
|
||
|
|
if isinstance(linear_constraints, Iterable):
|
||
|
|
return [
|
||
|
|
self.solutions[0].dual_solution.dual_values[c]
|
||
|
|
for c in linear_constraints
|
||
|
|
]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"dual_values: {type(linear_constraints).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def reduced_costs(self, variables: None = ...) -> Dict[model.Variable, float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def reduced_costs(self, variables: model.Variable) -> float:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def reduced_costs(self, variables=None):
|
||
|
|
"""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.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_dual_feasible_solution():
|
||
|
|
raise ValueError(_NO_DUAL_SOLUTION_ERROR)
|
||
|
|
assert self.solutions[0].dual_solution is not None
|
||
|
|
if variables is None:
|
||
|
|
return self.solutions[0].dual_solution.reduced_costs
|
||
|
|
if isinstance(variables, model.Variable):
|
||
|
|
return self.solutions[0].dual_solution.reduced_costs[variables]
|
||
|
|
if isinstance(variables, Iterable):
|
||
|
|
return [self.solutions[0].dual_solution.reduced_costs[v] for v in variables]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"reduced_costs: {type(variables).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def has_dual_ray(self) -> bool:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
return bool(self.dual_rays)
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_dual_values(
|
||
|
|
self, linear_constraints: None = ...
|
||
|
|
) -> Dict[model.LinearConstraint, float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_dual_values(self, linear_constraints: model.LinearConstraint) -> float:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_dual_values(
|
||
|
|
self, linear_constraints: Iterable[model.LinearConstraint]
|
||
|
|
) -> List[float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def ray_dual_values(self, linear_constraints=None):
|
||
|
|
"""The dual values from the first dual ray.
|
||
|
|
|
||
|
|
An error will be raised if there are no dual rays.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_dual_ray():
|
||
|
|
raise ValueError("No dual ray available.")
|
||
|
|
if linear_constraints is None:
|
||
|
|
return self.dual_rays[0].dual_values
|
||
|
|
if isinstance(linear_constraints, model.LinearConstraint):
|
||
|
|
return self.dual_rays[0].dual_values[linear_constraints]
|
||
|
|
if isinstance(linear_constraints, Iterable):
|
||
|
|
return [self.dual_rays[0].dual_values[v] for v in linear_constraints]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"ray_dual_values: {type(linear_constraints).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_reduced_costs(self, variables: None = ...) -> Dict[model.Variable, float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_reduced_costs(self, variables: model.Variable) -> float:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def ray_reduced_costs(self, variables: Iterable[model.Variable]) -> List[float]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def ray_reduced_costs(self, variables=None):
|
||
|
|
"""The reduced costs from the first dual ray.
|
||
|
|
|
||
|
|
An error will be raised if there are no dual rays.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_dual_ray():
|
||
|
|
raise ValueError("No dual ray available.")
|
||
|
|
if variables is None:
|
||
|
|
return self.dual_rays[0].reduced_costs
|
||
|
|
if isinstance(variables, model.Variable):
|
||
|
|
return self.dual_rays[0].reduced_costs[variables]
|
||
|
|
if isinstance(variables, Iterable):
|
||
|
|
return [self.dual_rays[0].reduced_costs[v] for v in variables]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"ray_reduced_costs: {type(variables).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def has_basis(self) -> bool:
|
||
|
|
"""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.
|
||
|
|
"""
|
||
|
|
if not self.solutions:
|
||
|
|
return False
|
||
|
|
return self.solutions[0].basis is not None
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def constraint_status(
|
||
|
|
self, linear_constraints: None = ...
|
||
|
|
) -> Dict[model.LinearConstraint, solution.BasisStatus]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def constraint_status(
|
||
|
|
self, linear_constraints: model.LinearConstraint
|
||
|
|
) -> solution.BasisStatus:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def constraint_status(
|
||
|
|
self, linear_constraints: Iterable[model.LinearConstraint]
|
||
|
|
) -> List[solution.BasisStatus]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def constraint_status(self, linear_constraints=None):
|
||
|
|
"""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.
|
||
|
|
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_basis():
|
||
|
|
raise ValueError(_NO_BASIS_ERROR)
|
||
|
|
assert self.solutions[0].basis is not None
|
||
|
|
if linear_constraints is None:
|
||
|
|
return self.solutions[0].basis.constraint_status
|
||
|
|
if isinstance(linear_constraints, model.LinearConstraint):
|
||
|
|
return self.solutions[0].basis.constraint_status[linear_constraints]
|
||
|
|
if isinstance(linear_constraints, Iterable):
|
||
|
|
return [
|
||
|
|
self.solutions[0].basis.constraint_status[c] for c in linear_constraints
|
||
|
|
]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"constraint_status: {type(linear_constraints).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def variable_status(
|
||
|
|
self, variables: None = ...
|
||
|
|
) -> Dict[model.Variable, solution.BasisStatus]:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def variable_status(self, variables: model.Variable) -> solution.BasisStatus:
|
||
|
|
...
|
||
|
|
|
||
|
|
@overload
|
||
|
|
def variable_status(
|
||
|
|
self, variables: Iterable[model.Variable]
|
||
|
|
) -> List[solution.BasisStatus]:
|
||
|
|
...
|
||
|
|
|
||
|
|
def variable_status(self, variables=None):
|
||
|
|
"""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.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
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).
|
||
|
|
"""
|
||
|
|
if not self.has_basis():
|
||
|
|
raise ValueError(_NO_BASIS_ERROR)
|
||
|
|
assert self.solutions[0].basis is not None
|
||
|
|
if variables is None:
|
||
|
|
return self.solutions[0].basis.variable_status
|
||
|
|
if isinstance(variables, model.Variable):
|
||
|
|
return self.solutions[0].basis.variable_status[variables]
|
||
|
|
if isinstance(variables, Iterable):
|
||
|
|
return [self.solutions[0].basis.variable_status[v] for v in variables]
|
||
|
|
raise TypeError(
|
||
|
|
"unsupported type in argument for "
|
||
|
|
f"variable_status: {type(variables).__name__!r}"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _get_problem_status(
|
||
|
|
result_proto: result_pb2.SolveResultProto,
|
||
|
|
) -> result_pb2.ProblemStatusProto:
|
||
|
|
if result_proto.termination.HasField("problem_status"):
|
||
|
|
return result_proto.termination.problem_status
|
||
|
|
return result_proto.solve_stats.problem_status
|
||
|
|
|
||
|
|
|
||
|
|
def _get_objective_bounds(
|
||
|
|
result_proto: result_pb2.SolveResultProto,
|
||
|
|
) -> result_pb2.ObjectiveBoundsProto:
|
||
|
|
if result_proto.termination.HasField("objective_bounds"):
|
||
|
|
return result_proto.termination.objective_bounds
|
||
|
|
return result_pb2.ObjectiveBoundsProto(
|
||
|
|
primal_bound=result_proto.solve_stats.best_primal_bound,
|
||
|
|
dual_bound=result_proto.solve_stats.best_dual_bound,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _upgrade_termination(
|
||
|
|
result_proto: result_pb2.SolveResultProto,
|
||
|
|
) -> result_pb2.TerminationProto:
|
||
|
|
return result_pb2.TerminationProto(
|
||
|
|
reason=result_proto.termination.reason,
|
||
|
|
limit=result_proto.termination.limit,
|
||
|
|
detail=result_proto.termination.detail,
|
||
|
|
problem_status=_get_problem_status(result_proto),
|
||
|
|
objective_bounds=_get_objective_bounds(result_proto),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_solve_result(
|
||
|
|
proto: result_pb2.SolveResultProto, mod: model.Model
|
||
|
|
) -> SolveResult:
|
||
|
|
"""Returns a SolveResult equivalent to the input proto."""
|
||
|
|
result = SolveResult()
|
||
|
|
# TODO(b/290091715): change to parse_termination(proto.termination)
|
||
|
|
# once solve_stats proto no longer has best_primal/dual_bound/problem_status
|
||
|
|
# and problem_status/objective_bounds are guaranteed to be present in
|
||
|
|
# termination proto.
|
||
|
|
result.termination = parse_termination(_upgrade_termination(proto))
|
||
|
|
result.solve_stats = parse_solve_stats(proto.solve_stats)
|
||
|
|
for solution_proto in proto.solutions:
|
||
|
|
result.solutions.append(solution.parse_solution(solution_proto, mod))
|
||
|
|
for primal_ray_proto in proto.primal_rays:
|
||
|
|
result.primal_rays.append(solution.parse_primal_ray(primal_ray_proto, mod))
|
||
|
|
for dual_ray_proto in proto.dual_rays:
|
||
|
|
result.dual_rays.append(solution.parse_dual_ray(dual_ray_proto, mod))
|
||
|
|
if proto.HasField("gscip_output"):
|
||
|
|
result.gscip_specific_output = proto.gscip_output
|
||
|
|
elif proto.HasField("osqp_output"):
|
||
|
|
result.osqp_specific_output = proto.osqp_output
|
||
|
|
elif proto.HasField("pdlp_output"):
|
||
|
|
result.pdlp_specific_output = proto.pdlp_output
|
||
|
|
return result
|