diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 10600a543b..4ddbd56254 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -16,6 +16,100 @@ import warnings +# ---- Display helpers for __str__/__repr__ ---- + +_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="} +_TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"} + + +def _var_display_name(var): + """Return the display name of a Variable.""" + name = var.VariableName + if name: + return name + if getattr(var, "index", -1) >= 0: + return f"C{var.index}" + return f"V{id(var)}" + + +def _type_display(type_val): + """Return a human-readable name for a variable type.""" + if isinstance(type_val, VType): + return type_val.name + if isinstance(type_val, (bytes, bytearray)): + type_val = type_val.decode() + return _TYPE_NAMES.get(type_val, str(type_val)) + + +class _ExprBuilder: + """Build an algebraic string from a sequence of terms. + + The first term is emitted without a sign; subsequent terms are joined + with ' + ' or ' - ' separators. A coefficient of 1.0 or -1.0 is + elided, so '1.0 * x' becomes 'x' and '-1.0 * x' becomes '-x'. + """ + + def __init__(self): + self.parts = [] + + def add_linear(self, coef, var): + """Add a linear term ``coef * var``.""" + if coef == 0.0: + return + var_str = _var_display_name(var) + if coef == 1.0: + self._append(var_str, negative=False) + elif coef == -1.0: + self._append(var_str, negative=True) + else: + self._append(f"{abs(coef)} * {var_str}", negative=coef < 0) + + def add_quadratic(self, coef, var1, var2): + """Add a quadratic term ``coef * var1 * var2``.""" + if coef == 0.0: + return + v1_str = _var_display_name(var1) + v2_str = _var_display_name(var2) + if v1_str == v2_str: + term_str = f"{v1_str}^2" + elif v1_str <= v2_str: + term_str = f"{v1_str} * {v2_str}" + else: + term_str = f"{v2_str} * {v1_str}" + if coef == 1.0: + self._append(term_str, negative=False) + elif coef == -1.0: + self._append(term_str, negative=True) + else: + self._append(f"{abs(coef)} * {term_str}", negative=coef < 0) + + def add_constant(self, value): + """Add a constant term.""" + if value == 0.0: + return + self._append(f"{abs(value)}", negative=value < 0) + + def _append(self, term, negative): + if not self.parts: + self.parts.append(f"-{term}" if negative else term) + else: + self.parts.append(f" - {term}" if negative else f" + {term}") + + def build(self): + if not self.parts: + return "0.0" + return "".join(self.parts) + + +def _format_linear(vars, coeffs, constant): + """Format a linear expression as an algebraic string.""" + builder = _ExprBuilder() + for var, coef in zip(vars, coeffs): + builder.add_linear(coef, var) + builder.add_constant(constant) + return builder.build() + + class VType(str, Enum): """ The type of a variable is continuous, integer, or semi-continuous. @@ -335,6 +429,19 @@ def __eq__(self, other): case _: raise ValueError("Unsupported operation") + def __str__(self): + return _var_display_name(self) + + def __repr__(self): + name = _var_display_name(self) + idx = getattr(self, "index", -1) + type_str = _type_display(self.VariableType) + return ( + f"" + ) + class QuadraticExpression: """ @@ -889,6 +996,25 @@ def __ge__(self, other): def __eq__(self, other): raise ValueError("Equality constraints are not supported.") + def __str__(self): + builder = _ExprBuilder() + if self.qmatrix is not None: + for row, col, val in zip( + self.qmatrix.row, self.qmatrix.col, self.qmatrix.data + ): + if val == 0.0: + continue + builder.add_quadratic(val, self.qvars[row], self.qvars[col]) + for v1, v2, coef in zip(self.qvars1, self.qvars2, self.qcoefficients): + builder.add_quadratic(coef, v1, v2) + for var, coef in zip(self.vars, self.coefficients): + builder.add_linear(coef, var) + builder.add_constant(self.constant) + return builder.build() + + def __repr__(self): + return f"" + def _quadratic_expression_to_qcmatrix(expr, rhs): """Build QCMATRIX COO data for a quadratic row ``expr`` sense ``rhs``. @@ -1280,6 +1406,12 @@ def __eq__(self, other): expr = self - other return Constraint(expr, EQ, 0.0) + def __str__(self): + return _format_linear(self.vars, self.coefficients, self.constant) + + def __repr__(self): + return f"" + class Constraint: """ @@ -1322,6 +1454,7 @@ def __init__(self, expr, sense, rhs, name=""): self.ConstraintName = name self.DualValue = float("nan") self.Slack = float("nan") + self._expr = expr if isinstance(expr, QuadraticExpression): self.is_quadratic = True @@ -1397,6 +1530,17 @@ def compute_slack(self): return self.RHS - lhs + def __str__(self): + sense_str = _SENSE_SYMBOLS.get(self.Sense, str(self.Sense)) + lhs = str(self._expr) if self._expr is not None else "0.0" + expr_constant = getattr(self._expr, "constant", 0.0) or 0.0 + user_rhs = self.RHS + expr_constant + return f"{lhs} {sense_str} {user_rhs}" + + def __repr__(self): + name = self.ConstraintName if self.ConstraintName else "" + return f"" + class Problem: """ @@ -2219,3 +2363,54 @@ def solve(self, settings=solver_settings.SolverSettings()): # Post Solve self.populate_solution(solution) return solution + + def __repr__(self): + name = self.Name if self.Name else "" + return ( + f"" + ) + + def __str__(self): + lines = [] + name = self.Name if self.Name else "" + lines.append(f"Problem: {name}") + sense_str = "MINIMIZE" if self.ObjSense == MINIMIZE else "MAXIMIZE" + lines.append(f" Objective: {sense_str}") + + n_cont = 0 + n_int = 0 + n_semi = 0 + for v in self.vars: + t = v.VariableType + if isinstance(t, (bytes, bytearray)): + t = t.decode() + if t in ("I", VType.INTEGER): + n_int += 1 + elif t in ("S", VType.SEMI_CONTINUOUS): + n_semi += 1 + else: + n_cont += 1 + lines.append( + f" Variables: {len(self.vars)} " + f"(continuous={n_cont}, integer={n_int}, " + f"semi-continuous={n_semi})" + ) + + n_linear = sum(1 for c in self.constrs if not c.is_quadratic) + n_quad = sum(1 for c in self.constrs if c.is_quadratic) + lines.append( + f" Constraints: {len(self.constrs)} " + f"(linear={n_linear}, quadratic={n_quad})" + ) + lines.append(f" Non-zeros: {self.NumNZs}") + + if self.solved: + status = self.Status + if hasattr(status, "name"): + status = status.name + lines.append(f" Status: {status}") + lines.append(f" Objective value: {self.ObjValue}") + + return "\n".join(lines) diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 860b7aef2a..14b17d938a 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -826,3 +826,129 @@ def test_quadratic_matrix_2(): assert x2.getValue() == pytest.approx(0.0000000, abs=1e-3) assert x3.getValue() == pytest.approx(0.1092896, abs=1e-3) assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3) + + +def test_str_and_repr(): + """Verify algebraic __str__ and detailed __repr__ for LP API classes.""" + prob = Problem("str_repr_test") + + # === Variable === + x = prob.addVariable(lb=0.0, ub=10.0, vtype=VType.CONTINUOUS, name="x") + y = prob.addVariable(lb=0.0, ub=5.0, vtype=VType.INTEGER, name="y") + z = prob.addVariable() + + # __str__: with name returns the name + assert str(x) == "x" + assert str(y) == "y" + # __str__: without name falls back to C{index} + assert str(z) == "C2" + + # __repr__: detailed summary + r = repr(x) + assert "cuopt.Variable" in r + assert "'x'" in r + assert "index=0" in r + assert "type=CONTINUOUS" in r + assert "bounds=[0.0, 10.0]" in r + assert "value=nan" in r + + r = repr(y) + assert "type=INTEGER" in r + assert "bounds=[0.0, 5.0]" in r + + r = repr(z) + assert "'C2'" in r + assert "index=2" in r + + # === LinearExpression === + expr1 = 2 * x + 3 * y + expr2 = expr1 - 5 + expr3 = -x + 2.5 + + # __str__ + assert str(expr1) == "2.0 * x + 3.0 * y" + assert str(expr2) == "2.0 * x + 3.0 * y - 5.0" + assert str(expr3) == "-x + 2.5" + # Empty expression collapses to 0.0 + assert str(LinearExpression([], [], 0.0)) == "0.0" + # Constant-only expression + assert str(LinearExpression([], [], 3.0)) == "3.0" + assert str(LinearExpression([], [], -3.0)) == "-3.0" + + # __repr__ + assert repr(expr1) == "" + + # === QuadraticExpression === + qexpr1 = x * x + qexpr2 = qexpr1 + 2 * x * y + 3 * x + qexpr3 = -x * x + 0.5 * y * y + x * y + + # __str__ + assert str(qexpr1) == "x^2" + assert str(qexpr2) == "x^2 + 2.0 * x * y + 3.0 * x" + assert str(qexpr3) == "-x^2 + 0.5 * y^2 + x * y" + # Empty quadratic expression + assert str(QuadraticExpression()) == "0.0" + + # __repr__ + assert repr(qexpr1) == "" + + # === Constraint === + c1 = 2 * x + 3 * y <= 10 + c2 = x - y >= 0 + c3 = x + 1 == 5 + prob.addConstraint(c1, name="c1") + prob.addConstraint(c2, name="c2") + prob.addConstraint(c3, name="c3") + + # __str__: shows the original (un-moved) form + assert str(c1) == "2.0 * x + 3.0 * y <= 10.0" + assert str(c2) == "x - y >= 0.0" + assert str(c3) == "x + 1.0 == 5.0" + + # __str__: unnamed constraint + c_anon = 2 * x + 3 * y <= 10 + assert "2.0 * x + 3.0 * y <= 10.0" in str(c_anon) + + # __repr__ + assert repr(c1) == "" + assert repr(c2) == "= 0.0>" + assert repr(c3) == "" + + # === Problem === + # __repr__ + r = repr(prob) + assert "cuopt.Problem" in r + assert "str_repr_test" in r + assert "3 vars" in r + assert "3 constrs" in r + assert "IsMIP=True" in r # y is integer + + # __str__: before solve + s = str(prob) + assert "str_repr_test" in s + assert "MINIMIZE" in s + assert "Variables: 3" in s + assert "continuous=2" in s + assert "integer=1" in s + assert "semi-continuous=0" in s + assert "Constraints: 3" in s + assert "linear=3" in s + assert "quadratic=0" in s + assert "Non-zeros: 5" in s + # No status before solve + assert "Status:" not in s + assert "Objective value:" not in s + + # __str__: after solve includes status and objective value + settings = SolverSettings() + settings.set_parameter("time_limit", 5) + prob.solve(settings) + s = str(prob) + assert "Status: Optimal" in s + assert "Objective value:" in s + + # Unnamed problem + empty_prob = Problem() + assert "Problem: " in str(empty_prob) + assert "'" in repr(empty_prob)