From cbf5bfd18b1c851c403eb9970166e135fcff4634 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:55:56 +0100 Subject: [PATCH 01/11] First attempt --- linopy/expressions.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/linopy/expressions.py b/linopy/expressions.py index 10e243de..ac27c468 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -2122,6 +2122,41 @@ def merge( data = [e.data if isinstance(e, linopy_types) else e for e in exprs] data = [fill_missing_coords(ds, fill_helper_dims=True) for ds in data] + # When using join='override', xr.concat places values positionally instead of + # aligning by label. We need to reindex datasets that have the same coordinate + # values but in a different order to ensure proper alignment. + if override and len(data) > 1: + reference = data[0] + aligned_data = [reference] + for ds in data[1:]: + needs_reindex = False + for dim in reference.dims: + if dim in HELPER_DIMS or dim not in ds.dims: + continue + if dim not in reference.coords or dim not in ds.coords: + continue + ref_coord = reference.coords[dim].values + ds_coord = ds.coords[dim].values + # Check: same length, same set of values, but different order + if len(ref_coord) == len(ds_coord) and not np.array_equal( + ref_coord, ds_coord + ): + try: + same_values = set(ref_coord) == set(ds_coord) + except TypeError: + # Unhashable types (e.g., tuples) - convert to strings + same_values = {str(v) for v in ref_coord} == { + str(v) for v in ds_coord + } + if same_values: + needs_reindex = True + break + if needs_reindex: + aligned_data.append(ds.reindex_like(reference)) + else: + aligned_data.append(ds) + data = aligned_data + if not kwargs: kwargs = { "coords": "minimal", From 09ecc7ccaf5a5dc85f5eb2a13ebd611272a87f85 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 20 Jan 2026 19:59:20 +0100 Subject: [PATCH 02/11] Second attempt --- linopy/expressions.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index ac27c468..1461200a 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -2129,7 +2129,7 @@ def merge( reference = data[0] aligned_data = [reference] for ds in data[1:]: - needs_reindex = False + reindex_dims = {} for dim in reference.dims: if dim in HELPER_DIMS or dim not in ds.dims: continue @@ -2149,10 +2149,9 @@ def merge( str(v) for v in ds_coord } if same_values: - needs_reindex = True - break - if needs_reindex: - aligned_data.append(ds.reindex_like(reference)) + reindex_dims[dim] = reference.coords[dim] + if reindex_dims: + aligned_data.append(ds.reindex(reindex_dims)) else: aligned_data.append(ds) data = aligned_data From 2902df1e2d3c79790a6e56458c319776fcb0f18b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:12:31 +0100 Subject: [PATCH 03/11] Third attempt --- linopy/expressions.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index 1461200a..51224e40 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -2128,15 +2128,15 @@ def merge( if override and len(data) > 1: reference = data[0] aligned_data = [reference] - for ds in data[1:]: + for ds_item in data[1:]: reindex_dims = {} - for dim in reference.dims: - if dim in HELPER_DIMS or dim not in ds.dims: + for dim_name in reference.dims: + if dim_name in HELPER_DIMS or dim_name not in ds_item.dims: continue - if dim not in reference.coords or dim not in ds.coords: + if dim_name not in reference.coords or dim_name not in ds_item.coords: continue - ref_coord = reference.coords[dim].values - ds_coord = ds.coords[dim].values + ref_coord = reference.coords[dim_name].values + ds_coord = ds_item.coords[dim_name].values # Check: same length, same set of values, but different order if len(ref_coord) == len(ds_coord) and not np.array_equal( ref_coord, ds_coord @@ -2149,11 +2149,11 @@ def merge( str(v) for v in ds_coord } if same_values: - reindex_dims[dim] = reference.coords[dim] + reindex_dims[dim_name] = reference.coords[dim_name] if reindex_dims: - aligned_data.append(ds.reindex(reindex_dims)) + aligned_data.append(ds_item.reindex(reindex_dims)) else: - aligned_data.append(ds) + aligned_data.append(ds_item) data = aligned_data if not kwargs: From eb0bdb714747a063fc8ac91dceeea3742a62e38f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:26:00 +0100 Subject: [PATCH 04/11] - Add test for merge with reordered coordinates to cover the reindexing logic - Mark defensive code paths with pragma: no cover (unreachable in practice) --- linopy/expressions.py | 6 +++--- test/test_linear_expression.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index 51224e40..a5b995c2 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -2134,7 +2134,7 @@ def merge( if dim_name in HELPER_DIMS or dim_name not in ds_item.dims: continue if dim_name not in reference.coords or dim_name not in ds_item.coords: - continue + continue # pragma: no cover ref_coord = reference.coords[dim_name].values ds_coord = ds_item.coords[dim_name].values # Check: same length, same set of values, but different order @@ -2143,8 +2143,8 @@ def merge( ): try: same_values = set(ref_coord) == set(ds_coord) - except TypeError: - # Unhashable types (e.g., tuples) - convert to strings + except TypeError: # pragma: no cover + # Unhashable types - convert to strings for comparison same_values = {str(v) for v in ref_coord} == { str(v) for v in ds_coord } diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index a75ace3f..f2226f81 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1194,6 +1194,33 @@ def test_merge(x: Variable, y: Variable, z: Variable) -> None: merge(expr1, expr2) +def test_merge_with_override_and_reordered_coords(m: Model) -> None: + """Test merge with join='override' when coordinates have same values but different order.""" + import pandas as pd + + # Create variables with same coordinate values but different order + coords_a = pd.Index(["x", "y", "z"], name="dim_0") + coords_b = pd.Index(["z", "x", "y"], name="dim_0") # Same values, different order + + v1 = m.add_variables(coords=[coords_a], name="v1") + v2 = m.add_variables(coords=[coords_b], name="v2") + + expr1 = 1 * v1 + expr2 = 2 * v2 + + # Merging along _term (default) triggers the override logic because + # both expressions have the same dimension sizes + res = merge([expr1, expr2], cls=LinearExpression) + + # Verify that the coordinates match the first expression's order + assert list(res.coords["dim_0"].values) == ["x", "y", "z"] + # The result should have 2 terms (one from each expression) + assert res.nterm == 2 + # Verify the coefficients are correctly aligned (not mismatched due to positional concat) + assert res.sel(dim_0="x").coeffs.values.tolist() == [1.0, 2.0] + assert res.sel(dim_0="z").coeffs.values.tolist() == [1.0, 2.0] + + def test_linear_expression_outer_sum(x: Variable, y: Variable) -> None: expr = x + y expr2: LinearExpression = sum([x, y]) # type: ignore From 28aae5b8157f1a6ab3ebcfa994bfefd2f1319ba6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:15:38 +0100 Subject: [PATCH 05/11] linopy/expressions.py: Replaced the pairwise reorder logic with a union-based approach. For each non-helper dimension, it computes the union of coordinate values across all datasets. If any mismatch is found, all datasets are reindexed to the union coordinates with FILL_VALUE defaults. This handles both the reordered-coords case and the overlapping-subset case. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/test_linear_expression.py: Added test_merge_with_overlapping_coords that creates variables with ["alice", "bob"] and ["bob", "charlie"], merges them, and verifies correct alignment — bob gets both terms, alice and charlie get only their respective term with fill values for the missing one. --- linopy/expressions.py | 62 +++++++++++++++++----------------- test/test_linear_expression.py | 30 ++++++++++++++++ 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index a5b995c2..853dd6d2 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -2123,38 +2123,38 @@ def merge( data = [fill_missing_coords(ds, fill_helper_dims=True) for ds in data] # When using join='override', xr.concat places values positionally instead of - # aligning by label. We need to reindex datasets that have the same coordinate - # values but in a different order to ensure proper alignment. + # aligning by label. We need to reindex datasets that have mismatched coordinate + # values (different order or different subsets) to ensure proper alignment. if override and len(data) > 1: - reference = data[0] - aligned_data = [reference] - for ds_item in data[1:]: - reindex_dims = {} - for dim_name in reference.dims: - if dim_name in HELPER_DIMS or dim_name not in ds_item.dims: - continue - if dim_name not in reference.coords or dim_name not in ds_item.coords: - continue # pragma: no cover - ref_coord = reference.coords[dim_name].values - ds_coord = ds_item.coords[dim_name].values - # Check: same length, same set of values, but different order - if len(ref_coord) == len(ds_coord) and not np.array_equal( - ref_coord, ds_coord - ): - try: - same_values = set(ref_coord) == set(ds_coord) - except TypeError: # pragma: no cover - # Unhashable types - convert to strings for comparison - same_values = {str(v) for v in ref_coord} == { - str(v) for v in ds_coord - } - if same_values: - reindex_dims[dim_name] = reference.coords[dim_name] - if reindex_dims: - aligned_data.append(ds_item.reindex(reindex_dims)) - else: - aligned_data.append(ds_item) - data = aligned_data + union_coords: dict[Hashable, list] = {} + needs_reindex = False + for dim_name in data[0].dims: + if dim_name in HELPER_DIMS: + continue + all_vals = [] + for ds_item in data: + if dim_name in ds_item.coords: + all_vals.append(ds_item.coords[dim_name].values) + if len(all_vals) > 1: + ref = all_vals[0] + for other in all_vals[1:]: + if not np.array_equal(ref, other): + # Build union preserving insertion order + seen: set = set() + union: list = [] + for vals in all_vals: + for v in vals: + key = v if isinstance(v, Hashable) else str(v) + if key not in seen: + seen.add(key) + union.append(v) + union_coords[dim_name] = union + needs_reindex = True + break + + if needs_reindex: + reindex_map = {k: pd.Index(v) for k, v in union_coords.items()} + data = [ds.reindex(reindex_map, fill_value=FILL_VALUE) for ds in data] if not kwargs: kwargs = { diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index f2226f81..f785de03 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -1221,6 +1221,36 @@ def test_merge_with_override_and_reordered_coords(m: Model) -> None: assert res.sel(dim_0="z").coeffs.values.tolist() == [1.0, 2.0] +def test_merge_with_overlapping_coords(m: Model) -> None: + """Test merge when expressions have overlapping but different coordinate subsets.""" + import pandas as pd + + coords_a = pd.Index(["alice", "bob"], name="person") + coords_b = pd.Index(["bob", "charlie"], name="person") + + v1 = m.add_variables(coords=[coords_a], name="ov1") + v2 = m.add_variables(coords=[coords_b], name="ov2") + + expr1 = 1 * v1 + expr2 = 2 * v2 + + res = merge([expr1, expr2], cls=LinearExpression) + + # Union coords should be alice, bob, charlie + assert list(res.coords["person"].values) == ["alice", "bob", "charlie"] + assert res.nterm == 2 + # bob: in both → coeffs [1, 2] + assert res.sel(person="bob").coeffs.values.tolist() == [1.0, 2.0] + # alice: only in expr1 → first term has coeff 1, second is fill (nan) + alice_coeffs = res.sel(person="alice").coeffs.values + assert alice_coeffs[0] == 1.0 + assert np.isnan(alice_coeffs[1]) + # charlie: only in expr2 → first term is fill (nan), second has coeff 2 + charlie_coeffs = res.sel(person="charlie").coeffs.values + assert np.isnan(charlie_coeffs[0]) + assert charlie_coeffs[1] == 2.0 + + def test_linear_expression_outer_sum(x: Variable, y: Variable) -> None: expr = x + y expr2: LinearExpression = sum([x, y]) # type: ignore From 1cddddd48885168cb312dfc0b4fa99e3e9590d61 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:28:19 +0100 Subject: [PATCH 06/11] instead of checking dimension sizes match (via check_common_keys_values) and then patching up mismatches with reindexing, _check_coords_match now checks that actual coordinate values and order are identical. When they're not, override is False and xarray's join='outer' handles alignment correctly. The entire reindex block is gone. --- linopy/expressions.py | 62 ++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 39 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index 853dd6d2..f8102bd5 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -47,7 +47,6 @@ LocIndexer, as_dataarray, assign_multiindex_safe, - check_common_keys_values, check_has_nulls, check_has_nulls_polars, fill_missing_coords, @@ -2049,6 +2048,28 @@ def as_expression( return LinearExpression(obj, model) +def _check_coords_match(exprs: Sequence) -> bool: + """ + Check that all expressions have identical coordinate values (and order) + for every non-helper dimension they share. Returns True only when + join='override' (positional concat) is safe. + """ + if len(exprs) < 2: + return True + ref = exprs[0] + for other in exprs[1:]: + for dim_name in ref.dims: + if dim_name in HELPER_DIMS or dim_name not in other.dims: + continue + if dim_name not in ref.coords or dim_name not in other.coords: + continue # pragma: no cover + if not np.array_equal( + ref.coords[dim_name].values, other.coords[dim_name].values + ): + return False + return True + + def merge( exprs: Sequence[ LinearExpression | QuadraticExpression | variables.Variable | Dataset @@ -2112,50 +2133,13 @@ def merge( model = exprs[0].model if cls in linopy_types and dim in HELPER_DIMS: - coord_dims = [ - {k: v for k, v in e.sizes.items() if k not in HELPER_DIMS} for e in exprs - ] - override = check_common_keys_values(coord_dims) # type: ignore + override = _check_coords_match(exprs) else: override = False data = [e.data if isinstance(e, linopy_types) else e for e in exprs] data = [fill_missing_coords(ds, fill_helper_dims=True) for ds in data] - # When using join='override', xr.concat places values positionally instead of - # aligning by label. We need to reindex datasets that have mismatched coordinate - # values (different order or different subsets) to ensure proper alignment. - if override and len(data) > 1: - union_coords: dict[Hashable, list] = {} - needs_reindex = False - for dim_name in data[0].dims: - if dim_name in HELPER_DIMS: - continue - all_vals = [] - for ds_item in data: - if dim_name in ds_item.coords: - all_vals.append(ds_item.coords[dim_name].values) - if len(all_vals) > 1: - ref = all_vals[0] - for other in all_vals[1:]: - if not np.array_equal(ref, other): - # Build union preserving insertion order - seen: set = set() - union: list = [] - for vals in all_vals: - for v in vals: - key = v if isinstance(v, Hashable) else str(v) - if key not in seen: - seen.add(key) - union.append(v) - union_coords[dim_name] = union - needs_reindex = True - break - - if needs_reindex: - reindex_map = {k: pd.Index(v) for k, v in union_coords.items()} - data = [ds.reindex(reindex_map, fill_value=FILL_VALUE) for ds in data] - if not kwargs: kwargs = { "coords": "minimal", From 3ce7abc1c38ff7e1ab4f0c3b471f9a36cc08251b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:34:43 +0100 Subject: [PATCH 07/11] Update failing tests --- test/test_linear_expression.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index f785de03..f45c6202 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -441,10 +441,10 @@ def test_linear_expression_sum( assert_linequal(expr.sum(["dim_0", TERM_DIM]), expr.sum("dim_0")) - # test special case otherride coords + # Disjoint coordinate subsets: outer join produces the union of coords expr = v.loc[:9] + v.loc[10:] assert expr.nterm == 2 - assert len(expr.coords["dim_2"]) == 10 + assert len(expr.coords["dim_2"]) == 20 def test_linear_expression_sum_with_const( @@ -465,10 +465,10 @@ def test_linear_expression_sum_with_const( assert_linequal(expr.sum(["dim_0", TERM_DIM]), expr.sum("dim_0")) - # test special case otherride coords + # Disjoint coordinate subsets: outer join produces the union of coords expr = v.loc[:9] + v.loc[10:] assert expr.nterm == 2 - assert len(expr.coords["dim_2"]) == 10 + assert len(expr.coords["dim_2"]) == 20 def test_linear_expression_sum_drop_zeros(z: Variable) -> None: From 0890dd117270af850a7446d842967de88e1cc3a2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:41:39 +0100 Subject: [PATCH 08/11] Update release_notes.rst --- doc/release_notes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index d9bc95aa..678cd526 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,7 @@ Release Notes .. Upcoming Version * Fix LP file writing for negative zero (-0.0) values that produced invalid syntax like "+-0.0" rejected by Gurobi +* Fix expression merge to properly align coordinates instead of placing values positionally; expressions with mismatched coordinates now use label-based alignment via ``join='outer'``. **Breaking:** expressions with disjoint coordinate subsets of the same size previously merged positionally (silently incorrect); they now produce the union of coordinates via outer join. Version 0.6.0 -------------- From f34584d7963d2c802a1493124ca92ba4573deb6a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:42:54 +0100 Subject: [PATCH 09/11] remove dead code --- linopy/common.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/linopy/common.py b/linopy/common.py index 7dd97b65..49b9df16 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -1159,24 +1159,6 @@ def deco(cls: Any) -> Any: return deco -def check_common_keys_values(list_of_dicts: list[dict[str, Any]]) -> bool: - """ - Check if all common keys among a list of dictionaries have the same value. - - Parameters - ---------- - list_of_dicts : list of dict - A list of dictionaries. - - Returns - ------- - bool - True if all common keys have the same value across all dictionaries, False otherwise. - """ - common_keys = set.intersection(*(set(d.keys()) for d in list_of_dicts)) - return all(len({d[k] for d in list_of_dicts if k in d}) == 1 for k in common_keys) - - def align( *objects: LinearExpression | QuadraticExpression | Variable | T_Alignable, join: JoinOptions = "inner", From e200c782745648e0393ed4e8cff61ba59998f30f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:07:21 +0100 Subject: [PATCH 10/11] Revert latest changes --- linopy/common.py | 18 ++++++++++ linopy/expressions.py | 62 +++++++++++++++++++++------------- test/test_linear_expression.py | 23 +++++++------ 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/linopy/common.py b/linopy/common.py index 49b9df16..7dd97b65 100644 --- a/linopy/common.py +++ b/linopy/common.py @@ -1159,6 +1159,24 @@ def deco(cls: Any) -> Any: return deco +def check_common_keys_values(list_of_dicts: list[dict[str, Any]]) -> bool: + """ + Check if all common keys among a list of dictionaries have the same value. + + Parameters + ---------- + list_of_dicts : list of dict + A list of dictionaries. + + Returns + ------- + bool + True if all common keys have the same value across all dictionaries, False otherwise. + """ + common_keys = set.intersection(*(set(d.keys()) for d in list_of_dicts)) + return all(len({d[k] for d in list_of_dicts if k in d}) == 1 for k in common_keys) + + def align( *objects: LinearExpression | QuadraticExpression | Variable | T_Alignable, join: JoinOptions = "inner", diff --git a/linopy/expressions.py b/linopy/expressions.py index f8102bd5..a5b995c2 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -47,6 +47,7 @@ LocIndexer, as_dataarray, assign_multiindex_safe, + check_common_keys_values, check_has_nulls, check_has_nulls_polars, fill_missing_coords, @@ -2048,28 +2049,6 @@ def as_expression( return LinearExpression(obj, model) -def _check_coords_match(exprs: Sequence) -> bool: - """ - Check that all expressions have identical coordinate values (and order) - for every non-helper dimension they share. Returns True only when - join='override' (positional concat) is safe. - """ - if len(exprs) < 2: - return True - ref = exprs[0] - for other in exprs[1:]: - for dim_name in ref.dims: - if dim_name in HELPER_DIMS or dim_name not in other.dims: - continue - if dim_name not in ref.coords or dim_name not in other.coords: - continue # pragma: no cover - if not np.array_equal( - ref.coords[dim_name].values, other.coords[dim_name].values - ): - return False - return True - - def merge( exprs: Sequence[ LinearExpression | QuadraticExpression | variables.Variable | Dataset @@ -2133,13 +2112,50 @@ def merge( model = exprs[0].model if cls in linopy_types and dim in HELPER_DIMS: - override = _check_coords_match(exprs) + coord_dims = [ + {k: v for k, v in e.sizes.items() if k not in HELPER_DIMS} for e in exprs + ] + override = check_common_keys_values(coord_dims) # type: ignore else: override = False data = [e.data if isinstance(e, linopy_types) else e for e in exprs] data = [fill_missing_coords(ds, fill_helper_dims=True) for ds in data] + # When using join='override', xr.concat places values positionally instead of + # aligning by label. We need to reindex datasets that have the same coordinate + # values but in a different order to ensure proper alignment. + if override and len(data) > 1: + reference = data[0] + aligned_data = [reference] + for ds_item in data[1:]: + reindex_dims = {} + for dim_name in reference.dims: + if dim_name in HELPER_DIMS or dim_name not in ds_item.dims: + continue + if dim_name not in reference.coords or dim_name not in ds_item.coords: + continue # pragma: no cover + ref_coord = reference.coords[dim_name].values + ds_coord = ds_item.coords[dim_name].values + # Check: same length, same set of values, but different order + if len(ref_coord) == len(ds_coord) and not np.array_equal( + ref_coord, ds_coord + ): + try: + same_values = set(ref_coord) == set(ds_coord) + except TypeError: # pragma: no cover + # Unhashable types - convert to strings for comparison + same_values = {str(v) for v in ref_coord} == { + str(v) for v in ds_coord + } + if same_values: + reindex_dims[dim_name] = reference.coords[dim_name] + if reindex_dims: + aligned_data.append(ds_item.reindex(reindex_dims)) + else: + aligned_data.append(ds_item) + data = aligned_data + if not kwargs: kwargs = { "coords": "minimal", diff --git a/test/test_linear_expression.py b/test/test_linear_expression.py index f45c6202..e66c8975 100644 --- a/test/test_linear_expression.py +++ b/test/test_linear_expression.py @@ -441,10 +441,10 @@ def test_linear_expression_sum( assert_linequal(expr.sum(["dim_0", TERM_DIM]), expr.sum("dim_0")) - # Disjoint coordinate subsets: outer join produces the union of coords + # test special case override coords expr = v.loc[:9] + v.loc[10:] assert expr.nterm == 2 - assert len(expr.coords["dim_2"]) == 20 + assert len(expr.coords["dim_2"]) == 10 def test_linear_expression_sum_with_const( @@ -465,10 +465,10 @@ def test_linear_expression_sum_with_const( assert_linequal(expr.sum(["dim_0", TERM_DIM]), expr.sum("dim_0")) - # Disjoint coordinate subsets: outer join produces the union of coords + # test special case override coords expr = v.loc[:9] + v.loc[10:] assert expr.nterm == 2 - assert len(expr.coords["dim_2"]) == 20 + assert len(expr.coords["dim_2"]) == 10 def test_linear_expression_sum_drop_zeros(z: Variable) -> None: @@ -1221,20 +1221,23 @@ def test_merge_with_override_and_reordered_coords(m: Model) -> None: assert res.sel(dim_0="z").coeffs.values.tolist() == [1.0, 2.0] -def test_merge_with_overlapping_coords(m: Model) -> None: - """Test merge when expressions have overlapping but different coordinate subsets.""" +def test_align_with_overlapping_coords(m: Model) -> None: + """ + Test that linopy.align enables correct addition of expressions with + overlapping but different coordinate subsets. + """ import pandas as pd + from linopy import align + coords_a = pd.Index(["alice", "bob"], name="person") coords_b = pd.Index(["bob", "charlie"], name="person") v1 = m.add_variables(coords=[coords_a], name="ov1") v2 = m.add_variables(coords=[coords_b], name="ov2") - expr1 = 1 * v1 - expr2 = 2 * v2 - - res = merge([expr1, expr2], cls=LinearExpression) + expr1, expr2 = align(1 * v1, 2 * v2, join="outer") + res = expr1 + expr2 # Union coords should be alice, bob, charlie assert list(res.coords["person"].values) == ["alice", "bob", "charlie"] From 18fa72120396afa81eda6517a1c9e03f95b98e6a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:10:21 +0100 Subject: [PATCH 11/11] Revert latest changes --- doc/release_notes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 678cd526..dfa22d21 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,7 +4,7 @@ Release Notes .. Upcoming Version * Fix LP file writing for negative zero (-0.0) values that produced invalid syntax like "+-0.0" rejected by Gurobi -* Fix expression merge to properly align coordinates instead of placing values positionally; expressions with mismatched coordinates now use label-based alignment via ``join='outer'``. **Breaking:** expressions with disjoint coordinate subsets of the same size previously merged positionally (silently incorrect); they now produce the union of coordinates via outer join. +* Fix expression merge to properly reindex coordinates when expressions have the same coordinate values in different order, preventing silent data corruption with ``join='override'``. For expressions with different coordinate subsets, use ``linopy.align(..., join='outer')`` before adding. Version 0.6.0 --------------