Skip to content

Commit c415b4e

Browse files
mcoughlinFabianHofmannpre-commit-ci[bot]
authored
feat: Add semi-continous variables as an option (#593)
* Add semi-continous variables as an option * Run the pre-commit * Fix mypy issues * Add release notes note * Fabian feedback * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Missing to_culpdx --------- Co-authored-by: Fabian Hofmann <fab.hof@gmx.de> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1e5a4ec commit c415b4e

7 files changed

Lines changed: 326 additions & 12 deletions

File tree

doc/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Upcoming Version
1717
* Add ``active`` parameter to ``piecewise()`` for gating piecewise linear functions with a binary variable (e.g. unit commitment). Supported for incremental, SOS2, and disjunctive methods.
1818
* Add the `sphinx-copybutton` to the documentation
1919
* Add SOS1 and SOS2 reformulations for solvers not supporting them.
20+
* Add semi-continous variables for solvers that support them
2021
* Improve handling of CPLEX solver quality attributes to ensure metrics such are extracted correctly when available.
2122
* Fix Xpress IIS label mapping for masked constraints and add a regression test for matching infeasible coordinates.
2223
* Enable quadratic problems with SCIP on windows.

linopy/io.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,11 @@ def bounds_to_file(
234234
"""
235235
Write out variables of a model to a lp file.
236236
"""
237-
names = list(m.variables.continuous) + list(m.variables.integers)
237+
names = (
238+
list(m.variables.continuous)
239+
+ list(m.variables.integers)
240+
+ list(m.variables.semi_continuous)
241+
)
238242
if not len(list(names)):
239243
return
240244

@@ -304,6 +308,44 @@ def binaries_to_file(
304308
_format_and_write(df, columns, f)
305309

306310

311+
def semi_continuous_to_file(
312+
m: Model,
313+
f: BufferedWriter,
314+
progress: bool = False,
315+
slice_size: int = 2_000_000,
316+
explicit_coordinate_names: bool = False,
317+
) -> None:
318+
"""
319+
Write out semi-continuous variables of a model to a lp file.
320+
"""
321+
names = m.variables.semi_continuous
322+
if not len(list(names)):
323+
return
324+
325+
print_variable, _ = get_printers(
326+
m, explicit_coordinate_names=explicit_coordinate_names
327+
)
328+
329+
f.write(b"\n\nsemi-continuous\n\n")
330+
if progress:
331+
names = tqdm(
332+
list(names),
333+
desc="Writing semi-continuous variables.",
334+
colour=TQDM_COLOR,
335+
)
336+
337+
for name in names:
338+
var = m.variables[name]
339+
for var_slice in var.iterate_slices(slice_size):
340+
df = var_slice.to_polars()
341+
342+
columns = [
343+
*print_variable(pl.col("labels")),
344+
]
345+
346+
_format_and_write(df, columns, f)
347+
348+
307349
def integers_to_file(
308350
m: Model,
309351
f: BufferedWriter,
@@ -509,6 +551,13 @@ def to_lp_file(
509551
slice_size=slice_size,
510552
explicit_coordinate_names=explicit_coordinate_names,
511553
)
554+
semi_continuous_to_file(
555+
m,
556+
f=f,
557+
progress=progress,
558+
slice_size=slice_size,
559+
explicit_coordinate_names=explicit_coordinate_names,
560+
)
512561
sos_to_file(
513562
m,
514563
f=f,
@@ -594,6 +643,12 @@ def to_mosek(
594643
if m.variables.sos:
595644
raise NotImplementedError("SOS constraints are not supported by MOSEK.")
596645

646+
if m.variables.semi_continuous:
647+
raise NotImplementedError(
648+
"Semi-continuous variables are not supported by MOSEK. "
649+
"Use a solver that supports them (gurobi, cplex, highs)."
650+
)
651+
597652
import mosek
598653

599654
print_variable, print_constraint = get_printers_scalar(
@@ -720,7 +775,11 @@ def to_gurobipy(
720775

721776
names = np.vectorize(print_variable)(M.vlabels).astype(object)
722777
kwargs = {}
723-
if len(m.binaries.labels) + len(m.integers.labels):
778+
if (
779+
len(m.binaries.labels)
780+
+ len(m.integers.labels)
781+
+ len(list(m.variables.semi_continuous))
782+
):
724783
kwargs["vtype"] = M.vtypes
725784
x = model.addMVar(M.vlabels.shape, M.lb, M.ub, name=list(names), **kwargs)
726785

@@ -793,11 +852,17 @@ def to_highspy(m: Model, explicit_coordinate_names: bool = False) -> Highs:
793852
M = m.matrices
794853
h = highspy.Highs()
795854
h.addVars(len(M.vlabels), M.lb, M.ub)
796-
if len(m.binaries) + len(m.integers):
855+
if len(m.binaries) + len(m.integers) + len(list(m.variables.semi_continuous)):
797856
vtypes = M.vtypes
798-
labels = np.arange(len(vtypes))[(vtypes == "B") | (vtypes == "I")]
799-
n = len(labels)
800-
h.changeColsIntegrality(n, labels, ones_like(labels))
857+
# Map linopy vtypes to HiGHS integrality values:
858+
# 0 = continuous, 1 = integer, 2 = semi-continuous
859+
integrality_map = {"C": 0, "B": 1, "I": 1, "S": 2}
860+
int_mask = (vtypes == "B") | (vtypes == "I") | (vtypes == "S")
861+
labels = np.arange(len(vtypes))[int_mask]
862+
integrality = np.array(
863+
[integrality_map[v] for v in vtypes[int_mask]], dtype=np.int32
864+
)
865+
h.changeColsIntegrality(len(labels), labels, integrality)
801866
if len(m.binaries):
802867
labels = np.arange(len(vtypes))[vtypes == "B"]
803868
n = len(labels)
@@ -856,6 +921,12 @@ def to_cupdlpx(m: Model, explicit_coordinate_names: bool = False) -> cupdlpxMode
856921
-------
857922
model : cupdlpx.Model
858923
"""
924+
if m.variables.semi_continuous:
925+
raise NotImplementedError(
926+
"Semi-continuous variables are not supported by cuPDLPx. "
927+
"Use a solver that supports them (gurobi, cplex, highs)."
928+
)
929+
859930
import cupdlpx
860931

861932
if explicit_coordinate_names:

linopy/matrices.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ def vtypes(self) -> ndarray:
8383
val = "B"
8484
elif name in m.integers:
8585
val = "I"
86+
elif name in m.semi_continuous:
87+
val = "S"
8688
else:
8789
val = "C"
8890
specs.append(pd.Series(val, index=m.variables[name].flat.labels))

linopy/model.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ def add_variables(
500500
mask: DataArray | ndarray | Series | None = None,
501501
binary: bool = False,
502502
integer: bool = False,
503+
semi_continuous: bool = False,
503504
**kwargs: Any,
504505
) -> Variable:
505506
"""
@@ -538,6 +539,11 @@ def add_variables(
538539
integer : bool
539540
Whether the new variable is a integer variable which are used for
540541
Mixed-Integer problems.
542+
semi_continuous : bool
543+
Whether the new variable is a semi-continuous variable. A
544+
semi-continuous variable can take the value 0 or any value
545+
between its lower and upper bounds. Requires a positive lower
546+
bound.
541547
**kwargs :
542548
Additional keyword arguments are passed to the DataArray creation.
543549
@@ -580,15 +586,23 @@ def add_variables(
580586
if name in self.variables:
581587
raise ValueError(f"Variable '{name}' already assigned to model")
582588

583-
if binary and integer:
584-
raise ValueError("Variable cannot be both binary and integer.")
589+
if sum([binary, integer, semi_continuous]) > 1:
590+
raise ValueError(
591+
"Variable can only be one of binary, integer, or semi-continuous."
592+
)
585593

586594
if binary:
587595
if (lower != -inf) or (upper != inf):
588596
raise ValueError("Binary variables cannot have lower or upper bounds.")
589597
else:
590598
lower, upper = 0, 1
591599

600+
if semi_continuous:
601+
if not np.isscalar(lower) or float(lower) <= 0: # type: ignore[arg-type]
602+
raise ValueError(
603+
"Semi-continuous variables require a positive scalar lower bound."
604+
)
605+
592606
data = Dataset(
593607
{
594608
"lower": as_dataarray(lower, coords, **kwargs),
@@ -626,7 +640,11 @@ def add_variables(
626640
data.labels.values = np.where(mask.values, data.labels.values, -1)
627641

628642
data = data.assign_attrs(
629-
label_range=(start, end), name=name, binary=binary, integer=integer
643+
label_range=(start, end),
644+
name=name,
645+
binary=binary,
646+
integer=integer,
647+
semi_continuous=semi_continuous,
630648
)
631649

632650
if self.chunk:
@@ -1018,6 +1036,13 @@ def integers(self) -> Variables:
10181036
"""
10191037
return self.variables.integers
10201038

1039+
@property
1040+
def semi_continuous(self) -> Variables:
1041+
"""
1042+
Get all semi-continuous variables.
1043+
"""
1044+
return self.variables.semi_continuous
1045+
10211046
@property
10221047
def is_linear(self) -> bool:
10231048
return self.objective.is_linear
@@ -1028,9 +1053,11 @@ def is_quadratic(self) -> bool:
10281053

10291054
@property
10301055
def type(self) -> str:
1031-
if (len(self.binaries) or len(self.integers)) and len(self.continuous):
1056+
if (
1057+
len(self.binaries) or len(self.integers) or len(self.semi_continuous)
1058+
) and len(self.continuous):
10321059
variable_type = "MI"
1033-
elif len(self.binaries) or len(self.integers):
1060+
elif len(self.binaries) or len(self.integers) or len(self.semi_continuous):
10341061
variable_type = "I"
10351062
else:
10361063
variable_type = ""
@@ -1469,6 +1496,15 @@ def solve(
14691496
"Use reformulate_sos=True or 'auto', or a solver that supports SOS (gurobi, cplex)."
14701497
)
14711498

1499+
if self.variables.semi_continuous:
1500+
if not solver_supports(
1501+
solver_name, SolverFeature.SEMI_CONTINUOUS_VARIABLES
1502+
):
1503+
raise ValueError(
1504+
f"Solver {solver_name} does not support semi-continuous variables. "
1505+
"Use a solver that supports them (gurobi, cplex, highs)."
1506+
)
1507+
14721508
try:
14731509
solver_class = getattr(solvers, f"{solvers.SolverName(solver_name).name}")
14741510
# initialize the solver as object of solver subclass <solver_class>

linopy/solver_capabilities.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ class SolverFeature(Enum):
4949
# Special constraint types
5050
SOS_CONSTRAINTS = auto() # Special Ordered Sets (SOS1/SOS2) constraints
5151

52+
# Special variable types
53+
SEMI_CONTINUOUS_VARIABLES = auto() # Semi-continuous variable support
54+
5255
# Solver-specific
5356
SOLVER_ATTRIBUTE_ACCESS = auto() # Direct access to solver variable attributes
5457

@@ -85,6 +88,7 @@ def supports(self, feature: SolverFeature) -> bool:
8588
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
8689
SolverFeature.IIS_COMPUTATION,
8790
SolverFeature.SOS_CONSTRAINTS,
91+
SolverFeature.SEMI_CONTINUOUS_VARIABLES,
8892
SolverFeature.SOLVER_ATTRIBUTE_ACCESS,
8993
}
9094
),
@@ -100,6 +104,7 @@ def supports(self, feature: SolverFeature) -> bool:
100104
SolverFeature.LP_FILE_NAMES,
101105
SolverFeature.READ_MODEL_FROM_FILE,
102106
SolverFeature.SOLUTION_FILE_NOT_NEEDED,
107+
SolverFeature.SEMI_CONTINUOUS_VARIABLES,
103108
}
104109
),
105110
),
@@ -133,6 +138,7 @@ def supports(self, feature: SolverFeature) -> bool:
133138
SolverFeature.LP_FILE_NAMES,
134139
SolverFeature.READ_MODEL_FROM_FILE,
135140
SolverFeature.SOS_CONSTRAINTS,
141+
SolverFeature.SEMI_CONTINUOUS_VARIABLES,
136142
}
137143
),
138144
),

linopy/variables.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,8 @@ def __repr__(self) -> str:
13861386
sos_dim := ds.attrs.get(SOS_DIM_ATTR)
13871387
):
13881388
coords += f" - sos{sos_type} on {sos_dim}"
1389+
if ds.attrs.get("semi_continuous", False):
1390+
coords += " - semi-continuous"
13891391
r += f" * {name}{coords}\n"
13901392
if not len(list(self)):
13911393
r += "<empty>\n"
@@ -1525,7 +1527,23 @@ def continuous(self) -> Variables:
15251527
{
15261528
name: self.data[name]
15271529
for name in self
1528-
if not self[name].attrs["integer"] and not self[name].attrs["binary"]
1530+
if not self[name].attrs["integer"]
1531+
and not self[name].attrs["binary"]
1532+
and not self[name].attrs.get("semi_continuous", False)
1533+
},
1534+
self.model,
1535+
)
1536+
1537+
@property
1538+
def semi_continuous(self) -> Variables:
1539+
"""
1540+
Get all semi-continuous variables.
1541+
"""
1542+
return self.__class__(
1543+
{
1544+
name: self.data[name]
1545+
for name in self
1546+
if self[name].attrs.get("semi_continuous", False)
15291547
},
15301548
self.model,
15311549
)

0 commit comments

Comments
 (0)