Skip to content

Commit f4af3f1

Browse files
FBumannclaude
andauthored
feat: add BaseExpression.has_terms property (#743)
* feat: add BaseExpression.has_terms property Boolean array, true at slots with at least one live term (vars != -1), regardless of the constant. Gives downstream code a public way to find empty constraint rows for masking, without reaching into the internal vars / _term representation. Closes #741 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: group has_terms tests into a class Per review: TestHasTerms with basic/masking, const-divergence, merge+reindex, constant-only, and quadratic cases as methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 62cfe34 commit f4af3f1

4 files changed

Lines changed: 84 additions & 0 deletions

File tree

doc/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ Structure
248248
expressions.LinearExpression.coeffs
249249
expressions.LinearExpression.const
250250
expressions.LinearExpression.nterm
251+
expressions.LinearExpression.has_terms
251252

252253
Conversion
253254
----------
@@ -288,6 +289,7 @@ Structure
288289
expressions.QuadraticExpression.coeffs
289290
expressions.QuadraticExpression.const
290291
expressions.QuadraticExpression.nterm
292+
expressions.QuadraticExpression.has_terms
291293

292294
Conversion
293295
----------

doc/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Upcoming Version
55
----------------
66

77
* Add documentation about `LinearExpression.where` with `drop=True`. Add `BaseExpression.variable_names` property.
8+
* Add ``BaseExpression.has_terms`` property: boolean array, true at slots with at least one live term (`#741 <https://github.com/PyPSA/linopy/issues/741>`_).
89

910
**Features**
1011

linopy/expressions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,37 @@ def nterm(self) -> int:
13391339
"""
13401340
return len(self.data._term)
13411341

1342+
@property
1343+
def has_terms(self) -> DataArray:
1344+
"""
1345+
Get a boolean array which is true at slots with at least one live term.
1346+
1347+
A term is live when it references a variable (``vars != -1``). Slots
1348+
without any live term arise from outer joins in
1349+
:func:`merge <linopy.expressions.merge>`, from reindexing past the
1350+
original coordinates, or from masking. In contrast to
1351+
:meth:`isnull`, the constant is ignored: a slot carrying only a
1352+
constant has no terms.
1353+
1354+
Returns
1355+
-------
1356+
xr.DataArray
1357+
1358+
Examples
1359+
--------
1360+
Mask out constraint rows whose left-hand side has no terms:
1361+
1362+
>>> import linopy
1363+
>>> import pandas as pd
1364+
>>> m = linopy.Model()
1365+
>>> x = m.add_variables(coords=[pd.RangeIndex(3, name="i")], name="x")
1366+
>>> lhs = (1 * x).reindex(i=pd.RangeIndex(5, name="i"))
1367+
>>> lhs.has_terms.values
1368+
array([ True, True, True, False, False])
1369+
"""
1370+
helper_dims = set(self.vars.dims).intersection(HELPER_DIMS)
1371+
return (self.vars != -1).any(helper_dims).rename("has_terms")
1372+
13421373
@property
13431374
def variable_names(self) -> set[str]:
13441375
"""

test/test_linear_expression.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,56 @@ def test_linear_expression_isnull(v: Variable) -> None:
11561156
assert expr.isnull().sum() == 10
11571157

11581158

1159+
class TestHasTerms:
1160+
"""has_terms: true at slots with at least one live term, regardless of the constant."""
1161+
1162+
def test_basic_and_masking(self, v: Variable) -> None:
1163+
expr = np.arange(20) * v
1164+
assert expr.has_terms.all()
1165+
1166+
filter = (expr.coeffs >= 10).any(TERM_DIM)
1167+
masked = expr.where(filter)
1168+
assert_equal(masked.has_terms, filter.rename("has_terms"))
1169+
1170+
def test_ignores_const(self, v: Variable) -> None:
1171+
# has_terms differs from isnull() at slots whose constant was revived by
1172+
# fillna: no longer null, but still without terms
1173+
expr = np.arange(20) * v
1174+
filter = (expr.coeffs >= 10).any(TERM_DIM)
1175+
masked = expr.where(filter)
1176+
assert_equal(masked.isnull(), ~masked.has_terms)
1177+
1178+
filled = masked.fillna(0)
1179+
assert not filled.isnull().any()
1180+
assert_equal(filled.has_terms, filter.rename("has_terms"))
1181+
1182+
def test_merge_reindex(self, x: Variable, y: Variable) -> None:
1183+
# the nodal-balance pattern: outer merge, then reindex to a superset of
1184+
# coordinates; slots beyond the original coordinates carry no terms
1185+
lhs = merge([1 * x, 1 * y], join="outer").reindex(
1186+
dim_0=pd.RangeIndex(4, name="dim_0")
1187+
)
1188+
assert lhs.has_terms.values.tolist() == [True, True, False, False]
1189+
1190+
def test_constant_only(self, m: Model) -> None:
1191+
expr = LinearExpression(xr.DataArray([1, 2], dims=["dim_0"]), m)
1192+
assert expr.nterm == 0
1193+
assert not expr.has_terms.any()
1194+
1195+
def test_quadratic(self, v: Variable) -> None:
1196+
# linear terms inside a quadratic expression carry one factor == -1;
1197+
# they must still count as live terms
1198+
quad = v * v + 2 * v
1199+
assert quad.has_terms.all()
1200+
assert TERM_DIM not in quad.has_terms.dims
1201+
1202+
filter = xr.DataArray(
1203+
np.arange(20) >= 10, dims="dim_2", coords={"dim_2": range(20)}
1204+
)
1205+
masked = quad.where(filter)
1206+
assert_equal(masked.has_terms, filter.rename("has_terms"))
1207+
1208+
11591209
def test_linear_expression_flat(v: Variable) -> None:
11601210
coeff = np.arange(1, 21) # use non-zero coefficients
11611211
expr = coeff * v

0 commit comments

Comments
 (0)