#!/usr/bin/env python3 # Copyright 2010-2024 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. import math from typing import Any, Callable, Dict, Mapping, Union from absl.testing import absltest from absl.testing import parameterized import numpy as np import numpy.testing as np_testing import pandas as pd import os from google.protobuf import text_format from ortools.linear_solver import linear_solver_pb2 from ortools.linear_solver.python import model_builder as mb from ortools.linear_solver.python import model_builder_helper as mbh def build_dict(expr: mb.LinearExprT) -> Dict[mbh.Variable, float]: res = {} flat_expr = mbh.FlatExpression(expr) print(f"expr = {expr} flat_expr = {flat_expr}", flush=True) for var, coeff in zip(flat_expr.vars, flat_expr.coeffs): print(f"process {var} * {coeff}", flush=True) if not coeff: continue res[var] = coeff return res class ModelBuilderTest(absltest.TestCase): # Number of decimal places to use for numerical tolerance for # checking primal, dual, objective values and other values. NUM_PLACES = 5 # pylint: disable=too-many-statements def run_minimal_linear_example(self, solver_name): """Minimal Linear Example.""" model = mb.Model() model.name = "minimal_linear_example" x1 = model.new_num_var(0.0, math.inf, "x1") x2 = model.new_num_var(0.0, math.inf, "x2") x3 = model.new_num_var(0.0, math.inf, "x3") self.assertEqual(3, model.num_variables) self.assertFalse(x1.is_integral) self.assertEqual(0.0, x1.lower_bound) self.assertEqual(math.inf, x2.upper_bound) x1.lower_bound = 1.0 self.assertEqual(1.0, x1.lower_bound) model.maximize(10.0 * x1 + 6 * x2 + 4.0 * x3 - 3.5) self.assertEqual(4.0, x3.objective_coefficient) self.assertEqual(-3.5, model.objective_offset) model.objective_offset = -5.5 self.assertEqual(-5.5, model.objective_offset) c0 = model.add(x1 + x2 + x3 <= 100.0) self.assertEqual(100, c0.upper_bound) c1 = model.add(10 * x1 + 4.0 * x2 + 5.0 * x3 <= 600.0, "c1") self.assertEqual("c1", c1.name) c2 = model.add(2.0 * x1 + 2.0 * x2 + 6.0 * x3 <= 300.0) self.assertEqual(-math.inf, c2.lower_bound) solver = mb.Solver(solver_name) if not solver.solver_is_supported(): print(f"Solver {solver_name} is not supported") return self.assertTrue(pd.isna(solver.value(x1))) self.assertTrue(pd.isna(solver.value(x2))) self.assertTrue(pd.isna(solver.value(x3))) self.assertTrue(pd.isna(solver.reduced_cost(x1))) self.assertTrue(pd.isna(solver.reduced_cost(x2))) self.assertTrue(pd.isna(solver.dual_value(c0))) self.assertTrue(pd.isna(solver.dual_value(c1))) self.assertTrue(pd.isna(solver.dual_value(c2))) self.assertTrue(pd.isna(solver.activity(c0))) self.assertTrue(pd.isna(solver.activity(c1))) self.assertTrue(pd.isna(solver.activity(c2))) self.assertTrue(pd.isna(solver.objective_value)) self.assertTrue(pd.isna(solver.best_objective_bound)) self.assertEqual(mb.SolveStatus.OPTIMAL, solver.solve(model)) # The problem has an optimal solution. self.assertAlmostEqual( 733.333333 + model.objective_offset, solver.objective_value, places=self.NUM_PLACES, ) self.assertAlmostEqual( solver.value(10.0 * x1 + 6 * x2 + 4.0 * x3 - 5.5), solver.objective_value, places=self.NUM_PLACES, ) self.assertAlmostEqual(33.333333, solver.value(x1), places=self.NUM_PLACES) self.assertAlmostEqual(66.666667, solver.value(x2), places=self.NUM_PLACES) self.assertAlmostEqual(0.0, solver.value(x3), places=self.NUM_PLACES) dual_objective_value = ( solver.dual_value(c0) * c0.upper_bound + solver.dual_value(c1) * c1.upper_bound + solver.dual_value(c2) * c2.upper_bound + model.objective_offset ) self.assertAlmostEqual( solver.objective_value, dual_objective_value, places=self.NUM_PLACES ) # x1 and x2 are basic self.assertAlmostEqual(0.0, solver.reduced_cost(x1), places=self.NUM_PLACES) self.assertAlmostEqual(0.0, solver.reduced_cost(x2), places=self.NUM_PLACES) # x3 is non-basic x3_expected_reduced_cost = ( 4.0 - 1.0 * solver.dual_value(c0) - 5.0 * solver.dual_value(c1) ) self.assertAlmostEqual( x3_expected_reduced_cost, solver.reduced_cost(x3), places=self.NUM_PLACES, ) self.assertAlmostEqual(100.0, solver.activity(c0), places=self.NUM_PLACES) self.assertAlmostEqual(600.0, solver.activity(c1), places=self.NUM_PLACES) self.assertAlmostEqual(200.0, solver.activity(c2), places=self.NUM_PLACES) self.assertIn("minimal_linear_example", model.export_to_lp_string(False)) self.assertIn("minimal_linear_example", model.export_to_mps_string(False)) def test_minimal_linear_example(self): self.run_minimal_linear_example("glop") def test_import_from_mps_string(self): mps_data = """ * Generated by MPModelProtoExporter * Name : SupportedMaximizationProblem * Format : Free * Constraints : 0 * Variables : 1 * Binary : 0 * Integer : 0 * Continuous : 1 NAME SupportedMaximizationProblem OBJSENSE MAX ROWS N COST COLUMNS X_ONE COST 1 BOUNDS UP BOUND X_ONE 4 ENDATA """ model = mb.Model() self.assertTrue(model.import_from_mps_string(mps_data)) self.assertEqual(model.name, "SupportedMaximizationProblem") def test_import_from_mps_file(self): path = os.path.dirname(__file__) mps_path = f"{path}/../testdata/maximization.mps" model = mb.Model() self.assertTrue(model.import_from_mps_file(mps_path)) self.assertEqual(model.name, "SupportedMaximizationProblem") def test_import_from_lp_string(self): lp_data = """ min: x + y; bin: b1, b2, b3; 1 <= x <= 42; constraint_num1: 5 b1 + 3b2 + x <= 7; 4 y + b2 - 3 b3 <= 2; constraint_num2: -4 b1 + b2 - 3 z <= -2; """ model = mb.Model() self.assertTrue(model.import_from_lp_string(lp_data)) self.assertEqual(6, model.num_variables) self.assertEqual(3, model.num_constraints) self.assertEqual(1, model.var_from_index(0).lower_bound) self.assertEqual(42, model.var_from_index(0).upper_bound) self.assertEqual("x", model.var_from_index(0).name) def test_import_from_lp_file(self): path = os.path.dirname(__file__) lp_path = f"{path}/../testdata/small_model.lp" model = mb.Model() self.assertTrue(model.import_from_lp_file(lp_path)) self.assertEqual(6, model.num_variables) self.assertEqual(3, model.num_constraints) self.assertEqual(1, model.var_from_index(0).lower_bound) self.assertEqual(42, model.var_from_index(0).upper_bound) self.assertEqual("x", model.var_from_index(0).name) def test_class_api(self): model = mb.Model() x = model.new_int_var(0, 10, "x") y = model.new_int_var(1, 10, "y") z = model.new_int_var(2, 10, "z") t = model.new_int_var(3, 10, "t") e1 = mb.LinearExpr.sum([x, y, z]) flat_e1 = mbh.FlatExpression(e1) expected_vars = np.array([0, 1, 2], dtype=np.int32) np_testing.assert_array_equal(expected_vars, flat_e1.variable_indices()) np_testing.assert_array_equal( np.array([1, 1, 1], dtype=np.double), flat_e1.coeffs ) self.assertEqual(flat_e1.offset, 0.0) self.assertEqual(e1.__str__(), "(x + y + z)") e2 = mb.LinearExpr.sum([e1, 4.0]) flat_e2 = mbh.FlatExpression(e2) np_testing.assert_array_equal(expected_vars, flat_e2.variable_indices()) np_testing.assert_array_equal( np.array([1, 1, 1], dtype=np.double), flat_e2.coeffs ) self.assertEqual(flat_e2.offset, 4.0) self.assertEqual(e2.__str__(), "((x + y + z) + 4)") self.assertEqual(flat_e2.__str__(), "x + y + z + 4") e3 = mb.LinearExpr.term(e2, 2) flat_e3 = mbh.FlatExpression(e3) np_testing.assert_array_equal(expected_vars, flat_e3.variable_indices()) np_testing.assert_array_equal( np.array([2, 2, 2], dtype=np.double), flat_e3.coeffs ) self.assertEqual(flat_e3.offset, 8.0) self.assertEqual(e3.__str__(), "(2 * ((x + y + z) + 4))") self.assertEqual(flat_e3.__str__(), "2 * x + 2 * y + 2 * z + 8") e4 = mb.LinearExpr.weighted_sum([x, t], [-1, 1], constant=2) flat_e4 = mbh.FlatExpression(e4) np_testing.assert_array_equal( np.array([0, 3], dtype=np.int32), flat_e4.variable_indices() ) np_testing.assert_array_equal( np.array([-1, 1], dtype=np.double), flat_e4.coeffs ) self.assertEqual(flat_e4.offset, 2.0) self.assertEqual(e4.__str__(), "(-x + t + 2)") e4b = mb.LinearExpr.weighted_sum([e4 * 3], [1]) flat_e4b = mbh.FlatExpression(e4b) np_testing.assert_array_equal( np.array([0, 3], dtype=np.int32), flat_e4b.variable_indices() ) np_testing.assert_array_equal( np.array([-3, 3], dtype=np.double), flat_e4b.coeffs ) self.assertEqual(flat_e4b.offset, 6.0) self.assertEqual(e4b.__str__(), "(3 * (-x + t + 2))") e5 = mb.LinearExpr.sum([e1, -3, e4]) flat_e5 = mbh.FlatExpression(e5) np_testing.assert_array_equal( np.array([1, 2, 3], dtype=np.int32), flat_e5.variable_indices() ) np_testing.assert_array_equal( np.array([1, 1, 1], dtype=np.double), flat_e5.coeffs ) self.assertEqual(flat_e5.offset, -1.0) self.assertEqual(flat_e5.__str__(), "y + z + t - 1") e6 = mb.LinearExpr.term(x, 2.0, constant=1.0) flat_e6 = mbh.FlatExpression(e6) np_testing.assert_array_equal( np.array([0], dtype=np.int32), flat_e6.variable_indices() ) np_testing.assert_array_equal(np.array([2], dtype=np.double), flat_e6.coeffs) self.assertEqual(flat_e6.offset, 1.0) e7 = mb.LinearExpr.term(x, 1.0, constant=0.0) self.assertEqual(x, e7) e8 = mb.LinearExpr.term(2, 3, constant=4) self.assertEqual(e8, 10) e9 = mb.LinearExpr.term(x * 2 + 3, 1, constant=0) e10 = mb.LinearExpr.term(x, 2, constant=3) self.assertEqual( str(mbh.FlatExpression(e9)), str(mbh.FlatExpression(e10)), ) def test_variables(self): model = mb.Model() x = model.new_int_var(0.0, 4.0, "x") self.assertEqual(0, x.index) self.assertEqual(0.0, x.lower_bound) self.assertEqual(4.0, x.upper_bound) self.assertEqual("x", x.name) x.lower_bound = 1.0 x.upper_bound = 3.0 self.assertEqual(1.0, x.lower_bound) self.assertEqual(3.0, x.upper_bound) self.assertTrue(x.is_integral) # Tests the equality operator. y = model.new_int_var(0.0, 4.0, "y") x_copy = model.var_from_index(0) self.assertEqual(x, x) self.assertEqual(x, x_copy) self.assertNotEqual(x, y) # Tests the hash method. var_set = set() var_set.add(x) self.assertIn(x, var_set) self.assertIn(x_copy, var_set) self.assertNotIn(y, var_set) def test_duplicate_variables(self): model = mb.Model() x = model.new_int_var(0.0, 4.0, "x") y = model.new_int_var(0.0, 4.0, "y") z = model.new_int_var(0.0, 4.0, "z") model.add(x + 2 * y == x - z) model.minimize(x + y + z) solver = mb.Solver("sat") self.assertEqual(mb.SolveStatus.OPTIMAL, solver.solve(model)) def test_add_term(self): model = mb.Model() x = model.new_int_var(0.0, 4.0, "x") y = model.new_int_var(0.0, 4.0, "y") z = model.new_int_var(0.0, 4.0, "z") t = model.new_int_var(0.0, 4.0, "t") ct = model.add(x + 2 * y == 3) self.assertEqual(ct.helper.constraint_var_indices(ct.index), [0, 1]) self.assertEqual(ct.helper.constraint_coefficients(ct.index), [1, 2]) ct.add_term(x, 2) self.assertEqual(ct.helper.constraint_var_indices(ct.index), [0, 1]) self.assertEqual(ct.helper.constraint_coefficients(ct.index), [3, 2]) ct.set_coefficient(x, 5) self.assertEqual(ct.helper.constraint_var_indices(ct.index), [0, 1]) self.assertEqual(ct.helper.constraint_coefficients(ct.index), [5, 2]) ct.add_term(z, 4) self.assertEqual(ct.helper.constraint_var_indices(ct.index), [0, 1, 2]) self.assertEqual(ct.helper.constraint_coefficients(ct.index), [5, 2, 4]) ct.set_coefficient(t, -1) self.assertEqual(ct.helper.constraint_var_indices(ct.index), [0, 1, 2, 3]) self.assertEqual(ct.helper.constraint_coefficients(ct.index), [5, 2, 4, -1]) def test_issue_3614(self): total_number_of_choices = 5 + 1 total_unique_products = 3 standalone_features = list(range(5)) feature_bundle_incidence_matrix = {} for idx in range(len(standalone_features)): feature_bundle_incidence_matrix[idx, 0] = 0 feature_bundle_incidence_matrix[0, 0] = 1 feature_bundle_incidence_matrix[1, 0] = 1 bundle_start_idx = len(standalone_features) # Model model = mb.Model() y = {} v = {} for i in range(total_number_of_choices): y[i] = model.new_bool_var(f"y_{i}") for j in range(total_unique_products): for i in range(len(standalone_features)): v[i, j] = model.new_bool_var(f"v_{(i,j)}") model.add( v[i, j] == ( y[i] + ( feature_bundle_incidence_matrix[(i, 0)] * y[bundle_start_idx] ) ) ) solver = mb.Solver("sat") status = solver.solve(model) self.assertEqual(mb.SolveStatus.OPTIMAL, status) def test_create_false_ct(self): # Create the model. model = mb.Model() x = model.new_num_var(0.0, math.inf, "x") ct = model.add(False) self.assertTrue(ct.is_under_specified) self.assertRaises(ValueError, ct.add_term, x, 1) model.maximize(x) solver = mb.Solver("glop") status = solver.solve(model) self.assertEqual(status, mb.SolveStatus.INFEASIBLE) def test_create_true_ct(self): # Create the model. model = mb.Model() x = model.new_num_var(0.0, 5.0, "x") ct = model.add(True) self.assertEqual(ct.lower_bound, 0.0) self.assertEqual(ct.upper_bound, 0.0) self.assertTrue(ct.is_under_specified) self.assertRaises(ValueError, ct.add_term, x, 1) model.maximize(x) solver = mb.Solver("glop") status = solver.solve(model) self.assertEqual(status, mb.SolveStatus.OPTIMAL) class InternalHelperTest(absltest.TestCase): def test_anonymous_variables(self): helper = mb.Model().helper index = helper.add_var() variable = mb.Variable(helper, index) self.assertEqual(variable.name, f"variable#{index}") def test_anonymous_constraints(self): helper = mb.Model().helper index = helper.add_linear_constraint() constraint = mb.LinearConstraint(helper, index=index) self.assertEqual(constraint.name, f"linear_constraint#{index}") class LinearBaseTest(parameterized.TestCase): def setUp(self): super().setUp() simple_model = mb.Model() self.x = simple_model.new_var_series( name="x", index=pd.Index(range(3), name="i") ) self.y = simple_model.new_var_series( name="y", index=pd.Index(range(5), name="i") ) self.simple_model = simple_model @parameterized.named_parameters( # Variable / Indexing dict( testcase_name="x[0]", expr=lambda x, y: x[0], expected_str="x[0]", ), dict( testcase_name="x[1]", expr=lambda x, y: x[1], expected_str="x[1]", ), dict( testcase_name="x[2]", expr=lambda x, y: x[2], expected_str="x[2]", ), dict( testcase_name="y[0]", expr=lambda x, y: y[0], expected_str="y[0]", ), dict( testcase_name="y[4]", expr=lambda x, y: y[4], expected_str="y[4]", ), # Sum dict( testcase_name="x[0] + 5", expr=lambda x, y: x[0] + 5, expected_str="x[0] + 5", ), dict( testcase_name="x[0] - 5", expr=lambda x, y: x[0] - 5, expected_str="x[0] - 5", ), dict( testcase_name="5 - x[0]", expr=lambda x, y: 5 - x[0], expected_str="-x[0] + 5", ), dict( testcase_name="5 + x[0]", expr=lambda x, y: 5 + x[0], expected_str="x[0] + 5", ), dict( testcase_name="x[0] + y[0]", expr=lambda x, y: x[0] + y[0], expected_str="x[0] + y[0]", ), dict( testcase_name="x[0] + y[0] + 5", expr=lambda x, y: x[0] + y[0] + 5, expected_str="x[0] + y[0] + 5", ), dict( testcase_name="5 + x[0] + y[0]", expr=lambda x, y: 5 + x[0] + y[0], expected_str="x[0] + y[0] + 5", ), dict( testcase_name="5 + x[0] - x[0]", expr=lambda x, y: 5 + x[0] - x[0], expected_str="5", ), dict( testcase_name="5 + x[0] - y[0]", expr=lambda x, y: 5 + x[0] - y[0], expected_str="x[0] - y[0] + 5", ), dict( testcase_name="x.sum()", expr=lambda x, y: x.sum(), expected_str="x[0] + x[1] + x[2]", ), dict( testcase_name="x.add(y, fill_value=0).sum() + 5", expr=lambda x, y: x.add(y, fill_value=0).sum() + 5, expected_str="x[0] + x[1] + x[2] + y[0] + y[1] + ... + 5", ), dict( testcase_name="sum(x, y + 5)", expr=lambda x, y: mb.LinearExpr.sum([x.sum(), y.sum() + 5]), expected_str="x[0] + x[1] + x[2] + y[0] + y[1] + ... + 5", ), # Product dict( testcase_name="- x.sum()", expr=lambda x, y: -x.sum(), expected_str="-x[0] - x[1] - x[2]", ), dict( testcase_name="5 - x.sum()", expr=lambda x, y: 5 - x.sum(), expected_str="-x[0] - x[1] - x[2] + 5", ), dict( testcase_name="x.sum() / 2", expr=lambda x, y: x.sum() / 2, expected_str="0.5 * x[0] + 0.5 * x[1] + 0.5 * x[2]", ), dict( testcase_name="(3 * x).sum()", expr=lambda x, y: (3 * x).sum(), expected_str="3 * x[0] + 3 * x[1] + 3 * x[2]", ), dict( testcase_name="(x * 3).sum()", expr=lambda x, y: (x * 3).sum(), expected_str="3 * x[0] + 3 * x[1] + 3 * x[2]", ), dict( testcase_name="x.sum() * 3", expr=lambda x, y: x.sum() * 3, expected_str="3 * x[0] + 3 * x[1] + 3 * x[2]", ), dict( testcase_name="3 * x.sum()", expr=lambda x, y: 3 * x.sum(), expected_str="3 * x[0] + 3 * x[1] + 3 * x[2]", ), dict( testcase_name="0 * x.sum() + y.sum()", expr=lambda x, y: 0 * x.sum() + y.sum(), expected_str="y[0] + y[1] + y[2] + y[3] + y[4]", ), # LinearExpression dict( testcase_name="FlatExpression(x.sum())", expr=lambda x, y: mbh.FlatExpression(x.sum()), expected_str="x[0] + x[1] + x[2]", ), dict( testcase_name="FlatExpression(FlatExpression(x.sum()))", # pylint: disable=g-long-lambda expr=lambda x, y: mbh.FlatExpression(mbh.FlatExpression(x.sum())), expected_str="x[0] + x[1] + x[2]", ), dict( testcase_name="""FlatExpression(sum([ FlatExpression(x.sum()), FlatExpression(x.sum()), ]))""", # pylint: disable=g-long-lambda expr=lambda x, y: mbh.FlatExpression( sum( [ mbh.FlatExpression(x.sum()), mbh.FlatExpression(x.sum()), ] ) ), expected_str="2 * x[0] + 2 * x[1] + 2 * x[2]", ), ) def test_str(self, expr, expected_str): x = self.x y = self.y self.assertEqual(str(mbh.FlatExpression(expr(x, y))), expected_str) class LinearBaseErrorsTest(absltest.TestCase): def test_unknown_linear_type(self): with self.assertRaises(TypeError): class UnknownLinearType(mb.LinearExpr): def __init__(self): mb.LinearExpr.__init__(self) mbh.FlatExpression(UnknownLinearType()) def test_division_by_zero(self): with self.assertRaises(ZeroDivisionError): model = mb.Model() x = model.new_var_series(name="x", index=pd.Index(range(1))) print(x / 0) def test_boolean_expression(self): with self.assertRaisesRegex(NotImplementedError, r"instance as a Boolean"): model = mb.Model() x = model.new_var_series(name="x", index=pd.Index(range(1))) bool(x.sum()) class BoundedLinearBaseTest(parameterized.TestCase): def setUp(self): super().setUp() simple_model = mb.Model() self.x = simple_model.new_var_series( name="x", index=pd.Index(range(3), name="i") ) self.y = simple_model.new_var_series( name="y", index=pd.Index(range(5), name="i") ) self.simple_model = simple_model @parameterized.product( lhs=( lambda x, y: x.sum(), lambda x, y: -x.sum(), lambda x, y: x.sum() * 0, lambda x, y: x.sum() * 3, lambda x, y: x[0], lambda x, y: x[1], lambda x, y: x[2], lambda x, y: -math.inf, lambda x, y: -1, lambda x, y: 0, lambda x, y: 1, lambda x, y: 1.1, lambda x, y: math.inf, ), rhs=( lambda x, y: y.sum(), lambda x, y: -y.sum(), lambda x, y: y.sum() * 0, lambda x, y: y.sum() * 3, lambda x, y: y[0], lambda x, y: y[1], lambda x, y: y[2], lambda x, y: -math.inf, lambda x, y: -1, lambda x, y: 0, lambda x, y: 1, lambda x, y: 1.1, lambda x, y: math.inf, ), op=( lambda lhs, rhs: lhs == rhs, lambda lhs, rhs: lhs <= rhs, lambda lhs, rhs: lhs >= rhs, ), ) def test_str(self, lhs, rhs, op): x = self.x y = self.y l: mb.LinearExprT = lhs(x, y) r: mb.LinearExprT = rhs(x, y) result = op(l, r) if isinstance(l, mb.LinearExpr) or isinstance(r, mb.LinearExpr): self.assertIsInstance(result, mbh.BoundedLinearExpression) self.assertIn("=", str(result), msg="is one of ==, <=, or >=") else: self.assertIsInstance(result, bool) def test_doublesided_bounded_expressions(self): x = self.x self.assertEqual("0 <= x[0] <= 1", str(mb.BoundedLinearExpression(x[0], 0, 1))) def test_free_bounded_expressions(self): self.assertEqual( "-inf <= x[0] <= inf", str(mb.BoundedLinearExpression(self.x[0], -math.inf, math.inf)), ) def test_var_eq_var_as_bool(self): x = self.x y = self.y self.assertEqual(x[0], x[0]) self.assertNotEqual(x[0], x[1]) self.assertNotEqual(x[0], y[0]) self.assertEqual(x[1], x[1]) self.assertNotEqual(x[1], x[0]) self.assertNotEqual(x[1], y[1]) self.assertEqual(y[0], y[0]) self.assertNotEqual(y[0], y[1]) self.assertNotEqual(y[0], x[0]) self.assertEqual(y[1], y[1]) self.assertNotEqual(y[1], y[0]) self.assertNotEqual(y[1], x[1]) class BoundedLinearBaseErrorsTest(absltest.TestCase): def test_single_var_bounded_linear_expression_as_bool(self): with self.assertRaisesRegex( NotImplementedError, "Evaluating a BoundedLinearExpression" ): model = mb.Model() x = model.new_bool_var(name="x") bool(mb.BoundedLinearExpression(x, 0, 1)) def test_bounded_linear_expression_as_bool(self): with self.assertRaisesRegex(TypeError, "incompatible constructor arguments"): model = mb.Model() x = model.new_var_series(name="x", index=pd.Index(range(1))) bool(mb.BoundedLinearExpression(x, 0, 1)) class ModelBuilderErrorsTest(absltest.TestCase): def test_new_var_series_errors(self): with self.assertRaisesRegex(TypeError, r"Non-index object"): model = mb.Model() model.new_var_series(name="", index=pd.DataFrame()) with self.assertRaisesRegex(TypeError, r"invalid type"): model = mb.Model() model.new_var_series(name="x", index=pd.Index([0]), lower_bounds="0") with self.assertRaisesRegex(TypeError, r"invalid type"): model = mb.Model() model.new_var_series(name="x", index=pd.Index([0]), upper_bounds="0") with self.assertRaisesRegex(TypeError, r"invalid type"): model = mb.Model() model.new_var_series(name="x", index=pd.Index([0]), is_integral="True") with self.assertRaisesRegex(ValueError, r"not a valid identifier"): model = mb.Model() model.new_var_series(name="", index=pd.Index([0])) with self.assertRaisesRegex(ValueError, r"is greater than"): model = mb.Model() model.new_var_series( name="x", index=pd.Index([0]), lower_bounds=0.2, upper_bounds=0.1, ) with self.assertRaisesRegex(ValueError, r"is greater than"): model = mb.Model() model.new_var_series( name="x", index=pd.Index([0]), lower_bounds=0.1, upper_bounds=0.2, is_integral=True, ) with self.assertRaisesRegex(ValueError, r"index does not match"): model = mb.Model() model.new_var_series( name="x", index=pd.Index([0]), lower_bounds=pd.Series([1, 2]) ) with self.assertRaisesRegex(ValueError, r"index does not match"): model = mb.Model() model.new_var_series( name="x", index=pd.Index([0]), upper_bounds=pd.Series([1, 2]) ) with self.assertRaisesRegex(ValueError, r"index does not match"): model = mb.Model() model.new_var_series( name="x", index=pd.Index([0]), is_integral=pd.Series([False, True]) ) def test_add_linear_constraints_errors(self): with self.assertRaisesRegex(TypeError, r"Not supported"): model = mb.Model() model.add("True", name="c") with self.assertRaisesRegex(TypeError, r"invalid type="): model = mb.Model() model.add(pd.Series(["T"]), name="c") class ModelBuilderVariablesTest(parameterized.TestCase): _variable_indices = ( pd.Index(range(3)), pd.Index(range(5), name="i"), pd.MultiIndex.from_product(((1, 2), ("a", "b", "c")), names=["i", "j"]), pd.MultiIndex.from_product((("a", "b"), (1, 2, 3))), ) _bounds = ( lambda index: (-math.inf, -10.5), lambda index: (-math.inf, -1), lambda index: (-math.inf, 0), lambda index: (-math.inf, 10), lambda index: (-math.inf, math.inf), lambda index: (-10, -1.1), lambda index: (-10, 0), lambda index: (-10, -10), lambda index: (-10, 3), lambda index: (-9, math.inf), lambda index: (-1, 1), lambda index: (0, 0), lambda index: (0, 1), lambda index: (0, math.inf), lambda index: (1, 1), lambda index: (1, 10.1), lambda index: (1, math.inf), lambda index: (100.1, math.inf), # pylint: disable=g-long-lambda lambda index: ( pd.Series(-math.inf, index=index), pd.Series(-10.5, index=index), ), lambda index: ( pd.Series(-math.inf, index=index), pd.Series(-1, index=index), ), lambda index: ( pd.Series(-math.inf, index=index), pd.Series(0, index=index), ), lambda index: ( pd.Series(-math.inf, index=index), pd.Series(10, index=index), ), lambda index: ( pd.Series(-math.inf, index=index), pd.Series(math.inf, index=index), ), lambda index: (pd.Series(-10, index=index), pd.Series(-1.1, index=index)), lambda index: (pd.Series(-10, index=index), pd.Series(0, index=index)), lambda index: (pd.Series(-10, index=index), pd.Series(-10, index=index)), lambda index: (pd.Series(-10, index=index), pd.Series(3, index=index)), lambda index: ( pd.Series(-9, index=index), pd.Series(math.inf, index=index), ), lambda index: (pd.Series(-1, index=index), pd.Series(1, index=index)), lambda index: (pd.Series(0, index=index), pd.Series(0, index=index)), lambda index: (pd.Series(0, index=index), pd.Series(1, index=index)), lambda index: ( pd.Series(0, index=index), pd.Series(math.inf, index=index), ), lambda index: (pd.Series(1, index=index), pd.Series(1, index=index)), lambda index: (pd.Series(1, index=index), pd.Series(10.1, index=index)), lambda index: ( pd.Series(1, index=index), pd.Series(math.inf, index=index), ), lambda index: ( pd.Series(100.1, index=index), pd.Series(math.inf, index=index), ), ) _is_integer = ( lambda index: False, lambda index: True, lambda index: pd.Series(False, index=index), lambda index: pd.Series(True, index=index), ) @parameterized.product( index=_variable_indices, bounds=_bounds, is_integer=_is_integer ) def test_new_var_series(self, index, bounds, is_integer): model = mb.Model() variables = model.new_var_series( name="test_variable", index=index, lower_bounds=bounds(index)[0], upper_bounds=bounds(index)[1], is_integral=is_integer(index), ) self.assertLen(variables, len(index)) self.assertLen(set(variables), len(index)) for i in index: self.assertEqual(variables[i].name, f"test_variable[{i}]") @parameterized.product( index=_variable_indices, bounds=_bounds, is_integer=_is_integer ) def test_get_variable_lower_bounds(self, index, bounds, is_integer): lower_bound, upper_bound = bounds(index) model = mb.Model() x = model.new_var_series( name="x", index=index, lower_bounds=lower_bound, upper_bounds=upper_bound, is_integral=is_integer(index), ) y = model.new_var_series( name="y", index=index, lower_bounds=lower_bound, upper_bounds=upper_bound, is_integral=is_integer(index), ) for lower_bounds in ( model.get_variable_lower_bounds(x), model.get_variable_lower_bounds(y), ): self.assertSequenceAlmostEqual( lower_bounds, mb._convert_to_series_and_validate_index(lower_bound, index), ) self.assertSequenceAlmostEqual( model.get_variable_lower_bounds(), pd.concat( [ model.get_variable_lower_bounds(x), model.get_variable_lower_bounds(y), ] ), ) variables = model.get_variables() lower_bounds = model.get_variable_lower_bounds(variables) self.assertSequenceAlmostEqual(lower_bounds.index, variables) @parameterized.product( index=_variable_indices, bounds=_bounds, is_integer=_is_integer ) def test_get_variable_upper_bounds(self, index, bounds, is_integer): lower_bound, upper_bound = bounds(index) model = mb.Model() x = model.new_var_series( name="x", index=index, lower_bounds=lower_bound, upper_bounds=upper_bound, is_integral=is_integer(index), ) y = model.new_var_series( name="y", index=index, lower_bounds=lower_bound, upper_bounds=upper_bound, is_integral=is_integer(index), ) for upper_bounds in ( model.get_variable_upper_bounds(x), model.get_variable_upper_bounds(y), ): self.assertSequenceAlmostEqual( upper_bounds, mb._convert_to_series_and_validate_index(upper_bound, index), ) self.assertSequenceAlmostEqual( model.get_variable_upper_bounds(), pd.concat( [ model.get_variable_upper_bounds(x), model.get_variable_upper_bounds(y), ] ), ) variables = model.get_variables() upper_bounds = model.get_variable_upper_bounds(variables) self.assertSequenceAlmostEqual(upper_bounds.index, variables) class ModelBuilderLinearConstraintsTest(parameterized.TestCase): constraint_test_cases = [ # pylint: disable=g-long-lambda dict( testcase_name="True", name="true", bounded_exprs=lambda x, y: True, constraint_count=1, lower_bounds=[0.0], upper_bounds=[0.0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="pd.Series(True)", name="true", bounded_exprs=lambda x, y: pd.Series(True), constraint_count=1, lower_bounds=[0.0], upper_bounds=[0.0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="False", name="false", bounded_exprs=lambda x, y: False, constraint_count=1, lower_bounds=[1.0], upper_bounds=[-1.0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="pd.Series(False)", name="false", bounded_exprs=lambda x, y: pd.Series(False), constraint_count=1, lower_bounds=[1.0], upper_bounds=[-1.0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="x[0] <= 1.5", name="x0_le_c", bounded_exprs=lambda x, y: x[0] <= 1.5, constraint_count=1, lower_bounds=[-math.inf], upper_bounds=[1.5], expression_terms=lambda x, y: [{x[0]: 1}], expression_offsets=[0], ), dict( testcase_name="x[0] == 1", name="x0_eq_c", bounded_exprs=lambda x, y: x[0] == 1, constraint_count=1, lower_bounds=[1], upper_bounds=[1], expression_terms=lambda x, y: [{x[0]: 1}], expression_offsets=[0], ), dict( testcase_name="x[0] >= -1", name="x0_ge_c", bounded_exprs=lambda x, y: x[0] >= -1, constraint_count=1, lower_bounds=[-1], upper_bounds=[math.inf], expression_terms=lambda x, y: [{x[0]: 1}], expression_offsets=[0], ), dict( testcase_name="-1.5 <= x[0]", name="c_le_x0", bounded_exprs=lambda x, y: -1.5 <= x[0], constraint_count=1, lower_bounds=[-1.5], upper_bounds=[math.inf], expression_terms=lambda x, y: [{x[0]: 1}], expression_offsets=[0], ), dict( testcase_name="0 == x[0]", name="c_eq_x0", bounded_exprs=lambda x, y: 0 == x[0], constraint_count=1, lower_bounds=[0], upper_bounds=[0], expression_terms=lambda x, y: [{x[0]: 1}], expression_offsets=[0], ), dict( testcase_name="10 >= x[0]", name="c_ge_x0", bounded_exprs=lambda x, y: 10 >= x[0], constraint_count=1, lower_bounds=[-math.inf], upper_bounds=[10], expression_terms=lambda x, y: [{x[0]: 1}], expression_offsets=[0], ), dict( testcase_name="x[0] <= x[0]", name="x0_le_x0", bounded_exprs=lambda x, y: x[0] <= x[0], constraint_count=1, lower_bounds=[-math.inf], upper_bounds=[0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="x[0] == x[0]", name="x0_eq_x0", bounded_exprs=lambda x, y: x[0] == x[0], constraint_count=1, lower_bounds=[0], upper_bounds=[0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="pd.Series(x[0] == x[0])", name="x0_eq_x0_series", bounded_exprs=lambda x, y: pd.Series(x[0] == x[0]), constraint_count=1, lower_bounds=[0], upper_bounds=[0], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="x[0] >= x[0]", name="x0_ge_x0", bounded_exprs=lambda x, y: x[0] >= x[0], constraint_count=1, lower_bounds=[0], upper_bounds=[math.inf], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( # x[0] - x[0] <= 3 testcase_name="x[0] - 1 <= x[0] + 2", name="x0c_le_x0c", bounded_exprs=lambda x, y: pd.Series(x[0] - 1 <= x[0] + 2), constraint_count=1, lower_bounds=[-math.inf], upper_bounds=[3], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( # x[0] - x[0] == 3 testcase_name="x[0] - 1 == x[0] + 2", name="x0c_eq_x0c", bounded_exprs=lambda x, y: pd.Series(x[0] - 1 == x[0] + 2), constraint_count=1, lower_bounds=[3], upper_bounds=[3], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( # x[0] - x[0] >= 3 testcase_name="x[0] - 1 >= x[0] + 2", name="x0c_ge_x0c", bounded_exprs=lambda x, y: pd.Series(x[0] - 1 >= x[0] + 2), constraint_count=1, lower_bounds=[3], upper_bounds=[math.inf], expression_terms=lambda x, y: [{}], expression_offsets=[0], ), dict( testcase_name="x[0] <= x[1]", name="x0_le_x1", bounded_exprs=lambda x, y: x[0] <= x[1], constraint_count=1, lower_bounds=[-math.inf], upper_bounds=[0], expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], expression_offsets=[0], ), dict( testcase_name="x[0] == x[1]", name="x0_eq_x1", bounded_exprs=lambda x, y: x[0] == x[1], constraint_count=1, lower_bounds=[0], upper_bounds=[0], expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], expression_offsets=[0], ), dict( testcase_name="x[0] >= x[1]", name="x0_ge_x1", bounded_exprs=lambda x, y: x[0] >= x[1], constraint_count=1, lower_bounds=[0], upper_bounds=[math.inf], expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], expression_offsets=[0], ), dict( # x[0] - x[1] <= -3 testcase_name="x[0] + 1 <= x[1] - 2", name="x0c_le_x1c", bounded_exprs=lambda x, y: x[0] + 1 <= x[1] - 2, constraint_count=1, lower_bounds=[-math.inf], upper_bounds=[-3], expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], expression_offsets=[0], ), dict( # x[0] - x[1] == -3 testcase_name="x[0] + 1 == x[1] - 2", name="x0c_eq_x1c", bounded_exprs=lambda x, y: x[0] + 1 == x[1] - 2, constraint_count=1, lower_bounds=[-3], upper_bounds=[-3], expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], expression_offsets=[0], ), dict( # x[0] - x[1] >= -3 testcase_name="x[0] + 1 >= x[1] - 2", name="x0c_ge_x1c", bounded_exprs=lambda x, y: pd.Series(x[0] + 1 >= x[1] - 2), constraint_count=1, lower_bounds=[-3], upper_bounds=[math.inf], expression_terms=lambda x, y: [{x[0]: 1, x[1]: -1}], expression_offsets=[0], ), dict( testcase_name="x <= 0", name="x_le_c", bounded_exprs=lambda x, y: x.apply(lambda expr: expr <= 0), constraint_count=3, lower_bounds=[-math.inf] * 3, upper_bounds=[0] * 3, expression_terms=lambda x, y: [{xi: 1} for xi in x], expression_offsets=[0] * 3, ), dict( testcase_name="x >= 0", name="x_ge_c", bounded_exprs=lambda x, y: x.apply(lambda expr: expr >= 0), constraint_count=3, lower_bounds=[0] * 3, upper_bounds=[math.inf] * 3, expression_terms=lambda x, y: [{xi: 1} for xi in x], expression_offsets=[0] * 3, ), dict( testcase_name="x == 0", name="x_eq_c", bounded_exprs=lambda x, y: x.apply(lambda expr: expr == 0), constraint_count=3, lower_bounds=[0] * 3, upper_bounds=[0] * 3, expression_terms=lambda x, y: [{xi: 1} for xi in x], expression_offsets=[0] * 3, ), dict( testcase_name="y == 0", name="y_eq_c", bounded_exprs=(lambda x, y: y.apply(lambda expr: expr == 0)), constraint_count=2 * 3, lower_bounds=[0] * 2 * 3, upper_bounds=[0] * 2 * 3, expression_terms=lambda x, y: [{yi: 1} for yi in y], expression_offsets=[0] * 3 * 2, ), dict( testcase_name='y.groupby("i").sum() == 0', name="ygroupbyi_eq_c", bounded_exprs=( lambda x, y: y.groupby("i").sum().apply(lambda expr: expr == 0) ), constraint_count=2, lower_bounds=[0] * 2, upper_bounds=[0] * 2, expression_terms=lambda x, y: [ {y[1, "a"]: 1, y[1, "b"]: 1, y[1, "c"]: 1}, {y[2, "a"]: 1, y[2, "b"]: 1, y[2, "c"]: 1}, ], expression_offsets=[0] * 2, ), dict( testcase_name='y.groupby("j").sum() == 0', name="ygroupbyj_eq_c", bounded_exprs=( lambda x, y: y.groupby("j").sum().apply(lambda expr: expr == 0) ), constraint_count=3, lower_bounds=[0] * 3, upper_bounds=[0] * 3, expression_terms=lambda x, y: [ {y[1, "a"]: 1, y[2, "a"]: 1}, {y[1, "b"]: 1, y[2, "b"]: 1}, {y[1, "c"]: 1, y[2, "c"]: 1}, ], expression_offsets=[0] * 3, ), dict( testcase_name='3 * x + y.groupby("i").sum() <= 0', name="broadcast_align_fill", bounded_exprs=( lambda x, y: (3 * x) .add(y.groupby("i").sum(), fill_value=0) .apply(lambda expr: expr <= 0) ), constraint_count=3, lower_bounds=[-math.inf] * 3, upper_bounds=[0] * 3, expression_terms=lambda x, y: [ {x[0]: 3}, {x[1]: 3, y[1, "a"]: 1, y[1, "b"]: 1, y[1, "c"]: 1}, {x[2]: 3, y[2, "a"]: 1, y[2, "b"]: 1, y[2, "c"]: 1}, ], expression_offsets=[0] * 3, ), ] def create_test_model(self, name, bounded_exprs): model = mb.Model() x = model.new_var_series( name="x", index=pd.Index(range(3), name="i"), ) y = model.new_var_series( name="y", index=pd.MultiIndex.from_product( ((1, 2), ("a", "b", "c")), names=["i", "j"] ), ) model.add(name=name, ct=bounded_exprs(x, y)) return model, {"x": x, "y": y} @parameterized.named_parameters( # pylint: disable=g-complex-comprehension { f: tc[f] for f in [ "testcase_name", "name", "bounded_exprs", "constraint_count", ] } for tc in constraint_test_cases ) def test_get_linear_constraints( self, name, bounded_exprs, constraint_count, ): model, _ = self.create_test_model(name, bounded_exprs) linear_constraints = model.get_linear_constraints() self.assertIsInstance(linear_constraints, pd.Index) self.assertLen(linear_constraints, constraint_count) def test_get_linear_constraints_empty(self): linear_constraints = mb.Model().get_linear_constraints() self.assertIsInstance(linear_constraints, pd.Index) self.assertEmpty(linear_constraints) @parameterized.named_parameters( # pylint: disable=g-complex-comprehension { f: tc[f] for f in [ "testcase_name", "name", "bounded_exprs", "lower_bounds", ] } for tc in constraint_test_cases ) def test_get_linear_constraint_lower_bounds( self, name, bounded_exprs, lower_bounds, ): model, _ = self.create_test_model(name, bounded_exprs) for linear_constraint_lower_bounds in ( model.get_linear_constraint_lower_bounds(), model.get_linear_constraint_lower_bounds(model.get_linear_constraints()), ): self.assertSequenceAlmostEqual(linear_constraint_lower_bounds, lower_bounds) @parameterized.named_parameters( # pylint: disable=g-complex-comprehension { f: tc[f] for f in [ "testcase_name", "name", "bounded_exprs", "upper_bounds", ] } for tc in constraint_test_cases ) def test_get_linear_constraint_upper_bounds( self, name, bounded_exprs, upper_bounds, ): model, _ = self.create_test_model(name, bounded_exprs) for linear_constraint_upper_bounds in ( model.get_linear_constraint_upper_bounds(), model.get_linear_constraint_upper_bounds(model.get_linear_constraints()), ): self.assertSequenceAlmostEqual(linear_constraint_upper_bounds, upper_bounds) @parameterized.named_parameters( # pylint: disable=g-complex-comprehension { f: tc[f] for f in [ "testcase_name", "name", "bounded_exprs", "expression_terms", "expression_offsets", ] } for tc in constraint_test_cases ) def test_get_linear_constraint_expressions( self, name, bounded_exprs, expression_terms, expression_offsets, ): model, variables = self.create_test_model(name, bounded_exprs) x = variables["x"] y = variables["y"] for linear_constraint_expressions in ( model.get_linear_constraint_expressions(), model.get_linear_constraint_expressions(model.get_linear_constraints()), ): expr_terms = expression_terms(x, y) self.assertLen(linear_constraint_expressions, len(expr_terms)) for expr, expr_term in zip(linear_constraint_expressions, expr_terms): self.assertDictEqual(build_dict(expr), expr_term) self.assertSequenceAlmostEqual( [expr.offset for expr in linear_constraint_expressions], expression_offsets, ) class ModelBuilderObjectiveTest(parameterized.TestCase): _expressions = ( lambda x, y: -3, lambda x, y: 0, lambda x, y: 10, lambda x, y: x[0], lambda x, y: x[1], lambda x, y: x[2], lambda x, y: y[0], lambda x, y: y[1], lambda x, y: x[0] + 5, lambda x, y: -3 + y[1], lambda x, y: 3 * x[0], lambda x, y: x[0] * 3 * 5 - 3, lambda x, y: x.sum(), lambda x, y: 101 + 2 * 3 * x.sum(), lambda x, y: x.sum() * 2, lambda x, y: sum(y), lambda x, y: x.sum() + 2 * y.sum() + 3, ) _variable_indices = ( pd.Index(range(3)), pd.Index(range(3), name="i"), pd.Index(range(10), name="i"), ) def assertLinearExpressionAlmostEqual( self, expr1: mbh.LinearExpr, expr2: mbh.LinearExpr, ) -> None: """Test that the two linear expressions are almost equal.""" flat_expr1 = mbh.FlatExpression(expr1) flat_expr2 = mbh.FlatExpression(expr2) self.assertEqual(len(flat_expr1.vars), len(flat_expr2.vars)) if len(flat_expr1.vars) > 0: # pylint: disable=g-explicit-length-test self.assertSequenceEqual(flat_expr1.vars, flat_expr2.vars) self.assertSequenceAlmostEqual( flat_expr1.coeffs, flat_expr2.coeffs, places=5 ) else: self.assertEmpty(flat_expr1.coeffs) self.assertEmpty(flat_expr2.coeffs) self.assertAlmostEqual(flat_expr1.offset, flat_expr2.offset) @parameterized.product( expression=_expressions, variable_indices=_variable_indices, is_maximize=(True, False), ) def test_set_objective( self, expression: Callable[[pd.Series, pd.Series], mb.LinearExprT], variable_indices: pd.Index, is_maximize: bool, ): model = mb.Model() x = model.new_var_series(name="x", index=variable_indices) y = model.new_var_series(name="y", index=variable_indices) objective_expression = expression(x, y) print(f"objective_expression: {objective_expression}") if is_maximize: model.maximize(objective_expression) else: model.minimize(objective_expression) got_objective_expression = model.objective_expression() self.assertLinearExpressionAlmostEqual( got_objective_expression, objective_expression ) def test_set_new_objective(self): model = mb.Model() x = model.new_var_series(name="x", index=pd.Index(range(3))) old_objective_expression = 1 new_objective_expression = x.sum() - 2.3 # Set and check for old objective. model.maximize(old_objective_expression) flat_got_objective_expression = mbh.FlatExpression(model.objective_expression()) self.assertEmpty(flat_got_objective_expression.vars) self.assertEmpty(flat_got_objective_expression.coeffs) self.assertAlmostEqual(flat_got_objective_expression.offset, 1) # Set to a new objective and check that it is different. model.minimize(new_objective_expression) got_objective_expression = model.objective_expression() self.assertLinearExpressionAlmostEqual( got_objective_expression, new_objective_expression ) @parameterized.product( expression=_expressions, variable_indices=_variable_indices, ) def test_minimize( self, expression: Callable[[pd.Series, pd.Series], mb.LinearExprT], variable_indices: pd.Index, ): model = mb.Model() x = model.new_var_series(name="x", index=variable_indices) y = model.new_var_series(name="y", index=variable_indices) objective_expression = mbh.FlatExpression(expression(x, y)) model.minimize(objective_expression) got_objective_expression = model.objective_expression() self.assertLinearExpressionAlmostEqual( got_objective_expression, objective_expression ) @parameterized.product( expression=_expressions, variable_indices=_variable_indices, ) def test_maximize( self, expression: Callable[[pd.Series, pd.Series], float], variable_indices: pd.Index, ): model = mb.Model() x = model.new_var_series(name="x", index=variable_indices) y = model.new_var_series(name="y", index=variable_indices) objective_expression = mbh.FlatExpression(expression(x, y)) model.maximize(objective_expression) got_objective_expression = model.objective_expression() self.assertLinearExpressionAlmostEqual( got_objective_expression, objective_expression ) class ModelBuilderProtoTest(absltest.TestCase): def test_export_to_proto(self): expected = linear_solver_pb2.MPModelProto() text_format.Parse( """ name: "test_name" maximize: true objective_offset: 0 variable { lower_bound: 0 upper_bound: 1000 objective_coefficient: 1 is_integer: false name: "x[0]" } variable { lower_bound: 0 upper_bound: 1000 objective_coefficient: 1 is_integer: false name: "x[1]" } constraint { var_index: 0 coefficient: 1 lower_bound: -inf upper_bound: 10 name: "Ct[0]" } constraint { var_index: 1 coefficient: 1 lower_bound: -inf upper_bound: 10 name: "Ct[1]" } """, expected, ) model = mb.Model() model.name = "test_name" x = model.new_var_series("x", pd.Index(range(2)), 0, 1000) model.add(ct=x.apply(lambda expr: expr <= 10), name="Ct") model.maximize(x.sum()) self.assertEqual(str(expected), str(model.export_to_proto())) class SolverTest(parameterized.TestCase): _solvers = ( { "name": "sat", "is_integer": True, # CP-SAT supports only pure integer variables. }, { "name": "glop", "solver_specific_parameters": "use_preprocessing: False", "is_integer": False, # GLOP does not properly support integers. }, { "name": "scip", "is_integer": False, }, { "name": "scip", "is_integer": True, }, { "name": "highs_lp", "is_integer": False, }, { "name": "highs", "is_integer": True, }, ) _variable_indices = ( pd.Index(range(0)), # No variables. pd.Index(range(1)), # Single variable. pd.Index(range(3)), # Multiple variables. ) _variable_bounds = (-1, 0, 10.1) _solve_statuses = ( mb.SolveStatus.OPTIMAL, mb.SolveStatus.INFEASIBLE, mb.SolveStatus.UNBOUNDED, ) _set_objectives = (True, False) _objective_senses = (True, False) _objective_expressions = ( sum, lambda x: sum(x) + 5.2, lambda x: -10.1, lambda x: 0, ) def _create_model( self, variable_indices: pd.Index = pd.Index(range(3)), variable_bound: float = 0, is_integer: bool = False, solve_status: mb.SolveStatus = mb.SolveStatus.OPTIMAL, set_objective: bool = True, is_maximize: bool = True, objective_expression: Callable[[pd.Series], float] = lambda x: x.sum(), ) -> mb.ModelBuilder: """Constructs an optimization problem. It has the following formulation: ``` maximize / minimize objective_expression(x) satisfying constraints (if solve_status != UNBOUNDED and objective_sense == MAXIMIZE) x[variable_indices] <= variable_bound (if solve_status != UNBOUNDED and objective_sense == MINIMIZE) x[variable_indices] >= variable_bound x[variable_indices] is_integer False (if solve_status == INFEASIBLE) ``` Args: variable_indices (pd.Index): The indices of the variable(s). variable_bound (float): The upper- or lower-bound(s) of the variable(s). is_integer (bool): Whether the variables should be integer. solve_status (mb.SolveStatus): The solve status to target. set_objective (bool): Whether to set the objective of the model. is_maximize (bool): Whether to maximize the objective of the model. objective_expression (Callable[[pd.Series], float]): The expression to maximize or minimize if set_objective=True. Returns: mb.ModelBuilder: The resulting problem. """ model = mb.Model() # Variable(s) x = model.new_var_series( name="x", index=pd.Index(variable_indices), is_integral=is_integer, ) # Constraint(s) if solve_status == mb.SolveStatus.INFEASIBLE: # Force infeasibility here to test that we get pd.NA later. model.add(False, name="bool") elif solve_status != mb.SolveStatus.UNBOUNDED: if is_maximize: model.add(x.apply(lambda xi: xi <= variable_bound), "upper_bound") else: model.add(x.apply(lambda xi: xi >= variable_bound), "lower_bound") # Objective if set_objective: if is_maximize: model.maximize(objective_expression(x)) else: model.minimize(objective_expression(x)) return model @parameterized.product( solver=_solvers, variable_indices=_variable_indices, variable_bound=_variable_bounds, solve_status=_solve_statuses, set_objective=_set_objectives, is_maximize=_objective_senses, objective_expression=_objective_expressions, ) def test_solve_status( self, solver: Dict[str, Union[str, Mapping[str, Any], bool]], variable_indices: pd.Index, variable_bound: float, solve_status: mb.SolveStatus, set_objective: bool, is_maximize: bool, objective_expression: Callable[[pd.Series], float], ): model = self._create_model( variable_indices=variable_indices, variable_bound=variable_bound, is_integer=solver["is_integer"], solve_status=solve_status, set_objective=set_objective, is_maximize=is_maximize, objective_expression=objective_expression, ) model_solver = mb.Solver(solver["name"]) if not model_solver.solver_is_supported(): print(f'Solver {solver["name"]} is not supported') return if solver.get("solver_specific_parameters"): model_solver.set_solver_specific_parameters( solver.get("solver_specific_parameters") ) got_solve_status = model_solver.solve(model) # pylint: disable=g-explicit-length-test # (we disable explicit-length-test here because `variable_indices: pd.Index` # evaluates to an ambiguous boolean value.) if len(variable_indices) > 0: # Test cases with >=1 variable. self.assertNotEmpty(variable_indices) if ( isinstance( objective_expression(model.get_variables()), (int, float), ) and solve_status != mb.SolveStatus.INFEASIBLE ): # Feasibility implies optimality when objective is a constant term. self.assertEqual(got_solve_status, mb.SolveStatus.OPTIMAL) elif not set_objective and solve_status != mb.SolveStatus.INFEASIBLE: # Feasibility implies optimality when objective is not set. self.assertEqual(got_solve_status, mb.SolveStatus.OPTIMAL) elif solver["name"] == "sat" and got_solve_status == 8: # CP_SAT returns status=8 for some infeasible and unbounded cases. self.assertIn( solve_status, (mb.SolveStatus.INFEASIBLE, mb.SolveStatus.UNBOUNDED), ) elif ( solver["name"] == "highs" and got_solve_status == mb.SolveStatus.INFEASIBLE and solve_status == mb.SolveStatus.UNBOUNDED ): # Highs is can return INFEASIBLE when UNBOUNDED is expected. pass else: self.assertEqual(got_solve_status, solve_status) elif solve_status == mb.SolveStatus.UNBOUNDED: # Unbounded problems are optimal when there are no variables. self.assertEqual(got_solve_status, mb.SolveStatus.OPTIMAL) else: self.assertEqual(got_solve_status, solve_status) @parameterized.product( solver=_solvers, variable_indices=_variable_indices, variable_bound=_variable_bounds, solve_status=_solve_statuses, set_objective=_set_objectives, is_maximize=_objective_senses, objective_expression=_objective_expressions, ) def test_get_variable_values( self, solver: Dict[str, Union[str, Mapping[str, Any], bool]], variable_indices: pd.Index, variable_bound: float, solve_status: mb.SolveStatus, set_objective: bool, is_maximize: bool, objective_expression: Callable[[pd.Series], float], ): model = self._create_model( variable_indices=variable_indices, variable_bound=variable_bound, is_integer=solver["is_integer"], solve_status=solve_status, set_objective=set_objective, is_maximize=is_maximize, objective_expression=objective_expression, ) model_solver = mb.Solver(solver["name"]) if not model_solver.solver_is_supported(): print(f'Solver {solver["name"]} is not supported') return if solver.get("solver_specific_parameters"): model_solver.set_solver_specific_parameters( solver.get("solver_specific_parameters") ) got_solve_status = model_solver.solve(model) variables = model.get_variables() variable_values = model_solver.values(variables) # Test the type of `variable_values` (we always get pd.Series) self.assertIsInstance(variable_values, pd.Series) # Test the index of `variable_values` (match the input variables [if any]) self.assertSequenceAlmostEqual( variable_values.index, mb._get_index(model._get_variables(variables)), ) if got_solve_status not in ( mb.SolveStatus.OPTIMAL, mb.SolveStatus.FEASIBLE, ): # self.assertSequenceAlmostEqual does not work here because we cannot do # equality comparison for NA values (NAs will propagate and we will get # 'TypeError: boolean value of NA is ambiguous') for variable_value in variable_values: self.assertTrue(pd.isna(variable_value)) elif set_objective and not isinstance( objective_expression(model.get_variables()), (int, float), ): # The variable values are only well-defined when the objective is set # and depends on the variable(s). if not solver["is_integer"]: self.assertSequenceAlmostEqual( variable_values, [variable_bound] * len(variable_values) ) elif is_maximize: self.assertTrue(solver["is_integer"]) # Assert a known assumption. self.assertSequenceAlmostEqual( variable_values, [math.floor(variable_bound)] * len(variable_values), ) else: self.assertTrue(solver["is_integer"]) # Assert a known assumption. self.assertSequenceAlmostEqual( variable_values, [math.ceil(variable_bound)] * len(variable_values), ) @parameterized.product( solver=_solvers, variable_indices=_variable_indices, variable_bound=_variable_bounds, solve_status=_solve_statuses, set_objective=_set_objectives, is_maximize=_objective_senses, objective_expression=_objective_expressions, ) def test_get_objective_value( self, solver: Dict[str, Union[str, Mapping[str, Any], bool]], variable_indices: pd.Index, variable_bound: float, solve_status: mb.SolveStatus, set_objective: bool, is_maximize: bool, objective_expression: Callable[[pd.Series], float], ): model = self._create_model( variable_indices=variable_indices, variable_bound=variable_bound, is_integer=solver["is_integer"], solve_status=solve_status, set_objective=set_objective, is_maximize=is_maximize, objective_expression=objective_expression, ) model_solver = mb.Solver(solver["name"]) if not model_solver.solver_is_supported(): print(f'Solver {solver["name"]} is not supported') return if solver.get("solver_specific_parameters"): model_solver.set_solver_specific_parameters( solver.get("solver_specific_parameters") ) got_status = model_solver.solve(model) # Test objective value if got_status not in (mb.SolveStatus.OPTIMAL, mb.SolveStatus.FEASIBLE): self.assertTrue(pd.isna(model_solver.objective_value)) return if set_objective: variable_values = model_solver.values(model.get_variables()) self.assertAlmostEqual( model_solver.objective_value, objective_expression(variable_values), ) else: self.assertAlmostEqual(model_solver.objective_value, 0) class ModelBuilderExamplesTest(absltest.TestCase): def test_simple_problem(self): # max 5x1 + 4x2 + 3x3 # s.t 2x1 + 3x2 + x3 <= 5 # 4x1 + x2 + 2x3 <= 11 # 3x1 + 4x2 + 2x3 <= 8 # x1, x2, x3 >= 0 # Values = (2,0,1) # Reduced Costs = (0,-3,0) model = mb.Model() x = model.new_var_series( "x", pd.Index(range(3)), lower_bounds=0, is_integral=True ) self.assertLen(model.get_variables(), 3) model.maximize(x.dot([5, 4, 3])) model.add(x.dot([2, 3, 1]) <= 5) model.add(x.dot([4, 1, 2]) <= 11) model.add(x.dot([3, 4, 2]) <= 8) self.assertLen(model.get_linear_constraints(), 3) solver = mb.Solver("glop") test_red_cost = solver.reduced_costs(model.get_variables()) test_dual_values = solver.dual_values(model.get_variables()) self.assertLen(test_red_cost, 3) self.assertLen(test_dual_values, 3) for reduced_cost in test_red_cost: self.assertTrue(pd.isna(reduced_cost)) for dual_value in test_dual_values: self.assertTrue(pd.isna(dual_value)) run = solver.solve(model) self.assertEqual(run, mb.SolveStatus.OPTIMAL) i = solver.values(model.get_variables()) self.assertSequenceAlmostEqual(i, [2, 0, 1]) red_cost = solver.reduced_costs(model.get_variables()) dual_val = solver.dual_values(model.get_linear_constraints()) self.assertSequenceAlmostEqual(red_cost, [0, -3, 0]) self.assertSequenceAlmostEqual(dual_val, [1, 0, 1]) self.assertAlmostEqual(2, solver.value(x[0])) self.assertAlmostEqual(0, solver.reduced_cost((x[0]))) self.assertAlmostEqual(-3, solver.reduced_cost((x[1]))) self.assertAlmostEqual(0, solver.reduced_cost((x[2]))) self.assertAlmostEqual(1, solver.dual_value((x[0]))) self.assertAlmostEqual(0, solver.dual_value((x[1]))) self.assertAlmostEqual(1, solver.dual_value((x[2]))) def test_graph_k_color(self): # Assign a color to each vertex of graph, s.t that no two adjacent # vertices share the same color. Assume N vertices, and max number of # colors is K # Consider graph with edges: # Edge 1: (0,1) # Edge 2: (0,2) # Edge 3: (1,3) # Trying to color graph with at most 3 colors (0, 1, 2) # Two sets of variables: # x - pandas series representing coloring status of nodes # y - pandas series indicating whether color has been used # Min: y0 + y1 + y2 # s.t: Every vertex must be assigned exactly one color # if two vertices are adjacent they cannot have the same color model = mb.Model() num_colors = 3 num_nodes = 4 x = model.new_var_series( "x", pd.MultiIndex.from_product( (range(num_nodes), range(num_colors)), names=["node", "color"], ), lower_bounds=0, upper_bounds=1, is_integral=True, ) y = model.new_var_series( "y", pd.Index(range(num_colors), name="color"), lower_bounds=0, upper_bounds=1, is_integral=True, ) model.minimize(y.dot([1, 1, 1])) # Every vertex must be assigned exactly one color model.add(x.groupby("node").sum().apply(lambda expr: expr == 1)) # If a vertex i is assigned to a color j then color j is used: # namely, we re-arrange the terms to express: "x - y <= 0". model.add(x.sub(y, fill_value=0).apply(lambda expr: expr <= 0)) # if two vertices are adjacent they cannot have the same color j for j in range(num_colors): model.add(x[0, j] + x[1, j] <= 1) model.add(x[0, j] + x[2, j] <= 1) model.add(x[1, j] + x[3, j] <= 1) solver = mb.Solver("sat") run = solver.solve(model) self.assertEqual(run, mb.SolveStatus.OPTIMAL) self.assertEqual(solver.objective_value, 2) def test_knapsack_problem(self): # Basic Knapsack Problem: Given N items, # with N different weights and values, find the maximum possible value of # the Items while meeting a weight requirement # We have 3 items: # Item x1: Weight = 10, Value = 60 # Item x2: Weight = 20, Value = 100 # Item x3: Weight = 30, Value = 120 # Max: 60x1 + 100x2 + 120x3 # s.t: 10x1 + 20x2 + 30x3 <= 50 model = mb.Model() x = model.new_bool_var_series("x", pd.Index(range(3))) self.assertLen(model.get_variables(), 3) model.maximize(x.dot([60, 100, 120])) model.add(x.dot([10, 20, 30]) <= 50) self.assertLen(model.get_linear_constraints(), 1) solver = mb.Solver("sat") run = solver.solve(model) self.assertEqual(run, mb.SolveStatus.OPTIMAL) i = solver.values(model.get_variables()) self.assertEqual(solver.objective_value, 220) self.assertSequenceAlmostEqual(i, [0, 1, 1]) def test_max_flow_problem(self): # Testing max flow problem with 8 nodes # 0-8, source 0, target 8 # Edges: # (0,1), (0,2), (0,3), (1,4), (2,4),(2,5), (2,6), (3,5), (4,7), (5,7), (6,7) # Variables: flow_var- pandas series of flows, indexed by edges # Max: flow[(0,1)] + flow[(0,2)] + flow[(0,3)] # S.t: flow[(0,1)] <= 3 # flow[(0,2)] <= 2 # flow[(0,3)] <= 2 # flow[(1,4)] <= 5 # flow[(1,5)] <= 1 # flow[(2,4)] <= 1 # flow[(2,5)] <= 3 # flow[(2,6)] <= 1 # flow[(3,5)] <= 1 # flow[(4,7)] <= 4 # flow[(5,7)] <= 2 # flow[(6,7)] <= 4 # Flow conservation constraints: # flow[(0,1)] = flow[(1,4)] + flow[(1,5)] # flow[(0,2)] = flow[(2,4)] + flow[(2,5)] + flow[(2,6)] # flow[(0,3)] = flow[(3,5)] # flow[(1,4)] + flow[(2,4)] = flow[(4,7)] # flow[(1,5)] + flow[(2,5)] + flow[(3,5)] = X[(5,7)] # flow[(2,6)] = flow[(6,7)] model = mb.Model() nodes = [1, 2, 3, 4, 5, 6] edge_capacities = pd.Series( { (0, 1): 3, (0, 2): 2, (0, 3): 2, (1, 4): 5, (1, 5): 1, (2, 4): 1, (2, 5): 3, (2, 6): 1, (3, 5): 1, (4, 7): 4, (5, 7): 2, (6, 7): 4, } ) flow_var = model.new_var_series( "flow_var", pd.MultiIndex.from_tuples( edge_capacities.index, names=("source", "target") ), lower_bounds=0, is_integral=True, ) self.assertLen(model.get_variables(), 12) model.maximize(flow_var[0, :].sum()) model.add( (flow_var - edge_capacities).apply(lambda expr: expr <= 0), name="capacity_constraint", ) for node in nodes: # must specify constraint name when directly comparing two variables model.add( flow_var.xs(node, level=0).sum() == flow_var.xs(node, level=1).sum(), name="flow_conservation", ) solver = mb.Solver("sat") run = solver.solve(model) self.assertEqual(run, mb.SolveStatus.OPTIMAL) self.assertEqual(solver.objective_value, 6) def test_add_enforced(self): model = mb.Model() x = model.new_int_var(0, 10, "x") y = model.new_int_var(0, 10, "y") z = model.new_bool_var("z") ct = model.add_enforced(x + 2 * y >= 10, z, False) self.assertEqual(ct.lower_bound, 10.0) self.assertEqual(z.index, ct.indicator_variable.index) self.assertFalse(ct.indicator_value) if __name__ == "__main__": absltest.main()