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.
IndicatorConstraint: a conditional constraint driven by a binary variableOneHotConstraint: exactly one of a set of binary variables equals 1Sos1Constraint: at most one of a set of variables is non-zero
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 passes Indicator and SOS1 constraints through to SCIP’s addConsIndicator / addConsSOS1 (equality indicators are split into two inequality indicators). It does not accept OneHot directly, so callers must explicitly lower OneHot to a regular equality before passing the resulting input to the adapter. This preparation is separate from INPUT_CLASS membership and adapter applicability; see Adapter Input Classes and Explicit Constraint Lowering.
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(). The indicator argument accepts a variable ID, a standalone DecisionVariable, or an AttachedDecisionVariable.
from ommx 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 accepts Indicator constraints and passes them directly to SCIP.
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 import OneHotConstraint
xs = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)]
oh = OneHotConstraint(variables=xs)
assert oh.variables == [0, 1, 2]
Each entry passed to variables may be a variable ID, a standalone DecisionVariable, or an AttachedDecisionVariable. This input is exposed as the VariableIDLike type alias because only the variable identity is consumed. When the constraint is inserted, the enclosing instance validates the referenced IDs and any constraint-specific requirement against its own decision variables. For OneHot, the referenced variables must exist and be binary. The constraint stores their IDs, which are available through oh.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}
Explicitly lower OneHot before passing the instance to the PySCIPOpt Adapter. The conversion rewrites the constraint as the regular equality \(x_0 + x_1 + x_2 - 1 = 0\). Because this produces a different input value, check its applicability before solving.
instance_oh.convert_all_one_hots_to_constraints()
assert OMMXPySCIPOptAdapter.check_applicability(instance_oh).is_applicable
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
convert_all_one_hots_to_constraints() mutates instance_oh in place, so after the explicit preparation the OneHot constraint is removed and a record of the conversion remains in removed_one_hot_constraints. Solving does not perform this conversion.
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 import Sos1Constraint
ys = [DecisionVariable.continuous(i, lower=0, upper=10, name="y", subscripts=[i]) for i in range(3, 6)]
s1 = Sos1Constraint(variables=ys)
assert s1.variables == [3, 4, 5]
As with OneHotConstraint, each variables entry accepts VariableIDLike: a variable ID, a standalone decision variable, or an attached decision variable. The SOS1 constraint stores their IDs, which are available through s1.variables.
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 accepts SOS1 constraints and passes them directly to SCIP.
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=xs)}, # OneHot ID=1
sos1_constraints={1: Sos1Constraint(variables=ys)}, # 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 Explicit Constraint Lowering), 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 exposes a single constraints_df() method that dispatches on kind=:
Constraint type |
|
|---|---|
Regular |
|
Indicator |
|
OneHot |
|
SOS1 |
|
solution.constraints_df() # regular (default)
solution.constraints_df(kind="indicator") # indicator
sample_set.constraints_df(kind="one_hot") # one-hot
The DataFrame is indexed by the kind-qualified id column (regular_constraint_id, indicator_constraint_id, one_hot_constraint_id, sos1_constraint_id) — accidental cross-id-space df.join() mistakes surface in df.head() and friends.
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 reason columns via include=#
removed_reason is no longer a default column of constraints_df(). Pass "removed_reason" in include= to fold the reason name and the removed_reason.{key} parameter columns back in (rows whose constraint was not removed before evaluation get NA in those columns):
df = solution.constraints_df(
include=("label", "parameters", "removed_reason"),
)
The same applies to Indicator, OneHot, and SOS1: pass the corresponding kind= together with "removed_reason" in include=. The long-format constraint_removed_reasons_df() sidecar (also kind=-dispatched) remains the right surface when you want one row per (constraint id, parameter key) pair for joins or aggregation.
Relax / Restore#
IndicatorConstraint supports the same relax / restore workflow as regular constraints.
Instance.relax_indicator_constraint(): relax (deactivate) an indicator constraint and record a reason string. The relaxed constraint is moved intoremoved_indicator_constraints.Instance.restore_indicator_constraint(): restore a previously relaxed indicator constraint. Fails if the indicator variable has already been substituted or fixed.
For OneHot and SOS1, movement into removed_one_hot_constraints / removed_sos1_constraints happens via the conversion APIs covered in Explicit Constraint Lowering.