diff --git a/doc/release_notes.rst b/doc/release_notes.rst index b4a92e64..24903afb 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -21,6 +21,7 @@ Upcoming Version * Improve handling of CPLEX solver quality attributes to ensure metrics such are extracted correctly when available. * Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates. * Enable quadratic problems with SCIP on windows. +* Add ``format_labels()`` on ``Constraints``/``Variables`` and ``format_infeasibilities()`` on ``Model`` that return strings instead of printing to stdout, allowing usage with logging, storage, or custom output handling. Deprecate ``print_labels()`` and ``print_infeasibilities()``. Version 0.6.5 diff --git a/linopy/common.py b/linopy/common.py index 09f67355..278f2c61 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -986,7 +986,7 @@ def get_label_position( raise ValueError("Array's with more than two dimensions is not supported") -def print_coord(coord: dict[str, Any] | Iterable[Any]) -> str: +def format_coord(coord: dict[str, Any] | Iterable[Any]) -> str: """ Format coordinates into a string representation. @@ -999,11 +999,11 @@ def print_coord(coord: dict[str, Any] | Iterable[Any]) -> str: with nested coordinates grouped in parentheses. Examples: - >>> print_coord({"x": 1, "y": 2}) + >>> format_coord({"x": 1, "y": 2}) '[1, 2]' - >>> print_coord([1, 2, 3]) + >>> format_coord([1, 2, 3]) '[1, 2, 3]' - >>> print_coord([(1, 2), (3, 4)]) + >>> format_coord([(1, 2), (3, 4)]) '[(1, 2), (3, 4)]' """ # Handle empty input @@ -1024,7 +1024,7 @@ def print_coord(coord: dict[str, Any] | Iterable[Any]) -> str: return f"[{', '.join(formatted)}]" -def print_single_variable(model: Any, label: int) -> str: +def format_single_variable(model: Any, label: int) -> str: if label == -1: return "None" @@ -1043,10 +1043,10 @@ def print_single_variable(model: Any, label: int) -> str: else: bounds = f" ∈ [{lower:.4g}, {upper:.4g}]" - return f"{name}{print_coord(coord)}{bounds}" + return f"{name}{format_coord(coord)}{bounds}" -def print_single_expression( +def format_single_expression( c: np.ndarray, v: np.ndarray, const: float, @@ -1058,7 +1058,7 @@ def print_single_expression( c, v = np.atleast_1d(c), np.atleast_1d(v) # catch case that to many terms would be printed - def print_line( + def format_line( expr: list[tuple[float, tuple[str, Any] | list[tuple[str, Any]]]], const: float ) -> str: res = [] @@ -1072,11 +1072,11 @@ def print_line( var_string = "" for name, coords in var: if name is not None: - coord_string = print_coord(coords) + coord_string = format_coord(coords) var_string += f" {name}{coord_string}" else: name, coords = var - coord_string = print_coord(coords) + coord_string = format_coord(coords) var_string = f" {name}{coord_string}" res.append(f"{coeff_string}{var_string}") @@ -1103,7 +1103,7 @@ def print_line( truncate = max_terms // 2 positions = model.variables.get_label_position(v[..., :truncate]) expr = list(zip(c[:truncate], positions)) - res = print_line(expr, const) + res = format_line(expr, const) res += " ... " expr = list( zip( @@ -1111,15 +1111,15 @@ def print_line( model.variables.get_label_position(v[-truncate:]), ) ) - residual = print_line(expr, const) + residual = format_line(expr, const) if residual != " None": res += residual return res expr = list(zip(c, model.variables.get_label_position(v))) - return print_line(expr, const) + return format_line(expr, const) -def print_single_constraint(model: Any, label: int) -> str: +def format_single_constraint(model: Any, label: int) -> str: constraints = model.constraints name, coord = constraints.get_label_position(label) @@ -1128,10 +1128,10 @@ def print_single_constraint(model: Any, label: int) -> str: sign = model.constraints[name].sign.sel(coord).item() rhs = model.constraints[name].rhs.sel(coord).item() - expr = print_single_expression(coeffs, vars, 0, model) + expr = format_single_expression(coeffs, vars, 0, model) sign = SIGNS_pretty[sign] - return f"{name}{print_coord(coord)}: {expr} {sign} {rhs:.12g}" + return f"{name}{format_coord(coord)}: {expr} {sign} {rhs:.12g}" def has_optimized_model(func: Callable[..., Any]) -> Callable[..., Any]: diff --git a/linopy/constraints.py b/linopy/constraints.py index d3ebef19..bb6d8e68 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -15,6 +15,7 @@ Any, overload, ) +from warnings import warn import numpy as np import pandas as pd @@ -36,6 +37,9 @@ check_has_nulls, check_has_nulls_polars, filter_nulls_polars, + format_coord, + format_single_constraint, + format_single_expression, format_string_as_variable_name, generate_indices_for_printout, get_dims_with_index_levels, @@ -44,9 +48,6 @@ iterate_slices, maybe_group_terms_polars, maybe_replace_signs, - print_coord, - print_single_constraint, - print_single_expression, replace_by_map, require_constant, save_join, @@ -304,7 +305,7 @@ def __repr__(self) -> str: for i, ind in enumerate(indices) ] if self.mask is None or self.mask.values[indices]: - expr = print_single_expression( + expr = format_single_expression( self.coeffs.values[indices], self.vars.values[indices], 0, @@ -312,9 +313,9 @@ def __repr__(self) -> str: ) sign = SIGNS_pretty[self.sign.values[indices]] rhs = self.rhs.values[indices] - line = print_coord(coord) + f": {expr} {sign} {rhs}" + line = format_coord(coord) + f": {expr} {sign} {rhs}" else: - line = print_coord(coord) + ": None" + line = format_coord(coord) + ": None" lines.append(line) lines = align_lines_by_delimiter(lines, list(SIGNS_pretty.values())) @@ -323,7 +324,7 @@ def __repr__(self) -> str: underscore = "-" * (len(shape_str) + len(mask_str) + len(header_string) + 4) lines.insert(0, f"{header_string} [{shape_str}]{mask_str}:\n{underscore}") elif size == 1: - expr = print_single_expression( + expr = format_single_expression( self.coeffs.values, self.vars.values, 0, self.model ) lines.append( @@ -1016,29 +1017,47 @@ def get_label_position( self._label_position_index = LabelPositionIndex(self) return get_label_position(self, values, self._label_position_index) - def print_labels( + def format_labels( self, values: Sequence[int], display_max_terms: int | None = None - ) -> None: + ) -> str: """ - Print a selection of labels of the constraints. + Get a string representation of a selection of constraint labels. Parameters ---------- values : list, array-like One dimensional array of constraint labels. + display_max_terms : int, optional + Maximum number of terms to display per constraint. If ``None``, + uses the global ``linopy.options.display_max_terms`` setting. + + Returns + ------- + str + String representation of the selected constraints. """ with options as opts: if display_max_terms is not None: opts.set_value(display_max_terms=display_max_terms) - res = [print_single_constraint(self.model, v) for v in values] + res = [format_single_constraint(self.model, v) for v in values] - output = "\n".join(res) - try: - print(output) - except UnicodeEncodeError: - # Replace Unicode math symbols with ASCII equivalents for Windows console - output = output.replace("≤", "<=").replace("≥", ">=").replace("≠", "!=") - print(output) + return "\n".join(res) + + def print_labels( + self, values: Sequence[int], display_max_terms: int | None = None + ) -> None: + """ + Print a selection of labels of the constraints. + + .. deprecated:: + Use :meth:`format_labels` instead. + """ + warn( + "`Constraints.print_labels` is deprecated. Use `Constraints.format_labels` instead.", + DeprecationWarning, + stacklevel=2, + ) + print(self.format_labels(values, display_max_terms=display_max_terms)) def set_blocks(self, block_map: np.ndarray) -> None: """ @@ -1157,7 +1176,7 @@ def __repr__(self) -> str: """ Get the representation of the AnonymousScalarConstraint. """ - expr_string = print_single_expression( + expr_string = format_single_expression( np.array(self.lhs.coeffs), np.array(self.lhs.vars), 0, self.lhs.model ) return f"AnonymousScalarConstraint: {expr_string} {self.sign} {self.rhs}" diff --git a/linopy/expressions.py b/linopy/expressions.py index d2ae9022..ca491c3e 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -53,6 +53,8 @@ check_has_nulls_polars, fill_missing_coords, filter_nulls_polars, + format_coord, + format_single_expression, forward_as_properties, generate_indices_for_printout, get_dims_with_index_levels, @@ -62,8 +64,6 @@ is_constant, iterate_slices, maybe_group_terms_polars, - print_coord, - print_single_expression, to_dataframe, to_polars, ) @@ -429,16 +429,16 @@ def __repr__(self) -> str: self.data.indexes[dims[i]][ind] for i, ind in enumerate(indices) ] if self.mask is None or self.mask.values[indices]: - expr = print_single_expression( + expr = format_single_expression( self.coeffs.values[indices], self.vars.values[indices], self.const.values[indices], self.model, ) - line = print_coord(coord) + f": {expr}" + line = format_coord(coord) + f": {expr}" else: - line = print_coord(coord) + ": None" + line = format_coord(coord) + ": None" lines.append(line) shape_str = ", ".join(f"{d}: {s}" for d, s in zip(dim_names, dim_sizes)) @@ -446,7 +446,7 @@ def __repr__(self) -> str: underscore = "-" * (len(shape_str) + len(mask_str) + len(header_string) + 4) lines.insert(0, f"{header_string} [{shape_str}]{mask_str}:\n{underscore}") elif size == 1: - expr = print_single_expression( + expr = format_single_expression( self.coeffs.values, self.vars.values, self.const.item(), self.model ) lines.append(f"{header_string}\n{'-' * len(header_string)}\n{expr}") @@ -2470,7 +2470,7 @@ def __init__( self._model = model def __repr__(self) -> str: - expr_string = print_single_expression( + expr_string = format_single_expression( np.array(self.coeffs), np.array(self.vars), 0, self.model ) return f"ScalarLinearExpression: {expr_string}" diff --git a/linopy/io.py b/linopy/io.py index 2213cbb5..e860c1c5 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -89,10 +89,10 @@ def signed_number(expr: pl.Expr) -> tuple[pl.Expr, pl.Expr]: ) -def print_coord(coord: str) -> str: - from linopy.common import print_coord +def format_coord(coord: str) -> str: + from linopy.common import format_coord - coord = print_coord(coord).translate(coord_sanitizer) + coord = format_coord(coord).translate(coord_sanitizer) return coord @@ -105,12 +105,12 @@ def get_printers_scalar( def print_variable(var: Any) -> str: name, coord = m.variables.get_label_position(var) name = clean_name(name) - return f"{name}{print_coord(coord)}#{var}" + return f"{name}{format_coord(coord)}#{var}" def print_constraint(cons: Any) -> str: name, coord = m.constraints.get_label_position(cons) name = clean_name(name) # type: ignore - return f"{name}{print_coord(coord)}#{cons}" # type: ignore + return f"{name}{format_coord(coord)}#{cons}" # type: ignore return print_variable, print_constraint else: @@ -133,12 +133,12 @@ def get_printers( def print_variable(var: Any) -> str: name, coord = m.variables.get_label_position(var) name = clean_name(name) - return f"{name}{print_coord(coord)}#{var}" + return f"{name}{format_coord(coord)}#{var}" def print_constraint(cons: Any) -> str: name, coord = m.constraints.get_label_position(cons) name = clean_name(name) # type: ignore - return f"{name}{print_coord(coord)}#{cons}" # type: ignore + return f"{name}{format_coord(coord)}#{cons}" # type: ignore def print_variable_series(series: pl.Series) -> tuple[pl.Expr, pl.Series]: return pl.lit(" "), series.map_elements( diff --git a/linopy/model.py b/linopy/model.py index 54334411..51220e48 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -13,6 +13,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile, gettempdir from typing import Any, Literal, overload +from warnings import warn import numpy as np import pandas as pd @@ -1822,9 +1823,9 @@ def _extract_iis_constraints(self, solver_model: Any, iis_num: int) -> list[Any] return miisrow - def print_infeasibilities(self, display_max_terms: int | None = None) -> None: + def format_infeasibilities(self, display_max_terms: int | None = None) -> str: """ - Print a list of infeasible constraints. + Return a string representation of infeasible constraints. This function requires that the model was solved using `gurobi` or `xpress` and the termination condition was infeasible. @@ -1832,20 +1833,35 @@ def print_infeasibilities(self, display_max_terms: int | None = None) -> None: Parameters ---------- display_max_terms : int, optional - The maximum number of infeasible terms to display. If `None`, - all infeasible terms will be displayed. + The maximum number of infeasible terms to display. If ``None``, + uses the global ``linopy.options.display_max_terms`` setting. Returns ------- - None - This function does not return anything. It simply prints the - infeasible constraints. + str + String representation of the infeasible constraints. """ labels = self.compute_infeasibilities() - self.constraints.print_labels(labels, display_max_terms=display_max_terms) + return self.constraints.format_labels( + labels, display_max_terms=display_max_terms + ) + + def print_infeasibilities(self, display_max_terms: int | None = None) -> None: + """ + Print a list of infeasible constraints. + + .. deprecated:: + Use :meth:`format_infeasibilities` instead. + """ + warn( + "`Model.print_infeasibilities` is deprecated. Use `Model.format_infeasibilities` instead.", + DeprecationWarning, + stacklevel=2, + ) + print(self.format_infeasibilities(display_max_terms=display_max_terms)) @deprecated( - details="Use `compute_infeasibilities`/`print_infeasibilities` instead." + details="Use `compute_infeasibilities`/`format_infeasibilities` instead." ) def compute_set_of_infeasible_constraints(self) -> Dataset: """ diff --git a/linopy/variables.py b/linopy/variables.py index 4332a037..512268a3 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -38,14 +38,14 @@ check_has_nulls, check_has_nulls_polars, filter_nulls_polars, + format_coord, + format_single_variable, format_string_as_variable_name, generate_indices_for_printout, get_dims_with_index_levels, get_label_position, has_optimized_model, iterate_slices, - print_coord, - print_single_variable, require_constant, save_join, set_int_index, @@ -352,9 +352,9 @@ def __repr__(self) -> str: ] label = self.labels.values[indices] line = ( - print_coord(coord) + format_coord(coord) + ": " - + print_single_variable(self.model, label) + + format_single_variable(self.model, label) ) lines.append(line) # lines = align_lines_by_delimiter(lines, "∈") @@ -369,7 +369,7 @@ def __repr__(self) -> str: ) else: lines.append( - f"Variable\n{'-' * 8}\n{print_single_variable(self.model, self.labels.item())}" + f"Variable\n{'-' * 8}\n{format_single_variable(self.model, self.labels.item())}" ) return "\n".join(lines) @@ -1655,17 +1655,36 @@ def get_label_position_with_index( self._label_position_index = LabelPositionIndex(self) return self._label_position_index.find_single_with_index(label) - def print_labels(self, values: list[int]) -> None: + def format_labels(self, values: list[int]) -> str: """ - Print a selection of labels of the variables. + Get a string representation of a selection of variable labels. Parameters ---------- values : list, array-like - One dimensional array of constraint labels. + One dimensional array of variable labels. + + Returns + ------- + str + String representation of the selected variables. """ - res = [print_single_variable(self.model, v) for v in values] - print("\n".join(res)) + res = [format_single_variable(self.model, v) for v in values] + return "\n".join(res) + + def print_labels(self, values: list[int]) -> None: + """ + Print a selection of labels of the variables. + + .. deprecated:: + Use :meth:`format_labels` instead. + """ + warn( + "`Variables.print_labels` is deprecated. Use `Variables.format_labels` instead.", + DeprecationWarning, + stacklevel=2, + ) + print(self.format_labels(values)) @property def flat(self) -> pd.DataFrame: @@ -1734,7 +1753,7 @@ def __repr__(self) -> str: if self.label == -1: return "ScalarVariable: None" name, coord = self.model.variables.get_label_position(self.label) - coord_string = print_coord(coord) + coord_string = format_coord(coord) return f"ScalarVariable: {name}{coord_string}" @property