Skip to content

Commit

Permalink
Allow negating activation variables (#116)
Browse files Browse the repository at this point in the history
  • Loading branch information
mtth authored Sep 30, 2023
1 parent 5edf7a4 commit 46a2a24
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 7 deletions.
45 changes: 39 additions & 6 deletions opvious/modeling/fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,12 @@ def wrapper(fn):
class ActivationVariable(ModelFragment):
"""Indicator variable activation fragment
This variable tracks an underlying non-negative tensor. Assuming both of
its bounds are defined (see below) it will bel equal to 1 iff the
underlying tensor is positive and 0 otherwise.
This variable tracks an underlying tensor or tensor-like expression.
Assuming both of its bounds are defined (see below) it will be equal to 1
iff the underlying tensor is positive and 0 otherwise.
Args:
tensor: Non-negative tensor-like
tensor: Tensor-like
quantifiables: Tensor quantifiables, can be omitted if the tensor is a
variable or parameter
upper_bound: Value of the upper bound used in the activation
Expand All @@ -235,6 +235,7 @@ class ActivationVariable(ModelFragment):
constraint. If `True` the variable's image's lower bound will be
used, if `False` no deactivation constraint will be added.
name: Name of the generated activation variable
negate: Negate the returned indicator variable.
projection: Mask used to project the variable's quantification. When
this is set, the indicator variable will be set to 1 iff at least
one of the projected tensor values is positive.
Expand All @@ -249,6 +250,7 @@ def __new__(
upper_bound: Union[ExpressionLike, TensorLike, bool] = True,
lower_bound: Union[ExpressionLike, TensorLike, bool] = False,
name: Optional[Name] = None,
negate=False,
projection: Projection = -1,
) -> ActivationVariable:
if not quantifiables and isinstance(tensor, Tensor):
Expand Down Expand Up @@ -282,7 +284,8 @@ def activates(self):
bound = bound(*cp.lifted)
elif bound is True:
bound = tensor_image().upper_bound
yield bound * self.value(*cp) >= tensor(*cp.lifted)
value = 1 - self.value(*cp) if negate else self.value(*cp)
yield bound * value >= tensor(*cp.lifted)

@constraint(disabled=lower_bound is False)
def deactivates(self):
Expand All @@ -299,7 +302,8 @@ def deactivates(self):
bound = bound(*cp)
elif bound is True:
bound = tensor_image().lower_bound
yield bound * self.value(*cp) <= term
value = 1 - self.value(*cp) if negate else self.value(*cp)
yield bound * value <= term

return _Fragment()

Expand Down Expand Up @@ -338,3 +342,32 @@ def __call__(self, *subs: ExpressionLike) -> Expression:


ActivationIndicator = ActivationVariable # Deprecated alias


@method_decorator(require_call=True)
def activation_variable(
*quantifiables: Quantifiable,
upper_bound: Union[ExpressionLike, TensorLike, bool] = True,
lower_bound: Union[ExpressionLike, TensorLike, bool] = False,
name: Optional[Name] = None,
negate=False,
projection: Projection = -1,
) -> Callable[[Callable[..., TensorLike]], ActivationVariable]:
"""Transforms a method into an :class:`ActivationVariable` fragment
Note that this method may alter the underlying method's call signature if a
projection is specified.
"""

def wrapper(fn):
return ActivationVariable(
fn,
*quantifiables,
lower_bound=lower_bound,
upper_bound=upper_bound,
name=name,
negate=negate,
projection=projection,
)

return wrapper
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "opvious"
version = "0.18.2rc1"
version = "0.18.2rc2"
description = "Opvious Python SDK"
authors = ["Opvious Engineering <[email protected]>"]
readme = "README.md"
Expand Down
63 changes: 63 additions & 0 deletions tests/test_modeling.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,68 @@ def all_covered(self):
yield count >= 1


class JobShopScheduling(om.Model):
tasks = om.Dimension()
duration = om.Parameter.natural(tasks)
machine = om.Parameter.natural(tasks)
dependency = om.Parameter.indicator(
(tasks, tasks), qualifiers=["child", "parent"]
)
task_start = om.Variable.natural(tasks)
horizon = om.Variable.natural()

@property
def competing_tasks(self):
for t1, t2 in self.tasks * self.tasks:
if t1 != t2 and self.machine(t1) == self.machine(t2):
yield t1, t2

def task_end(self, t):
return self.task_start(t) + self.duration(t)

@om.constraint
def all_tasks_end_within_horizon(self):
for t in self.tasks:
yield self.task_end(t) <= self.horizon()

@om.constraint
def child_starts_after_parent_ends(self):
for c, p in self.tasks * self.tasks:
if self.dependency(c, p):
yield self.task_start(c) >= self.task_end(p)

@om.fragments.activation_variable(
lambda init, self: init(
self.competing_tasks,
negate=True,
upper_bound=self.duration.total(),
)
)
def must_start_after(self, t1, t2):
return self.task_end(t2) - self.task_start(t1)

@om.fragments.activation_variable(
lambda init, self: init(
self.competing_tasks,
negate=True,
upper_bound=self.duration.total(),
)
)
def must_end_before(self, t1, t2):
return self.task_end(t1) - self.task_start(t2)

@om.constraint
def one_active_task_per_machine(self):
for t1, t2 in self.competing_tasks:
yield self.must_end_before(t1, t2) + self.must_start_after(
t1, t2
) >= 1

@om.objective
def minimize_horizon(self):
return self.horizon()


@pytest.mark.skipif(
not client.authenticated, reason="No access token detected"
)
Expand All @@ -235,6 +297,7 @@ class TestModeling:
GroupExpenses(),
Sudoku(),
BinPacking(),
JobShopScheduling(),
]

@pytest.mark.asyncio
Expand Down

0 comments on commit 46a2a24

Please sign in to comment.