Special Constraints#

In addition to regular constraints (Constraint — an equality or inequality over a Function), OMMX provides several constraint types frequently used in mathematical optimization as first-class citizens. This page introduces the following three special constraint types, their usage, and how to solve them with the PySCIPOpt Adapter.

The examples below use the PySCIPOpt Adapter, as in Solving optimization problems with OMMX Adapter. Install it first:

pip install ommx-pyscipopt-adapter

The PySCIPOpt Adapter declares support for Indicator and SOS1 constraints and passes them through to SCIP’s addConsIndicator / addConsSOS1 (equality indicators are split into two inequality indicators). OneHot is not declared as supported, so the adapter automatically converts it into a regular equality constraint before handing it to SCIP. For more on adapter capability declarations and conversions, see Adapter Capability Model and Conversions.

IndicatorConstraint#

An indicator constraint enforces a constraint \(f(x) \leq 0\) (or \(f(x) = 0\)) only when a binary variable \(z = 1\). When \(z = 0\), the constraint is unconditionally satisfied.

Create an IndicatorConstraint from an existing Constraint by calling Constraint.with_indicator().

from ommx.v1 import Instance, DecisionVariable, Equality

z = DecisionVariable.binary(0, name="z")
x = DecisionVariable.continuous(1, lower=0, upper=10, name="x")

# z = 1 => x <= 5
ic = (x <= 5).with_indicator(z)
assert ic.indicator_variable_id == 0
assert ic.equality == Equality.LessThanOrEqualToZero

Add it to an instance by passing a dict[int, IndicatorConstraint] to the indicator_constraints= argument of Instance.from_components.

instance = Instance.from_components(
    decision_variables=[z, x],
    objective=x,
    constraints={0: z == 1},       # fix z = 1
    indicator_constraints={0: ic}, # z = 1 => x <= 5
    sense=Instance.MAXIMIZE,
)
assert set(instance.indicator_constraints.keys()) == {0}

The PySCIPOpt Adapter declares support for indicator constraints, so we can solve this directly.

from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter

solution = OMMXPySCIPOptAdapter.solve(instance)
# With z = 1, the constraint x <= 5 is active, so the maximum value of x is 5
assert abs(solution.objective - 5.0) < 1e-6

OneHotConstraint#

A one-hot constraint over a set of binary variables \(\{x_1, \ldots, x_n\}\) requires \(\sum_i x_i = 1\) — i.e. exactly one of them is 1.

from ommx.v1 import OneHotConstraint

xs = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)]
oh = OneHotConstraint(variables=[0, 1, 2])
assert oh.variables == [0, 1, 2]

The IDs passed to variables must correspond to binary variables that are in the instance’s decision_variables. Mathematically the constraint is equivalent to the linear equality \(x_0 + x_1 + x_2 - 1 = 0\), but holding it as a first-class constraint lets supporting solvers (many MIP solvers accept one-hot natively) handle it efficiently.

values = [5.0, 10.0, 3.0]
instance_oh = Instance.from_components(
    decision_variables=xs,
    objective=sum(v * x for v, x in zip(values, xs)),
    constraints={},
    one_hot_constraints={0: oh},
    sense=Instance.MAXIMIZE,
)
assert set(instance_oh.one_hot_constraints.keys()) == {0}

The PySCIPOpt Adapter does not declare OneHot support, so inside solve the constraint is automatically converted to the regular equality \(x_0 + x_1 + x_2 - 1 = 0\) before being handed to SCIP.

solution = OMMXPySCIPOptAdapter.solve(instance_oh)
# Exactly one of the three is chosen, so x_1 with the largest value 10 is selected
assert abs(solution.objective - 10.0) < 1e-6

instance_oh is mutated in place by solve, so after the call the OneHot constraint is removed and a record of the conversion remains in removed_one_hot_constraints.

assert instance_oh.one_hot_constraints == {}
assert len(instance_oh.constraints) == 1
assert set(instance_oh.removed_one_hot_constraints.keys()) == {0}

Sos1Constraint#

An SOS1 (Special Ordered Set type 1) constraint over a set of variables \(\{x_1, \ldots, x_n\}\) requires that at most one of them be non-zero. It differs from one-hot in the following ways:

  • One-hot requires \(\sum x_i = 1\), so exactly one variable is non-zero.

  • SOS1 permits up to one variable to be non-zero (zero variables non-zero is also allowed).

  • SOS1 variables are not necessarily binary — continuous variables work too.

from ommx.v1 import Sos1Constraint

ys = [DecisionVariable.continuous(i, lower=0, upper=10, name="y", subscripts=[i]) for i in range(3, 6)]
s1 = Sos1Constraint(variables=[3, 4, 5])
assert s1.variables == [3, 4, 5]
instance_s1 = Instance.from_components(
    decision_variables=ys,
    objective=sum(ys),
    constraints={},
    sos1_constraints={0: s1},
    sense=Instance.MAXIMIZE,
)
assert set(instance_s1.sos1_constraints.keys()) == {0}

The PySCIPOpt Adapter declares support for SOS1, so we can solve this directly.

solution = OMMXPySCIPOptAdapter.solve(instance_s1)
# Only one variable may be non-zero, so one is set to its upper bound 10 and the others to 0
assert abs(solution.objective - 10.0) < 1e-6

Independent ID spaces per constraint type#

In OMMX, each of the four constraint collections — regular / Indicator / OneHot / SOS1 — has an independent ID space. The four dicts passed to Instance.from_components are keyed independently, so using the same integer ID across different constraint types does not cause a collision.

For example, “regular constraint ID=1” and “Indicator constraint ID=1” coexist as distinct constraints.

z2 = DecisionVariable.binary(10, name="z2")
x2 = DecisionVariable.continuous(11, lower=0, upper=10, name="x2")

instance_mix = Instance.from_components(
    decision_variables=[z2, x2] + xs + ys,
    objective=x2,
    constraints={1: z2 == 1},                                        # regular ID=1
    indicator_constraints={1: (x2 <= 5).with_indicator(z2)},         # Indicator ID=1
    one_hot_constraints={1: OneHotConstraint(variables=[0, 1, 2])},  # OneHot ID=1
    sos1_constraints={1: Sos1Constraint(variables=[3, 4, 5])},       # SOS1 ID=1
    sense=Instance.MAXIMIZE,
)

# Each of the four dicts holds its own ID=1 constraint independently
assert set(instance_mix.constraints.keys()) == {1}
assert set(instance_mix.indicator_constraints.keys()) == {1}
assert set(instance_mix.one_hot_constraints.keys()) == {1}
assert set(instance_mix.sos1_constraints.keys()) == {1}

When a special constraint is converted to a regular constraint (see Capability Model and Conversions), the generated regular constraint is allocated from the Constraint ID space. Only regular constraint IDs can collide after conversion.

Accessing evaluation results#

The Solution or SampleSet obtained after solving provides a DataFrame accessor for each special constraint type alongside the one for regular constraints.

Constraint type

Accessor (on Solution)

Regular

constraints_df

Indicator

indicator_constraints_df

OneHot

one_hot_constraints_df

SOS1

sos1_constraints_df

The Indicator DataFrame includes an indicator_active column that disambiguates “the indicator was OFF (constraint trivially satisfied)” from “the indicator was ON and the constraint was actually satisfied”. Indicator constraints do not carry a dual variable — a dual value is not well-defined for a conditional constraint — so dual_variable is omitted.

removed_reasons_df separation#

For regular constraints, removed_reason is no longer a column of constraints_df. It lives in removed_reasons_df as a separate table, which you can join as needed:

df = solution.constraints_df.join(solution.removed_reasons_df)

The same split applies to Indicator, OneHot, and SOS1: each has its own indicator_removed_reasons_df / one_hot_removed_reasons_df / sos1_removed_reasons_df on both Solution and SampleSet.

Relax / Restore#

IndicatorConstraint supports the same relax / restore workflow as regular constraints.

For OneHot and SOS1, movement into removed_one_hot_constraints / removed_sos1_constraints happens via the conversion APIs covered in Capability Model and Conversions.