|
| 1 | +.. _sos-constraints: |
| 2 | + |
| 3 | +Special Ordered Sets (SOS) Constraints |
| 4 | +======================================= |
| 5 | + |
| 6 | +Special Ordered Sets (SOS) are a constraint type used in mixed-integer programming to model situations where only one or two variables from an ordered set can be non-zero. Linopy supports both SOS Type 1 and SOS Type 2 constraints. |
| 7 | + |
| 8 | +.. contents:: |
| 9 | + :local: |
| 10 | + :depth: 2 |
| 11 | + |
| 12 | +Overview |
| 13 | +-------- |
| 14 | + |
| 15 | +SOS constraints are particularly useful for: |
| 16 | + |
| 17 | +- **SOS1**: Modeling mutually exclusive choices (e.g., selecting one facility from multiple locations) |
| 18 | +- **SOS2**: Piecewise linear approximations of nonlinear functions |
| 19 | +- Improving branch-and-bound efficiency in mixed-integer programming |
| 20 | + |
| 21 | +Types of SOS Constraints |
| 22 | +------------------------- |
| 23 | + |
| 24 | +SOS Type 1 (SOS1) |
| 25 | +~~~~~~~~~~~~~~~~~~ |
| 26 | + |
| 27 | +In an SOS1 constraint, **at most one** variable in the ordered set can be non-zero. |
| 28 | + |
| 29 | +**Example use cases:** |
| 30 | +- Facility location problems (choose one location among many) |
| 31 | +- Technology selection (choose one technology option) |
| 32 | +- Mutually exclusive investment decisions |
| 33 | + |
| 34 | +SOS Type 2 (SOS2) |
| 35 | +~~~~~~~~~~~~~~~~~~ |
| 36 | + |
| 37 | +In an SOS2 constraint, **at most two adjacent** variables in the ordered set can be non-zero. The adjacency is determined by the ordering weights (coordinates) of the variables. |
| 38 | + |
| 39 | +**Example use cases:** |
| 40 | +- Piecewise linear approximation of nonlinear functions |
| 41 | +- Portfolio optimization with discrete risk levels |
| 42 | +- Production planning with discrete capacity levels |
| 43 | + |
| 44 | +Basic Usage |
| 45 | +----------- |
| 46 | + |
| 47 | +Adding SOS Constraints |
| 48 | +~~~~~~~~~~~~~~~~~~~~~~~ |
| 49 | + |
| 50 | +To add SOS constraints to variables in linopy: |
| 51 | + |
| 52 | +.. code-block:: python |
| 53 | +
|
| 54 | + import linopy |
| 55 | + import pandas as pd |
| 56 | + import xarray as xr |
| 57 | +
|
| 58 | + # Create model |
| 59 | + m = linopy.Model() |
| 60 | +
|
| 61 | + # Create variables with numeric coordinates |
| 62 | + coords = pd.Index([0, 1, 2], name="options") |
| 63 | + x = m.add_variables(coords=[coords], name="x", lower=0, upper=1) |
| 64 | +
|
| 65 | + # Add SOS1 constraint |
| 66 | + m.add_sos_constraints(x, sos_type=1, sos_dim="options") |
| 67 | +
|
| 68 | + # For SOS2 constraint |
| 69 | + breakpoints = pd.Index([0.0, 1.0, 2.0], name="breakpoints") |
| 70 | + lambdas = m.add_variables(coords=[breakpoints], name="lambdas", lower=0, upper=1) |
| 71 | + m.add_sos_constraints(lambdas, sos_type=2, sos_dim="breakpoints") |
| 72 | +
|
| 73 | +Method Signature |
| 74 | +~~~~~~~~~~~~~~~~ |
| 75 | + |
| 76 | +.. code-block:: python |
| 77 | +
|
| 78 | + Model.add_sos_constraints(variable, sos_type, sos_dim) |
| 79 | +
|
| 80 | +**Parameters:** |
| 81 | + |
| 82 | +- ``variable`` : Variable |
| 83 | + The variable to which the SOS constraint should be applied |
| 84 | +- ``sos_type`` : {1, 2} |
| 85 | + Type of SOS constraint (1 or 2) |
| 86 | +- ``sos_dim`` : str |
| 87 | + Name of the dimension along which the SOS constraint applies |
| 88 | + |
| 89 | +**Requirements:** |
| 90 | + |
| 91 | +- The specified dimension must exist in the variable |
| 92 | +- The coordinates for the SOS dimension must be numeric (used as weights for ordering) |
| 93 | +- Only one SOS constraint can be applied per variable |
| 94 | + |
| 95 | +Examples |
| 96 | +-------- |
| 97 | + |
| 98 | +Example 1: Facility Location (SOS1) |
| 99 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 100 | + |
| 101 | +.. code-block:: python |
| 102 | +
|
| 103 | + import linopy |
| 104 | + import pandas as pd |
| 105 | + import xarray as xr |
| 106 | +
|
| 107 | + # Problem data |
| 108 | + locations = pd.Index([0, 1, 2, 3], name="locations") |
| 109 | + costs = xr.DataArray([100, 150, 120, 80], coords=[locations]) |
| 110 | + benefits = xr.DataArray([200, 300, 250, 180], coords=[locations]) |
| 111 | +
|
| 112 | + # Create model |
| 113 | + m = linopy.Model() |
| 114 | +
|
| 115 | + # Decision variables: build facility at location i |
| 116 | + build = m.add_variables(coords=[locations], name="build", lower=0, upper=1) |
| 117 | +
|
| 118 | + # SOS1 constraint: at most one facility can be built |
| 119 | + m.add_sos_constraints(build, sos_type=1, sos_dim="locations") |
| 120 | +
|
| 121 | + # Objective: maximize net benefit |
| 122 | + net_benefit = benefits - costs |
| 123 | + m.add_objective(-((net_benefit * build).sum())) |
| 124 | +
|
| 125 | + # Solve |
| 126 | + m.solve(solver_name="highs") |
| 127 | +
|
| 128 | + if m.status == "ok": |
| 129 | + solution = build.solution.to_pandas() |
| 130 | + selected_location = solution[solution > 0.5].index[0] |
| 131 | + print(f"Build facility at location {selected_location}") |
| 132 | +
|
| 133 | +Example 2: Piecewise Linear Approximation (SOS2) |
| 134 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 135 | + |
| 136 | +.. code-block:: python |
| 137 | +
|
| 138 | + import numpy as np |
| 139 | +
|
| 140 | + # Approximate f(x) = x² over [0, 3] with breakpoints |
| 141 | + breakpoints = pd.Index([0, 1, 2, 3], name="breakpoints") |
| 142 | +
|
| 143 | + x_vals = xr.DataArray(breakpoints.to_series()) |
| 144 | + y_vals = x_vals**2 |
| 145 | +
|
| 146 | + # Create model |
| 147 | + m = linopy.Model() |
| 148 | +
|
| 149 | + # SOS2 variables (interpolation weights) |
| 150 | + lambdas = m.add_variables(lower=0, upper=1, coords=[breakpoints], name="lambdas") |
| 151 | + m.add_sos_constraints(lambdas, sos_type=2, sos_dim="breakpoints") |
| 152 | +
|
| 153 | + # Interpolated coordinates |
| 154 | + x = m.add_variables(name="x", lower=0, upper=3) |
| 155 | + y = m.add_variables(name="y", lower=0, upper=9) |
| 156 | +
|
| 157 | + # Constraints |
| 158 | + m.add_constraints(lambdas.sum() == 1, name="convexity") |
| 159 | + m.add_constraints(x == lambdas @ x_vals, name="x_interpolation") |
| 160 | + m.add_constraints(y == lambdas @ y_vals, name="y_interpolation") |
| 161 | + m.add_constraints(x >= 1.5, name="x_minimum") |
| 162 | +
|
| 163 | + # Objective: minimize approximated function value |
| 164 | + m.add_objective(y) |
| 165 | +
|
| 166 | + # Solve |
| 167 | + m.solve(solver_name="highs") |
| 168 | +
|
| 169 | +Working with Multi-dimensional Variables |
| 170 | +----------------------------------------- |
| 171 | + |
| 172 | +SOS constraints are created for each dimension that is not sos_dim. |
| 173 | + |
| 174 | +.. code-block:: python |
| 175 | +
|
| 176 | + # Multi-period production planning |
| 177 | + periods = pd.Index(range(3), name="periods") |
| 178 | + modes = pd.Index([0, 1, 2], name="modes") |
| 179 | +
|
| 180 | + # 2D variables: periods × modes |
| 181 | + period_modes = m.add_variables( |
| 182 | + lower=0, upper=1, coords=[periods, modes], name="use_mode" |
| 183 | + ) |
| 184 | +
|
| 185 | + # Adds SOS1 constraint for each period |
| 186 | + m.add_sos_constraints(period_modes, sos_type=1, sos_dim="modes") |
| 187 | +
|
| 188 | +Accessing SOS Variables |
| 189 | +----------------------- |
| 190 | + |
| 191 | +You can easily identify and access variables with SOS constraints: |
| 192 | + |
| 193 | +.. code-block:: python |
| 194 | +
|
| 195 | + # Get all variables with SOS constraints |
| 196 | + sos_variables = m.variables.sos |
| 197 | + print(f"SOS variables: {list(sos_variables.keys())}") |
| 198 | +
|
| 199 | + # Check SOS properties of a variable |
| 200 | + for var_name in sos_variables: |
| 201 | + var = m.variables[var_name] |
| 202 | + sos_type = var.attrs["sos_type"] |
| 203 | + sos_dim = var.attrs["sos_dim"] |
| 204 | + print(f"{var_name}: SOS{sos_type} on dimension '{sos_dim}'") |
| 205 | +
|
| 206 | +Variable Representation |
| 207 | +~~~~~~~~~~~~~~~~~~~~~~~ |
| 208 | + |
| 209 | +Variables with SOS constraints show their SOS information in string representations: |
| 210 | + |
| 211 | +.. code-block:: python |
| 212 | +
|
| 213 | + print(build) |
| 214 | + # Output: Variable (locations: 4) - sos1 on locations |
| 215 | + # ----------------------------------------------- |
| 216 | + # [0]: build[0] ∈ [0, 1] |
| 217 | + # [1]: build[1] ∈ [0, 1] |
| 218 | + # [2]: build[2] ∈ [0, 1] |
| 219 | + # [3]: build[3] ∈ [0, 1] |
| 220 | +
|
| 221 | +LP File Export |
| 222 | +-------------- |
| 223 | + |
| 224 | +The generated LP file will include a SOS section: |
| 225 | + |
| 226 | +.. code-block:: text |
| 227 | +
|
| 228 | + sos |
| 229 | +
|
| 230 | + s0: S1 :: x0:0 x1:1 x2:2 |
| 231 | + s3: S2 :: x3:0.0 x4:1.0 x5:2.0 |
| 232 | +
|
| 233 | +Solver Compatibility |
| 234 | +-------------------- |
| 235 | + |
| 236 | +SOS constraints are supported by most modern mixed-integer programming solvers through the LP file format: |
| 237 | + |
| 238 | +**Supported solvers:** |
| 239 | +- HiGHS |
| 240 | +- Gurobi |
| 241 | +- CPLEX |
| 242 | +- COIN-OR CBC |
| 243 | +- SCIP |
| 244 | +- Xpress |
| 245 | + |
| 246 | +**Note:** Some solvers may have varying levels of SOS support. Check your solver's documentation for specific capabilities. |
| 247 | + |
| 248 | +Common Patterns |
| 249 | +--------------- |
| 250 | + |
| 251 | +Piecewise Linear Cost Function |
| 252 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 253 | + |
| 254 | +.. code-block:: python |
| 255 | +
|
| 256 | + def add_piecewise_cost(model, variable, breakpoints, costs): |
| 257 | + """Add piecewise linear cost function using SOS2.""" |
| 258 | + n_segments = len(breakpoints) |
| 259 | + lambda_coords = pd.Index(range(n_segments), name="segments") |
| 260 | +
|
| 261 | + lambdas = model.add_variables( |
| 262 | + coords=[lambda_coords], name="cost_lambdas", lower=0, upper=1 |
| 263 | + ) |
| 264 | + model.add_sos_constraints(lambdas, sos_type=2, sos_dim="segments") |
| 265 | +
|
| 266 | + cost_var = model.add_variables(name="cost", lower=0) |
| 267 | +
|
| 268 | + x_vals = xr.DataArray(breakpoints, coords=[lambda_coords]) |
| 269 | + c_vals = xr.DataArray(costs, coords=[lambda_coords]) |
| 270 | +
|
| 271 | + model.add_constraints(lambdas.sum() == 1, name="cost_convexity") |
| 272 | + model.add_constraints(variable == (x_vals * lambdas).sum(), name="cost_x_def") |
| 273 | + model.add_constraints(cost_var == (c_vals * lambdas).sum(), name="cost_def") |
| 274 | +
|
| 275 | + return cost_var |
| 276 | +
|
| 277 | +Mutually Exclusive Investments |
| 278 | +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| 279 | + |
| 280 | +.. code-block:: python |
| 281 | +
|
| 282 | + def add_exclusive_investments(model, projects, costs, returns): |
| 283 | + """Add mutually exclusive investment decisions using SOS1.""" |
| 284 | + project_coords = pd.Index(projects, name="projects") |
| 285 | +
|
| 286 | + invest = model.add_variables( |
| 287 | + coords=[project_coords], name="invest", binary=True |
| 288 | + ) |
| 289 | + model.add_sos_constraints(invest, sos_type=1, sos_dim="projects") |
| 290 | +
|
| 291 | + total_cost = (invest * costs).sum() |
| 292 | + total_return = (invest * returns).sum() |
| 293 | +
|
| 294 | + return invest, total_cost, total_return |
| 295 | +
|
| 296 | +
|
| 297 | +See Also |
| 298 | +-------- |
| 299 | + |
| 300 | +- :doc:`creating-variables`: Creating variables with coordinates |
| 301 | +- :doc:`creating-constraints`: Adding regular constraints |
| 302 | +- :doc:`user-guide`: General linopy usage patterns |
| 303 | +- Example notebook: ``examples/sos-constraints-example.ipynb`` |
0 commit comments