From d902167fd85f780485bbf6a4fc736e7d97e43f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:56:11 +0000 Subject: [PATCH 1/5] Support comparison operators in extra-output expressions (#237) Extra-output expressions such as `dual(balance) >= unsupplied_cost - 5` previously raised NotImplementedError at post-solve evaluation because VectorizedExtraOutputBuilder inherited the base comparison() that unconditionally rejects ComparisonNode. Override comparison() in the post-solve builder to evaluate both operands (concrete DataArrays) element-wise and return a float indicator (1.0 where the condition holds, 0.0 otherwise). The base builder still rejects comparisons, since pre-solve they are constraints, not values. min()/max() already worked post-solve; add regression tests covering both the min and comparison extra-output cases. Bump version to 0.1.3. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AmfPS89otsiw1q7LDGwumU --- pyproject.toml | 2 +- src/gems/simulation/extra_output.py | 30 ++++++- .../test_simulation_table_extra_outputs.py | 85 +++++++++++++++++++ uv.lock | 2 +- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e988fa21..e7b91d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gemspy" -version = "0.1.2" +version = "0.1.3" description = "Python interpreter for GEMS: modelling and simulation of complex energy systems under uncertainty" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/gems/simulation/extra_output.py b/src/gems/simulation/extra_output.py index bd5f1d78..6044d05a 100644 --- a/src/gems/simulation/extra_output.py +++ b/src/gems/simulation/extra_output.py @@ -26,12 +26,19 @@ """ from dataclasses import dataclass, field -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, cast import numpy as np import xarray as xr -from gems.expression.expression import DualNode, ReducedCostNode, VariableNode +from gems.expression.expression import ( + Comparator, + ComparisonNode, + DualNode, + ReducedCostNode, + VariableNode, +) +from gems.expression.visitor import visit from gems.model.port import PortFieldId from gems.simulation.vectorized_builder import VectorizedBuilderBase from gems.study.system import Component @@ -146,3 +153,22 @@ def reduced_cost(self, node: ReducedCostNode) -> xr.DataArray: f"{self.model_id!r}." ) return self.var_reduced_cost_arrays[key] + + def comparison(self, node: ComparisonNode) -> xr.DataArray: + """Evaluate a comparison post-solve as a float indicator DataArray. + + Both operands resolve to concrete ``xr.DataArray`` values (solved + variables, duals, parameters, ...), so the comparison is evaluated + element-wise and returned as ``1.0`` where it holds and ``0.0`` + otherwise. This is only meaningful post-solve; the pre-solve builder + keeps rejecting comparisons (they are constraints, not values). + """ + left = cast(xr.DataArray, visit(node.left, self)) + right = cast(xr.DataArray, visit(node.right, self)) + if node.comparator == Comparator.LESS_THAN: # "<=" + indicator = left <= right + elif node.comparator == Comparator.GREATER_THAN: # ">=" + indicator = left >= right + else: # Comparator.EQUAL -> "=" + indicator = left == right + return cast(xr.DataArray, indicator).astype(float) diff --git a/tests/unittests/simulation/test_simulation_table_extra_outputs.py b/tests/unittests/simulation/test_simulation_table_extra_outputs.py index 7e71d980..65d3da88 100644 --- a/tests/unittests/simulation/test_simulation_table_extra_outputs.py +++ b/tests/unittests/simulation/test_simulation_table_extra_outputs.py @@ -162,3 +162,88 @@ def test_extra_output_abs_round_on_variable() -> None: ) assert abs_shift == pytest.approx(2.3) assert rounded == pytest.approx(3.0) + + +def test_extra_output_min_on_variable() -> None: + """ + min(variable, parameter) is allowed in extra outputs and evaluated + element-wise post-solve (issue #237). + """ + from gems.expression import param, var + from gems.expression.expression import literal, minimum + from gems.model.model import model + from gems.model.parameter import float_parameter + from gems.model.variable import float_variable + from gems.simulation import TimeBlock, build_problem + from gems.study import ConstantData, DataBase, Study, System, create_component + + SIMPLE_MODEL = model( + id="SIMPLE_MIN", + parameters=[float_parameter("cap")], + variables=[float_variable("a", lower_bound=literal(5), upper_bound=literal(5))], + extra_outputs={"capped": minimum(var("a"), param("cap"))}, + ) + + database = DataBase() + comp = create_component(model=SIMPLE_MODEL, id="comp_1") + database.add_data("comp_1", "cap", ConstantData(3.0)) + + system = System("test_min_extra") + system.add_component(comp) + + problem = build_problem( + Study(system, database), TimeBlock(1, [0]), scenario_ids=list(range(1)) + ) + problem.solve(solver_name="highs") + + df = SimulationTableBuilder().build(problem) + capped = ( + df.component("comp_1").output("capped").value(time_index=0, scenario_index=0) + ) + # min(a=5, cap=3) == 3 + assert capped == pytest.approx(3.0) + + +def test_extra_output_comparison() -> None: + """ + Comparison operators (>=, <=) are allowed in extra outputs and evaluated + post-solve as a float indicator: 1.0 where the condition holds, 0.0 + otherwise (issue #237). Previously these raised NotImplementedError. + """ + from gems.expression import param, var + from gems.expression.expression import Comparator, ComparisonNode, literal + from gems.model.model import model + from gems.model.parameter import float_parameter + from gems.model.variable import float_variable + from gems.simulation import TimeBlock, build_problem + from gems.study import ConstantData, DataBase, Study, System, create_component + + SIMPLE_MODEL = model( + id="SIMPLE_CMP", + parameters=[float_parameter("threshold")], + variables=[float_variable("a", lower_bound=literal(5), upper_bound=literal(5))], + extra_outputs={ + # a (=5) >= threshold (=3) -> 1.0 + "ge": ComparisonNode(var("a"), param("threshold"), Comparator.GREATER_THAN), + # a (=5) <= threshold (=3) -> 0.0 + "le": ComparisonNode(var("a"), param("threshold"), Comparator.LESS_THAN), + }, + ) + + database = DataBase() + comp = create_component(model=SIMPLE_MODEL, id="comp_1") + database.add_data("comp_1", "threshold", ConstantData(3.0)) + + system = System("test_comparison_extra") + system.add_component(comp) + + problem = build_problem( + Study(system, database), TimeBlock(1, [0]), scenario_ids=list(range(1)) + ) + problem.solve(solver_name="highs") + + df = SimulationTableBuilder().build(problem) + ge = df.component("comp_1").output("ge").value(time_index=0, scenario_index=0) + le = df.component("comp_1").output("le").value(time_index=0, scenario_index=0) + assert ge == pytest.approx(1.0) + assert le == pytest.approx(0.0) diff --git a/uv.lock b/uv.lock index f6f6edd8..c0442cc4 100644 --- a/uv.lock +++ b/uv.lock @@ -678,7 +678,7 @@ wheels = [ [[package]] name = "gemspy" -version = "0.1.2" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "antlr4-python3-runtime" }, From 77cb37993581112103802362ab2b1abf82851f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:56:23 +0000 Subject: [PATCH 2/5] Revert version bump, document extra-output comparison fix in changelog Keep the version at 0.1.2 (revert pyproject.toml and uv.lock) and record the extra-output comparison-operator bug fix under an [Unreleased] changelog section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AmfPS89otsiw1q7LDGwumU --- docs/CHANGELOG.md | 9 +++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b901c7e6..4b24fcd4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to GemsPy are documented here. +## [Unreleased] + +### Fixed +- Comparison operators (`>=`, `<=`, `=`) in extra-output expressions no longer raise + `NotImplementedError` at post-solve evaluation; they now evaluate to a 0/1 indicator + (e.g. `dual(balance) >= unsupplied_cost - 5`). + +--- + ## [0.1.2] - 2026-06-11 ### Added diff --git a/pyproject.toml b/pyproject.toml index e7b91d8d..e988fa21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gemspy" -version = "0.1.3" +version = "0.1.2" description = "Python interpreter for GEMS: modelling and simulation of complex energy systems under uncertainty" readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index c0442cc4..f6f6edd8 100644 --- a/uv.lock +++ b/uv.lock @@ -678,7 +678,7 @@ wheels = [ [[package]] name = "gemspy" -version = "0.1.3" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "antlr4-python3-runtime" }, From e095198c47923db3c9843aa7842e2aa3c8c70ffe Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:45:03 +0200 Subject: [PATCH 3/5] Apply suggestion from @aoustry --- .../unittests/simulation/test_simulation_table_extra_outputs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/simulation/test_simulation_table_extra_outputs.py b/tests/unittests/simulation/test_simulation_table_extra_outputs.py index 65d3da88..0a358e12 100644 --- a/tests/unittests/simulation/test_simulation_table_extra_outputs.py +++ b/tests/unittests/simulation/test_simulation_table_extra_outputs.py @@ -208,7 +208,7 @@ def test_extra_output_comparison() -> None: """ Comparison operators (>=, <=) are allowed in extra outputs and evaluated post-solve as a float indicator: 1.0 where the condition holds, 0.0 - otherwise (issue #237). Previously these raised NotImplementedError. + otherwise. Previously these raised NotImplementedError. """ from gems.expression import param, var from gems.expression.expression import Comparator, ComparisonNode, literal From 58c4f3da420ceec1a985fa0b951102644f22d217 Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:45:32 +0200 Subject: [PATCH 4/5] Apply suggestion from @aoustry --- .../unittests/simulation/test_simulation_table_extra_outputs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/simulation/test_simulation_table_extra_outputs.py b/tests/unittests/simulation/test_simulation_table_extra_outputs.py index 0a358e12..63b8c64f 100644 --- a/tests/unittests/simulation/test_simulation_table_extra_outputs.py +++ b/tests/unittests/simulation/test_simulation_table_extra_outputs.py @@ -167,7 +167,7 @@ def test_extra_output_abs_round_on_variable() -> None: def test_extra_output_min_on_variable() -> None: """ min(variable, parameter) is allowed in extra outputs and evaluated - element-wise post-solve (issue #237). + element-wise post-solve. """ from gems.expression import param, var from gems.expression.expression import literal, minimum From 25fab1c3efda2fc5fe828131ae023c7a9d8464de Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:56:18 +0200 Subject: [PATCH 5/5] fix: filter solution/dual arrays to own model components in extra outputs (#246) * fix: filter solution/dual arrays to own model components in extra outputs When multiple models coexist in a problem, linopy's merged solution/dual Datasets outer-join their component coordinates, padding each model's variable and constraint-dual arrays with NaN entries for other models' components. minimum()/maximum() in extra outputs use xr.where, which requires aligned coordinates and breaks on this padding. Filter var_solution_arrays and constraint_dual_arrays back down to each model's own components in simulation_table.py, mirroring the existing guard in _collect_vars_outputs. Extends test_extra_output_min_on_variable with a second coexisting model to reproduce and guard against the issue, per Juliette Gerbaux's review on #240. --- docs/CHANGELOG.md | 4 ++++ src/gems/simulation/simulation_table.py | 11 +++++++++-- .../simulation/test_simulation_table_extra_outputs.py | 9 +++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4b24fcd4..5916016b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to GemsPy are documented here. - Comparison operators (`>=`, `<=`, `=`) in extra-output expressions no longer raise `NotImplementedError` at post-solve evaluation; they now evaluate to a 0/1 indicator (e.g. `dual(balance) >= unsupplied_cost - 5`). +- Extra-output `minimum()`/`maximum()` no longer break when several models coexist + in the same problem: solution and constraint-dual arrays are now filtered back + down to each model's own components before evaluation, instead of keeping the + NaN-padded entries introduced by linopy's outer join across models. --- diff --git a/src/gems/simulation/simulation_table.py b/src/gems/simulation/simulation_table.py index e662079a..9f8abbea 100644 --- a/src/gems/simulation/simulation_table.py +++ b/src/gems/simulation/simulation_table.py @@ -271,7 +271,11 @@ def _collect_extra_outputs( if solution is not None: for (mk, vname), lv in problem._linopy_vars.items(): if lv.name in solution: - var_solution_arrays[(mk, vname)] = solution[lv.name] + sol_da = solution[lv.name] + if "component" in sol_da.dims: + own_components = list(lv.coords["component"].values) + sol_da = sol_da.sel(component=own_components) + var_solution_arrays[(mk, vname)] = sol_da constraint_dual_arrays = self._collect_constraint_duals(problem) var_reduced_cost_arrays = self._collect_reduced_costs(problem) @@ -339,9 +343,10 @@ def _collect_constraint_duals( """Return constraint shadow prices keyed by (model_key, constraint_name).""" dual_dataset = problem.linopy_model.dual result: Dict[Tuple[str, str], xr.DataArray] = {} - for mk in problem.study.model_components: + for mk, components in problem.study.model_components.items(): model = problem.study.models[mk] prefix = mk.replace("-", "_") + own_components = [c.id for c in components] all_constraints = {**model.constraints, **model.binding_constraints} for cname in all_constraints: safe = cname.replace(" ", "_").replace("-", "_") @@ -355,6 +360,8 @@ def _collect_constraint_duals( dual_val = dual_val + dual_dataset[lb_name] # type: ignore[operator] if ub_name in dual_dataset: dual_val = dual_val + dual_dataset[ub_name] # type: ignore[operator] + if "component" in dual_val.dims: + dual_val = dual_val.sel(component=own_components) result[(mk, cname)] = dual_val return result diff --git a/tests/unittests/simulation/test_simulation_table_extra_outputs.py b/tests/unittests/simulation/test_simulation_table_extra_outputs.py index 63b8c64f..8dda87f2 100644 --- a/tests/unittests/simulation/test_simulation_table_extra_outputs.py +++ b/tests/unittests/simulation/test_simulation_table_extra_outputs.py @@ -188,8 +188,17 @@ def test_extra_output_min_on_variable() -> None: comp = create_component(model=SIMPLE_MODEL, id="comp_1") database.add_data("comp_1", "cap", ConstantData(3.0)) + OTHER_MODEL = model( + id="OTHER_MODEL", + parameters=[float_parameter("p")], + variables=[float_variable("b", lower_bound=literal(1), upper_bound=literal(1))], + ) + other_comp = create_component(model=OTHER_MODEL, id="comp_2") + database.add_data("comp_2", "p", ConstantData(0.0)) + system = System("test_min_extra") system.add_component(comp) + system.add_component(other_comp) problem = build_problem( Study(system, database), TimeBlock(1, [0]), scenario_ids=list(range(1))