-
Notifications
You must be signed in to change notification settings - Fork 203
Add algebraic __str__ and detailed __repr__ to Python LP API classes #1400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,133 @@ | |
| import warnings | ||
|
|
||
|
|
||
| # ---- Display helpers for __str__/__repr__ ---- | ||
|
|
||
| # 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||
|
|
||
| # 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why getattr instead of |
||
| 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. | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
| """ | ||
|
|
@@ -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``. | ||
|
|
@@ -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: | ||
| """ | ||
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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: | ||
| """ | ||
|
|
@@ -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) | ||
There was a problem hiding this comment.
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?