From facc922a50090b9a1f7e8c13a666d9b82c2faefc Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:38:56 +0200 Subject: [PATCH 01/10] feat/ add operators abs & round (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add abs and round unary operators (#216) Two new unary operators that mirror the floor/ceil pattern: - abs(x): absolute value - round(x): banker's rounding (round-half-to-even), matching np.round and Python 3's built-in round. Like floor/ceil, both operators have degree 0 when their argument has degree 0, so they are usable inside constraints, binding-constraints, objective contributions, and variable lower/upper bounds whenever the argument is constant (parameters and literals). Inside extra-outputs they may wrap any expression — including ones depending on decision variables — since extra-outputs are evaluated as numeric xr.DataArrays post-solve. --- docs/CHANGELOG.md | 10 ++++ src/gems/expression/__init__.py | 2 + src/gems/expression/copy.py | 8 ++++ src/gems/expression/degree.py | 10 ++++ src/gems/expression/equality.py | 12 +++++ src/gems/expression/evaluate.py | 10 ++++ src/gems/expression/expression.py | 18 ++++++++ src/gems/expression/indexing.py | 8 ++++ .../expression/parsing/parse_expression.py | 2 + src/gems/expression/print.py | 8 ++++ src/gems/expression/visitor.py | 12 +++++ src/gems/model/port.py | 8 ++++ src/gems/simulation/linearize.py | 20 ++++++++ src/gems/simulation/vectorized_builder.py | 22 +++++++++ .../parsing/test_expression_parsing.py | 30 ++++++++++++ .../expressions/visitor/test_degree.py | 13 +++++- .../expressions/visitor/test_equality.py | 8 ++++ .../expressions/visitor/test_evaluation.py | 15 ++++++ .../expressions/visitor/test_printer.py | 10 ++++ .../test_simulation_table_extra_outputs.py | 46 +++++++++++++++++++ .../test_vectorized_linear_expr_builder.py | 30 +++++++++++- tests/unittests/system/test_model.py | 16 +++++++ 22 files changed, 316 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6487c93a..d0d86098 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to GemsPy are documented here. +## [Unreleased] + +### Added +- Math operators `abs` and `round` in the GemsPy expression language. + - Can be applied to parameters and literals in constraints, bounds, and objective contributions (degree-0 context). + - Can be applied to any expression in extra-outputs (post-solve evaluation), including decision variables. + - Use `.abs()` and `.round()` methods on expression objects, or `abs(expr)` and `round(expr)` in parsed expression strings. + +--- + ## [0.1.1] - 2026-05-29 ### Scenario-scope playlist (replaces `nb-scenarios`) diff --git a/src/gems/expression/__init__.py b/src/gems/expression/__init__.py index 2e0c0752..3d19ab50 100644 --- a/src/gems/expression/__init__.py +++ b/src/gems/expression/__init__.py @@ -14,6 +14,7 @@ from .degree import ExpressionDegreeVisitor, compute_degree from .evaluate import EvaluationContext, EvaluationVisitor, ValueProvider, evaluate from .expression import ( + AbsNode, AdditionNode, CeilNode, Comparator, @@ -27,6 +28,7 @@ MultiplicationNode, NegationNode, ParameterNode, + RoundNode, VariableNode, literal, maximum, diff --git a/src/gems/expression/copy.py b/src/gems/expression/copy.py index 4dc984ec..b8915769 100644 --- a/src/gems/expression/copy.py +++ b/src/gems/expression/copy.py @@ -14,6 +14,7 @@ from typing import List, cast from .expression import ( + AbsNode, AdditionNode, AllTimeSumNode, CeilNode, @@ -26,6 +27,7 @@ ParameterNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, ScenarioOperatorNode, TimeEvalNode, TimeShiftNode, @@ -89,6 +91,12 @@ def floor(self, node: FloorNode) -> ExpressionNode: def ceil(self, node: CeilNode) -> ExpressionNode: return CeilNode(visit(node.operand, self)) + def abs(self, node: AbsNode) -> ExpressionNode: + return AbsNode(visit(node.operand, self)) + + def round(self, node: RoundNode) -> ExpressionNode: + return RoundNode(visit(node.operand, self)) + def maximum(self, node: MaxNode) -> ExpressionNode: return MaxNode([visit(op, self) for op in node.operands]) diff --git a/src/gems/expression/degree.py b/src/gems/expression/degree.py index 99ed7e56..9d634af4 100644 --- a/src/gems/expression/degree.py +++ b/src/gems/expression/degree.py @@ -14,6 +14,7 @@ import gems.expression.scenario_operator from gems.expression.expression import ( + AbsNode, AllTimeSumNode, CeilNode, FloorNode, @@ -21,6 +22,7 @@ MinNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, TimeEvalNode, TimeShiftNode, TimeSumNode, @@ -106,6 +108,14 @@ def ceil(self, node: CeilNode) -> int | float: d = visit(node.operand, self) return 0 if d == 0 else math.inf + def abs(self, node: AbsNode) -> int | float: + d = visit(node.operand, self) + return 0 if d == 0 else math.inf + + def round(self, node: RoundNode) -> int | float: + d = visit(node.operand, self) + return 0 if d == 0 else math.inf + def maximum(self, node: MaxNode) -> int | float: return 0 if all(visit(op, self) == 0 for op in node.operands) else math.inf diff --git a/src/gems/expression/equality.py b/src/gems/expression/equality.py index 625d7d4a..9cc1a5c9 100644 --- a/src/gems/expression/equality.py +++ b/src/gems/expression/equality.py @@ -26,6 +26,7 @@ VariableNode, ) from gems.expression.expression import ( + AbsNode, AllTimeSumNode, BinaryOperatorNode, CeilNode, @@ -34,6 +35,7 @@ MinNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, ScenarioOperatorNode, TimeEvalNode, TimeShiftNode, @@ -99,6 +101,10 @@ def visit(self, left: ExpressionNode, right: ExpressionNode) -> bool: return self.floor(left, right) if isinstance(left, CeilNode) and isinstance(right, CeilNode): return self.ceil(left, right) + if isinstance(left, AbsNode) and isinstance(right, AbsNode): + return self.abs(left, right) + if isinstance(left, RoundNode) and isinstance(right, RoundNode): + return self.round(left, right) if isinstance(left, MaxNode) and isinstance(right, MaxNode): return self.maximum(left, right) if isinstance(left, MinNode) and isinstance(right, MinNode): @@ -183,6 +189,12 @@ def floor(self, left: FloorNode, right: FloorNode) -> bool: def ceil(self, left: CeilNode, right: CeilNode) -> bool: return self.visit(left.operand, right.operand) + def abs(self, left: AbsNode, right: AbsNode) -> bool: + return self.visit(left.operand, right.operand) + + def round(self, left: RoundNode, right: RoundNode) -> bool: + return self.visit(left.operand, right.operand) + def maximum(self, left: MaxNode, right: MaxNode) -> bool: return len(left.operands) == len(right.operands) and all( self.visit(l, r) for l, r in zip(left.operands, right.operands) diff --git a/src/gems/expression/evaluate.py b/src/gems/expression/evaluate.py index 11d2f8cd..8bc1e1e1 100644 --- a/src/gems/expression/evaluate.py +++ b/src/gems/expression/evaluate.py @@ -16,6 +16,7 @@ from typing import Dict from gems.expression.expression import ( + AbsNode, AllTimeSumNode, CeilNode, FloorNode, @@ -23,6 +24,7 @@ MinNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, TimeEvalNode, TimeShiftNode, TimeSumNode, @@ -118,6 +120,14 @@ def floor(self, node: FloorNode) -> float: def ceil(self, node: CeilNode) -> float: return float(math.ceil(visit(node.operand, self))) + def abs(self, node: AbsNode) -> float: + value: float = visit(node.operand, self) + return abs(value) + + def round(self, node: RoundNode) -> float: + value: float = visit(node.operand, self) + return float(round(value)) + def maximum(self, node: MaxNode) -> float: return max(visit(op, self) for op in node.operands) diff --git a/src/gems/expression/expression.py b/src/gems/expression/expression.py index c7cb6bea..d9808c25 100644 --- a/src/gems/expression/expression.py +++ b/src/gems/expression/expression.py @@ -124,6 +124,12 @@ def floor(self) -> "ExpressionNode": def ceil(self) -> "ExpressionNode": return CeilNode(self) + def abs(self) -> "ExpressionNode": + return AbsNode(self) + + def round(self) -> "ExpressionNode": + return RoundNode(self) + def expec(self) -> "ExpressionNode": return _apply_if_node(self, lambda x: ScenarioOperatorNode(x, "Expectation")) @@ -235,6 +241,18 @@ class CeilNode(UnaryOperatorNode): pass +@dataclass(frozen=True, eq=False) +class AbsNode(UnaryOperatorNode): + pass + + +@dataclass(frozen=True, eq=False) +class RoundNode(UnaryOperatorNode): + """Round to nearest integer using banker's rounding (np.round / Python 3 round).""" + + pass + + @dataclass(frozen=True, eq=False) class BinaryOperatorNode(ExpressionNode): left: ExpressionNode diff --git a/src/gems/expression/indexing.py b/src/gems/expression/indexing.py index 36ac822c..216ff70b 100644 --- a/src/gems/expression/indexing.py +++ b/src/gems/expression/indexing.py @@ -17,6 +17,7 @@ from gems.expression.indexing_structure import IndexingStructure from .expression import ( + AbsNode, AdditionNode, AllTimeSumNode, CeilNode, @@ -32,6 +33,7 @@ ParameterNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, ScenarioOperatorNode, TimeEvalNode, TimeShiftNode, @@ -131,6 +133,12 @@ def floor(self, node: FloorNode) -> IndexingStructure: def ceil(self, node: CeilNode) -> IndexingStructure: return visit(node.operand, self) + def abs(self, node: AbsNode) -> IndexingStructure: + return visit(node.operand, self) + + def round(self, node: RoundNode) -> IndexingStructure: + return visit(node.operand, self) + def maximum(self, node: MaxNode) -> IndexingStructure: return self._combine(node.operands) diff --git a/src/gems/expression/parsing/parse_expression.py b/src/gems/expression/parsing/parse_expression.py index 47316bf8..f2b0d002 100644 --- a/src/gems/expression/parsing/parse_expression.py +++ b/src/gems/expression/parsing/parse_expression.py @@ -265,6 +265,8 @@ def visitRightAtom(self, ctx: ExprParser.RightAtomContext) -> ExpressionNode: "expec": ExpressionNode.expec, "floor": ExpressionNode.floor, "ceil": ExpressionNode.ceil, + "abs": ExpressionNode.abs, + "round": ExpressionNode.round, } _N_ARY_FUNCTIONS = { diff --git a/src/gems/expression/print.py b/src/gems/expression/print.py index 54b1f015..7e2d2f36 100644 --- a/src/gems/expression/print.py +++ b/src/gems/expression/print.py @@ -14,6 +14,7 @@ from typing import Dict from gems.expression.expression import ( + AbsNode, AllTimeSumNode, CeilNode, ExpressionNode, @@ -22,6 +23,7 @@ MinNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, TimeEvalNode, TimeShiftNode, TimeSumNode, @@ -122,6 +124,12 @@ def floor(self, node: FloorNode) -> str: def ceil(self, node: CeilNode) -> str: return f"ceil({visit(node.operand, self)})" + def abs(self, node: AbsNode) -> str: + return f"abs({visit(node.operand, self)})" + + def round(self, node: RoundNode) -> str: + return f"round({visit(node.operand, self)})" + def maximum(self, node: MaxNode) -> str: return "max(" + ", ".join(visit(op, self) for op in node.operands) + ")" diff --git a/src/gems/expression/visitor.py b/src/gems/expression/visitor.py index 3a894648..07f0fc5f 100644 --- a/src/gems/expression/visitor.py +++ b/src/gems/expression/visitor.py @@ -19,6 +19,7 @@ from typing import Generic, Protocol, TypeVar from gems.expression.expression import ( + AbsNode, AdditionNode, AllTimeSumNode, CeilNode, @@ -34,6 +35,7 @@ ParameterNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, ScenarioOperatorNode, TimeEvalNode, TimeShiftNode, @@ -104,6 +106,12 @@ def floor(self, node: FloorNode) -> T: ... @abstractmethod def ceil(self, node: CeilNode) -> T: ... + @abstractmethod + def abs(self, node: AbsNode) -> T: ... + + @abstractmethod + def round(self, node: RoundNode) -> T: ... + @abstractmethod def maximum(self, node: MaxNode) -> T: ... @@ -149,6 +157,10 @@ def visit(root: ExpressionNode, visitor: ExpressionVisitor[T]) -> T: return visitor.floor(root) elif isinstance(root, CeilNode): return visitor.ceil(root) + elif isinstance(root, AbsNode): + return visitor.abs(root) + elif isinstance(root, RoundNode): + return visitor.round(root) elif isinstance(root, MaxNode): return visitor.maximum(root) elif isinstance(root, MinNode): diff --git a/src/gems/model/port.py b/src/gems/model/port.py index d6f6db62..7c1a090c 100644 --- a/src/gems/model/port.py +++ b/src/gems/model/port.py @@ -26,6 +26,7 @@ VariableNode, ) from gems.expression.expression import ( + AbsNode, AllTimeSumNode, BinaryOperatorNode, CeilNode, @@ -34,6 +35,7 @@ MinNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, ScenarioOperatorNode, TimeEvalNode, TimeShiftNode, @@ -151,6 +153,12 @@ def floor(self, node: FloorNode) -> None: def ceil(self, node: CeilNode) -> None: visit(node.operand, self) + def abs(self, node: AbsNode) -> None: + visit(node.operand, self) + + def round(self, node: RoundNode) -> None: + visit(node.operand, self) + def maximum(self, node: MaxNode) -> None: for op in node.operands: visit(op, self) diff --git a/src/gems/simulation/linearize.py b/src/gems/simulation/linearize.py index 6ade5305..e551d535 100644 --- a/src/gems/simulation/linearize.py +++ b/src/gems/simulation/linearize.py @@ -31,11 +31,13 @@ import xarray as xr from gems.expression.expression import ( + AbsNode, AdditionNode, CeilNode, FloorNode, MaxNode, MinNode, + RoundNode, VariableNode, ) from gems.expression.visitor import visit @@ -128,6 +130,24 @@ def ceil(self, node: CeilNode) -> VectorizedExpr: "it cannot be used with decision variables in a linear programme." ) + def abs(self, node: AbsNode) -> VectorizedExpr: + operand = visit(node.operand, self) + if isinstance(operand, xr.DataArray): + return np.abs(operand) # type: ignore[return-value] + raise NotImplementedError( + "abs() is only supported for parameter (DataArray) expressions; " + "it cannot be used with decision variables in a linear programme." + ) + + def round(self, node: RoundNode) -> VectorizedExpr: + operand = visit(node.operand, self) + if isinstance(operand, xr.DataArray): + return np.round(operand) # type: ignore[return-value] + raise NotImplementedError( + "round() is only supported for parameter (DataArray) expressions; " + "it cannot be used with decision variables in a linear programme." + ) + def maximum(self, node: MaxNode) -> VectorizedExpr: operands = [visit(op, self) for op in node.operands] if all(isinstance(op, xr.DataArray) for op in operands): diff --git a/src/gems/simulation/vectorized_builder.py b/src/gems/simulation/vectorized_builder.py index 0d5d05b5..5bbc10e4 100644 --- a/src/gems/simulation/vectorized_builder.py +++ b/src/gems/simulation/vectorized_builder.py @@ -42,6 +42,7 @@ from gems.expression.evaluate import EvaluationContext, EvaluationVisitor from gems.expression.expression import ( + AbsNode, AdditionNode, AllTimeSumNode, CeilNode, @@ -57,6 +58,7 @@ ParameterNode, PortFieldAggregatorNode, PortFieldNode, + RoundNode, ScenarioOperatorNode, TimeEvalNode, TimeShiftNode, @@ -368,6 +370,14 @@ def ceil(self, node: CeilNode) -> VectorizedExpr: operand = visit(node.operand, self) return np.ceil(operand) # type: ignore[return-value,arg-type,call-overload] + def abs(self, node: AbsNode) -> VectorizedExpr: + operand = visit(node.operand, self) + return np.abs(operand) # type: ignore[return-value,arg-type,call-overload] + + def round(self, node: RoundNode) -> VectorizedExpr: + operand = visit(node.operand, self) + return np.round(operand) # type: ignore[return-value,arg-type,call-overload] + def maximum(self, node: MaxNode) -> VectorizedExpr: operands = [visit(op, self) for op in node.operands] result = operands[0] @@ -525,6 +535,12 @@ def floor(self, node: FloorNode) -> xr.DataArray: def ceil(self, node: CeilNode) -> xr.DataArray: return np.ceil(visit(node.operand, self)) # type: ignore[return-value] + def abs(self, node: AbsNode) -> xr.DataArray: + return np.abs(visit(node.operand, self)) # type: ignore[return-value] + + def round(self, node: RoundNode) -> xr.DataArray: + return np.round(visit(node.operand, self)) # type: ignore[return-value] + def maximum(self, node: MaxNode) -> xr.DataArray: ops = [visit(op, self) for op in node.operands] return functools.reduce( @@ -654,6 +670,12 @@ def floor(self, node: FloorNode) -> Optional[xr.DataArray]: def ceil(self, node: CeilNode) -> Optional[xr.DataArray]: return visit(node.operand, self) + def abs(self, node: AbsNode) -> Optional[xr.DataArray]: + return visit(node.operand, self) + + def round(self, node: RoundNode) -> Optional[xr.DataArray]: + return visit(node.operand, self) + def maximum(self, node: MaxNode) -> Optional[xr.DataArray]: return functools.reduce( _and_mask, (visit(op, self) for op in node.operands), None diff --git a/tests/unittests/expressions/parsing/test_expression_parsing.py b/tests/unittests/expressions/parsing/test_expression_parsing.py index ff052562..df291897 100644 --- a/tests/unittests/expressions/parsing/test_expression_parsing.py +++ b/tests/unittests/expressions/parsing/test_expression_parsing.py @@ -131,6 +131,36 @@ "ceil(p)", param("p").ceil(), ), + ( + {}, + {"p"}, + "abs(p)", + param("p").abs(), + ), + ( + {}, + {"p"}, + "round(p)", + param("p").round(), + ), + ( + {}, + {"p", "q"}, + "abs(p - q)", + (param("p") - param("q")).abs(), + ), + ( + {}, + {"p", "q"}, + "round(p / q)", + (param("p") / param("q")).round(), + ), + ( + {}, + {"p", "q"}, + "max(0, abs(p - q))", + maximum(literal(0), (param("p") - param("q")).abs()), + ), ( {}, {"a", "b"}, diff --git a/tests/unittests/expressions/visitor/test_degree.py b/tests/unittests/expressions/visitor/test_degree.py index e2b3b492..15ac34e4 100644 --- a/tests/unittests/expressions/visitor/test_degree.py +++ b/tests/unittests/expressions/visitor/test_degree.py @@ -23,7 +23,7 @@ var, visit, ) -from gems.expression.expression import CeilNode, FloorNode +from gems.expression.expression import AbsNode, CeilNode, FloorNode, RoundNode def test_degree() -> None: @@ -47,6 +47,17 @@ def test_floor_ceil_degree() -> None: assert visit(CeilNode(x), ExpressionDegreeVisitor()) == math.inf +def test_abs_round_degree() -> None: + x = var("x") + p = param("p") + + assert visit(AbsNode(p), ExpressionDegreeVisitor()) == 0 + assert visit(RoundNode(p), ExpressionDegreeVisitor()) == 0 + assert visit(AbsNode(x), ExpressionDegreeVisitor()) == math.inf + assert visit(RoundNode(x), ExpressionDegreeVisitor()) == math.inf + assert visit(AbsNode(p - param("q")), ExpressionDegreeVisitor()) == 0 + + def test_max_min_degree() -> None: x = var("x") p = param("p") diff --git a/tests/unittests/expressions/visitor/test_equality.py b/tests/unittests/expressions/visitor/test_equality.py index b2efd0fb..f16146df 100644 --- a/tests/unittests/expressions/visitor/test_equality.py +++ b/tests/unittests/expressions/visitor/test_equality.py @@ -33,6 +33,8 @@ var("x").expec(), var("x").floor(), var("x").ceil(), + var("x").abs(), + var("x").round(), maximum(var("x"), param("p")), minimum(var("x"), param("p")), ], @@ -61,6 +63,12 @@ def test_equals(expr: ExpressionNode) -> None: (var("x").floor(), var("y").floor()), (var("x").ceil(), var("y").ceil()), (var("x").floor(), var("x").ceil()), # different node type + # abs / round + (var("x").abs(), var("y").abs()), + (var("x").round(), var("y").round()), + (var("x").abs(), var("x").round()), # different node type + (var("x").abs(), var("x").floor()), # different node type + (var("x").round(), var("x").ceil()), # different node type # max / min (maximum(var("x"), param("p")), maximum(var("y"), param("p"))), (minimum(var("x"), param("p")), minimum(var("x"), param("q"))), diff --git a/tests/unittests/expressions/visitor/test_evaluation.py b/tests/unittests/expressions/visitor/test_evaluation.py index 8c555fcb..d1e91106 100644 --- a/tests/unittests/expressions/visitor/test_evaluation.py +++ b/tests/unittests/expressions/visitor/test_evaluation.py @@ -88,3 +88,18 @@ def test_floor_ceil_max_min() -> None: assert visit( minimum(param("p"), param("q"), literal(5.0)), EvaluationVisitor(context) ) == pytest.approx(1.3) + + +def test_abs_round() -> None: + context = EvaluationContext(parameters={"p": -2.7, "q": 1.5, "r": 2.5, "s": 3.5}) + + assert visit(param("p").abs(), EvaluationVisitor(context)) == pytest.approx(2.7) + assert visit((-param("q")).abs(), EvaluationVisitor(context)) == pytest.approx(1.5) + assert visit(literal(0).abs(), EvaluationVisitor(context)) == 0.0 + + # Banker's rounding (round-half-to-even): 0.5 -> 0, 1.5 -> 2, 2.5 -> 2, 3.5 -> 4. + assert visit(param("q").round(), EvaluationVisitor(context)) == 2.0 + assert visit(param("r").round(), EvaluationVisitor(context)) == 2.0 + assert visit(param("s").round(), EvaluationVisitor(context)) == 4.0 + assert visit(literal(0.5).round(), EvaluationVisitor(context)) == 0.0 + assert visit(param("p").round(), EvaluationVisitor(context)) == -3.0 diff --git a/tests/unittests/expressions/visitor/test_printer.py b/tests/unittests/expressions/visitor/test_printer.py index 34510760..a4800975 100644 --- a/tests/unittests/expressions/visitor/test_printer.py +++ b/tests/unittests/expressions/visitor/test_printer.py @@ -39,3 +39,13 @@ def test_floor_ceil_max_min_printer() -> None: # variadic (3+ operands) assert visit(maximum(p, q, param("r")), PrinterVisitor()) == "max(p, q, r)" assert visit(minimum(p, q, param("r")), PrinterVisitor()) == "min(p, q, r)" + + +def test_abs_round_printer() -> None: + p = param("p") + q = param("q") + + assert visit(p.abs(), PrinterVisitor()) == "abs(p)" + assert visit(p.round(), PrinterVisitor()) == "round(p)" + assert visit((p - q).abs(), PrinterVisitor()) == "abs((p - q))" + assert visit((p / q).round(), PrinterVisitor()) == "round((p / q))" diff --git a/tests/unittests/simulation/test_simulation_table_extra_outputs.py b/tests/unittests/simulation/test_simulation_table_extra_outputs.py index 5e220843..7e71d980 100644 --- a/tests/unittests/simulation/test_simulation_table_extra_outputs.py +++ b/tests/unittests/simulation/test_simulation_table_extra_outputs.py @@ -116,3 +116,49 @@ def test_extra_output_nonlinear() -> None: df.component("comp_1").output("squared").value(time_index=0, scenario_index=0) ) assert squared == pytest.approx(9.0) + + +def test_extra_output_abs_round_on_variable() -> None: + """ + abs() and round() applied to a decision variable are allowed in extra + outputs (post-solve evaluation), even though they would be rejected as + nonlinear inside a constraint or bound. + """ + from gems.expression import var + from gems.expression.expression import literal + from gems.model.model import model + from gems.model.variable import float_variable + from gems.simulation import TimeBlock, build_problem + from gems.study import DataBase, Study, System, create_component + + SIMPLE_MODEL = model( + id="SIMPLE_ABS_ROUND", + variables=[ + float_variable("a", lower_bound=literal(2.7), upper_bound=literal(2.7)) + ], + extra_outputs={ + "abs_shift": (var("a") - literal(5)).abs(), + "rounded": var("a").round(), + }, + ) + + database = DataBase() + comp = create_component(model=SIMPLE_MODEL, id="comp_1") + + system = System("test_abs_round_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) + abs_shift = ( + df.component("comp_1").output("abs_shift").value(time_index=0, scenario_index=0) + ) + rounded = ( + df.component("comp_1").output("rounded").value(time_index=0, scenario_index=0) + ) + assert abs_shift == pytest.approx(2.3) + assert rounded == pytest.approx(3.0) diff --git a/tests/unittests/simulation/test_vectorized_linear_expr_builder.py b/tests/unittests/simulation/test_vectorized_linear_expr_builder.py index 192d4d0e..684691c9 100644 --- a/tests/unittests/simulation/test_vectorized_linear_expr_builder.py +++ b/tests/unittests/simulation/test_vectorized_linear_expr_builder.py @@ -362,7 +362,7 @@ def test_comparison_equal_also_raises(builder: VectorizedLinearExprBuilder) -> N # --------------------------------------------------------------------------- -# 9. floor() / ceil() — linopy guard +# 9. floor() / ceil() / abs() / round() — linopy guard # --------------------------------------------------------------------------- @@ -398,6 +398,34 @@ def test_ceil_on_exact_integer_da(builder: VectorizedLinearExprBuilder) -> None: assert float(result) == pytest.approx(3.0) +def test_abs_on_da_works(builder: VectorizedLinearExprBuilder) -> None: + result = visit(literal(-2.5).abs(), builder) + assert isinstance(result, xr.DataArray) + assert float(result) == pytest.approx(2.5) + + +def test_abs_on_variable_raises(builder: VectorizedLinearExprBuilder) -> None: + with pytest.raises(NotImplementedError, match="abs"): + visit(var("x").abs(), builder) + + +def test_round_on_da_works(builder: VectorizedLinearExprBuilder) -> None: + result = visit(literal(2.7).round(), builder) + assert isinstance(result, xr.DataArray) + assert float(result) == pytest.approx(3.0) + + +def test_round_on_variable_raises(builder: VectorizedLinearExprBuilder) -> None: + with pytest.raises(NotImplementedError, match="round"): + visit(var("x").round(), builder) + + +def test_round_banker_on_da(builder: VectorizedLinearExprBuilder) -> None: + # Banker's rounding: 2.5 -> 2, 3.5 -> 4. + assert float(visit(literal(2.5).round(), builder)) == pytest.approx(2.0) + assert float(visit(literal(3.5).round(), builder)) == pytest.approx(4.0) + + # --------------------------------------------------------------------------- # 10. maximum() / minimum() — linopy guard # --------------------------------------------------------------------------- diff --git a/tests/unittests/system/test_model.py b/tests/unittests/system/test_model.py index 6c4c2241..93cdd77e 100644 --- a/tests/unittests/system/test_model.py +++ b/tests/unittests/system/test_model.py @@ -370,3 +370,19 @@ def test_variable_eq_with_non_variable_returns_false() -> None: assert v != "x" assert v != 42 assert v != None # noqa: E711 + + +def test_variable_bounds_accept_abs_round_of_parameters() -> None: + """abs/round of a parameter expression is constant, so usable in bounds.""" + v = float_variable("x", lower_bound=-param("p").abs(), upper_bound=param("p").abs()) + assert v.lower_bound is not None and v.upper_bound is not None + v2 = float_variable("x", upper_bound=(param("p") / param("q")).round()) + assert v2.upper_bound is not None + + +def test_variable_bounds_reject_abs_of_variable() -> None: + """abs of a variable is not constant; the bound check must reject it.""" + with pytest.raises(ValueError, match="bounds of variables must be constant"): + float_variable("x", upper_bound=var("y").abs()) + with pytest.raises(ValueError, match="bounds of variables must be constant"): + float_variable("x", lower_bound=var("y").round()) From caf7c2b13ca677e044dad47f4c465fee45a189ae Mon Sep 17 00:00:00 2001 From: "Antoine Oustry, PhD" <58943406+aoustry@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:19:45 +0200 Subject: [PATCH 02/10] feat(readme): modernize the design of the readme file (#223) * Restyle README with modern layout * Add GEMS favicon next to 'The GEMS framework' heading * Remove top logo image from README header * Replace 'no-code' with 'low-code' in README * Use GEMS favicon in quick-link nav * Vendor GEMS favicon under docs/images and reference it locally * Add uv install instructions to README --- README.md | 106 ++++++++++++++++++++++++++++++----- docs/images/gems_favicon.png | Bin 0 -> 144579 bytes 2 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 docs/images/gems_favicon.png diff --git a/README.md b/README.md index e62eecaa..9bbfff30 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# GemsPy, a Python interpreter for GEMS +
+## 🐍 The GemsPy package
-**Online GemsPy documentation**: [gemspy.readthedocs.io](https://gemspy.readthedocs.io/en/latest/).
+`GemsPy` ships a generic interpreter of **GEMS** capable of generating optimisation problems from any study case that adheres to the modelling language syntax, then solving them with off-the-shelf solvers.
-## The [GEMS](https://gems-energy.readthedocs.io/en/latest/) framework
+The Python API lets you:
-### The rationale behind [GEMS](https://gems-energy.readthedocs.io/en/latest/)
+- read case studies stored in YAML format,
+- modify existing studies,
+- or create new ones from scratch by scripting.
-[GEMS](https://gems-energy.readthedocs.io/en/latest/) introduces a novel approach to model and simulate energy systems, centered around a simple principle: getting models out of the code.
+The [Getting started](https://gemspy.readthedocs.io/en/latest/getting-started/) page of the online documentation walks you through the **GEMS** input file format and the basics of the GemsPy API.
-To develop and test new models of energy system components, writing software code should not be a prerequisite. This is where the **Gems** framework excels, offering users a "no-code" modelling experience with unparalleled versatility.
+---
-### Gems = a high-level modelling language + a data structure
+## 🔗 Link with Antares Simulator
-The Gems framework consists of a **high-level modelling language**, close to mathematical syntax, and a **data structure** for describing energy systems.
+GemsPy is part of the **Antares** project, but its implementation is completely independent from the [Antares Simulator](https://antares-simulator.readthedocs.io/en/latest/user-guide/modeler/01-overview-modeler/) software. It was initially designed to prototype the next features of Antares, but its structuring and development practices have produced a high-quality, self-supporting codebase. It is now maintained to bring the flexibility of the GEMS modelling language and interpreter to Python users, and to keep exploring its potential.
-## The [GemsPy](https://gemspy.readthedocs.io/en/latest/) package
+---
-This Python package features a generic interpreter of **Gems** capable of generating optimisation problems from any study case that adhere to the modelling language syntax. It then employs off-the-shelf optimisation solvers to solve these problems. The Python API facilitates reading case studies stored in YAML format, modifying them, or creating new ones from scratch by scripting.
+## 📚 Documentation
-The [Getting started](https://gemspy.readthedocs.io/en/latest/getting-started/) page of the online documentation introduce you to the **Gems** input file format and the basics of the GemsPy API.
+Full documentation is hosted on Read the Docs: **[gemspy.readthedocs.io](https://gemspy.readthedocs.io/en/latest/)**.
+## 📄 License
-## Link with Antares Simulator software
-The GemsPy package forms part of the Antares project, but its implementation is completely independent of that of the AntaresSimulator software. Although it was initially designed to prototype the next features of the Antares software (for more information, see [Antares Simulator documentation](https://antares-simulator.readthedocs.io/en/latest/user-guide/modeler/01-overview-modeler/), its structuring and development practices have resulted in high-quality, self-supporting code. It is currently maintained to offer the flexibility of the designed modelling language and interpreter to Python users and to continue exploring its potential.
+Distributed under the **Mozilla Public License 2.0**. See [LICENSE](LICENSE) for details.
diff --git a/docs/images/gems_favicon.png b/docs/images/gems_favicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..ab3af6ea8f48173f05fc37cfcec1e6ddc1ef64e0
GIT binary patch
literal 144579
zcmV)zK#{+RP)!(|lpp~m1rr T-Exh6V+sj(6b
z@l(~%h*|bVac1)3`2o47?!E6SE?l`tFu1!atWMs^iS|>QuCfQ)sZhCwldT<7C7?m8
zpux;sdE^o8H|jhm81Aoh(qp4K@tRB1EQO# #y0fq{rBpHd)|!mPkask{7?V9zvsKZE$(~t8NUAmkNDs)s2nN-ZZ5S
zE72q5NKbGOm75ZkW>%D56Dg>abaeITjz{ds&c{ZhHLI4-d+v+gz2!r{^?%_6&Yu1;
znDV>;{1uMYG5k+~{iSdDiPtUnyycc%)w%X&L{2x
zxqMNgBeE-s>bPaAAPfwaDht?m9xh%U+m*}ve*5Sbu72pV?|Ar$Kl|l>^iTh<_r2o{
z*Z#d6Ua$SeKlOLs{rdm=ub*9h$^Nmg{f`A&_K{Es{b>>?mun{(E^*mI`&zIT-J!*$
zSmRT;+q>%jv|R|MIt{rUIvxBBkR0&h9=RizU+fLh?)+-;tFwBxB(YAKi)kY^94_3c
z+t0q>C*S)<96$W1zvR#Tt@w$b|0ns{*L()w{GO}+;JqK#I6Ko}+fW5_i|&z@E2)=r
ziGq9a%!m%u(J~v7hHC>__|L8R4K+m#snrs_qF?|l
CvL9#~A$U(@P}@*ed5|Eb
z1r7#hBTY;)#!%Qux6ZV2Re%u _0Yb@4_+jIwO&Iw
zD8RFmURr39-F4bD8Q6C?5IHe#efUEczT&_5%Rlj