Skip to content

Commit fccfc6e

Browse files
Feature/simplify expression (#530)
* first attempt * formatting * updated doc * fixed test * fix simplify tests: correct comment and add cancellation tests - Fix misleading comment/error message (coefficient is 6, not 5) - Add test for full cancellation (x - x = 0) - Add test for partial cancellation (2x - 2x + 3y = 3y) --------- Co-authored-by: Fabian <[email protected]>
1 parent be4657d commit fccfc6e

File tree

4 files changed

+178
-0
lines changed

4 files changed

+178
-0
lines changed

doc/release_notes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ Release Notes
22
=============
33

44
.. Upcoming Version
5+
6+
* Add simplify method to LinearExpression to combine duplicate terms
57
* Add convenience function to create LinearExpression from constant
68
* Fix compatibility for xpress versions below 9.6 (regression)
79
* Performance: Up to 50x faster ``repr()`` for variables/constraints via O(log n) label lookup and direct numpy indexing

linopy/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,14 @@
3939
GROUP_DIM = "_group"
4040
FACTOR_DIM = "_factor"
4141
CONCAT_DIM = "_concat"
42+
CV_DIM = "_cv"
4243
HELPER_DIMS: list[str] = [
4344
TERM_DIM,
4445
STACKED_TERM_DIM,
4546
GROUPED_TERM_DIM,
4647
FACTOR_DIM,
4748
CONCAT_DIM,
49+
CV_DIM,
4850
]
4951

5052

linopy/expressions.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
)
6868
from linopy.config import options
6969
from linopy.constants import (
70+
CV_DIM,
7071
EQUAL,
7172
FACTOR_DIM,
7273
GREATER_EQUAL,
@@ -1466,6 +1467,94 @@ def to_polars(self) -> pl.DataFrame:
14661467
check_has_nulls_polars(df, name=self.type)
14671468
return df
14681469

1470+
def simplify(self) -> LinearExpression:
1471+
"""
1472+
Simplify the linear expression by combining terms with the same variable.
1473+
1474+
This method finds all terms that reference the same variable and adds
1475+
their coefficients together, reducing the number of terms in the expression.
1476+
1477+
Returns
1478+
-------
1479+
LinearExpression
1480+
A new LinearExpression with combined terms.
1481+
1482+
Examples
1483+
--------
1484+
>>> from linopy import Model
1485+
>>> m = Model()
1486+
>>> x = m.add_variables(name="x")
1487+
>>> expr = 2 * x + 3 * x # Creates two terms
1488+
>>> simplified = expr.simplify() # Combines into one term: 5 * x
1489+
"""
1490+
1491+
def _simplify_row(vars_row: np.ndarray, coeffs_row: np.ndarray) -> np.ndarray:
1492+
"""
1493+
For a given combination of expression coordinates, try to simplify by reducing duplicate variables
1494+
"""
1495+
input_len = len(vars_row)
1496+
1497+
# Filter out invalid entries
1498+
mask = (vars_row != -1) & (coeffs_row != 0) & ~np.isnan(coeffs_row)
1499+
valid_vars = vars_row[mask]
1500+
valid_coeffs = coeffs_row[mask]
1501+
1502+
if len(valid_vars) == 0:
1503+
# Return arrays filled with -1 and 0.0, same length as input
1504+
return np.vstack(
1505+
[
1506+
np.full(input_len, -1, dtype=float),
1507+
np.zeros(input_len, dtype=float),
1508+
]
1509+
)
1510+
1511+
# Use bincount to sum coefficients for each variable ID efficiently
1512+
max_var = int(valid_vars.max())
1513+
summed = np.bincount(
1514+
valid_vars, weights=valid_coeffs, minlength=max_var + 1
1515+
)
1516+
1517+
# Get non-zero entries
1518+
unique_vars = np.where(summed != 0)[0]
1519+
unique_coeffs = summed[unique_vars]
1520+
1521+
# Pad to match input length
1522+
result_vars = np.full(input_len, -1, dtype=float)
1523+
result_coeffs = np.zeros(input_len, dtype=float)
1524+
1525+
n_unique = len(unique_vars)
1526+
result_vars[:n_unique] = unique_vars
1527+
result_coeffs[:n_unique] = unique_coeffs
1528+
1529+
return np.vstack([result_vars, result_coeffs])
1530+
1531+
# Coeffs and vars have dimensions (.., TERM_DIM) where .. are the coordinate dimensions of the expression
1532+
# An operation is applied over the coordinate dimensions on both coeffs and vars, which are stacked together over a new "CV_DIM" dimension
1533+
combined: xr.DataArray = xr.apply_ufunc(
1534+
_simplify_row,
1535+
self.vars,
1536+
self.coeffs,
1537+
input_core_dims=[[TERM_DIM], [TERM_DIM]],
1538+
output_core_dims=[[CV_DIM, TERM_DIM]],
1539+
vectorize=True,
1540+
)
1541+
# Combined has dimensions (.., CV_DIM, TERM_DIM)
1542+
1543+
# Drop terms where all vars are -1 (i.e., empty terms across all coordinates)
1544+
vars = combined.isel({CV_DIM: 0}).astype(int)
1545+
non_empty_terms = (vars != -1).any(dim=[d for d in vars.dims if d != TERM_DIM])
1546+
combined = combined.isel({TERM_DIM: non_empty_terms})
1547+
1548+
# Extract vars and coeffs from the combined result
1549+
vars = combined.isel({CV_DIM: 0}).astype(int)
1550+
coeffs = combined.isel({CV_DIM: 1})
1551+
1552+
# Create new dataset with simplified data
1553+
new_data = self.data.copy()
1554+
new_data = assign_multiindex_safe(new_data, vars=vars, coeffs=coeffs)
1555+
1556+
return LinearExpression(new_data, self.model)
1557+
14691558
@classmethod
14701559
def _from_scalarexpression_list(
14711560
cls,

test/test_linear_expression.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,3 +1228,88 @@ def test_cumsum(m: Model, multiple: float) -> None:
12281228
expr = m.variables["x"] + m.variables["y"]
12291229
cumsum = (multiple * expr).cumsum()
12301230
cumsum.nterm == 2
1231+
1232+
1233+
def test_simplify_basic(x: Variable) -> None:
1234+
"""Test basic simplification with duplicate terms."""
1235+
expr = 2 * x + 3 * x + 1 * x
1236+
simplified = expr.simplify()
1237+
assert simplified.nterm == 1, f"Expected 1 term, got {simplified.nterm}"
1238+
1239+
x_len = len(x.coords["dim_0"])
1240+
# Check that the coefficient is 6 (2 + 3 + 1)
1241+
coeffs: np.ndarray = simplified.coeffs.values
1242+
assert len(coeffs) == x_len, f"Expected {x_len} coefficients, got {len(coeffs)}"
1243+
assert all(coeffs == 6.0), f"Expected coefficient 6.0, got {coeffs[0]}"
1244+
1245+
1246+
def test_simplify_multiple_dimensions() -> None:
1247+
model = Model()
1248+
a_index = pd.Index([0, 1, 2, 3], name="a")
1249+
b_index = pd.Index([0, 1, 2], name="b")
1250+
coords = [a_index, b_index]
1251+
x = model.add_variables(name="x", coords=coords)
1252+
1253+
expr = 2 * x + 3 * x + x
1254+
# Simplify
1255+
simplified = expr.simplify()
1256+
assert simplified.nterm == 1, f"Expected 1 term, got {simplified.nterm}"
1257+
assert simplified.ndim == 2, f"Expected 2 dimensions, got {simplified.ndim}"
1258+
assert all(simplified.coeffs.values.reshape(-1) == 6), (
1259+
f"Expected coefficients of 6, got {simplified.coeffs.values}"
1260+
)
1261+
1262+
1263+
def test_simplify_with_different_variables(x: Variable, y: Variable) -> None:
1264+
"""Test that different variables are kept separate."""
1265+
# Create expression: 2*x + 3*x + 4*y
1266+
expr = 2 * x + 3 * x + 4 * y
1267+
1268+
# Simplify
1269+
simplified = expr.simplify()
1270+
# Should have 2 terms (one for x with coeff 5, one for y with coeff 4)
1271+
assert simplified.nterm == 2, f"Expected 2 terms, got {simplified.nterm}"
1272+
1273+
coeffs: list[float] = simplified.coeffs.values.flatten().tolist()
1274+
assert set(coeffs) == {5.0, 4.0}, (
1275+
f"Expected coefficients {{5.0, 4.0}}, got {set(coeffs)}"
1276+
)
1277+
1278+
1279+
def test_simplify_with_constant(x: Variable) -> None:
1280+
"""Test that constants are preserved."""
1281+
expr = 2 * x + 3 * x + 10
1282+
1283+
# Simplify
1284+
simplified = expr.simplify()
1285+
1286+
# Check constant is preserved
1287+
assert all(simplified.const.values == 10.0), (
1288+
f"Expected constant 10.0, got {simplified.const.values}"
1289+
)
1290+
1291+
# Check coefficients
1292+
assert all(simplified.coeffs.values == 5.0), (
1293+
f"Expected coefficient 5.0, got {simplified.coeffs.values}"
1294+
)
1295+
1296+
1297+
def test_simplify_cancellation(x: Variable) -> None:
1298+
"""Test that terms cancel out correctly when coefficients sum to zero."""
1299+
expr = x - x
1300+
simplified = expr.simplify()
1301+
1302+
assert simplified.nterm == 0, f"Expected 0 terms, got {simplified.nterm}"
1303+
assert simplified.coeffs.values.size == 0
1304+
assert simplified.vars.values.size == 0
1305+
1306+
1307+
def test_simplify_partial_cancellation(x: Variable, y: Variable) -> None:
1308+
"""Test partial cancellation where some terms cancel but others remain."""
1309+
expr = 2 * x - 2 * x + 3 * y
1310+
simplified = expr.simplify()
1311+
1312+
assert simplified.nterm == 1, f"Expected 1 term, got {simplified.nterm}"
1313+
assert all(simplified.coeffs.values == 3.0), (
1314+
f"Expected coefficient 3.0, got {simplified.coeffs.values}"
1315+
)

0 commit comments

Comments
 (0)