#!/usr/bin/env python3 # Copyright 2010-2025 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. """An Objective for a MathOpt optimization model.""" import abc from typing import Any, Iterator from ortools.math_opt.elemental.python import enums from ortools.math_opt.python import from_model from ortools.math_opt.python import variables from ortools.math_opt.python.elemental import elemental class Objective(from_model.FromModel, metaclass=abc.ABCMeta): """The objective for an optimization model. An objective is either of the form: min o + sum_{i in I} c_i * x_i + sum_{i, j in I, i <= j} q_i,j * x_i * x_j or max o + sum_{i in I} c_i * x_i + sum_{(i, j) in Q} q_i,j * x_i * x_j where x_i are the decision variables of the problem and where all pairs (i, j) in Q satisfy i <= j. The values of o, c_i and q_i,j should be finite and not NaN. The objective can be configured as follows: * offset: a float property, o above. Should be finite and not NaN. * is_maximize: a bool property, if the objective is to maximize or minimize. * set_linear_coefficient and get_linear_coefficient control the c_i * x_i terms. The variables must be from the same model as this objective, and the c_i must be finite and not NaN. The coefficient for any variable not set is 0.0, and setting a coefficient to 0.0 removes it from I above. * set_quadratic_coefficient and get_quadratic_coefficient control the q_i,j * x_i * x_j terms. The variables must be from the same model as this objective, and the q_i,j must be finite and not NaN. The coefficient for any pair of variables not set is 0.0, and setting a coefficient to 0.0 removes the associated (i,j) from Q above. Do not create an Objective directly, use Model.objective to access the objective instead (or Model.add_auxiliary_objective()). Two Objective objects can represent the same objective (for the same model). They will have the same underlying Objective.elemental for storing the data. The Objective class is simply a reference to an Elemental. The objective is linear if only linear coefficients are set. This can be useful to avoid solve-time errors with solvers that do not accept quadratic objectives. To facilitate this linear objective guarantee we provide three functions to add to the objective: * add(), which accepts linear or quadratic expressions, * add_quadratic(), which also accepts linear or quadratic expressions and can be used to signal a quadratic objective is possible, and * add_linear(), which only accepts linear expressions and can be used to guarantee the objective remains linear. For quadratic terms, the order that variables are provided does not matter, we always canonicalize to first_var <= second_var. So if you set (x1, x2) to 7 then: * getting (x2, x1) returns 7 * setting (x2, x1) to 10 overwrites the value of 7. Likewise, when we return nonzero quadratic coefficients, we always use the form first_var <= second_var. Most problems have only a single objective, but hierarchical objectives are supported (see Model.add_auxiliary_objective()). Note that quadratic Auxiliary objectives are not supported. """ __slots__ = ("_elemental",) def __init__(self, elem: elemental.Elemental) -> None: """Do not invoke directly, prefer Model.objective.""" self._elemental: elemental.Elemental = elem @property def elemental(self) -> elemental.Elemental: """The underlying data structure for the model, for internal use only.""" return self._elemental @property @abc.abstractmethod def name(self) -> str: """The immutable name of this objective, for display only.""" @property @abc.abstractmethod def is_maximize(self) -> bool: """If true, the direction is maximization, otherwise minimization.""" @is_maximize.setter @abc.abstractmethod def is_maximize(self, is_maximize: bool) -> None: ... @property @abc.abstractmethod def offset(self) -> float: """A constant added to the objective.""" @offset.setter @abc.abstractmethod def offset(self, value: float) -> None: ... @property @abc.abstractmethod def priority(self) -> int: """For hierarchical problems, determines the order to apply objectives. The objectives are applied from lowest priority to highest. The default priority for the primary objective is zero, and auxiliary objectives must specific a priority at creation time. Priority has no effect for problems with only one objective. """ @priority.setter @abc.abstractmethod def priority(self, value: int) -> None: ... @abc.abstractmethod def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None: """Sets the coefficient of `var` to `coef` in the objective.""" @abc.abstractmethod def get_linear_coefficient(self, var: variables.Variable) -> float: """Returns the coefficinet of `var` (or zero if unset).""" @abc.abstractmethod def linear_terms(self) -> Iterator[variables.LinearTerm]: """Yields variable coefficient pairs for variables with nonzero objective coefficient in undefined order.""" @abc.abstractmethod def set_quadratic_coefficient( self, first_variable: variables.Variable, second_variable: variables.Variable, coef: float, ) -> None: """Sets the coefficient for product of variables (see class description).""" @abc.abstractmethod def get_quadratic_coefficient( self, first_variable: variables.Variable, second_variable: variables.Variable, ) -> float: """Gets the coefficient for product of variables (see class description).""" @abc.abstractmethod def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]: """Yields quadratic terms with nonzero objective coefficient in undefined order.""" @abc.abstractmethod def clear(self) -> None: """Clears objective coefficients and offset. Does not change direction.""" def as_linear_expression(self) -> variables.LinearExpression: """Returns an equivalent LinearExpression, or errors if quadratic.""" if any(self.quadratic_terms()): raise TypeError("Cannot get a quadratic objective as a linear expression") return variables.as_flat_linear_expression( self.offset + variables.LinearSum(self.linear_terms()) ) def as_quadratic_expression(self) -> variables.QuadraticExpression: """Returns an equivalent QuadraticExpression to this objetive.""" return variables.as_flat_quadratic_expression( self.offset + variables.LinearSum(self.linear_terms()) + variables.QuadraticSum(self.quadratic_terms()) ) def add(self, objective: variables.QuadraticTypes) -> None: """Adds the provided expression `objective` to the objective function. For a compile time guarantee that the objective remains linear, use add_linear() instead. Args: objective: the expression to add to the objective function. """ if isinstance(objective, (variables.LinearBase, int, float)): self.add_linear(objective) elif isinstance(objective, variables.QuadraticBase): self.add_quadratic(objective) else: raise TypeError( "unsupported type in objective argument for " f"Objective.add(): {type(objective).__name__!r}" ) def add_linear(self, objective: variables.LinearTypes) -> None: """Adds the provided linear expression `objective` to the objective function.""" if not isinstance(objective, (variables.LinearBase, int, float)): raise TypeError( "unsupported type in objective argument for " f"Objective.add_linear(): {type(objective).__name__!r}" ) objective_expr = variables.as_flat_linear_expression(objective) self.offset += objective_expr.offset for var, coefficient in objective_expr.terms.items(): self.set_linear_coefficient( var, self.get_linear_coefficient(var) + coefficient ) def add_quadratic(self, objective: variables.QuadraticTypes) -> None: """Adds the provided quadratic expression `objective` to the objective function.""" if not isinstance( objective, (variables.QuadraticBase, variables.LinearBase, int, float) ): raise TypeError( "unsupported type in objective argument for " f"Objective.add(): {type(objective).__name__!r}" ) objective_expr = variables.as_flat_quadratic_expression(objective) self.offset += objective_expr.offset for var, coefficient in objective_expr.linear_terms.items(): self.set_linear_coefficient( var, self.get_linear_coefficient(var) + coefficient ) for key, coefficient in objective_expr.quadratic_terms.items(): self.set_quadratic_coefficient( key.first_var, key.second_var, self.get_quadratic_coefficient(key.first_var, key.second_var) + coefficient, ) def set_to_linear_expression(self, linear_expr: variables.LinearTypes) -> None: """Sets the objective to optimize to `linear_expr`.""" if not isinstance(linear_expr, (variables.LinearBase, int, float)): raise TypeError( "unsupported type in objective argument for " f"set_to_linear_expression: {type(linear_expr).__name__!r}" ) self.clear() objective_expr = variables.as_flat_linear_expression(linear_expr) self.offset = objective_expr.offset for var, coefficient in objective_expr.terms.items(): self.set_linear_coefficient(var, coefficient) def set_to_quadratic_expression( self, quadratic_expr: variables.QuadraticTypes ) -> None: """Sets the objective to optimize the `quadratic_expr`.""" if not isinstance( quadratic_expr, (variables.QuadraticBase, variables.LinearBase, int, float), ): raise TypeError( "unsupported type in objective argument for " f"set_to_quadratic_expression: {type(quadratic_expr).__name__!r}" ) self.clear() objective_expr = variables.as_flat_quadratic_expression(quadratic_expr) self.offset = objective_expr.offset for var, coefficient in objective_expr.linear_terms.items(): self.set_linear_coefficient(var, coefficient) for quad_key, coefficient in objective_expr.quadratic_terms.items(): self.set_quadratic_coefficient( quad_key.first_var, quad_key.second_var, coefficient ) def set_to_expression(self, expr: variables.QuadraticTypes) -> None: """Sets the objective to optimize the `expr`.""" if isinstance(expr, (variables.LinearBase, int, float)): self.set_to_linear_expression(expr) elif isinstance(expr, variables.QuadraticBase): self.set_to_quadratic_expression(expr) else: raise TypeError( "unsupported type in objective argument for " f"set_to_expression: {type(expr).__name__!r}" ) class PrimaryObjective(Objective): """The main objective, but users should program against Objective directly.""" __slots__ = () @property def name(self) -> str: return self._elemental.primary_objective_name @property def is_maximize(self) -> bool: return self._elemental.get_attr(enums.BoolAttr0.MAXIMIZE, ()) @is_maximize.setter def is_maximize(self, is_maximize: bool) -> None: self._elemental.set_attr(enums.BoolAttr0.MAXIMIZE, (), is_maximize) @property def offset(self) -> float: return self._elemental.get_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET, ()) @offset.setter def offset(self, value: float) -> None: self._elemental.set_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET, (), value) @property def priority(self) -> int: return self._elemental.get_attr(enums.IntAttr0.OBJECTIVE_PRIORITY, ()) @priority.setter def priority(self, value: int) -> None: self._elemental.set_attr(enums.IntAttr0.OBJECTIVE_PRIORITY, (), value) def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None: from_model.model_is_same(self, var) self._elemental.set_attr( enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, (var.id,), coef ) def get_linear_coefficient(self, var: variables.Variable) -> float: from_model.model_is_same(self, var) return self._elemental.get_attr( enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, (var.id,) ) def linear_terms(self) -> Iterator[variables.LinearTerm]: keys = self._elemental.get_attr_non_defaults( enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT ) var_index = 0 coefs = self._elemental.get_attrs( enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT, keys ) for i in range(len(keys)): yield variables.LinearTerm( variable=variables.Variable(self._elemental, int(keys[i, var_index])), coefficient=float(coefs[i]), ) def set_quadratic_coefficient( self, first_variable: variables.Variable, second_variable: variables.Variable, coef: float, ) -> None: from_model.model_is_same(self, first_variable) from_model.model_is_same(self, second_variable) self._elemental.set_attr( enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT, (first_variable.id, second_variable.id), coef, ) def get_quadratic_coefficient( self, first_variable: variables.Variable, second_variable: variables.Variable, ) -> float: from_model.model_is_same(self, first_variable) from_model.model_is_same(self, second_variable) return self._elemental.get_attr( enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT, (first_variable.id, second_variable.id), ) def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]: keys = self._elemental.get_attr_non_defaults( enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT ) coefs = self._elemental.get_attrs( enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT, keys ) for i in range(len(keys)): yield variables.QuadraticTerm( variables.QuadraticTermKey( variables.Variable(self._elemental, int(keys[i, 0])), variables.Variable(self._elemental, int(keys[i, 1])), ), coefficient=float(coefs[i]), ) def clear(self) -> None: self._elemental.clear_attr(enums.DoubleAttr0.OBJECTIVE_OFFSET) self._elemental.clear_attr(enums.DoubleAttr1.OBJECTIVE_LINEAR_COEFFICIENT) self._elemental.clear_attr( enums.SymmetricDoubleAttr2.OBJECTIVE_QUADRATIC_COEFFICIENT ) def __eq__(self, other: Any) -> bool: if isinstance(other, PrimaryObjective): return self._elemental is other._elemental return False def __hash__(self) -> int: return hash(self._elemental) class AuxiliaryObjective(Objective): """An additional objective that can be optimized after objectives.""" __slots__ = ("_id",) def __init__(self, elem: elemental.Elemental, obj_id: int) -> None: """Internal only, prefer Model functions (add_auxiliary_objective() and get_auxiliary_objective()).""" super().__init__(elem) if not isinstance(obj_id, int): raise TypeError( f"obj_id type should be int, was: {type(obj_id).__name__!r}" ) self._id: int = obj_id @property def name(self) -> str: return self._elemental.get_element_name( enums.ElementType.AUXILIARY_OBJECTIVE, self._id ) @property def id(self) -> int: """Returns the id of this objective.""" return self._id @property def is_maximize(self) -> bool: return self._elemental.get_attr( enums.BoolAttr1.AUXILIARY_OBJECTIVE_MAXIMIZE, (self._id,) ) @is_maximize.setter def is_maximize(self, is_maximize: bool) -> None: self._elemental.set_attr( enums.BoolAttr1.AUXILIARY_OBJECTIVE_MAXIMIZE, (self._id,), is_maximize, ) @property def offset(self) -> float: return self._elemental.get_attr( enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET, (self._id,) ) @offset.setter def offset(self, value: float) -> None: self._elemental.set_attr( enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET, (self._id,), value, ) @property def priority(self) -> int: return self._elemental.get_attr( enums.IntAttr1.AUXILIARY_OBJECTIVE_PRIORITY, (self._id,) ) @priority.setter def priority(self, value: int) -> None: self._elemental.set_attr( enums.IntAttr1.AUXILIARY_OBJECTIVE_PRIORITY, (self._id,), value, ) def set_linear_coefficient(self, var: variables.Variable, coef: float) -> None: from_model.model_is_same(self, var) self._elemental.set_attr( enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT, (self._id, var.id), coef, ) def get_linear_coefficient(self, var: variables.Variable) -> float: from_model.model_is_same(self, var) return self._elemental.get_attr( enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT, ( self._id, var.id, ), ) def linear_terms(self) -> Iterator[variables.LinearTerm]: keys = self._elemental.slice_attr( enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT, 0, self._id, ) var_index = 1 coefs = self._elemental.get_attrs( enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT, keys ) for i in range(len(keys)): yield variables.LinearTerm( variable=variables.Variable(self._elemental, int(keys[i, var_index])), coefficient=float(coefs[i]), ) def set_quadratic_coefficient( self, first_variable: variables.Variable, second_variable: variables.Variable, coef: float, ) -> None: raise ValueError("Quadratic auxiliary objectives are not supported.") def get_quadratic_coefficient( self, first_variable: variables.Variable, second_variable: variables.Variable, ) -> float: from_model.model_is_same(self, first_variable) from_model.model_is_same(self, second_variable) if not self._elemental.element_exists( enums.ElementType.VARIABLE, first_variable.id ): raise ValueError(f"Variable {first_variable} does not exist") if not self._elemental.element_exists( enums.ElementType.VARIABLE, second_variable.id ): raise ValueError(f"Variable {second_variable} does not exist") return 0.0 def quadratic_terms(self) -> Iterator[variables.QuadraticTerm]: return iter(()) def clear(self) -> None: """Clears objective coefficients and offset. Does not change direction.""" self._elemental.clear_attr(enums.DoubleAttr1.AUXILIARY_OBJECTIVE_OFFSET) self._elemental.clear_attr( enums.DoubleAttr2.AUXILIARY_OBJECTIVE_LINEAR_COEFFICIENT ) def __eq__(self, other: Any) -> bool: if isinstance(other, AuxiliaryObjective): return self._elemental is other._elemental and self._id == other._id return False def __hash__(self) -> int: return hash((self._elemental, self._id))