From 4d65f7f993fba1382a6aac3761c9975db936b0de Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 24 Mar 2026 08:52:06 +0100 Subject: [PATCH 1/3] fix: review fixes for PR #630 (matrix accessor rewrite) - Fix __repr__ passing CSR positions instead of variable labels - Fix set_blocks failing on frozen Constraint - Extract _active_to_dataarray helper to reduce DRY violations - Simplify reset_dual to direct mutation instead of reconstruction - Add tests for freeze/mutable roundtrip, VariableLabelIndex, to_matrix_with_rhs, from_mutable mixed signs, repr correctness --- linopy/constraints.py | 66 ++++++++++++--------------------- test/test_constraint.py | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 43 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index a161aaec..db2398f4 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -613,16 +613,19 @@ def nterm(self) -> int: def coord_names(self) -> list[str]: return [str(c.name) for c in self._coords] + def _active_to_dataarray( + self, active_values: np.ndarray, fill: float | int = -1 + ) -> DataArray: + full = np.full(self.full_size, fill, dtype=active_values.dtype) + full[self.active_positions] = active_values + return DataArray(full.reshape(self.shape), coords=self._coords) + @property def labels(self) -> DataArray: """Get labels DataArray, shape (*coord_dims).""" if self._cindex is None: return DataArray([]) - shape = self.shape - full_size = self.full_size - labels_flat = np.full(full_size, -1, dtype=np.int64) - labels_flat[self.active_positions] = self._con_labels - return DataArray(labels_flat.reshape(shape), coords=self._coords) + return self._active_to_dataarray(self._con_labels, fill=-1) @property def coeffs(self) -> DataArray: @@ -654,10 +657,7 @@ def sign(self) -> DataArray: @property def rhs(self) -> DataArray: """Get RHS DataArray, shape (*coord_dims).""" - shape = self.shape - rhs_full = np.full(self.full_size, np.nan) - rhs_full[self.active_positions] = self._rhs - return DataArray(rhs_full.reshape(shape), coords=self._coords) + return self._active_to_dataarray(self._rhs, fill=np.nan) @property @has_optimized_model @@ -667,9 +667,7 @@ def dual(self) -> DataArray: raise AttributeError( "Underlying is optimized but does not have dual values stored." ) - dual_full = np.full(self.full_size, np.nan) - dual_full[self.active_positions] = self._dual - return DataArray(dual_full.reshape(self.shape), coords=self._coords) + return self._active_to_dataarray(self._dual, fill=np.nan) @dual.setter def dual(self, value: DataArray) -> None: @@ -731,24 +729,10 @@ def _to_dataset(self, nterm: int) -> Dataset: def data(self) -> Dataset: """Reconstruct the xarray Dataset from the CSR representation.""" ds = self._to_dataset(self.nterm) - shape = self.shape - active_pos = self.active_positions - rhs_full = np.full(self.full_size, np.nan) - rhs_full[active_pos] = self._rhs - ds = ds.assign( - sign=DataArray(np.full(shape, self._sign), coords=self._coords), - rhs=DataArray(rhs_full.reshape(shape), coords=self._coords), - ) + ds = ds.assign(sign=self.sign, rhs=self.rhs) if self._dual is not None: - dual_full = np.full(self.full_size, np.nan) - dual_full[active_pos] = self._dual - ds = ds.assign( - dual=DataArray(dual_full.reshape(shape), coords=self._coords) - ) - attrs: dict[str, Any] = {"name": self._name} - if self._cindex is not None: - attrs["label_range"] = (self._cindex, self._cindex + self.full_size) - return ds.assign_attrs(attrs) + ds = ds.assign(dual=self._active_to_dataarray(self._dual, fill=np.nan)) + return ds.assign_attrs(self.attrs) def __repr__(self) -> str: """Print the constraint without reconstructing the full Dataset.""" @@ -771,11 +755,13 @@ def __repr__(self) -> str: header_string = f"{self.type} `{self._name}`" if self._name else f"{self.type}" lines = [] + vlabels = self._model.variables.label_index.vlabels + def row_expr(row: int) -> str: start, end = int(csr.indptr[row]), int(csr.indptr[row + 1]) vars_row = np.full(nterm, -1, dtype=np.int64) coeffs_row = np.zeros(nterm, dtype=csr.dtype) - vars_row[: end - start] = csr.indices[start:end] + vars_row[: end - start] = vlabels[csr.indices[start:end]] coeffs_row[: end - start] = csr.data[start:end] return f"{print_single_expression(coeffs_row, vars_row, 0, self._model)} {SIGNS_pretty[self._sign]} {self._rhs[row]}" @@ -1630,7 +1616,12 @@ def set_blocks(self, block_map: np.ndarray) -> None: res = res.where(not_missing.any(constraint.term_dim), -1) res = res.where(not_zero.any(constraint.term_dim), 0) - constraint._data = assign_multiindex_safe(constraint.data, blocks=res) + if isinstance(constraint, MutableConstraint): + constraint._data = assign_multiindex_safe(constraint.data, blocks=res) + else: + mc = constraint.mutable() + mc._data = assign_multiindex_safe(mc.data, blocks=res) + self.data[name] = Constraint.from_mutable(mc, constraint._cindex) @property def flat(self) -> pd.DataFrame: @@ -1687,18 +1678,7 @@ def reset_dual(self) -> None: """ for k, c in self.items(): if isinstance(c, Constraint): - if c._dual is not None: - self.data[k] = Constraint( - c._csr, - c._con_labels, - c._rhs, - c._sign, - c._coords, - c._model, - c._name, - cindex=c._cindex, - dual=None, - ) + c._dual = None elif isinstance(c, MutableConstraint): if "dual" in c.data: c._data = c.data.drop_vars("dual") diff --git a/test/test_constraint.py b/test/test_constraint.py index da3bbf25..1f73bd60 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -705,3 +705,84 @@ def test_constraints_inequalities(m: Model) -> None: def test_constraints_equalities(m: Model) -> None: assert isinstance(m.constraints.equalities, Constraints) + + +def test_freeze_mutable_roundtrip(m: Model) -> None: + frozen = m.constraints["c"] + assert isinstance(frozen, Constraint) + mc = frozen.mutable() + assert isinstance(mc, MutableConstraint) + refrozen = Constraint.from_mutable(mc, frozen._cindex) + assert_equal(frozen.labels, refrozen.labels) + assert_equal(frozen.rhs, refrozen.rhs) + assert_equal(frozen.sign, refrozen.sign) + np.testing.assert_array_equal(frozen._csr.toarray(), refrozen._csr.toarray()) + np.testing.assert_array_equal(frozen._con_labels, refrozen._con_labels) + + +def test_freeze_mutable_roundtrip_with_masking() -> None: + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(5, name="i")], name="x") + mask = xr.DataArray([True, False, True, False, True], dims=["i"]) + m.add_constraints(x.where(mask) >= 0, name="c") + frozen = m.constraints["c"] + mc = frozen.mutable() + refrozen = Constraint.from_mutable(mc, frozen._cindex) + assert_equal(frozen.labels, refrozen.labels) + assert_equal(frozen.rhs, refrozen.rhs) + assert frozen.ncons == refrozen.ncons == 3 + + +def test_from_mutable_mixed_signs_raises() -> None: + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(3, name="i")], name="x") + m.add_constraints(x >= 0, name="mixed", freeze=False) + mc = m.constraints["mixed"] + assert isinstance(mc, MutableConstraint) + mc._data["sign"] = xr.DataArray(["<=", ">=", "<="], dims=["i"]) + with pytest.raises(ValueError, match="per-element signs"): + Constraint.from_mutable(mc) + + +def test_variable_label_index(m: Model) -> None: + li = m.variables.label_index + assert li.n_active_vars > 0 + assert len(li.vlabels) == li.n_active_vars + assert li.label_to_pos.shape[0] == m._xCounter + for lbl in li.vlabels: + assert li.label_to_pos[lbl] >= 0 + assert (li.label_to_pos[li.vlabels] == np.arange(li.n_active_vars)).all() + + +def test_variable_label_index_invalidation(m: Model) -> None: + li = m.variables.label_index + old_vlabels = li.vlabels.copy() + m.add_variables(name="w") + li.invalidate() + assert len(li.vlabels) > len(old_vlabels) + + +def test_to_matrix_with_rhs(m: Model) -> None: + c = m.constraints["c"] + li = m.variables.label_index + csr, con_labels, b, sense = c.to_matrix_with_rhs(li) + assert csr.shape[0] == len(con_labels) + assert csr.shape[0] == len(b) + assert csr.shape[0] == len(sense) + assert all(s in ("<", ">", "=") for s in sense) + np.testing.assert_array_equal(b, c._rhs) + + +def test_to_matrix_with_rhs_mutable(m: Model) -> None: + mc = m.constraints["c"].mutable() + li = m.variables.label_index + csr, con_labels, b, sense = mc.to_matrix_with_rhs(li) + assert csr.shape[0] == len(con_labels) + assert csr.shape[0] == len(b) + assert csr.shape[0] == len(sense) + + +def test_constraint_repr_shows_variable_names(m: Model) -> None: + c = m.constraints["c"] + r = repr(c) + assert "x" in r From 351ef7167da4323e243bbe27e4fcbf20107974d2 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 25 Mar 2026 16:14:52 +0100 Subject: [PATCH 2/3] feat: support mixed per-element signs in frozen Constraint, add rhs/lhs setters - Store _sign as str (uniform, fast) or np.ndarray (mixed, per-row) - Add rhs/lhs setters on Constraint via _refreeze_after pattern - Invalidate _dual on mutation; update netcdf serialization for array signs - Add tests for setters, mixed-sign freeze/roundtrip/sanitize/repr/netcdf --- linopy/constraints.py | 101 ++++++++++++++++++++++++++----------- test/test_constraint.py | 107 ++++++++++++++++++++++++++++++++++++++-- test/test_io.py | 25 ++++++++++ 3 files changed, 200 insertions(+), 33 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index db2398f4..7685c774 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -529,7 +529,7 @@ def __init__( csr: scipy.sparse.csr_array, con_labels: np.ndarray, rhs: np.ndarray, - sign: str, + sign: str | np.ndarray, coords: list[pd.Index], model: Model, name: str = "", @@ -614,7 +614,7 @@ def coord_names(self) -> list[str]: return [str(c.name) for c in self._coords] def _active_to_dataarray( - self, active_values: np.ndarray, fill: float | int = -1 + self, active_values: np.ndarray, fill: float | int | str = -1 ) -> DataArray: full = np.full(self.full_size, fill, dtype=active_values.dtype) full[self.active_positions] = active_values @@ -651,14 +651,40 @@ def vars(self) -> DataArray: @property def sign(self) -> DataArray: - """Get sign DataArray (scalar, same sign for all entries).""" - return DataArray(np.full(self.shape, self._sign), coords=self._coords) + """Get sign DataArray.""" + if isinstance(self._sign, str): + return DataArray(np.full(self.shape, self._sign), coords=self._coords) + return self._active_to_dataarray(self._sign, fill="") @property def rhs(self) -> DataArray: """Get RHS DataArray, shape (*coord_dims).""" return self._active_to_dataarray(self._rhs, fill=np.nan) + @rhs.setter + def rhs(self, value: ExpressionLike | VariableLike | ConstantLike) -> None: + self._refreeze_after(lambda mc: setattr(mc, "rhs", value)) + + @property + def lhs(self) -> expressions.LinearExpression: + """Get LHS as LinearExpression (triggers Dataset reconstruction).""" + return self.mutable().lhs + + @lhs.setter + def lhs(self, value: ExpressionLike | VariableLike | ConstantLike) -> None: + self._refreeze_after(lambda mc: setattr(mc, "lhs", value)) + + def _refreeze_after(self, mutate: Callable[[MutableConstraint], None]) -> None: + mc = self.mutable() + mutate(mc) + refrozen = Constraint.from_mutable(mc, self._cindex) + self._csr = refrozen._csr + self._con_labels = refrozen._con_labels + self._rhs = refrozen._rhs + self._sign = refrozen._sign + self._coords = refrozen._coords + self._dual = None + @property @has_optimized_model def dual(self) -> DataArray: @@ -763,7 +789,8 @@ def row_expr(row: int) -> str: coeffs_row = np.zeros(nterm, dtype=csr.dtype) vars_row[: end - start] = vlabels[csr.indices[start:end]] coeffs_row[: end - start] = csr.data[start:end] - return f"{print_single_expression(coeffs_row, vars_row, 0, self._model)} {SIGNS_pretty[self._sign]} {self._rhs[row]}" + sign = self._sign if isinstance(self._sign, str) else self._sign[row] + return f"{print_single_expression(coeffs_row, vars_row, 0, self._model)} {SIGNS_pretty[sign]} {self._rhs[row]}" if size > 1: for indices in generate_indices_for_printout(shape, max_lines): @@ -805,21 +832,22 @@ def to_netcdf_ds(self) -> Dataset: "rhs": DataArray(self._rhs, dims=["_flat"]), "_con_labels": DataArray(self._con_labels, dims=["_flat"]), } + if isinstance(self._sign, np.ndarray): + data_vars["_sign"] = DataArray(self._sign, dims=["_flat"]) data_vars.update(coords_to_dataset_vars(self._coords)) if self._dual is not None: data_vars["dual"] = DataArray(self._dual, dims=["_flat"]) dim_names = [c.name for c in self._coords] - return Dataset( - data_vars, - attrs={ - "_linopy_format": "csr", - "sign": self._sign, - "cindex": self._cindex if self._cindex is not None else -1, - "shape": list(csr.shape), - "coord_dims": dim_names, - "name": self._name, - }, - ) + attrs: dict[str, Any] = { + "_linopy_format": "csr", + "cindex": self._cindex if self._cindex is not None else -1, + "shape": list(csr.shape), + "coord_dims": dim_names, + "name": self._name, + } + if isinstance(self._sign, str): + attrs["sign"] = self._sign + return Dataset(data_vars, attrs=attrs) @classmethod def from_netcdf_ds(cls, ds: Dataset, model: Model, name: str) -> Constraint: @@ -831,7 +859,9 @@ def from_netcdf_ds(cls, ds: Dataset, model: Model, name: str) -> Constraint: shape=shape, ) rhs = ds["rhs"].values - sign = attrs["sign"] + sign: str | np.ndarray = ( + ds["_sign"].values if "_sign" in ds else attrs["sign"] + ) _cindex_raw = int(attrs["cindex"]) cindex: int | None = _cindex_raw if _cindex_raw >= 0 else None coord_dims = attrs["coord_dims"] @@ -859,7 +889,10 @@ def to_matrix_with_rhs( self, label_index: VariableLabelIndex ) -> tuple[scipy.sparse.csr_array, np.ndarray, np.ndarray, np.ndarray]: """Return (csr, con_labels, b, sense) — all pre-stored, no recomputation.""" - sense = np.full(len(self._rhs), self._sign[0]) + if isinstance(self._sign, str): + sense = np.full(len(self._rhs), self._sign[0]) + else: + sense = np.array([s[0] for s in self._sign]) return self._csr, self._con_labels, self._rhs, sense def sanitize_zeros(self) -> Constraint: @@ -874,18 +907,25 @@ def sanitize_missings(self) -> Constraint: def sanitize_infinities(self) -> Constraint: """Mask out rows with invalid infinite RHS values (mutates in-place).""" - if self._sign == LESS_EQUAL: - invalid = self._rhs == np.inf - elif self._sign == GREATER_EQUAL: - invalid = self._rhs == -np.inf + if isinstance(self._sign, str): + if self._sign == LESS_EQUAL: + invalid = self._rhs == np.inf + elif self._sign == GREATER_EQUAL: + invalid = self._rhs == -np.inf + else: + return self else: - return self + invalid = ((self._sign == LESS_EQUAL) & (self._rhs == np.inf)) | ( + (self._sign == GREATER_EQUAL) & (self._rhs == -np.inf) + ) if not invalid.any(): return self keep = ~invalid self._csr = self._csr[keep] self._con_labels = self._con_labels[keep] self._rhs = self._rhs[keep] + if not isinstance(self._sign, str): + self._sign = self._sign[keep] return self def freeze(self) -> Constraint: @@ -925,13 +965,14 @@ def from_mutable( active_mask = (labels_flat != -1) & (vars_flat != -1).any(axis=1) rhs = con.rhs.values.ravel()[active_mask] sign_vals = con.sign.values.ravel() - unique_signs = np.unique(sign_vals[active_mask]) - if len(unique_signs) > 1: - raise ValueError( - "Constraint has per-element signs; cannot freeze to immutable Constraint. " - "This is a known limitation — use MutableConstraint instead." - ) - sign = str(unique_signs[0]) if len(unique_signs) == 1 else "=" + active_signs = sign_vals[active_mask] + unique_signs = np.unique(active_signs) + if len(unique_signs) == 0: + sign: str | np.ndarray = "=" + elif len(unique_signs) == 1: + sign = str(unique_signs[0]) + else: + sign = active_signs dual = ( con.data["dual"].values.ravel()[active_mask] if "dual" in con.data else None ) diff --git a/test/test_constraint.py b/test/test_constraint.py index 1f73bd60..6ad5e64d 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -733,15 +733,17 @@ def test_freeze_mutable_roundtrip_with_masking() -> None: assert frozen.ncons == refrozen.ncons == 3 -def test_from_mutable_mixed_signs_raises() -> None: +def test_from_mutable_mixed_signs() -> None: m = Model() x = m.add_variables(coords=[pd.RangeIndex(3, name="i")], name="x") m.add_constraints(x >= 0, name="mixed", freeze=False) mc = m.constraints["mixed"] assert isinstance(mc, MutableConstraint) mc._data["sign"] = xr.DataArray(["<=", ">=", "<="], dims=["i"]) - with pytest.raises(ValueError, match="per-element signs"): - Constraint.from_mutable(mc) + frozen = Constraint.from_mutable(mc) + assert isinstance(frozen._sign, np.ndarray) + assert list(frozen._sign) == ["<=", ">=", "<="] + assert_equal(frozen.sign, mc.sign) def test_variable_label_index(m: Model) -> None: @@ -786,3 +788,102 @@ def test_constraint_repr_shows_variable_names(m: Model) -> None: c = m.constraints["c"] r = repr(c) assert "x" in r + + +def test_freeze_mixed_signs_from_rule() -> None: + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(4, name="i")], name="x") + coords = [pd.RangeIndex(4, name="i")] + + def bound(m, i): + if i % 2: + return x.at[i] >= i + return x.at[i] == 0.0 + + con = m.add_constraints(bound, coords=coords, name="mixed_rule") + assert isinstance(con, Constraint) + assert isinstance(con._sign, np.ndarray) + assert con.ncons == 4 + expected_signs = ["=", ">=", "=", ">="] + assert list(con._sign) == expected_signs + np.testing.assert_array_equal(con.sign.values, expected_signs) + + +def test_frozen_rhs_setter() -> None: + m = Model() + time = pd.RangeIndex(5, name="t") + x = m.add_variables(lower=0, coords=[time], name="x") + con = m.add_constraints(x >= 1, name="c") + assert isinstance(con, Constraint) + con.rhs = 10 + np.testing.assert_array_equal(con._rhs, np.full(5, 10.0)) + factor = pd.Series(range(5), index=time) + con.rhs = 2 * factor + np.testing.assert_array_equal(con._rhs, 2 * np.arange(5, dtype=float)) + + +def test_frozen_lhs_setter() -> None: + m = Model() + time = pd.RangeIndex(5, name="t") + x = m.add_variables(lower=0, coords=[time], name="x") + y = m.add_variables(lower=0, coords=[time], name="y") + con = m.add_constraints(x >= 0, name="c") + assert isinstance(con, Constraint) + con.lhs = 3 * x + 2 * y + lhs = con.mutable().lhs + assert lhs.nterm == 2 + + +def test_frozen_setter_invalidates_dual() -> None: + m = Model() + x = m.add_variables(lower=0, coords=[pd.RangeIndex(3, name="i")], name="x") + con = m.add_constraints(x >= 0, name="c") + con._dual = np.array([1.0, 2.0, 3.0]) + con.rhs = 10 + assert con._dual is None + + +def test_mixed_sign_to_matrix_with_rhs() -> None: + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(4, name="i")], name="x") + coords = [pd.RangeIndex(4, name="i")] + + def bound(m, i): + if i % 2: + return x.at[i] >= i + return x.at[i] == 0.0 + + con = m.add_constraints(bound, coords=coords, name="c") + li = m.variables.label_index + csr, con_labels, b, sense = con.to_matrix_with_rhs(li) + assert len(sense) == 4 + assert list(sense) == ["=", ">", "=", ">"] + + +def test_mixed_sign_sanitize_infinities() -> None: + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(4, name="i")], name="x") + m.add_constraints(x >= 0, name="c", freeze=False) + mc = m.constraints["c"] + mc._data["sign"] = xr.DataArray(["<=", ">=", "<=", ">="], dims=["i"]) + mc._data["rhs"] = xr.DataArray([np.inf, -np.inf, 1.0, 2.0], dims=["i"]) + frozen = mc.freeze() + frozen.sanitize_infinities() + assert frozen.ncons == 2 + np.testing.assert_array_equal(frozen._rhs, [1.0, 2.0]) + + +def test_mixed_sign_repr() -> None: + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(4, name="i")], name="x") + coords = [pd.RangeIndex(4, name="i")] + + def bound(m, i): + if i % 2: + return x.at[i] >= i + return x.at[i] == 0.0 + + con = m.add_constraints(bound, coords=coords, name="c") + r = repr(con) + assert "≥" in r + assert "=" in r diff --git a/test/test_io.py b/test/test_io.py index 3d269636..1a97bd22 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -95,6 +95,31 @@ def test_model_to_netcdf_frozen_constraint(tmp_path: Path) -> None: assert_model_equal(m, p) +def test_model_to_netcdf_mixed_sign_constraint(tmp_path: Path) -> None: + from linopy.constraints import Constraint + + m = Model() + x = m.add_variables(coords=[pd.RangeIndex(4, name="i")], name="x") + + def bound(m, i): + if i % 2: + return x.at[i] >= i + return x.at[i] == 0.0 + + m.add_constraints(bound, coords=[pd.RangeIndex(4, name="i")], name="c") + assert isinstance(m.constraints["c"], Constraint) + + fn = tmp_path / "test_mixed_sign.nc" + m.to_netcdf(fn) + p = read_netcdf(fn) + + assert isinstance(p.constraints["c"], Constraint) + import numpy as np + + np.testing.assert_array_equal(m.constraints["c"]._sign, p.constraints["c"]._sign) + assert_model_equal(m, p) + + def test_model_to_netcdf_with_sense(model: Model, tmp_path: Path) -> None: m = model m.objective.sense = "max" From c8a4ddc6579cdc265452f20c1c5e6531b0164414 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 25 Mar 2026 16:35:54 +0100 Subject: [PATCH 3/3] feat: mixed per-element signs in frozen Constraint, rhs/lhs setters, DRY helpers --- linopy/constraints.py | 34 ++++++++++++++++++---------------- test/test_constraint.py | 14 +++++++------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index 7685c774..a6d9b2c9 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -497,9 +497,9 @@ class Constraint(ConstraintBase): constraint grid (including masked/empty rows). rhs : np.ndarray Shape (n_flat,). Right-hand-side values. - sign : str - Constraint sign: one of '=', '<=', '>='. - Note: per-element signs are not supported (documented regression vs MutableConstraint). + sign : str or np.ndarray + Constraint sign. Either a single str ('=', '<=', '>=') for uniform + signs, or a per-row np.ndarray of sign strings for mixed signs. coords : list of pd.Index One index per coordinate dimension defining the constraint grid. model : Model @@ -781,13 +781,11 @@ def __repr__(self) -> str: header_string = f"{self.type} `{self._name}`" if self._name else f"{self.type}" lines = [] - vlabels = self._model.variables.label_index.vlabels - def row_expr(row: int) -> str: start, end = int(csr.indptr[row]), int(csr.indptr[row + 1]) vars_row = np.full(nterm, -1, dtype=np.int64) coeffs_row = np.zeros(nterm, dtype=csr.dtype) - vars_row[: end - start] = vlabels[csr.indices[start:end]] + vars_row[: end - start] = csr.indices[start:end] coeffs_row[: end - start] = csr.data[start:end] sign = self._sign if isinstance(self._sign, str) else self._sign[row] return f"{print_single_expression(coeffs_row, vars_row, 0, self._model)} {SIGNS_pretty[sign]} {self._rhs[row]}" @@ -859,9 +857,7 @@ def from_netcdf_ds(cls, ds: Dataset, model: Model, name: str) -> Constraint: shape=shape, ) rhs = ds["rhs"].values - sign: str | np.ndarray = ( - ds["_sign"].values if "_sign" in ds else attrs["sign"] - ) + sign: str | np.ndarray = ds["_sign"].values if "_sign" in ds else attrs["sign"] _cindex_raw = int(attrs["cindex"]) cindex: int | None = _cindex_raw if _cindex_raw >= 0 else None coord_dims = attrs["coord_dims"] @@ -1657,12 +1653,7 @@ def set_blocks(self, block_map: np.ndarray) -> None: res = res.where(not_missing.any(constraint.term_dim), -1) res = res.where(not_zero.any(constraint.term_dim), 0) - if isinstance(constraint, MutableConstraint): - constraint._data = assign_multiindex_safe(constraint.data, blocks=res) - else: - mc = constraint.mutable() - mc._data = assign_multiindex_safe(mc.data, blocks=res) - self.data[name] = Constraint.from_mutable(mc, constraint._cindex) + constraint._data = assign_multiindex_safe(constraint.data, blocks=res) @property def flat(self) -> pd.DataFrame: @@ -1719,7 +1710,18 @@ def reset_dual(self) -> None: """ for k, c in self.items(): if isinstance(c, Constraint): - c._dual = None + if c._dual is not None: + self.data[k] = Constraint( + c._csr, + c._con_labels, + c._rhs, + c._sign, + c._coords, + c._model, + c._name, + cindex=c._cindex, + dual=None, + ) elif isinstance(c, MutableConstraint): if "dual" in c.data: c._data = c.data.drop_vars("dual") diff --git a/test/test_constraint.py b/test/test_constraint.py index 6ad5e64d..091e4e54 100644 --- a/test/test_constraint.py +++ b/test/test_constraint.py @@ -709,10 +709,10 @@ def test_constraints_equalities(m: Model) -> None: def test_freeze_mutable_roundtrip(m: Model) -> None: frozen = m.constraints["c"] - assert isinstance(frozen, Constraint) + assert isinstance(frozen, linopy.constraints.Constraint) mc = frozen.mutable() assert isinstance(mc, MutableConstraint) - refrozen = Constraint.from_mutable(mc, frozen._cindex) + refrozen = linopy.constraints.Constraint.from_mutable(mc, frozen._cindex) assert_equal(frozen.labels, refrozen.labels) assert_equal(frozen.rhs, refrozen.rhs) assert_equal(frozen.sign, refrozen.sign) @@ -727,7 +727,7 @@ def test_freeze_mutable_roundtrip_with_masking() -> None: m.add_constraints(x.where(mask) >= 0, name="c") frozen = m.constraints["c"] mc = frozen.mutable() - refrozen = Constraint.from_mutable(mc, frozen._cindex) + refrozen = linopy.constraints.Constraint.from_mutable(mc, frozen._cindex) assert_equal(frozen.labels, refrozen.labels) assert_equal(frozen.rhs, refrozen.rhs) assert frozen.ncons == refrozen.ncons == 3 @@ -740,7 +740,7 @@ def test_from_mutable_mixed_signs() -> None: mc = m.constraints["mixed"] assert isinstance(mc, MutableConstraint) mc._data["sign"] = xr.DataArray(["<=", ">=", "<="], dims=["i"]) - frozen = Constraint.from_mutable(mc) + frozen = linopy.constraints.Constraint.from_mutable(mc) assert isinstance(frozen._sign, np.ndarray) assert list(frozen._sign) == ["<=", ">=", "<="] assert_equal(frozen.sign, mc.sign) @@ -801,7 +801,7 @@ def bound(m, i): return x.at[i] == 0.0 con = m.add_constraints(bound, coords=coords, name="mixed_rule") - assert isinstance(con, Constraint) + assert isinstance(con, linopy.constraints.Constraint) assert isinstance(con._sign, np.ndarray) assert con.ncons == 4 expected_signs = ["=", ">=", "=", ">="] @@ -814,7 +814,7 @@ def test_frozen_rhs_setter() -> None: time = pd.RangeIndex(5, name="t") x = m.add_variables(lower=0, coords=[time], name="x") con = m.add_constraints(x >= 1, name="c") - assert isinstance(con, Constraint) + assert isinstance(con, linopy.constraints.Constraint) con.rhs = 10 np.testing.assert_array_equal(con._rhs, np.full(5, 10.0)) factor = pd.Series(range(5), index=time) @@ -828,7 +828,7 @@ def test_frozen_lhs_setter() -> None: x = m.add_variables(lower=0, coords=[time], name="x") y = m.add_variables(lower=0, coords=[time], name="y") con = m.add_constraints(x >= 0, name="c") - assert isinstance(con, Constraint) + assert isinstance(con, linopy.constraints.Constraint) con.lhs = 3 * x + 2 * y lhs = con.mutable().lhs assert lhs.nterm == 2