Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,63 @@ jobs:
- name: Run type checker (mypy)
run: |
mypy .

no-extras:
# The base install must work without polars, dask, tqdm and deprecation.
# This job catches a hard import reintroduced into the core path.
name: Core install without optional dependencies
needs: [build]
runs-on: ubuntu-latest

steps:
- name: Set up Python 3.12
uses: actions/setup-python@v7
with:
python-version: 3.12

- name: Download package
uses: actions/download-artifact@v8
with:
name: Packages
path: dist

- name: Install the wheel with no extras
run: |
python -m pip install uv
uv pip install --system "$(ls dist/*.whl)"
uv pip install --system highspy

- name: Smoke test the core install
run: |
python - <<'PY'
import importlib.util

# The wheel with no extras must not pull in any of the optional extras.
for optional in ("polars", "dask", "tqdm"):
assert importlib.util.find_spec(optional) is None, optional

import pandas as pd

from linopy import Model

m = Model()
t = pd.RangeIndex(6, name="t")
x = m.add_variables(lower=0, coords=[t], name="x")
y = m.add_variables(lower=0, coords=[t], name="y")
m.add_constraints(x + y >= 1)
m.add_objective(2 * x + 3 * y)

# xarray's ffill/bfill import bottleneck directly, with no numpy
# fallback, so bottleneck has to stay a declared dependency.
x.ffill("t")
x.bfill("t")

status, condition = m.solve(io_api="direct")
assert status == "ok", (status, condition)
# One x per time step at cost 2, since x + y >= 1 and 2x + 3y.
assert abs(float(m.objective.value) - 2.0 * len(t)) < 1e-9

m.to_file("smoke.mps", io_api="mps")

print("core install smoke test passed")
PY
31 changes: 31 additions & 0 deletions doc/prerequisites.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,37 @@ or
conda install -c conda-forge linopy


Optional dependencies
---------------------

The base install pulls numpy, scipy, xarray, pandas, packaging and bottleneck.
Three features need an extra:

``linopy[polars]``
The LP file writer and the ``to_polars()`` export methods. This is required
by the default ``Model.solve()``, which writes an LP file. Install it if you
want the default solve path or any polars export:

.. code:: bash

uv pip install "linopy[polars]"

Without it, ``Model.solve()`` raises ``ImportError``. Pass
``io_api="direct"`` to solve without writing a file, or ``io_api="mps"`` to
write an MPS file instead (the MPS writer uses highspy).

``linopy[dask]``
Chunked (dask-backed) arrays, for example via ``Variable.chunk()``. Plain
numpy and xarray models do not need it.

``linopy[progress]``
Progress bars while writing LP files. Without it, LP export runs silently.

.. code:: bash

uv pip install "linopy[polars,dask,progress]"


Install a solver
----------------

Expand Down
36 changes: 36 additions & 0 deletions linopy/_polars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""
Placeholder used in place of :mod:`polars` when it is not installed.

Polars backs the ``to_polars()`` export methods and the LP file writer, which
the default ``Model.solve()`` uses. It is an optional dependency, so every
module that touches it imports it as::

try:
import polars as pl
except ImportError:
from linopy._polars import missing_polars as pl

Attribute access on the placeholder raises :class:`ImportError` with an install
hint, so the failure surfaces at the first real use rather than at import time.
"""

from typing import Any

_INSTALL_HINT = (
"polars is required for this feature. Install it with `pip install linopy[polars]`."
)


class _MissingPolars:
"""Stand-in for the ``polars`` module; every attribute access raises."""

def __getattr__(self, name: str) -> Any:
# Tools probe dunders such as ``__wrapped__`` and expect AttributeError.
# Raising ImportError there breaks pytest's doctest collection.
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
raise ImportError(_INSTALL_HINT)


missing_polars = _MissingPolars()
19 changes: 13 additions & 6 deletions linopy/alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,10 @@
from collections import Counter
from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence
from functools import partial
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, overload
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast, overload

import numpy as np
import pandas as pd
import polars as pl
import scipy.sparse
from numpy import arange
from xarray import Coordinates, DataArray, Dataset, broadcast
Expand All @@ -50,7 +49,12 @@
enforce_aux_conflict,
first_mismatched_dim,
)
from linopy.types import UNLABELED_TYPES, CoordsLike, DimsLike
from linopy.types import (
POLARS_SERIES_TYPES,
UNLABELED_TYPES,
CoordsLike,
DimsLike,
)


def as_constant(other: Any) -> Any:
Expand Down Expand Up @@ -416,8 +420,11 @@ def as_dataarray(
arr = pandas_to_dataarray(arr, coords=coords, dims=dims, **kwargs)
elif isinstance(arr, np.ndarray):
arr = numpy_to_dataarray(arr, coords=coords, dims=dims, **kwargs)
elif isinstance(arr, pl.Series):
arr = numpy_to_dataarray(arr.to_numpy(), coords=coords, dims=dims, **kwargs)
elif isinstance(arr, POLARS_SERIES_TYPES):
# mypy cannot narrow `arr` through the runtime polars type tuple.
arr = numpy_to_dataarray(
cast(Any, arr).to_numpy(), coords=coords, dims=dims, **kwargs
)
elif isinstance(arr, np.number | int | float | str | bool | list):
if isinstance(arr, np.number):
arr = float(arr)
Expand All @@ -438,7 +445,7 @@ def as_dataarray(
pd.DataFrame,
np.ndarray,
DataArray,
pl.Series,
*POLARS_SERIES_TYPES,
]
supported_types_str = ", ".join([t.__name__ for t in supported_types])
raise TypeError(
Expand Down
35 changes: 5 additions & 30 deletions linopy/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@

import numpy as np
import pandas as pd
import polars as pl
from numpy import nan, signedinteger
from numpy.typing import DTypeLike
from polars.datatypes import DataTypeClass
from xarray import DataArray, Dataset, apply_ufunc, broadcast
from xarray import align as xr_align
from xarray.core import indexing
from xarray.namedarray.utils import is_dict_like

try:
import polars as pl
except ImportError:
from linopy._polars import missing_polars as pl # type: ignore[no-redef]

from linopy.alignment import _as_index
from linopy.config import options
from linopy.constants import (
Expand Down Expand Up @@ -148,34 +151,6 @@ def check_has_nulls(df: pd.DataFrame, name: str) -> None:
raise ValueError(f"Fields {name} contains nan's in field(s) {fields}")


def infer_schema_polars(ds: Dataset) -> dict[str, DataTypeClass]:
"""
Infer the polars data schema from a xarray dataset.

Args:
----
ds (polars.DataFrame): The Polars DataFrame for which to infer the schema.

Returns:
-------
dict: A dictionary mapping column names to their corresponding Polars data types.
"""
schema: dict[str, DataTypeClass] = {}
for name, array in ds.items():
name = str(name)
if np.issubdtype(array.dtype, np.integer):
schema[name] = pl.Int32 if array.dtype.itemsize <= 4 else pl.Int64
elif np.issubdtype(array.dtype, np.floating):
schema[name] = pl.Float64
elif np.issubdtype(array.dtype, np.bool_):
schema[name] = pl.Boolean
elif np.issubdtype(array.dtype, np.object_):
schema[name] = pl.Object
else:
schema[name] = pl.Utf8
return schema


def to_polars(ds: Dataset, **kwargs: Any) -> pl.DataFrame:
"""
Convert an xarray Dataset to a polars DataFrame.
Expand Down
6 changes: 5 additions & 1 deletion linopy/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@

import numpy as np
import pandas as pd
import polars as pl
import scipy.sparse
import xarray as xr
from numpy import array, ndarray
Expand All @@ -41,6 +40,11 @@
from xarray.core.indexes import Indexes
from xarray.core.utils import Frozen

try:
import polars as pl
except ImportError:
from linopy._polars import missing_polars as pl # type: ignore[no-redef]

from linopy import expressions, variables
from linopy.alignment import as_dataarray, broadcast_to_coords
from linopy.common import (
Expand Down
6 changes: 5 additions & 1 deletion linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@

import numpy as np
import pandas as pd
import polars as pl
import scipy
import xarray as xr
import xarray.core.groupby
Expand All @@ -60,6 +59,11 @@

from types import EllipsisType, NotImplementedType

try:
import polars as pl
except ImportError:
from linopy._polars import missing_polars as pl # type: ignore[no-redef]

from linopy import constraints, variables
from linopy.alignment import (
_matmul_operand_to_dataarray,
Expand Down
39 changes: 34 additions & 5 deletions linopy/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@

import numpy as np
import pandas as pd
import polars as pl
import scipy.sparse
import xarray as xr
from tqdm import tqdm

try:
import polars as pl
except ImportError:
from linopy._polars import missing_polars as pl # type: ignore[no-redef]

try:
from tqdm import tqdm as _tqdm
except ImportError:
_tqdm = None

from linopy import solvers
from linopy.common import (
Expand Down Expand Up @@ -59,6 +67,13 @@
TQDM_COLOR = "#80bfff"


def tqdm(iterable: Any, **kwargs: Any) -> Any:
"""Wrap in a progress bar when tqdm is installed, otherwise pass through."""
if _tqdm is None:
return iterable
return _tqdm(iterable, **kwargs)


name_sanitizer = str.maketrans("-+*^[] ", "_______")


Expand Down Expand Up @@ -190,7 +205,7 @@ def get_printers_scalar(
m: Model, explicit_coordinate_names: bool = False
) -> tuple[Callable, Callable]:
"""
Get batch printer functions for numpy label arrays (non-polars).
Get batch printer functions for numpy label arrays.

Returns two callables that take an int64 numpy array of labels and return
a list of name strings.
Expand All @@ -217,10 +232,10 @@ def print_constraints(labels: np.ndarray) -> list[str]:
else:

def print_variables(labels: np.ndarray) -> list[str]:
return ("x" + pl.Series(labels).cast(pl.String)).to_list()
return np.char.add("x", labels.astype(str)).tolist()

def print_constraints(labels: np.ndarray) -> list[str]:
return ("c" + pl.Series(labels).cast(pl.String)).to_list()
return np.char.add("c", labels.astype(str)).tolist()

return print_variables, print_constraints

Expand Down Expand Up @@ -712,6 +727,20 @@ def to_lp_file(
progress: bool = True,
explicit_coordinate_names: bool = False,
) -> None:
"""
Write out a model to an LP file.

Requires the optional ``polars`` dependency. Progress bars require ``tqdm``;
without it the export runs silently.
"""
try:
import polars # noqa: F401
except ImportError as exc:
raise ImportError(
"LP file export requires polars. Install it with "
"`pip install linopy[polars]`, or pass io_api='direct' to solve()."
) from exc

with open(fn, mode="wb") as f:
start = time.time()
variable_scaling = variable_scaling_lookup(m)
Expand Down
Loading
Loading