Skip to content

Commit ba4ac0a

Browse files
committed
feat: add sos constraints
1 parent 84f6896 commit ba4ac0a

3 files changed

Lines changed: 130 additions & 2 deletions

File tree

linopy/io.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from tqdm import tqdm
2424

2525
from linopy import solvers
26+
from linopy.common import to_polars
2627
from linopy.constants import CONCAT_DIM
2728
from linopy.objective import Objective
2829

@@ -327,6 +328,66 @@ def integers_to_file(
327328
formatted.write_csv(f, **kwargs)
328329

329330

331+
def sos_to_file(
332+
m: Model,
333+
f: BufferedWriter,
334+
progress: bool = False,
335+
slice_size: int = 2_000_000,
336+
explicit_coordinate_names: bool = False,
337+
) -> None:
338+
"""
339+
Write out integers of a model to a lp file.
340+
"""
341+
names = m.variables.sos
342+
if not len(list(names)):
343+
return
344+
345+
print_variable, _ = get_printers(
346+
m, explicit_coordinate_names=explicit_coordinate_names
347+
)
348+
349+
f.write(b"\n\nsos\n\n")
350+
if progress:
351+
names = tqdm(
352+
list(names),
353+
desc="Writing sos constraints.",
354+
colour=TQDM_COLOR,
355+
)
356+
357+
for name in names:
358+
var = m.variables[name]
359+
sos_type = var.attrs["sos_type"]
360+
sos_dim = var.attrs["sos_dim"]
361+
362+
other_dims = tuple([dim for dim in var.labels.dims if dim != sos_dim])
363+
for var_slice in var.iterate_slices(slice_size, other_dims):
364+
ds = var_slice.labels.to_dataset()
365+
ds["sos_labels"] = ds["labels"].isel({sos_dim: 0})
366+
ds["weights"] = ds.coords[sos_dim]
367+
df = to_polars(ds)
368+
369+
df = df.group_by("sos_labels").agg(
370+
pl.concat_str(
371+
*print_variable(pl.col("labels")), pl.lit(":"), pl.col("weights")
372+
)
373+
.str.join(" ")
374+
.alias("var_weights")
375+
)
376+
377+
columns = [
378+
pl.lit("s"),
379+
pl.col("sos_labels"),
380+
pl.lit(f": S{sos_type} :: "),
381+
pl.col("var_weights"),
382+
]
383+
384+
kwargs: Any = dict(
385+
separator=" ", null_value="", quote_style="never", include_header=False
386+
)
387+
formatted = df.select(pl.concat_str(columns, ignore_nulls=True))
388+
formatted.write_csv(f, **kwargs)
389+
390+
330391
def constraints_to_file(
331392
m: Model,
332393
f: BufferedWriter,
@@ -464,6 +525,13 @@ def to_lp_file(
464525
slice_size=slice_size,
465526
explicit_coordinate_names=explicit_coordinate_names,
466527
)
528+
sos_to_file(
529+
m,
530+
f=f,
531+
progress=progress,
532+
slice_size=slice_size,
533+
explicit_coordinate_names=explicit_coordinate_names,
534+
)
467535
f.write(b"end\n")
468536

469537
logger.info(f" Writing time: {round(time.time() - start, 2)}s")

linopy/model.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from collections.abc import Callable, Mapping, Sequence
1313
from pathlib import Path
1414
from tempfile import NamedTemporaryFile, gettempdir
15-
from typing import Any, overload
15+
from typing import Any, Literal, overload
1616

1717
import numpy as np
1818
import pandas as pd
@@ -551,6 +551,39 @@ def add_variables(
551551
self.variables.add(variable)
552552
return variable
553553

554+
def add_sos_constraints(
555+
self,
556+
variable: Variable,
557+
sos_type: Literal[1, 2],
558+
sos_dim: str,
559+
):
560+
"""
561+
Add an sos1 or sos2 constraint for one dimension of a variable
562+
563+
The dimension values are used as SOS.
564+
565+
Parameters
566+
----------
567+
variable : Variable
568+
sos_type : {1, 2}
569+
Type of SOS
570+
sos_dim : str
571+
Which dimension of variable to add SOS constraint to
572+
"""
573+
if sos_type not in (1, 2):
574+
raise ValueError(f"sos_type must be 1 or 2, got {sos_type}")
575+
if sos_dim not in variable.dims:
576+
raise ValueError(f"sos_dim must name a variable dimension, got {sos_dim}")
577+
578+
if "sos_type" in variable.attrs or "sos_dim" in variable.attrs:
579+
sos_type = variable.attrs.get("sos_type")
580+
sos_dim = variable.attrs.get("sos_dim")
581+
raise ValueError(
582+
"variable already has an sos{sos_type} constraint on {sos_dim}"
583+
)
584+
585+
variable.attrs.update(sos_type=sos_type, sos_dim=sos_dim)
586+
554587
def add_constraints(
555588
self,
556589
lhs: VariableLike

linopy/variables.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ def __init__(
193193
if "label_range" not in data.attrs:
194194
data.assign_attrs(label_range=(data.labels.min(), data.labels.max()))
195195

196+
if "sos_type" in data.attrs or "sos_dim" in data.attrs:
197+
if (sos_type := data.attrs.get("sos_type")) not in (1, 2):
198+
raise ValueError(f"sos_type must be 1 or 2, got {sos_type}")
199+
if (sos_dim := data.attrs.get("sos_dim")) not in data.dims:
200+
raise ValueError(
201+
f"sos_dim must name a variable dimension, got {sos_dim}"
202+
)
203+
196204
self._data = data
197205
self._model = model
198206

@@ -323,6 +331,8 @@ def __repr__(self) -> str:
323331
dim_names = self.coord_names
324332
dim_sizes = list(self.sizes.values())
325333
masked_entries = (~self.mask).sum().values
334+
sos_type = self.attrs.get("sos_type")
335+
sos_dim = self.attrs.get("sos_dim")
326336
lines = []
327337

328338
if dims:
@@ -344,9 +354,11 @@ def __repr__(self) -> str:
344354

345355
shape_str = ", ".join(f"{d}: {s}" for d, s in zip(dim_names, dim_sizes))
346356
mask_str = f" - {masked_entries} masked entries" if masked_entries else ""
357+
sos_str = f" - sos{sos_type} on {sos_dim}" if sos_type and sos_dim else ""
347358
lines.insert(
348359
0,
349-
f"Variable ({shape_str}){mask_str}\n{'-' * (len(shape_str) + len(mask_str) + 11)}",
360+
f"Variable ({shape_str}){mask_str}{sos_str}\n"
361+
f"{'-' * (len(shape_str) + len(mask_str) + len(sos_str) + 11)}",
350362
)
351363
else:
352364
lines.append(
@@ -1362,6 +1374,21 @@ def continuous(self) -> Variables:
13621374
self.model,
13631375
)
13641376

1377+
@property
1378+
def sos(self) -> Variables:
1379+
"""
1380+
Get all variables involved in an sos constraint.
1381+
"""
1382+
return self.__class__(
1383+
{
1384+
name: self.data[name]
1385+
for name in self
1386+
if self[name].attrs.get("sos_dim")
1387+
and self[name].attrs.get("sos_type") in (1, 2)
1388+
},
1389+
self.model,
1390+
)
1391+
13651392
@property
13661393
def solution(self) -> Dataset:
13671394
"""

0 commit comments

Comments
 (0)