Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions python/cuopt/cuopt/linear_programming/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,133 @@
import warnings


# ---- Display helpers for __str__/__repr__ ----

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The location of this code isn't very natural. Why are we defining the printing helpers before VType, CType, Variable, etc?


# Keyed by the underlying CType char codes ("L"/"G"/"E"). CType is a
# ``(str, Enum)`` whose members compare and hash equal to these codes, so the
# lookup works whether ``Constraint.Sense`` holds a CType member or a raw
# string. Using the codes (rather than the LE/GE/EQ aliases) also keeps this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this mapping be a member of the CType enum?

# module-level table independent of definition order.
_SENSE_SYMBOLS = {"L": "<=", "G": ">=", "E": "=="}
_TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use VType().name rather than hardcoding this mapping.


# Maximum number of terms rendered when stringifying a linear or quadratic
# expression. Beyond this, the head is shown followed by a ``... (N more
# terms)`` marker so that printing a model with thousands of terms stays
# readable in a REPL or notebook instead of flooding the output. Set to
# ``None`` to disable truncation entirely.
_MAX_DISPLAY_TERMS = 10


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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why getattr instead of var.getVariableIndex()? When would the attribute not exist? And when would it be -1?

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'.

When ``max_terms`` is set, only the first ``max_terms`` non-zero terms
are rendered; any remaining terms are counted and summarized as a
trailing ``... (N more terms)`` marker. This keeps the output bounded
for expressions with very many terms. ``max_terms=None`` (the default)
renders every term.
"""

def __init__(self, max_terms=None):
self.parts = []
self.max_terms = max_terms
# Non-zero terms seen so far (rendered + hidden).
self.n_terms = 0
# Non-zero terms omitted because the cap was reached.
self.n_hidden = 0

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):
self.n_terms += 1
if self.max_terms is not None and self.n_terms > self.max_terms:
# Past the cap: count the term but don't render it.
self.n_hidden += 1
return
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 and not self.n_hidden:
return "0.0"
result = "".join(self.parts)
if self.n_hidden:
plural = "term" if self.n_hidden == 1 else "terms"
marker = f"... ({self.n_hidden} more {plural})"
result = f"{result} + {marker}" if result else marker
return result


def _format_linear(vars, coeffs, constant, max_terms=None):
"""Format a linear expression as an algebraic string."""
builder = _ExprBuilder(max_terms=max_terms)
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.
Expand Down Expand Up @@ -335,6 +462,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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again why getattr?

type_str = _type_display(self.VariableType)
return (
f"<cuopt.Variable '{name}' (index={idx}), "
f"type={type_str}, bounds=[{self.LB}, {self.UB}], "
f"value={self.Value}>"
)


class QuadraticExpression:
"""
Expand Down Expand Up @@ -889,6 +1029,25 @@ def __ge__(self, other):
def __eq__(self, other):
raise ValueError("Equality constraints are not supported.")

def __str__(self):
builder = _ExprBuilder(max_terms=_MAX_DISPLAY_TERMS)
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"<cuopt.QuadraticExpression: {self}>"


def _quadratic_expression_to_qcmatrix(expr, rhs):
"""Build QCMATRIX COO data for a quadratic row ``expr`` sense ``rhs``.
Expand Down Expand Up @@ -1280,6 +1439,17 @@ 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,
max_terms=_MAX_DISPLAY_TERMS,
)

def __repr__(self):
return f"<cuopt.LinearExpression: {self}>"


class Constraint:
"""
Expand Down Expand Up @@ -1322,6 +1492,7 @@ def __init__(self, expr, sense, rhs, name=""):
self.ConstraintName = name
self.DualValue = float("nan")
self.Slack = float("nan")
self._expr = expr

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we're potentially doubling the model's memory usage by storing an extra copy of the constraint data here. That's not ideal.


if isinstance(expr, QuadraticExpression):
self.is_quadratic = True
Expand Down Expand Up @@ -1397,6 +1568,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 "<unnamed>"
return f"<cuopt.Constraint '{name}': {self}>"


class Problem:
"""
Expand Down Expand Up @@ -2219,3 +2401,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 "<unnamed>"
return (
f"<cuopt.Problem '{name}' "
f"({len(self.vars)} vars, {len(self.constrs)} constrs, "
f"IsMIP={self.IsMIP})>"
)

def __str__(self):
lines = []
name = self.Name if self.Name else "<unnamed>"
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)
Loading