pyoframe 0.1.4__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyoframe/__init__.py CHANGED
@@ -8,6 +8,11 @@ from pyoframe.core import Constraint, Expression, Set, Variable, sum, sum_by
8
8
  from pyoframe.model import Model
9
9
  from pyoframe.monkey_patch import patch_dataframe_libraries
10
10
 
11
+ try:
12
+ from pyoframe._version import __version__, __version_tuple__ # noqa: F401
13
+ except ModuleNotFoundError: # pragma: no cover
14
+ pass
15
+
11
16
  patch_dataframe_libraries()
12
17
 
13
18
  __all__ = [
pyoframe/_arithmetic.py CHANGED
@@ -10,7 +10,6 @@ from pyoframe.constants import (
10
10
  COEF_KEY,
11
11
  CONST_TERM,
12
12
  KEY_TYPE,
13
- POLARS_VERSION,
14
13
  QUAD_VAR_KEY,
15
14
  RESERVED_COL_KEYS,
16
15
  VAR_KEY,
@@ -81,14 +80,14 @@ def _multiply_expressions_core(self: "Expression", other: "Expression") -> "Expr
81
80
  self, other = other, self
82
81
  self_degree, other_degree = other_degree, self_degree
83
82
  if other_degree == 1:
84
- assert (
85
- self_degree == 1
86
- ), "This should always be true since the sum of degrees must be <=2."
83
+ assert self_degree == 1, (
84
+ "This should always be true since the sum of degrees must be <=2."
85
+ )
87
86
  return _quadratic_multiplication(self, other)
88
87
 
89
- assert (
90
- other_degree == 0
91
- ), "This should always be true since other cases have already been handled."
88
+ assert other_degree == 0, (
89
+ "This should always be true since other cases have already been handled."
90
+ )
92
91
  multiplier = other.data.drop(
93
92
  VAR_KEY
94
93
  ) # QUAD_VAR_KEY doesn't need to be dropped since we know it doesn't exist
@@ -248,12 +247,12 @@ def _add_expressions_core(*expressions: "Expression") -> "Expression":
248
247
  left_data = left.data.join(get_indices(right), how="inner", on=dims)
249
248
  right_data = right.data.join(get_indices(left), how="inner", on=dims)
250
249
  elif strat == (UnmatchedStrategy.UNSET, UnmatchedStrategy.UNSET):
251
- assert (
252
- not Config.disable_unmatched_checks
253
- ), "This code should not be reached when unmatched checks are disabled."
250
+ assert not Config.disable_unmatched_checks, (
251
+ "This code should not be reached when unmatched checks are disabled."
252
+ )
254
253
  outer_join = get_indices(left).join(
255
254
  get_indices(right),
256
- how="full" if POLARS_VERSION.major >= 1 else "outer",
255
+ how="full",
257
256
  on=dims,
258
257
  )
259
258
  if outer_join.get_column(dims[0]).null_count() > 0:
@@ -280,9 +279,9 @@ def _add_expressions_core(*expressions: "Expression") -> "Expression":
280
279
  + str(left_data.filter(left_data.get_column(COEF_KEY).is_null()))
281
280
  )
282
281
  elif strat == (UnmatchedStrategy.KEEP, UnmatchedStrategy.UNSET):
283
- assert (
284
- not Config.disable_unmatched_checks
285
- ), "This code should not be reached when unmatched checks are disabled."
282
+ assert not Config.disable_unmatched_checks, (
283
+ "This code should not be reached when unmatched checks are disabled."
284
+ )
286
285
  unmatched = right.data.join(get_indices(left), how="anti", on=dims)
287
286
  if len(unmatched) > 0:
288
287
  raise PyoframeError(
pyoframe/_version.py ADDED
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.2.0'
21
+ __version_tuple__ = version_tuple = (0, 2, 0)
pyoframe/constants.py CHANGED
@@ -8,11 +8,6 @@ from typing import Literal, Optional
8
8
 
9
9
  import polars as pl
10
10
  import pyoptinterface as poi
11
- from packaging import version
12
-
13
- # Constant to help split our logic depending on the polars version in use.
14
- # This approach is compatible with polars-lts-cpu.
15
- POLARS_VERSION = version.parse(pl.__version__)
16
11
 
17
12
  COEF_KEY = "__coeff"
18
13
  VAR_KEY = "__variable_id"
@@ -20,6 +15,7 @@ QUAD_VAR_KEY = "__quadratic_variable_id"
20
15
  CONSTRAINT_KEY = "__constraint_id"
21
16
  SOLUTION_KEY = "solution"
22
17
  DUAL_KEY = "dual"
18
+ SUPPORTED_SOLVERS = ["gurobi", "highs"]
23
19
  SUPPORTED_SOLVER_TYPES = Literal["gurobi", "highs"]
24
20
  KEY_TYPE = pl.UInt32
25
21
 
@@ -44,7 +40,7 @@ class _ConfigMeta(type):
44
40
  cls._defaults = {
45
41
  k: v
46
42
  for k, v in dct.items()
47
- if not k.startswith("_") and type(v) != classmethod
43
+ if not k.startswith("_") and type(v) != classmethod # noqa: E721 (didn't want to mess with it since it works)
48
44
  }
49
45
 
50
46
 
@@ -59,10 +55,19 @@ class Config(metaclass=_ConfigMeta):
59
55
  print_uses_variable_names: bool = True
60
56
  print_max_line_length: int = 80
61
57
  print_max_lines: int = 15
62
- # Number of elements to show when printing a set to the console (additional elements are replaced with ...)
63
58
  print_max_set_elements: int = 50
59
+ "Number of elements to show when printing a set to the console (additional elements are replaced with ...)"
60
+
64
61
  enable_is_duplicated_expression_safety_check: bool = False
65
62
 
63
+ integer_tolerance: float = 1e-8
64
+ """
65
+ For convenience, Pyoframe returns the solution of integer and binary variables as integers not floating point values.
66
+ To do so, Pyoframe must convert the solver-provided floating point values to integers. To avoid unexpected rounding errors,
67
+ Pyoframe uses this tolerance to check that the floating point result is an integer as expected. Overly tight tolerances can trigger
68
+ unexpected errors. Setting the tolerance to zero disables the check.
69
+ """
70
+
66
71
  @classmethod
67
72
  def reset_defaults(cls):
68
73
  """
pyoframe/core.py CHANGED
@@ -33,7 +33,6 @@ from pyoframe.constants import (
33
33
  CONSTRAINT_KEY,
34
34
  DUAL_KEY,
35
35
  KEY_TYPE,
36
- POLARS_VERSION,
37
36
  QUAD_VAR_KEY,
38
37
  RESERVED_COL_KEYS,
39
38
  SOLUTION_KEY,
@@ -294,9 +293,9 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
294
293
  over_merged = over_frames[0]
295
294
 
296
295
  for df in over_frames[1:]:
297
- assert (
298
- set(over_merged.columns) & set(df.columns) == set()
299
- ), "All coordinates must have unique column names."
296
+ assert set(over_merged.columns) & set(df.columns) == set(), (
297
+ "All coordinates must have unique column names."
298
+ )
300
299
  over_merged = over_merged.join(df, how="cross")
301
300
  return over_merged
302
301
 
@@ -309,9 +308,9 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
309
308
 
310
309
  def __mul__(self, other):
311
310
  if isinstance(other, Set):
312
- assert (
313
- set(self.data.columns) & set(other.data.columns) == set()
314
- ), "Cannot multiply two sets with columns in common."
311
+ assert set(self.data.columns) & set(other.data.columns) == set(), (
312
+ "Cannot multiply two sets with columns in common."
313
+ )
315
314
  return Set(self.data, other.data)
316
315
  return super().__mul__(other)
317
316
 
@@ -346,18 +345,11 @@ class Set(ModelElement, SupportsMath, SupportPolarsMethodMixin):
346
345
  elif isinstance(set, Constraint):
347
346
  df = set.data.select(set.dimensions_unsafe)
348
347
  elif isinstance(set, SupportsMath):
349
- if POLARS_VERSION.major < 1:
350
- df = (
351
- set.to_expr()
352
- .data.drop(RESERVED_COL_KEYS)
353
- .unique(maintain_order=True)
354
- )
355
- else:
356
- df = (
357
- set.to_expr()
358
- .data.drop(RESERVED_COL_KEYS, strict=False)
359
- .unique(maintain_order=True)
360
- )
348
+ df = (
349
+ set.to_expr()
350
+ .data.drop(RESERVED_COL_KEYS, strict=False)
351
+ .unique(maintain_order=True)
352
+ )
361
353
  elif isinstance(set, pd.Index):
362
354
  df = pl.from_pandas(pd.DataFrame(index=set).reset_index())
363
355
  elif isinstance(set, pd.DataFrame):
@@ -620,13 +612,13 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
620
612
  """
621
613
  df: pl.DataFrame = Set(set).data
622
614
  set_dims = _get_dimensions(df)
623
- assert (
624
- set_dims is not None
625
- ), "Cannot use .within() with a set with no dimensions."
615
+ assert set_dims is not None, (
616
+ "Cannot use .within() with a set with no dimensions."
617
+ )
626
618
  dims = self.dimensions
627
- assert (
628
- dims is not None
629
- ), "Cannot use .within() with an expression with no dimensions."
619
+ assert dims is not None, (
620
+ "Cannot use .within() with an expression with no dimensions."
621
+ )
630
622
  dims_in_common = [dim for dim in dims if dim in set_dims]
631
623
  by_dims = df.select(dims_in_common).unique(maintain_order=True)
632
624
  return self._new(self.data.join(by_dims, on=dims_in_common))
@@ -800,15 +792,9 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
800
792
  keys = keys.with_columns(
801
793
  pl.lit(CONST_TERM).alias(QUAD_VAR_KEY).cast(KEY_TYPE)
802
794
  )
803
- if POLARS_VERSION.major >= 1:
804
- data = data.join(
805
- keys, on=dim + self._variable_columns, how="full", coalesce=True
806
- )
807
- else:
808
- data = data.join(
809
- keys, on=dim + self._variable_columns, how="outer_coalesce"
810
- )
811
- data = data.with_columns(pl.col(COEF_KEY).fill_null(0.0))
795
+ data = data.join(
796
+ keys, on=dim + self._variable_columns, how="full", coalesce=True
797
+ ).with_columns(pl.col(COEF_KEY).fill_null(0.0))
812
798
 
813
799
  data = data.with_columns(
814
800
  pl.when(pl.col(VAR_KEY) == CONST_TERM)
@@ -826,10 +812,7 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
826
812
  constant_terms = constant_terms.drop(QUAD_VAR_KEY)
827
813
  if dims is not None:
828
814
  dims_df = self.data.select(dims).unique(maintain_order=True)
829
- if POLARS_VERSION.major >= 1:
830
- df = constant_terms.join(dims_df, on=dims, how="full", coalesce=True)
831
- else:
832
- df = constant_terms.join(dims_df, on=dims, how="outer_coalesce")
815
+ df = constant_terms.join(dims_df, on=dims, how="full", coalesce=True)
833
816
  return df.with_columns(pl.col(COEF_KEY).fill_null(0.0))
834
817
  else:
835
818
  if len(constant_terms) == 0:
@@ -870,9 +853,9 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
870
853
  >>> m.expr_2.evaluate()
871
854
  63.0
872
855
  """
873
- assert (
874
- self._model is not None
875
- ), "Expression must be added to the model to use .value"
856
+ assert self._model is not None, (
857
+ "Expression must be added to the model to use .value"
858
+ )
876
859
 
877
860
  df = self.data
878
861
  sm = self._model.poi
@@ -899,16 +882,23 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
899
882
  df = df.group_by(dims, maintain_order=True)
900
883
  return df.sum()
901
884
 
902
- def to_poi(self) -> poi.ScalarAffineFunction:
885
+ def to_poi(self) -> poi.ScalarAffineFunction | poi.ScalarQuadraticFunction:
903
886
  if self.dimensions is not None:
904
887
  raise ValueError(
905
888
  "Only non-dimensioned expressions can be converted to PyOptInterface."
906
889
  ) # pragma: no cover
907
890
 
908
- return poi.ScalarAffineFunction(
909
- coefficients=self.data.get_column(COEF_KEY).to_numpy(),
910
- variables=self.data.get_column(VAR_KEY).to_numpy(),
911
- )
891
+ if self.is_quadratic:
892
+ return poi.ScalarQuadraticFunction(
893
+ coefficients=self.data.get_column(COEF_KEY).to_numpy(),
894
+ var1s=self.data.get_column(VAR_KEY).to_numpy(),
895
+ var2s=self.data.get_column(QUAD_VAR_KEY).to_numpy(),
896
+ )
897
+ else:
898
+ return poi.ScalarAffineFunction(
899
+ coefficients=self.data.get_column(COEF_KEY).to_numpy(),
900
+ variables=self.data.get_column(VAR_KEY).to_numpy(),
901
+ )
912
902
 
913
903
  def to_str_table(self, include_const_term=True):
914
904
  data = self.data if include_const_term else self.variable_terms
@@ -951,10 +941,10 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
951
941
 
952
942
  if dimensions is not None:
953
943
  data = data.group_by(dimensions, maintain_order=True).agg(
954
- pl.col("expr").str.concat(delimiter=" ")
944
+ pl.col("expr").str.join(delimiter=" ")
955
945
  )
956
946
  else:
957
- data = data.select(pl.col("expr").str.concat(delimiter=" "))
947
+ data = data.select(pl.col("expr").str.join(delimiter=" "))
958
948
 
959
949
  # Remove leading +
960
950
  data = data.with_columns(pl.col("expr").str.strip_chars(characters=" +"))
@@ -1010,7 +1000,7 @@ class Expression(ModelElement, SupportsMath, SupportPolarsMethodMixin):
1010
1000
  include_const_term=include_const_term,
1011
1001
  )
1012
1002
  str_table = self.to_str_create_prefix(str_table)
1013
- result += str_table.select(pl.col("expr").str.concat(delimiter="\n")).item()
1003
+ result += str_table.select(pl.col("expr").str.join(delimiter="\n")).item()
1014
1004
 
1015
1005
  return result
1016
1006
 
@@ -1054,6 +1044,31 @@ def sum(
1054
1044
  over: Union[str, Sequence[str], SupportsToExpr],
1055
1045
  expr: Optional[SupportsToExpr] = None,
1056
1046
  ) -> "Expression":
1047
+ """
1048
+ Sum an expression over specified dimensions.
1049
+ If no dimensions are specified, the sum is taken over all of the expression's dimensions.
1050
+
1051
+ Examples:
1052
+ >>> expr = pl.DataFrame({
1053
+ ... "time": ["mon", "tue", "wed", "mon", "tue"],
1054
+ ... "place": ["Toronto", "Toronto", "Toronto", "Vancouver", "Vancouver"],
1055
+ ... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6]
1056
+ ... }).to_expr()
1057
+ >>> expr
1058
+ <Expression size=5 dimensions={'time': 3, 'place': 2} terms=5>
1059
+ [mon,Toronto]: 1000000
1060
+ [tue,Toronto]: 3000000
1061
+ [wed,Toronto]: 2000000
1062
+ [mon,Vancouver]: 1000000
1063
+ [tue,Vancouver]: 2000000
1064
+ >>> pf.sum("time", expr)
1065
+ <Expression size=2 dimensions={'place': 2} terms=2>
1066
+ [Toronto]: 6000000
1067
+ [Vancouver]: 3000000
1068
+ >>> pf.sum(expr)
1069
+ <Expression size=1 dimensions={} terms=1>
1070
+ 9000000
1071
+ """
1057
1072
  if expr is None:
1058
1073
  assert isinstance(over, SupportsMath)
1059
1074
  over = over.to_expr()
@@ -1069,13 +1084,34 @@ def sum(
1069
1084
 
1070
1085
 
1071
1086
  def sum_by(by: Union[str, Sequence[str]], expr: SupportsToExpr) -> "Expression":
1087
+ """
1088
+ Like `pf.sum()`, but the sum is taken over all dimensions except those specified in `by` (just like a groupby operation).
1089
+
1090
+ Examples:
1091
+ >>> expr = pl.DataFrame({
1092
+ ... "time": ["mon", "tue", "wed", "mon", "tue"],
1093
+ ... "place": ["Toronto", "Toronto", "Toronto", "Vancouver", "Vancouver"],
1094
+ ... "tiktok_posts": [1e6, 3e6, 2e6, 1e6, 2e6]
1095
+ ... }).to_expr()
1096
+ >>> expr
1097
+ <Expression size=5 dimensions={'time': 3, 'place': 2} terms=5>
1098
+ [mon,Toronto]: 1000000
1099
+ [tue,Toronto]: 3000000
1100
+ [wed,Toronto]: 2000000
1101
+ [mon,Vancouver]: 1000000
1102
+ [tue,Vancouver]: 2000000
1103
+ >>> pf.sum_by("place", expr)
1104
+ <Expression size=2 dimensions={'place': 2} terms=2>
1105
+ [Toronto]: 6000000
1106
+ [Vancouver]: 3000000
1107
+ """
1072
1108
  if isinstance(by, str):
1073
1109
  by = [by]
1074
1110
  expr = expr.to_expr()
1075
1111
  dimensions = expr.dimensions
1076
- assert (
1077
- dimensions is not None
1078
- ), "Cannot sum by dimensions with an expression with no dimensions."
1112
+ assert dimensions is not None, (
1113
+ "Cannot sum by dimensions with an expression with no dimensions."
1114
+ )
1079
1115
  remaining_dims = [dim for dim in dimensions if dim not in by]
1080
1116
  return sum(over=remaining_dims, expr=expr)
1081
1117
 
@@ -1329,9 +1365,9 @@ class Constraint(ModelElementWithId):
1329
1365
  return self
1330
1366
 
1331
1367
  var_name = f"{self.name}_relaxation"
1332
- assert not hasattr(
1333
- m, var_name
1334
- ), "Conflicting names, relaxation variable already exists on the model."
1368
+ assert not hasattr(m, var_name), (
1369
+ "Conflicting names, relaxation variable already exists on the model."
1370
+ )
1335
1371
  var = Variable(self, lb=0, ub=max)
1336
1372
  setattr(m, var_name, var)
1337
1373
 
@@ -1374,7 +1410,7 @@ class Constraint(ModelElementWithId):
1374
1410
  [str_table, rhs], how=("align" if dims else "horizontal")
1375
1411
  )
1376
1412
  constr_str = constr_str.select(
1377
- pl.concat_str("expr", pl.lit(f" {self.sense.value} "), "rhs").str.concat(
1413
+ pl.concat_str("expr", pl.lit(f" {self.sense.value} "), "rhs").str.join(
1378
1414
  delimiter="\n"
1379
1415
  )
1380
1416
  ).item()
@@ -1459,9 +1495,9 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1459
1495
  equals: Optional[SupportsMath] = None,
1460
1496
  ):
1461
1497
  if equals is not None:
1462
- assert (
1463
- len(indexing_sets) == 0
1464
- ), "Cannot specify both 'equals' and 'indexing_sets'"
1498
+ assert len(indexing_sets) == 0, (
1499
+ "Cannot specify both 'equals' and 'indexing_sets'"
1500
+ )
1465
1501
  indexing_sets = (equals,)
1466
1502
 
1467
1503
  data = Set(*indexing_sets).data if len(indexing_sets) > 0 else pl.DataFrame()
@@ -1578,9 +1614,78 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1578
1614
  @property
1579
1615
  @unwrap_single_values
1580
1616
  def solution(self):
1581
- solution = self.attr.Value
1617
+ """
1618
+ Retrieve a variable's optimal value after the model has been solved.
1619
+ Returned as a DataFrame if the variable has dimensions, otherwise as a single value.
1620
+ Binary and integer variables are returned as integers.
1621
+
1622
+ Examples:
1623
+ >>> m = pf.Model()
1624
+ >>> m.var_continuous = pf.Variable({"dim1": [1, 2, 3]}, lb=5, ub=5)
1625
+ >>> m.var_integer = pf.Variable({"dim1": [1, 2, 3]}, lb=4.5, ub=5.5, vtype=VType.INTEGER)
1626
+ >>> m.var_dimensionless = pf.Variable(lb=4.5, ub=5.5, vtype=VType.INTEGER)
1627
+ >>> m.var_continuous.solution
1628
+ Traceback (most recent call last):
1629
+ ...
1630
+ RuntimeError: Failed to retrieve solution for variable. Are you sure the model has been solved?
1631
+ >>> m.optimize()
1632
+ >>> m.var_continuous.solution
1633
+ shape: (3, 2)
1634
+ ┌──────┬──────────┐
1635
+ │ dim1 ┆ solution │
1636
+ │ --- ┆ --- │
1637
+ │ i64 ┆ f64 │
1638
+ ╞══════╪══════════╡
1639
+ │ 1 ┆ 5.0 │
1640
+ │ 2 ┆ 5.0 │
1641
+ │ 3 ┆ 5.0 │
1642
+ └──────┴──────────┘
1643
+ >>> m.var_integer.solution
1644
+ shape: (3, 2)
1645
+ ┌──────┬──────────┐
1646
+ │ dim1 ┆ solution │
1647
+ │ --- ┆ --- │
1648
+ │ i64 ┆ i64 │
1649
+ ╞══════╪══════════╡
1650
+ │ 1 ┆ 5 │
1651
+ │ 2 ┆ 5 │
1652
+ │ 3 ┆ 5 │
1653
+ └──────┴──────────┘
1654
+ >>> m.var_dimensionless.solution
1655
+ 5
1656
+ """
1657
+ try:
1658
+ solution = self.attr.Value
1659
+ except RuntimeError as e:
1660
+ raise RuntimeError(
1661
+ "Failed to retrieve solution for variable. Are you sure the model has been solved?"
1662
+ ) from e
1582
1663
  if isinstance(solution, pl.DataFrame):
1583
1664
  solution = solution.rename({"Value": SOLUTION_KEY})
1665
+
1666
+ if self.vtype in [VType.BINARY, VType.INTEGER]:
1667
+ if isinstance(solution, pl.DataFrame):
1668
+ solution = solution.with_columns(
1669
+ pl.col("solution").alias("solution_float"),
1670
+ pl.col("solution").round().cast(pl.Int64),
1671
+ )
1672
+ if Config.integer_tolerance != 0:
1673
+ df = solution.filter(
1674
+ (pl.col("solution_float") - pl.col("solution")).abs()
1675
+ > Config.integer_tolerance
1676
+ )
1677
+ assert df.is_empty(), (
1678
+ f"Variable {self.name} has a non-integer value: {df}\nThis should not happen."
1679
+ )
1680
+ solution = solution.drop("solution_float")
1681
+ else:
1682
+ solution_float = solution
1683
+ solution = int(round(solution))
1684
+ if Config.integer_tolerance != 0:
1685
+ assert abs(solution - solution_float) < Config.integer_tolerance, (
1686
+ f"Value of variable {self.name} is not an integer: {solution}. This should not happen."
1687
+ )
1688
+
1584
1689
  return solution
1585
1690
 
1586
1691
  def __repr__(self):
@@ -1606,10 +1711,7 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1606
1711
 
1607
1712
  def to_expr(self) -> Expression:
1608
1713
  self._assert_has_ids()
1609
- if POLARS_VERSION.major < 1:
1610
- return self._new(self.data.drop(SOLUTION_KEY))
1611
- else:
1612
- return self._new(self.data.drop(SOLUTION_KEY, strict=False))
1714
+ return self._new(self.data.drop(SOLUTION_KEY, strict=False))
1613
1715
 
1614
1716
  def _new(self, data: pl.DataFrame):
1615
1717
  self._assert_has_ids()
@@ -1686,12 +1788,7 @@ class Variable(ModelElementWithId, SupportsMath, SupportPolarsMethodMixin):
1686
1788
  expr = self.to_expr()
1687
1789
  data = expr.data.rename({dim: "__prev"})
1688
1790
 
1689
- if POLARS_VERSION.major < 1:
1690
- data = data.join(
1691
- wrapped, left_on="__prev", right_on="__next", how="inner"
1692
- ).drop(["__prev", "__next"])
1693
- else:
1694
- data = data.join(
1695
- wrapped, left_on="__prev", right_on="__next", how="inner"
1696
- ).drop(["__prev", "__next"], strict=False)
1791
+ data = data.join(
1792
+ wrapped, left_on="__prev", right_on="__next", how="inner"
1793
+ ).drop(["__prev", "__next"], strict=False)
1697
1794
  return expr._new(data)
pyoframe/model.py CHANGED
@@ -8,6 +8,7 @@ import pyoptinterface as poi
8
8
  from pyoframe.constants import (
9
9
  CONST_TERM,
10
10
  SUPPORTED_SOLVER_TYPES,
11
+ SUPPORTED_SOLVERS,
11
12
  Config,
12
13
  ObjSense,
13
14
  ObjSenseValue,
@@ -34,6 +35,7 @@ class Model:
34
35
  Gurobi only: a dictionary of parameters to set when creating the Gurobi environment.
35
36
  use_var_names:
36
37
  Whether to pass variable names to the solver. Set to `True` if you'd like outputs from e.g. `Model.write()` to be legible.
38
+ Does not work with HiGHS (see [here](https://github.com/Bravos-Power/pyoframe/issues/102#issuecomment-2727521430)).
37
39
  sense:
38
40
  Either "min" or "max". Indicates whether it's a minmization or maximization problem.
39
41
  Typically, this parameter can be omitted (`None`) as it will automatically be
@@ -76,10 +78,10 @@ class Model:
76
78
 
77
79
  def __init__(
78
80
  self,
79
- name=None,
81
+ name: Optional[str] = None,
80
82
  solver: Optional[SUPPORTED_SOLVER_TYPES] = None,
81
83
  solver_env: Optional[Dict[str, str]] = None,
82
- use_var_names=False,
84
+ use_var_names: bool = False,
83
85
  sense: Union[ObjSense, ObjSenseValue, None] = None,
84
86
  ):
85
87
  self.poi, self.solver_name = Model.create_poi_model(solver, solver_env)
@@ -106,7 +108,7 @@ class Model:
106
108
  ):
107
109
  if solver is None:
108
110
  if Config.default_solver is None:
109
- for solver_option in ["highs", "gurobi"]:
111
+ for solver_option in SUPPORTED_SOLVERS:
110
112
  try:
111
113
  return cls.create_poi_model(solver_option, solver_env)
112
114
  except RuntimeError:
@@ -122,13 +124,13 @@ class Model:
122
124
  from pyoptinterface import gurobi
123
125
 
124
126
  if solver_env is None:
125
- model = gurobi.Model()
127
+ env = gurobi.Env()
126
128
  else:
127
129
  env = gurobi.Env(empty=True)
128
130
  for key, value in solver_env.items():
129
131
  env.set_raw_parameter(key, value)
130
132
  env.start()
131
- model = gurobi.Model(env)
133
+ model = gurobi.Model(env)
132
134
  elif solver == "highs":
133
135
  from pyoptinterface import highs
134
136
 
@@ -233,9 +235,9 @@ class Model:
233
235
  and __name not in Model._reserved_attributes
234
236
  ):
235
237
  if isinstance(__value, ModelElementWithId):
236
- assert not hasattr(
237
- self, __name
238
- ), f"Cannot create {__name} since it was already created."
238
+ assert not hasattr(self, __name), (
239
+ f"Cannot create {__name} since it was already created."
240
+ )
239
241
 
240
242
  __value.on_add_to_model(self, __name)
241
243
 
@@ -256,12 +258,32 @@ class Model:
256
258
  objective=bool(self.objective),
257
259
  )
258
260
 
259
- def write(self, file_path: Union[Path, str]):
261
+ def write(self, file_path: Union[Path, str], pretty: bool = False):
262
+ """
263
+ Output the model to a file.
264
+
265
+ Typical usage includes writing the solution to a `.sol` file as well as writing the problem to a `.lp` or `.mps` file.
266
+ Set `use_var_names` in your model constructor to `True` if you'd like the output to contain human-readable names (useful for debugging).
267
+
268
+ Parameters:
269
+ file_path:
270
+ The path to the file to write to.
271
+ pretty:
272
+ Only used when writing .sol files in HiGHS. If `True`, will use HiGH's pretty print columnar style which contains more information.
273
+ """
260
274
  file_path = Path(file_path)
261
275
  file_path.parent.mkdir(parents=True, exist_ok=True)
262
- self.poi.write(str(file_path))
276
+ kwargs = {}
277
+ if self.solver_name == "highs":
278
+ if self.use_var_names:
279
+ self.params.write_solution_style = 1
280
+ kwargs["pretty"] = pretty
281
+ self.poi.write(str(file_path), **kwargs)
263
282
 
264
283
  def optimize(self):
284
+ """
285
+ Optimize the model using your selected solver (e.g. Gurobi, HiGHS).
286
+ """
265
287
  self.poi.optimize()
266
288
 
267
289
  @for_solvers("gurobi")
@@ -282,11 +304,11 @@ class Model:
282
304
  >>> m.maximize = 3 * m.X + 2 * m.Y + m.Z
283
305
  >>> m.optimize()
284
306
  >>> m.X.solution, m.Y.solution, m.Z.solution
285
- (1.0, 9.0, 0.0)
307
+ (1, 9, 0.0)
286
308
  >>> m.my_constraint.dual
287
309
  Traceback (most recent call last):
288
310
  ...
289
- polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'Pi'
311
+ RuntimeError: Unable to retrieve attribute 'Pi'
290
312
  >>> m.convert_to_fixed()
291
313
  >>> m.optimize()
292
314
  >>> m.my_constraint.dual
@@ -322,44 +344,44 @@ class Model:
322
344
  >>> m.bad_constraint.attr.IIS
323
345
  Traceback (most recent call last):
324
346
  ...
325
- polars.exceptions.ComputeError: RuntimeError: Unable to retrieve attribute 'IISConstr'
347
+ RuntimeError: Unable to retrieve attribute 'IISConstr'
326
348
  >>> m.compute_IIS()
327
349
  >>> m.bad_constraint.attr.IIS
328
350
  True
329
351
  """
330
352
  self.poi.computeIIS()
331
353
 
332
- @for_solvers("gurobi")
333
354
  def dispose(self):
334
355
  """
335
- Tries to close the solver connection by deleting the model and forcing the garbage collector to run.
356
+ Disposes of the model and cleans up the solver environment.
336
357
 
337
- Gurobi only. Once this method is called, this model is no longer usable.
358
+ When using Gurobi compute server, this cleanup will
359
+ ensure your run is not marked as 'ABORTED'.
338
360
 
339
- This method will not work if you have a variable that references self.poi.
340
- Unfortunately, this is a limitation from the underlying solver interface library.
341
- See https://github.com/metab0t/PyOptInterface/issues/36 for context.
361
+ Note that once the model is disposed, it cannot be used anymore.
342
362
 
343
363
  Examples:
344
- >>> m = pf.Model(solver="gurobi")
364
+ >>> m = pf.Model()
345
365
  >>> m.X = pf.Variable(ub=1)
346
366
  >>> m.maximize = m.X
347
367
  >>> m.optimize()
348
368
  >>> m.X.solution
349
369
  1.0
350
370
  >>> m.dispose()
351
- >>> m.X.solution
352
- Traceback (most recent call last):
353
- ...
354
- AttributeError: 'Model' object has no attribute 'poi'
355
371
  """
356
- import gc
357
-
358
- env = self.poi._env
359
- del self.poi
360
- gc.collect()
361
- del env
362
- gc.collect()
372
+ env = None
373
+ if hasattr(self.poi, "_env"):
374
+ env = self.poi._env
375
+ self.poi.close()
376
+ if env is not None:
377
+ env.close()
378
+
379
+ def __del__(self):
380
+ # This ensures that the model is closed *before* the environment is. This avoids the Gurobi warning:
381
+ # Warning: environment still referenced so free is deferred (Continue to use WLS)
382
+ # I include the hasattr check to avoid errors in case __init__ failed and poi was never set.
383
+ if hasattr(self, "poi"):
384
+ self.poi.close()
363
385
 
364
386
  def _set_param(self, name, value):
365
387
  self.poi.set_raw_parameter(name, value)
@@ -370,11 +392,17 @@ class Model:
370
392
  def _set_attr(self, name, value):
371
393
  try:
372
394
  self.poi.set_model_attribute(poi.ModelAttribute[name], value)
373
- except KeyError:
374
- self.poi.set_model_raw_attribute(name, value)
395
+ except KeyError as e:
396
+ if self.solver_name == "gurobi":
397
+ self.poi.set_model_raw_attribute(name, value)
398
+ else:
399
+ raise e
375
400
 
376
401
  def _get_attr(self, name):
377
402
  try:
378
403
  return self.poi.get_model_attribute(poi.ModelAttribute[name])
379
- except KeyError:
380
- return self.poi.get_model_raw_attribute(name)
404
+ except KeyError as e:
405
+ if self.solver_name == "gurobi":
406
+ return self.poi.get_model_raw_attribute(name)
407
+ else:
408
+ raise e
pyoframe/model_element.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from collections import defaultdict
5
4
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
6
5
 
7
6
  import polars as pl
@@ -22,9 +21,9 @@ if TYPE_CHECKING: # pragma: no cover
22
21
  class ModelElement(ABC):
23
22
  def __init__(self, data: pl.DataFrame, **kwargs) -> None:
24
23
  # Sanity checks, no duplicate column names
25
- assert len(data.columns) == len(
26
- set(data.columns)
27
- ), "Duplicate column names found."
24
+ assert len(data.columns) == len(set(data.columns)), (
25
+ "Duplicate column names found."
26
+ )
28
27
 
29
28
  cols = _get_dimensions(data)
30
29
  if cols is None:
pyoframe/util.py CHANGED
@@ -379,9 +379,9 @@ class NamedVariableMapper:
379
379
 
380
380
  def _element_to_map(self, element) -> pl.DataFrame:
381
381
  element_name = element.name # type: ignore
382
- assert (
383
- element_name is not None
384
- ), "Element must have a name to be used in a named mapping."
382
+ assert element_name is not None, (
383
+ "Element must have a name to be used in a named mapping."
384
+ )
385
385
  element._assert_has_ids()
386
386
  return concat_dimensions(
387
387
  element.data.select(element.dimensions_unsafe + [VAR_KEY]),
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: pyoframe
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Blazing fast linear program interface
5
5
  Author-email: Bravos Power <dev@bravospower.com>
6
+ License-Expression: MIT
6
7
  Project-URL: Homepage, https://bravos-power.github.io/pyoframe/
7
8
  Project-URL: documentation, https://bravos-power.github.io/pyoframe/
8
9
  Project-URL: repository, https://github.com/Bravos-Power/pyoframe/
@@ -10,40 +11,40 @@ Project-URL: Issues, https://github.com/Bravos-Power/pyoframe/issues
10
11
  Classifier: Programming Language :: Python :: 3
11
12
  Classifier: Operating System :: OS Independent
12
13
  Classifier: Development Status :: 3 - Alpha
13
- Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Natural Language :: English
15
15
  Requires-Python: >=3.9
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
- Requires-Dist: polars<2,>=0.20
18
+ Requires-Dist: polars~=1.0
19
19
  Requires-Dist: numpy
20
20
  Requires-Dist: pyarrow
21
21
  Requires-Dist: pandas
22
- Requires-Dist: packaging
23
- Requires-Dist: pyoptinterface~=0.4
22
+ Requires-Dist: pyoptinterface<1,>=0.4.1
24
23
  Provides-Extra: dev
25
- Requires-Dist: black[jupyter]; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+ Requires-Dist: polars>=1.30.0; extra == "dev"
26
26
  Requires-Dist: bumpver; extra == "dev"
27
- Requires-Dist: isort; extra == "dev"
28
27
  Requires-Dist: pip-tools; extra == "dev"
29
28
  Requires-Dist: pytest; extra == "dev"
30
29
  Requires-Dist: pytest-cov; extra == "dev"
31
30
  Requires-Dist: pre-commit; extra == "dev"
32
31
  Requires-Dist: gurobipy; extra == "dev"
33
32
  Requires-Dist: highsbox; extra == "dev"
34
- Requires-Dist: pre-commit; extra == "dev"
35
33
  Requires-Dist: coverage; extra == "dev"
36
- Provides-Extra: docs
37
- Requires-Dist: mkdocs-material==9.*; extra == "docs"
38
- Requires-Dist: mkdocstrings[python]; extra == "docs"
39
- Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == "docs"
40
- Requires-Dist: mkdocs-git-committers-plugin-2; extra == "docs"
41
- Requires-Dist: mkdocs-gen-files; extra == "docs"
42
- Requires-Dist: mkdocs-section-index; extra == "docs"
43
- Requires-Dist: mkdocs-literate-nav; extra == "docs"
44
- Requires-Dist: mkdocs-table-reader-plugin; extra == "docs"
34
+ Requires-Dist: ipykernel; extra == "dev"
35
+ Requires-Dist: pytest-markdown-docs; extra == "dev"
36
+ Requires-Dist: mkdocs-material==9.*; extra == "dev"
37
+ Requires-Dist: mkdocstrings[python]; extra == "dev"
38
+ Requires-Dist: mkdocs-git-revision-date-localized-plugin; extra == "dev"
39
+ Requires-Dist: mkdocs-git-committers-plugin-2; extra == "dev"
40
+ Requires-Dist: mkdocs-gen-files; extra == "dev"
41
+ Requires-Dist: mkdocs-section-index; extra == "dev"
42
+ Requires-Dist: mkdocs-literate-nav; extra == "dev"
43
+ Requires-Dist: mkdocs-table-reader-plugin; extra == "dev"
44
+ Requires-Dist: markdown-hide-code>=0.1.1; extra == "dev"
45
45
  Provides-Extra: highs
46
46
  Requires-Dist: highsbox; extra == "highs"
47
+ Dynamic: license-file
47
48
 
48
49
  # Pyoframe: Fast and low-memory linear programming models
49
50
 
@@ -0,0 +1,15 @@
1
+ pyoframe/__init__.py,sha256=YswFUwm6GX98dXeT99hqxWqYWLELS71JZf1OpT1kvCg,619
2
+ pyoframe/_arithmetic.py,sha256=agJm2Sl4EjEG7q4n2YHka4mGfCQI3LjOXLaW6oCfGiQ,17222
3
+ pyoframe/_version.py,sha256=iB5DfB5V6YB5Wo4JmvS-txT42QtmGaWcWp3udRT7zCI,511
4
+ pyoframe/constants.py,sha256=WBCmhunavNVwJcmg9ojnA6TVJCLSrgWVE4YKZnhZNz4,4192
5
+ pyoframe/core.py,sha256=kK-eMI7teakAAG4JjOiwRBsCgNJIlOxR0b4JOUlQHs4,67239
6
+ pyoframe/model.py,sha256=a7pEwagVxHC1ZUMr8ifO4n0ca5Ways3wip-Wps0rlcg,14257
7
+ pyoframe/model_element.py,sha256=YmAdx4yM5irGTiZ5uQmDa-u05QdFKngIFy8qNnogvzo,5911
8
+ pyoframe/monkey_patch.py,sha256=9IfS14G6IPabmM9z80jzi_D4Rq0Mdx5aUCA39Yi2tgE,2044
9
+ pyoframe/objective.py,sha256=PBWxj30QkFlsvY6ijZ6KjyKdrJARD4to0ieF6GUqaQU,3238
10
+ pyoframe/util.py,sha256=dHIwAyyD9wn36yM8IOlrboTGUGA7STq3IBTxfYSOPjU,13480
11
+ pyoframe-0.2.0.dist-info/licenses/LICENSE,sha256=u_Spw4ynlwTMRZeCX-uacv_hBU547pBygiA6d2ONNV4,1074
12
+ pyoframe-0.2.0.dist-info/METADATA,sha256=GTwsHP14u5Zwb1E4pnjyeCS9-0DVmLXjNlo3ebZiSbw,3607
13
+ pyoframe-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ pyoframe-0.2.0.dist-info/top_level.txt,sha256=10z3OOJSVLriQ0IrFLMH8CH9zByugPWolqhlHlkNjV4,9
15
+ pyoframe-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright 2024 Bravos Power
3
+ Copyright (c) 2024-2025 Bravos Power
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,14 +0,0 @@
1
- pyoframe/__init__.py,sha256=nEN0OgqhevtsvxEiPbJLzwPojf3ngYAoT90M_1mc4kM,477
2
- pyoframe/_arithmetic.py,sha256=vqzMZip1kTkUUMyHohUyKJxx2nEeQIH7N57fwevLWaQ,17284
3
- pyoframe/constants.py,sha256=Dy1sCzykZlmkbvgsc5ckujqXPuYmsKkD3stANU0qr5Y,3784
4
- pyoframe/core.py,sha256=4heBn7RGOWV-Yg0OoiLr8sGBaygpTx-D-pLuC4nC0Dw,62865
5
- pyoframe/model.py,sha256=7nVH2oZeTuk53l76PLNElsiHEgmbTvrf7lGIa9_CYm4,13011
6
- pyoframe/model_element.py,sha256=RBmsE2FOct3UL2N2LZLfWYv2wL_QKqNmKmmR_pD_ERs,5945
7
- pyoframe/monkey_patch.py,sha256=9IfS14G6IPabmM9z80jzi_D4Rq0Mdx5aUCA39Yi2tgE,2044
8
- pyoframe/objective.py,sha256=PBWxj30QkFlsvY6ijZ6KjyKdrJARD4to0ieF6GUqaQU,3238
9
- pyoframe/util.py,sha256=Oyk8xh6FJHlb04X_cM4lN0UzdnKLXAMrKfyOf7IexiA,13480
10
- pyoframe-0.1.4.dist-info/LICENSE,sha256=dkwA40ZzT-3x6eu2a6mf_o7PNSqHbdsyaFNhLxGHNQs,1065
11
- pyoframe-0.1.4.dist-info/METADATA,sha256=v3ZH_JvXSg4cN515Qo0FvZviXIC0l-Ja5tgq5IdgwLI,3558
12
- pyoframe-0.1.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
13
- pyoframe-0.1.4.dist-info/top_level.txt,sha256=10z3OOJSVLriQ0IrFLMH8CH9zByugPWolqhlHlkNjV4,9
14
- pyoframe-0.1.4.dist-info/RECORD,,