From 3d33893dbc8d14c4f9f0070887a2b2e3e00abe0a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:25:37 +0200 Subject: [PATCH] test: make assert_linequal ignore dimension order linopy expressions inherit xarray's broadcasting, whose dimension order follows operand order: ``x + y`` yields ``(x_dim, y_dim)`` while ``y + x`` yields ``(y_dim, x_dim)``. That difference carries no mathematical meaning, yet xarray's order-sensitive ``assert_equal`` made the two compare unequal. ``assert_linequal`` already normalizes term order; align dimension order the same way before comparing, matching its "semantically equal" contract. Expressions with genuinely different dimension sets or values still fail. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/release_notes.rst | 1 + linopy/testing.py | 29 +++++++++++++++++++++++------ test/test_testing.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 test/test_testing.py diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 80f9076e1..fe37afd15 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -32,6 +32,7 @@ Upcoming Version * LP file export now honors bounds tightened below ``[0, 1]`` on a binary variable via the ``.lower``/``.upper`` setters after creation (e.g. ``upper = 0``). Previously such bounds were written only by ``io_api="direct"`` and dropped by ``io_api="lp"``. (https://github.com/PyPSA/linopy/issues/776) * Freezing an empty constraint group (e.g. an empty ``isel`` slice) no longer raises ``ValueError: cannot reshape array of size 0``. ``Model(freeze_constraints=True)`` and ``Constraint.freeze()`` now round-trip zero-row constraints losslessly. * ``Variable.where`` no longer raises ``ValueError: exact match required for all data variable names`` once a solution is attached (after ``Model.solve``) or the variable is fixed. The fill value now covers auxiliary data variables (``solution``, stashed bounds) instead of only ``labels``/``lower``/``upper``. +* ``linopy.testing.assert_linequal`` now aligns dimension order before comparing, so mathematically identical expressions built in different orders (e.g. ``x + y`` versus ``y + x``, which inherit different dimension orders from xarray broadcasting) are correctly treated as equal. Genuinely different expressions still fail. Version 0.8.0 ------------- diff --git a/linopy/testing.py b/linopy/testing.py index 310822846..5e88f2a9b 100644 --- a/linopy/testing.py +++ b/linopy/testing.py @@ -1,6 +1,7 @@ from __future__ import annotations import numpy as np +import xarray as xr from xarray.testing import assert_equal from linopy.constants import TERM_DIM @@ -10,6 +11,22 @@ from linopy.variables import Variable, _var_unwrap +def _align_dim_order(target: xr.Dataset, other: xr.Dataset) -> xr.Dataset: + """ + Reorder ``other``'s dimensions to match ``target`` when they share a dim set. + + linopy expressions inherit xarray's broadcasting, whose dimension order + follows operand order (``x + y`` yields ``(x_dim, y_dim)`` while ``y + x`` + yields ``(y_dim, x_dim)``). That difference is not semantically meaningful, + so it should not make two expressions compare unequal. If the dimension + sets differ the inputs are genuinely unequal; leave ``other`` untouched so + :func:`assert_equal` reports the real difference. + """ + if set(target.dims) == set(other.dims): + return other.transpose(*target.dims) + return other + + def _sort_by_vars_along_term(expr: LinearExpression) -> LinearExpression: """Sort a linear expression's terms by variable labels along _term.""" ds = expr.data @@ -35,15 +52,15 @@ def assert_linequal( """ Assert that two linear expressions are semantically equal. - Terms are sorted by variable labels along _term before comparing, - so expressions with different term orderings but identical mathematical - meaning are considered equal. + Terms are sorted by variable labels along _term and dimension order is + aligned before comparing, so expressions with different term orderings or + dimension orderings but identical mathematical meaning are considered equal. """ assert isinstance(a, LinearExpression) assert isinstance(b, LinearExpression) - a_sorted = _sort_by_vars_along_term(a) - b_sorted = _sort_by_vars_along_term(b) - return assert_equal(_expr_unwrap(a_sorted), _expr_unwrap(b_sorted)) + a_ds = _expr_unwrap(_sort_by_vars_along_term(a)) + b_ds = _expr_unwrap(_sort_by_vars_along_term(b)) + return assert_equal(a_ds, _align_dim_order(a_ds, b_ds)) def assert_quadequal( diff --git a/test/test_testing.py b/test/test_testing.py new file mode 100644 index 000000000..d0fabc86e --- /dev/null +++ b/test/test_testing.py @@ -0,0 +1,36 @@ +import pandas as pd +import pytest + +from linopy import Model +from linopy.testing import assert_linequal + + +@pytest.fixture +def model() -> Model: + return Model() + + +def test_assert_linequal_ignores_dimension_order(model: Model) -> None: + """ + Commutative arithmetic yields different dimension orders (``x + y`` gives + ``(i, j)`` while ``y + x`` gives ``(j, i)``), inherited from xarray + broadcasting. That is not semantically meaningful, so ``assert_linequal`` + must treat the two as equal. + """ + a = model.add_variables(coords=[pd.Index([0, 1], name="i")], name="a") + b = model.add_variables(coords=[pd.Index([0, 1, 2], name="j")], name="b") + + assert (a + b).data.coeffs.dims != (b + a).data.coeffs.dims + assert_linequal(a + b, b + a) + assert_linequal(2 * a + 3 * b, 3 * b + 2 * a) + + +def test_assert_linequal_still_detects_real_differences(model: Model) -> None: + """Aligning dimension order must not mask genuinely unequal expressions.""" + a = model.add_variables(coords=[pd.Index([0, 1], name="i")], name="a") + c = model.add_variables(coords=[pd.Index([0, 1], name="k")], name="c") + + with pytest.raises(AssertionError): + assert_linequal(1 * a, 1 * c) # different dimension sets + with pytest.raises(AssertionError): + assert_linequal(1 * a, 2 * a) # different coefficients