Skip to content

add_sos_constraints conflates coordinate labels with SOS weights: string dims rejected, unsorted numeric labels silently reorder SOS2 adjacency #892

Description

@FBumann

Not yet sure if this is a bug or just an api decision.

Note

This bug report was generated by AI (Claude), and the reproduction was verified by running it against current master (0.8.0.post1.dev141+g0402431f6).

Model.add_sos_constraints takes no weights= parameter and uses the coordinate labels of sos_dim as the SOS weights. That has two consequences:

  1. A string-labeled dimension cannot carry an SOS constraint at alladd_sos_constraints raises ValueError: SOS constraint requires numeric coordinates.
  2. Numeric labels that are not in sorted order silently redefine SOS2 adjacency. Solvers order SOS members by weight, so "consecutive" means consecutive by label value, not by array position. Two models that are element-for-element identical (same declaration order, same bounds, same objective coefficients) reach different optima depending only on what the coordinates are named — both reported as optimal.

Reproduction

import pandas as pd
import linopy

# 1. string-labeled dimension — SOS impossible
m = linopy.Model()
x = m.add_variables(lower=0, upper=1, coords=[pd.Index(["s1", "s2", "s3"], name="size")])
m.add_sos_constraints(x, sos_type=1, sos_dim="size")
# ValueError: SOS constraint requires numeric coordinates for dimension 'size', but got object

# 2. coordinate values silently define SOS2 adjacency
def solve_sos2(labels):
    m = linopy.Model()
    x = m.add_variables(lower=0, upper=1, coords=[pd.Index(labels, name="size")], name="x")
    m.add_sos_constraints(x, sos_type=2, sos_dim="size")
    m.add_objective((x * pd.Series([1.0, 1.0, 0.1], index=x.indexes["size"])).sum(), sense="max")
    m.solve(solver_name="highs", reformulate_sos=True, output_flag=False)
    return m.objective.value, m.solution["x"]

solve_sos2([0, 1, 2])     # objective 2.0 — nonzero members at positions 0, 1
solve_sos2([30, 10, 20])  # objective 1.1 — nonzero members 10, 20

Output:

case 1: SOS constraint requires numeric coordinates for dimension 'size', but got object

case 2: identical model, members declared in the same order
  labels [0, 1, 2]   -> objective 2.0  nonzero: [0, 1]
  labels [30, 10, 20] -> objective 1.1  nonzero: [10, 20]

In case 2 the objective coefficients are (1.0, 1.0, 0.1) by position in both runs. With labels [30, 10, 20] the weight order is 10 < 20 < 30, so the consecutive pairs are {10, 20} and {20, 30} — the first-two-declared pair {30, 10} is no longer expressible, and the optimum drops from 2.0 to 1.1 with no error or warning.

This is not an artifact of the big-M reformulation — all three emission paths agree on value order:

  • sos_reformulation.py:285-288 argsorts members by coordinate value before building adjacency,
  • solvers.py:138 passes labels.coords[sos_dim].values as weights to the native addSOS APIs (Gurobi, Xpress),
  • io.py:456 writes the coordinate values as weights into the LP file's sos section.

Suggested fix

An optional weights= parameter on add_sos_constraints (an array along sos_dim, defaulting to the current behavior) would resolve both: string-labeled dims get positional or user-supplied weights, and callers who mean array-order adjacency can say so. At minimum the docstring — currently just "The dimension values are used as SOS" (model.py:928) — should state that SOS2 adjacency follows sorted coordinate values, not array order.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions