Skip to content

Commit d383633

Browse files
authored
Equality refacto (#209)
* Equality refacto * Add and update tests * Update reference MPS
1 parent 77a743c commit d383633

7 files changed

Lines changed: 70 additions & 46 deletions

File tree

src/gems/model/constraint.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ def __post_init__(
7575
if is_unbounded(self.upper_bound) and not is_non_negative(self.upper_bound):
7676
raise ValueError("Upper bound should not be -Inf")
7777

78+
@property
79+
def is_equality(self) -> bool:
80+
return (
81+
not is_unbounded(self.lower_bound)
82+
and not is_unbounded(self.upper_bound)
83+
and expressions_equal(self.lower_bound, self.upper_bound)
84+
)
85+
7886
def replicate(self, /, **changes: Any) -> "Constraint":
7987
return replace(self, **changes)
8088

src/gems/simulation/optimization.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -691,23 +691,22 @@ def _create_constraints_for_model(
691691
# Sanitize constraint name for LP format (spaces → underscores)
692692
safe_name = constraint.name.replace(" ", "_").replace("-", "_")
693693

694-
# Lower bound constraint: lhs >= lb (if lb != -inf)
695-
if not is_unbounded(constraint.lower_bound):
694+
if constraint.is_equality:
696695
lb = visit(constraint.lower_bound, builder)
697696
if validity_mask is not None:
698697
lb = _apply_validity_mask(lb, validity_mask)
699-
name = f"{prefix}__{safe_name}__lb"
700-
con_lb = lhs >= lb # type: ignore[operator]
701-
self.linopy_model.add_constraints(con_lb, name=name) # type: ignore[arg-type]
702-
703-
# Upper bound constraint: lhs <= ub (if ub != +inf)
704-
if not is_unbounded(constraint.upper_bound):
705-
ub = visit(constraint.upper_bound, builder)
706-
if validity_mask is not None:
707-
ub = _apply_validity_mask(ub, validity_mask)
708-
name = f"{prefix}__{safe_name}__ub"
709-
con_ub = lhs <= ub # type: ignore[operator]
710-
self.linopy_model.add_constraints(con_ub, name=name) # type: ignore[arg-type]
698+
self.linopy_model.add_constraints(lhs == lb, name=f"{prefix}__{safe_name}__eq") # type: ignore[operator,arg-type]
699+
else:
700+
if not is_unbounded(constraint.lower_bound):
701+
lb = visit(constraint.lower_bound, builder)
702+
if validity_mask is not None:
703+
lb = _apply_validity_mask(lb, validity_mask)
704+
self.linopy_model.add_constraints(lhs >= lb, name=f"{prefix}__{safe_name}__lb") # type: ignore[operator,arg-type]
705+
if not is_unbounded(constraint.upper_bound):
706+
ub = visit(constraint.upper_bound, builder)
707+
if validity_mask is not None:
708+
ub = _apply_validity_mask(ub, validity_mask)
709+
self.linopy_model.add_constraints(lhs <= ub, name=f"{prefix}__{safe_name}__ub") # type: ignore[operator,arg-type]
711710

712711
def _add_objectives_for_model(
713712
self,

tests/e2e/functional/studies/13_1/expected_outputs/subproblem.mps

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
NAME
22
ROWS
33
N Obj
4-
G c0
4+
E c0
55
L c1
66
L c2
7-
L c3
87
COLUMNS
98
x0 Obj 1
109
x0 c0 -1
11-
x0 c1 -1
1210
x1 Obj 501
1311
x1 c0 1
14-
x1 c1 1
1512
x2 Obj 45
1613
x2 c0 1
1714
x2 c1 1
18-
x2 c2 1
19-
x3 c3 -1
15+
x3 c2 -1
2016
x4 Obj 10
2117
x4 c0 1
22-
x4 c1 1
23-
x4 c3 1
18+
x4 c2 1
2419
RHS
2520
RHS_V c0 400
26-
RHS_V c1 400
27-
RHS_V c2 200
21+
RHS_V c1 200
2822
BOUNDS
2923
UP BOUND x0 1000000
3024
UP BOUND x1 1000000

tests/e2e/functional/studies/13_2/expected_outputs/master.mps

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
NAME
22
ROWS
33
N Obj
4-
G c0
5-
L c1
4+
E c0
65
COLUMNS
76
x0 Obj 490
87
x1 Obj 200
98
x1 c0 1
10-
x1 c1 1
119
MARK0000 'MARKER' 'INTORG'
1210
x2 c0 -10
13-
x2 c1 -10
1411
MARK0001 'MARKER' 'INTEND'
1512
RHS
1613
BOUNDS

tests/e2e/functional/studies/13_2/expected_outputs/subproblem.mps

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,29 @@
11
NAME
22
ROWS
33
N Obj
4-
G c0
4+
E c0
55
L c1
66
L c2
77
L c3
8-
L c4
98
COLUMNS
109
x0 Obj 1
1110
x0 c0 -1
12-
x0 c1 -1
1311
x1 Obj 501
1412
x1 c0 1
15-
x1 c1 1
1613
x2 Obj 45
1714
x2 c0 1
1815
x2 c1 1
19-
x2 c2 1
20-
x3 c3 -1
16+
x3 c2 -1
2117
x4 Obj 10
2218
x4 c0 1
23-
x4 c1 1
24-
x4 c3 1
25-
x5 c4 -1
19+
x4 c2 1
20+
x5 c3 -1
2621
x6 Obj 10
2722
x6 c0 1
28-
x6 c1 1
29-
x6 c4 1
23+
x6 c3 1
3024
RHS
3125
RHS_V c0 400
32-
RHS_V c1 400
33-
RHS_V c2 200
26+
RHS_V c1 200
3427
BOUNDS
3528
UP BOUND x0 1000000
3629
UP BOUND x1 1000000

tests/e2e/functional/test_out_of_bounds_processing.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def test_out_of_bounds_processing(study_id: str, expected_objective: float) -> N
140140
# Constraints: 2 components (gen_1, gen_2) × 3 timesteps = 6 potential instances each.
141141
#
142142
# system_cyclic_with_param_in_shift — no drop mode, all instances present:
143-
# is_on_dynamics (lb + ub): 6 each
143+
# is_on_dynamics (eq): 6
144144
# min_up_duration (ub only): 6
145145
# min_down_duration (ub only): 6
146146
#
@@ -151,14 +151,12 @@ def test_out_of_bounds_processing(study_id: str, expected_objective: float) -> N
151151
# min_down_duration: both have d_min_down=1 → range [0,0] → never dropped → 6
152152
_EXPECTED_CONSTRAINT_COUNTS: Dict[str, Dict[str, int]] = {
153153
"system_cyclic_with_param_in_shift": {
154-
f"{_GEN_PREFIX}__is_on_dynamics__lb": 6,
155-
f"{_GEN_PREFIX}__is_on_dynamics__ub": 6,
154+
f"{_GEN_PREFIX}__is_on_dynamics__eq": 6,
156155
f"{_GEN_PREFIX}__min_up_duration__ub": 6,
157156
f"{_GEN_PREFIX}__min_down_duration__ub": 6,
158157
},
159158
"system_drop_with_param_in_shift": {
160-
f"{_GEN_PREFIX}__is_on_dynamics__lb": 4,
161-
f"{_GEN_PREFIX}__is_on_dynamics__ub": 4,
159+
f"{_GEN_PREFIX}__is_on_dynamics__eq": 4,
162160
f"{_GEN_PREFIX}__min_up_duration__ub": 5,
163161
f"{_GEN_PREFIX}__min_down_duration__ub": 6,
164162
},

tests/unittests/system/test_model.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,41 @@ def test_constraint_equals() -> None:
219219
)
220220

221221

222+
def test_is_equality_true_for_equality_comparison() -> None:
223+
c = Constraint(name="c", expression=var("x") == param("p"))
224+
assert c.is_equality is True
225+
226+
227+
def test_is_equality_false_for_inequality_comparison() -> None:
228+
assert Constraint(name="c", expression=var("x") <= param("p")).is_equality is False
229+
assert Constraint(name="c", expression=var("x") >= param("p")).is_equality is False
230+
231+
232+
def test_is_equality_false_for_range_constraint() -> None:
233+
c = Constraint(
234+
name="c", expression=var("x"), lower_bound=literal(0), upper_bound=literal(10)
235+
)
236+
assert c.is_equality is False
237+
238+
239+
def test_is_equality_true_for_equal_explicit_bounds() -> None:
240+
c = Constraint(
241+
name="c", expression=var("x"), lower_bound=param("p"), upper_bound=param("p")
242+
)
243+
assert c.is_equality is True
244+
245+
246+
def test_is_equality_false_for_one_sided_constraint() -> None:
247+
assert (
248+
Constraint(name="c", expression=var("x"), lower_bound=literal(0)).is_equality
249+
is False
250+
)
251+
assert (
252+
Constraint(name="c", expression=var("x"), upper_bound=literal(0)).is_equality
253+
is False
254+
)
255+
256+
222257
# --- Issue #76: tolerate absence of expec() in objective contributions ---
223258

224259

0 commit comments

Comments
 (0)