From 50ad70d5e2c5765895e73954def5bbd447be01d2 Mon Sep 17 00:00:00 2001 From: Robbie Muir Date: Sat, 24 Jan 2026 19:11:28 +0100 Subject: [PATCH 1/6] something --- test/test_linear_expression.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index a75ace3f..bb26b73b 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1313,3 +1313,13 @@ def test_simplify_partial_cancellation(x: Variable, y: Variable) -> None: assert all(simplified.coeffs.values == 3.0), ( f"Expected coefficient 3.0, got {simplified.coeffs.values}" ) + + +def test_respresentation_with_sparsisty(x: Variable) -> None: + model = Model() + time = pd.Index(name="t", data=[0, 1, 2, 3, 4]) + short_time = pd.Index(name="t", data=[0, 1]) + a = model.add_variables(name="a", coords=[time]) + a_expr = a.where(xr.DataArray([0, 0, 0, 1, 1], coords={"t": time})) # + a_reindexed = a_expr.sel(t=short_time) + assert isinstance(a_reindexed, LinearExpression) From d7f2409651e2b918f708f8350232ec3c346a6f76 Mon Sep 17 00:00:00 2001 From: Robbie Muir Date: Sat, 24 Jan 2026 21:32:44 +0100 Subject: [PATCH 2/6] formatting --- doc/release_notes.rst | 1 + examples/creating-expressions.ipynb | 95 ++++++++++++++++++++++++++++- linopy/expressions.py | 25 ++++++++ test/test_linear_expression.py | 17 ++++++ test/test_quadratic_expression.py | 8 +++ 5 files changed, 145 insertions(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index deed60d4..97bfab09 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,7 @@ Release Notes Upcoming Version ---------------- +* Add documentation about `LinearExpression.where` with `drop=True`. Add `LinearExpression.names_of_terms_used` property. * Add the `sphinx-copybutton` to the documentation Version 0.6.1 diff --git a/examples/creating-expressions.ipynb b/examples/creating-expressions.ipynb index aafd8a09..00cfef3d 100644 --- a/examples/creating-expressions.ipynb +++ b/examples/creating-expressions.ipynb @@ -160,7 +160,11 @@ "cell_type": "markdown", "id": "f7578221", "metadata": {}, - "source": ".. important::\n\n\tWhen combining variables or expression with dimensions of the same name and size, the first object will determine the coordinates of the resulting expression. For example:" + "source": [ + ".. important::\n", + "\n", + "\tWhen combining variables or expression with dimensions of the same name and size, the first object will determine the coordinates of the resulting expression. For example:" + ] }, { "cell_type": "code", @@ -308,6 +312,95 @@ "(x + y).where(mask) + xr.DataArray(5, coords=[time]).where(~mask, 0)" ] }, + { + "cell_type": "markdown", + "id": "6741e69e", + "metadata": {}, + "source": [ + "Sometimes `.where` may lead to a situation where some of the variables are completely masked" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc32bdca", + "metadata": {}, + "outputs": [], + "source": [ + "mask_a = xr.DataArray(False, coords=[time])\n", + "mask_b = xr.DataArray(time > 2, coords=[time])\n", + "\n", + "z = (x.where(mask_a) + y).where(mask_b)\n", + "z" + ] + }, + { + "cell_type": "markdown", + "id": "25bf798c", + "metadata": {}, + "source": [ + "In this example you can see that many of the elements of the LinearExpression are None, and `x` is not used at all. If you want to remove all the None terms, you can use `.where(.., drop=True)`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72c6b51b", + "metadata": {}, + "outputs": [], + "source": [ + "z = z.where(mask_b, drop=True)\n", + "z" + ] + }, + { + "cell_type": "markdown", + "id": "1c1e0b85", + "metadata": {}, + "source": [ + "That looks nicer!
\n", + "We still have the issue that the `x` term exists in the expression somehow, even though it is completely masked." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c577863", + "metadata": {}, + "outputs": [], + "source": [ + "z.nterm" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fe43d47d", + "metadata": {}, + "outputs": [], + "source": [ + "z.names_of_terms_used" + ] + }, + { + "cell_type": "markdown", + "id": "a76d40b1", + "metadata": {}, + "source": [ + "You can get rid of the redundant `x` term with `.simplify()`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc27341c", + "metadata": {}, + "outputs": [], + "source": [ + "z = z.simplify()\n", + "z.nterm" + ] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/linopy/expressions.py b/linopy/expressions.py index 10e243de..6ca43b2b 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -1071,6 +1071,31 @@ def nterm(self) -> int: """ return len(self.data._term) + @property + def names_of_terms_used(self) -> list[str]: + """ + The names of the terms (variables) that are used (i.e., excluding those that are completely masked). + """ + if self.nterm == 0: + return [] + + # Collect all unique labels from the expression (excluding -1) while preserving order + all_labels = self.vars.values.ravel() + valid_labels = all_labels[all_labels != -1] + + if len(valid_labels) == 0: + return [] + + # Get unique labels while preserving first occurrence order + unique_labels, first_indices = np.unique(valid_labels, return_index=True) + ordered_labels = unique_labels[np.argsort(first_indices)] + + # Batch lookup variable names for all labels + positions = self.model.variables.get_label_position(ordered_labels) + + # Deduplicate names while preserving order (dict.fromkeys is faster than set + list) + return list(dict.fromkeys(name for name, _ in positions if name is not None)) + @property def shape(self) -> tuple[int, ...]: """ diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index bb26b73b..7fbf7970 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1315,6 +1315,23 @@ def test_simplify_partial_cancellation(x: Variable, y: Variable) -> None: ) +def test_term_names() -> None: + m = Model() + time = pd.Index(range(3), name="time") + + a = m.add_variables(name="a", coords=[time]) + b = m.add_variables(name="b", coords=[time]) + + expr = a + b + assert expr.nterm == 2 + assert expr.names_of_terms_used == ["a", "b"] + + mask = xr.DataArray(False, coords=[time]) + expr = a + (b * 1).where(mask) + assert expr.nterm == 2 + assert expr.names_of_terms_used == ["a"] + + def test_respresentation_with_sparsisty(x: Variable) -> None: model = Model() time = pd.Index(name="t", data=[0, 1, 2, 3, 4]) diff --git a/test/test_quadratic_expression.py b/test/test_quadratic_expression.py index fc1bb25f..fb2d1098 100644 --- a/test/test_quadratic_expression.py +++ b/test/test_quadratic_expression.py @@ -360,3 +360,11 @@ def test_power_of_three(x: Variable) -> None: x**3 with pytest.raises(TypeError): (x * x) * (x * x) + + +def test_term_names(x: Variable, y: Variable) -> None: + expr = 2 * (x * x) + 3 * y + 1 + assert expr.names_of_terms_used == ["x", "y"] + + expr = 2 * (y * y) + 1 + assert expr.names_of_terms_used == ["y"] From f649f750713df9a4a322559a404bb01ad413e793 Mon Sep 17 00:00:00 2001 From: Robbie Muir Date: Sat, 24 Jan 2026 23:01:15 +0100 Subject: [PATCH 3/6] fix tests --- test/test_linear_expression.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index 7fbf7970..695976ac 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1330,13 +1330,3 @@ def test_term_names() -> None: expr = a + (b * 1).where(mask) assert expr.nterm == 2 assert expr.names_of_terms_used == ["a"] - - -def test_respresentation_with_sparsisty(x: Variable) -> None: - model = Model() - time = pd.Index(name="t", data=[0, 1, 2, 3, 4]) - short_time = pd.Index(name="t", data=[0, 1]) - a = model.add_variables(name="a", coords=[time]) - a_expr = a.where(xr.DataArray([0, 0, 0, 1, 1], coords={"t": time})) # - a_reindexed = a_expr.sel(t=short_time) - assert isinstance(a_reindexed, LinearExpression) From 7d5dedac744a1dc36a37efa5f64a2f1fabfdd931 Mon Sep 17 00:00:00 2001 From: Robbie Muir Date: Sat, 24 Jan 2026 23:14:30 +0100 Subject: [PATCH 4/6] =?UTF-8?q?improve=20code=20coverage=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/test_linear_expression.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index 695976ac..f49d07ad 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1330,3 +1330,11 @@ def test_term_names() -> None: expr = a + (b * 1).where(mask) assert expr.nterm == 2 assert expr.names_of_terms_used == ["a"] + + expr = (b * 1).where(mask) + assert expr.nterm == 1 + assert expr.names_of_terms_used == [] + + expr = LinearExpression.from_constant(model=m, constant=5) + assert expr.nterm == 0 + assert expr.names_of_terms_used == [] From b5fc46f73a0380f75093152eca4d0cefab11349f Mon Sep 17 00:00:00 2001 From: Robbie Muir Date: Sun, 25 Jan 2026 09:01:56 +0100 Subject: [PATCH 5/6] minor changes --- doc/release_notes.rst | 2 +- examples/creating-expressions.ipynb | 2 +- linopy/expressions.py | 19 +++++++++++----- test/test_linear_expression.py | 35 ++++++++++++++++++++++++----- test/test_quadratic_expression.py | 6 ++--- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 97bfab09..27647be7 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,7 +4,7 @@ Release Notes Upcoming Version ---------------- -* Add documentation about `LinearExpression.where` with `drop=True`. Add `LinearExpression.names_of_terms_used` property. +* Add documentation about `LinearExpression.where` with `drop=True`. Add `BaseExpression.variable_names` and `BaseExpression.nvar` properties. * Add the `sphinx-copybutton` to the documentation Version 0.6.1 diff --git a/examples/creating-expressions.ipynb b/examples/creating-expressions.ipynb index 00cfef3d..17e5a2ab 100644 --- a/examples/creating-expressions.ipynb +++ b/examples/creating-expressions.ipynb @@ -379,7 +379,7 @@ "metadata": {}, "outputs": [], "source": [ - "z.names_of_terms_used" + "z.variable_names" ] }, { diff --git a/linopy/expressions.py b/linopy/expressions.py index 6ca43b2b..a05d9206 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -1072,19 +1072,27 @@ def nterm(self) -> int: return len(self.data._term) @property - def names_of_terms_used(self) -> list[str]: + def nvar(self) -> int: """ - The names of the terms (variables) that are used (i.e., excluding those that are completely masked). + Get the number of unique variables in the linear expression. + Note that nvar <= nterm, as variables can appear multiple times and there can be terms which are completely masked out. + """ + return len(self.variable_names) + + @property + def variable_names(self) -> set[str]: + """ + The names of the unique variables present in the expression """ if self.nterm == 0: - return [] + return set() # Collect all unique labels from the expression (excluding -1) while preserving order all_labels = self.vars.values.ravel() valid_labels = all_labels[all_labels != -1] if len(valid_labels) == 0: - return [] + return set() # Get unique labels while preserving first occurrence order unique_labels, first_indices = np.unique(valid_labels, return_index=True) @@ -1093,8 +1101,7 @@ def names_of_terms_used(self) -> list[str]: # Batch lookup variable names for all labels positions = self.model.variables.get_label_position(ordered_labels) - # Deduplicate names while preserving order (dict.fromkeys is faster than set + list) - return list(dict.fromkeys(name for name, _ in positions if name is not None)) + return {p[0] for p in positions if p[0] is not None} @property def shape(self) -> tuple[int, ...]: diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index f49d07ad..174a4c38 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1315,7 +1315,7 @@ def test_simplify_partial_cancellation(x: Variable, y: Variable) -> None: ) -def test_term_names() -> None: +def test_variable_names() -> None: m = Model() time = pd.Index(range(3), name="time") @@ -1324,17 +1324,42 @@ def test_term_names() -> None: expr = a + b assert expr.nterm == 2 - assert expr.names_of_terms_used == ["a", "b"] + assert expr.variable_names == {"a", "b"} mask = xr.DataArray(False, coords=[time]) expr = a + (b * 1).where(mask) assert expr.nterm == 2 - assert expr.names_of_terms_used == ["a"] + assert expr.variable_names == {"a"} expr = (b * 1).where(mask) assert expr.nterm == 1 - assert expr.names_of_terms_used == [] + assert expr.variable_names == set() expr = LinearExpression.from_constant(model=m, constant=5) assert expr.nterm == 0 - assert expr.names_of_terms_used == [] + assert expr.variable_names == set() + + +def test_nvar_and_nterm() -> None: + m = Model() + time = pd.Index(range(3), name="time") + all_false = xr.DataArray(False, coords=[time]) + not_0 = xr.DataArray([False, True, True], coords=[time]) + not_1 = xr.DataArray([True, False, True], coords=[time]) + not_2 = xr.DataArray([True, True, False], coords=[time]) + + a = m.add_variables(name="a", coords=[time]) + b = m.add_variables(name="b", coords=[time]) + c = m.add_variables(name="c", coords=[time]) + + expr = (a.where(not_0) + b.where(not_1) + c.where(not_2)).densify_terms() + assert expr.nterm == 3 + assert expr.nvar == 3 + + expr = a + b.where(all_false) + assert expr.nterm == 2 + assert expr.nvar == 1 + + expr = expr.simplify() + assert expr.nterm == 1 + assert expr.nvar == 1 diff --git a/test/test_quadratic_expression.py b/test/test_quadratic_expression.py index fb2d1098..6cf697ba 100644 --- a/test/test_quadratic_expression.py +++ b/test/test_quadratic_expression.py @@ -362,9 +362,9 @@ def test_power_of_three(x: Variable) -> None: (x * x) * (x * x) -def test_term_names(x: Variable, y: Variable) -> None: +def test_variable_names(x: Variable, y: Variable) -> None: expr = 2 * (x * x) + 3 * y + 1 - assert expr.names_of_terms_used == ["x", "y"] + assert expr.variable_names == {"x", "y"} expr = 2 * (y * y) + 1 - assert expr.names_of_terms_used == ["y"] + assert expr.variable_names == {"y"} From 80c2f607958dcff57f285319ae0fe3000094b92a Mon Sep 17 00:00:00 2001 From: Robbie Muir Date: Sun, 25 Jan 2026 12:09:19 +0100 Subject: [PATCH 6/6] update notebook --- examples/creating-expressions.ipynb | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/creating-expressions.ipynb b/examples/creating-expressions.ipynb index 17e5a2ab..4067018b 100644 --- a/examples/creating-expressions.ipynb +++ b/examples/creating-expressions.ipynb @@ -339,7 +339,7 @@ "id": "25bf798c", "metadata": {}, "source": [ - "In this example you can see that many of the elements of the LinearExpression are None, and `x` is not used at all. If you want to remove all the None terms, you can use `.where(.., drop=True)`" + "In this example you can see that many of the elements of the LinearExpression are None. If you want to remove all the None terms, you can use `.where(.., drop=True)`" ] }, { @@ -358,8 +358,15 @@ "id": "1c1e0b85", "metadata": {}, "source": [ - "That looks nicer!
\n", - "We still have the issue that the `x` term exists in the expression somehow, even though it is completely masked." + "That looks nicer!
" + ] + }, + { + "cell_type": "markdown", + "id": "d8530a08", + "metadata": {}, + "source": [ + "You may notice that the variable `x` is not used at all. The expression still contains two terms (one of them is unused) but it only has one variable `y`" ] }, { @@ -387,7 +394,7 @@ "id": "a76d40b1", "metadata": {}, "source": [ - "You can get rid of the redundant `x` term with `.simplify()`" + "You can get rid of the unused term with `.simplify()`" ] }, {