Files
ortools-clone/ortools/linear_solver/python/model_builder_test.py

2148 lines
76 KiB
Python

#!/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()