Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/gems/model/constraint.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ def __post_init__(
if is_unbounded(self.upper_bound) and not is_non_negative(self.upper_bound):
raise ValueError("Upper bound should not be -Inf")

@property
def is_equality(self) -> bool:
return (
not is_unbounded(self.lower_bound)
and not is_unbounded(self.upper_bound)
and expressions_equal(self.lower_bound, self.upper_bound)
)

def replicate(self, /, **changes: Any) -> "Constraint":
return replace(self, **changes)

Expand Down
27 changes: 13 additions & 14 deletions src/gems/simulation/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,23 +691,22 @@ def _create_constraints_for_model(
# Sanitize constraint name for LP format (spaces → underscores)
safe_name = constraint.name.replace(" ", "_").replace("-", "_")

# Lower bound constraint: lhs >= lb (if lb != -inf)
if not is_unbounded(constraint.lower_bound):
if constraint.is_equality:
lb = visit(constraint.lower_bound, builder)
if validity_mask is not None:
lb = _apply_validity_mask(lb, validity_mask)
name = f"{prefix}__{safe_name}__lb"
con_lb = lhs >= lb # type: ignore[operator]
self.linopy_model.add_constraints(con_lb, name=name) # type: ignore[arg-type]

# Upper bound constraint: lhs <= ub (if ub != +inf)
if not is_unbounded(constraint.upper_bound):
ub = visit(constraint.upper_bound, builder)
if validity_mask is not None:
ub = _apply_validity_mask(ub, validity_mask)
name = f"{prefix}__{safe_name}__ub"
con_ub = lhs <= ub # type: ignore[operator]
self.linopy_model.add_constraints(con_ub, name=name) # type: ignore[arg-type]
self.linopy_model.add_constraints(lhs == lb, name=f"{prefix}__{safe_name}__eq") # type: ignore[operator,arg-type]
else:
if not is_unbounded(constraint.lower_bound):
lb = visit(constraint.lower_bound, builder)
if validity_mask is not None:
lb = _apply_validity_mask(lb, validity_mask)
self.linopy_model.add_constraints(lhs >= lb, name=f"{prefix}__{safe_name}__lb") # type: ignore[operator,arg-type]
if not is_unbounded(constraint.upper_bound):
ub = visit(constraint.upper_bound, builder)
if validity_mask is not None:
ub = _apply_validity_mask(ub, validity_mask)
self.linopy_model.add_constraints(lhs <= ub, name=f"{prefix}__{safe_name}__ub") # type: ignore[operator,arg-type]

def _add_objectives_for_model(
self,
Expand Down
14 changes: 4 additions & 10 deletions tests/e2e/functional/studies/13_1/expected_outputs/subproblem.mps
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
NAME
ROWS
N Obj
G c0
E c0
L c1
L c2
L c3
COLUMNS
x0 Obj 1
x0 c0 -1
x0 c1 -1
x1 Obj 501
x1 c0 1
x1 c1 1
x2 Obj 45
x2 c0 1
x2 c1 1
x2 c2 1
x3 c3 -1
x3 c2 -1
x4 Obj 10
x4 c0 1
x4 c1 1
x4 c3 1
x4 c2 1
RHS
RHS_V c0 400
RHS_V c1 400
RHS_V c2 200
RHS_V c1 200
BOUNDS
UP BOUND x0 1000000
UP BOUND x1 1000000
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
NAME
ROWS
N Obj
G c0
L c1
E c0
COLUMNS
x0 Obj 490
x1 Obj 200
x1 c0 1
x1 c1 1
MARK0000 'MARKER' 'INTORG'
x2 c0 -10
x2 c1 -10
MARK0001 'MARKER' 'INTEND'
RHS
BOUNDS
Expand Down
19 changes: 6 additions & 13 deletions tests/e2e/functional/studies/13_2/expected_outputs/subproblem.mps
Original file line number Diff line number Diff line change
@@ -1,36 +1,29 @@
NAME
ROWS
N Obj
G c0
E c0
L c1
L c2
L c3
L c4
COLUMNS
x0 Obj 1
x0 c0 -1
x0 c1 -1
x1 Obj 501
x1 c0 1
x1 c1 1
x2 Obj 45
x2 c0 1
x2 c1 1
x2 c2 1
x3 c3 -1
x3 c2 -1
x4 Obj 10
x4 c0 1
x4 c1 1
x4 c3 1
x5 c4 -1
x4 c2 1
x5 c3 -1
x6 Obj 10
x6 c0 1
x6 c1 1
x6 c4 1
x6 c3 1
RHS
RHS_V c0 400
RHS_V c1 400
RHS_V c2 200
RHS_V c1 200
BOUNDS
UP BOUND x0 1000000
UP BOUND x1 1000000
Expand Down
8 changes: 3 additions & 5 deletions tests/e2e/functional/test_out_of_bounds_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def test_out_of_bounds_processing(study_id: str, expected_objective: float) -> N
# Constraints: 2 components (gen_1, gen_2) × 3 timesteps = 6 potential instances each.
#
# system_cyclic_with_param_in_shift — no drop mode, all instances present:
# is_on_dynamics (lb + ub): 6 each
# is_on_dynamics (eq): 6
# min_up_duration (ub only): 6
# min_down_duration (ub only): 6
#
Expand All @@ -151,14 +151,12 @@ def test_out_of_bounds_processing(study_id: str, expected_objective: float) -> N
# min_down_duration: both have d_min_down=1 → range [0,0] → never dropped → 6
_EXPECTED_CONSTRAINT_COUNTS: Dict[str, Dict[str, int]] = {
"system_cyclic_with_param_in_shift": {
f"{_GEN_PREFIX}__is_on_dynamics__lb": 6,
f"{_GEN_PREFIX}__is_on_dynamics__ub": 6,
f"{_GEN_PREFIX}__is_on_dynamics__eq": 6,
f"{_GEN_PREFIX}__min_up_duration__ub": 6,
f"{_GEN_PREFIX}__min_down_duration__ub": 6,
},
"system_drop_with_param_in_shift": {
f"{_GEN_PREFIX}__is_on_dynamics__lb": 4,
f"{_GEN_PREFIX}__is_on_dynamics__ub": 4,
f"{_GEN_PREFIX}__is_on_dynamics__eq": 4,
f"{_GEN_PREFIX}__min_up_duration__ub": 5,
f"{_GEN_PREFIX}__min_down_duration__ub": 6,
},
Expand Down
35 changes: 35 additions & 0 deletions tests/unittests/system/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,41 @@ def test_constraint_equals() -> None:
)


def test_is_equality_true_for_equality_comparison() -> None:
c = Constraint(name="c", expression=var("x") == param("p"))
assert c.is_equality is True


def test_is_equality_false_for_inequality_comparison() -> None:
assert Constraint(name="c", expression=var("x") <= param("p")).is_equality is False
assert Constraint(name="c", expression=var("x") >= param("p")).is_equality is False


def test_is_equality_false_for_range_constraint() -> None:
c = Constraint(
name="c", expression=var("x"), lower_bound=literal(0), upper_bound=literal(10)
)
assert c.is_equality is False


def test_is_equality_true_for_equal_explicit_bounds() -> None:
c = Constraint(
name="c", expression=var("x"), lower_bound=param("p"), upper_bound=param("p")
)
assert c.is_equality is True


def test_is_equality_false_for_one_sided_constraint() -> None:
assert (
Constraint(name="c", expression=var("x"), lower_bound=literal(0)).is_equality
is False
)
assert (
Constraint(name="c", expression=var("x"), upper_bound=literal(0)).is_equality
is False
)


# --- Issue #76: tolerate absence of expec() in objective contributions ---


Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading