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). Its solve() API copies the input and uses the
recommended Preparation policy to lower OneHot constraints to regular
equalities. Callers using solve_without_preparation() must perform that lowering on the
Instance first. 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}
Pass the original Instance to the easy solve() API. Its recommended
Preparation lowers the OneHot constraint to the regular equality
\(x_0 + x_1 + x_2 - 1 = 0\) only on the Adapter’s private copy.
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
The caller-owned instance_oh remains unchanged:
assert set(instance_oh.one_hot_constraints.keys()) == {0}
assert instance_oh.constraints == {}
assert instance_oh.removed_one_hot_constraints == {}
Callers using solve_without_preparation() must instead lower OneHot in place
before solving. That explicit conversion removes the active OneHot constraint,
adds the equivalent regular constraint, and records the original in
removed_one_hot_constraints.
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
SOS1 Big-M formulations#
A Big-M formulation represents the SOS1 condition with regular constraints. For each member \(x_i\), introduce a binary selector \(y_i\) and finite coefficients \(M_i^+\) and \(M_i^-\) large enough to cover the member’s upper and lower domain values:
When \(y_i = 0\), the link constraints force \(x_i = 0\). The cardinality constraint then allows at most one member to be non-zero, so the Big-M rows imply SOS1. Conversely, when the coefficients cover the complete member domains, every SOS1-feasible member assignment can set the selector of its non-zero member to one and every other selector to zero, or set all selectors to zero when all members are zero. The two formulations therefore have the same feasible member assignments after fresh selectors are projected out. A full-domain binary member can be reused as its own selector, and a link whose side is already implied by the member’s domain can be omitted.
A call to promote_sos1_big_m() is an independent request
to transform a claimed existing Big-M formulation. It verifies the current
variables, domains, and rows satisfy sufficient conditions for this projected
equivalence before replacing the claimed formulation rows with a first-class
SOS1 constraint. It neither assumes that the rows came from
convert_sos1_to_constraints() nor rolls that operation back.
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.