Skip to content
Draft
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
2 changes: 1 addition & 1 deletion doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Upcoming Version
- Comparison operators (``==``, ``<=``, ``>=``) fill missing RHS coords with NaN (no constraint created)
- Fixes crash on ``subset + var`` / ``subset + expr`` reverse addition
- Fixes superset DataArrays expanding result coords beyond the variable's coordinate space
* When passing DataArray bounds to ``add_variables`` with explicit ``coords``, the ``coords`` parameter now defines the variable's coordinates. DataArray bounds are validated against these coords (raises ``ValueError`` on mismatch) and broadcast to missing dimensions. Previously, the ``coords`` parameter was silently ignored for DataArray inputs.
* Add ``add_piecewise_constraints()`` with SOS2, incremental, LP, and disjunctive formulations (``linopy.piecewise(x, x_pts, y_pts) == y``).
* Add ``linopy.piecewise()`` to create piecewise linear function descriptors (`PiecewiseExpression`) from separate x/y breakpoint arrays.
* Add ``linopy.breakpoints()`` factory for convenient breakpoint construction from lists, Series, DataFrames, DataArrays, or dicts. Supports slopes mode.
Expand All @@ -28,7 +29,6 @@ Version 0.6.5

* Expose the knitro context to allow for more flexible use of the knitro python API.


Version 0.6.4
--------------

Expand Down
222 changes: 197 additions & 25 deletions linopy/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import operator
import os
from collections.abc import Callable, Generator, Hashable, Iterable, Sequence
from collections.abc import Callable, Generator, Hashable, Iterable, Mapping, Sequence
from functools import partial, reduce, wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
Expand Down Expand Up @@ -128,6 +128,69 @@ def get_from_iterable(lst: DimsLike | None, index: int) -> Any | None:
return lst[index] if 0 <= index < len(lst) else None


def _coords_to_mapping(coords: CoordsLike, dims: DimsLike | None = None) -> Mapping:
"""
Normalize coords to a Mapping.

If coords is already a Mapping, return as-is. If it's a sequence,
convert to a dict using dims (if provided) or Index names.
"""
if isinstance(coords, Mapping):
return coords
seq = list(coords)
if dims is not None:
dim_names: list[str] = [dims] if isinstance(dims, str) else list(dims) # type: ignore[arg-type]
return dict(zip(dim_names, seq))
return {c.name: c for c in seq if hasattr(c, "name") and c.name}


def _validate_dataarray_coords(
arr: DataArray,
coords: CoordsLike,
dims: DimsLike | None = None,
) -> None:
"""
Validate a DataArray against expected coords (strict).

- Shared dimensions must have matching coordinates (raises ValueError).
- Extra dimensions in the DataArray always raise ValueError.

This function only validates — it does not modify ``arr``.
Expansion of missing dims should be done before calling this.
"""
expected = _coords_to_mapping(coords, dims)
if not expected:
return

# Filter to dimension coordinates only — skip non-dimension coords
# like MultiIndex levels (level1, level2) that share a dimension
# with their parent index.
if hasattr(coords, "dims"):
# xarray Coordinates object — only keep entries that are dimensions
dim_coords = {k: v for k, v in expected.items() if k in coords.dims}
else:
dim_coords = dict(expected)

extra = set(arr.dims) - set(dim_coords)
if extra:
raise ValueError(f"DataArray has extra dimensions not in coords: {extra}")

for k, v in dim_coords.items():
if k not in arr.dims:
continue
# Skip validation for multiindex dimensions — the level coords
# cannot be compared directly via pd.Index.equals
if isinstance(arr.indexes.get(k), pd.MultiIndex):
continue
actual = arr.coords[k]
v_idx = v if isinstance(v, pd.Index) else pd.Index(v)
if not actual.to_index().equals(v_idx):
raise ValueError(
f"Coordinates for dimension '{k}' do not match: "
f"expected {v_idx.tolist()}, got {actual.values.tolist()}"
)


def pandas_to_dataarray(
arr: pd.DataFrame | pd.Series,
coords: CoordsLike | None = None,
Expand Down Expand Up @@ -203,49 +266,46 @@ def numpy_to_dataarray(

if dims is not None and len(dims) and coords is not None:
if isinstance(coords, list):
coords = dict(zip(dims, coords[: arr.ndim]))
coords = _coords_to_mapping(coords, dims)
elif is_dict_like(coords):
coords = {k: v for k, v in coords.items() if k in dims}

return DataArray(arr, coords=coords, dims=dims, **kwargs)


def as_dataarray(
def _type_dispatch(
arr: Any,
coords: CoordsLike | None = None,
dims: DimsLike | None = None,
**kwargs: Any,
) -> DataArray:
"""
Convert an object to a DataArray.

Parameters
----------
arr:
The input object.
coords (Union[dict, list, None]):
The coordinates for the DataArray. If None, default coordinates will be used.
dims (Union[list, None]):
The dimensions for the DataArray. If None, the dimensions will be automatically generated.
**kwargs:
Additional keyword arguments to be passed to the DataArray constructor.
Convert arr to a DataArray via type dispatch.

Returns
-------
DataArray:
The converted DataArray.
This is the shared conversion logic used by both ``as_dataarray``
and ``_coerce_to_dataarray``. It does NOT validate or expand dims.
"""
if isinstance(arr, pd.Series | pd.DataFrame):
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, np.number):
arr = DataArray(float(arr), coords=coords, dims=dims, **kwargs)
elif isinstance(arr, int | float | str | bool | list):
arr = DataArray(arr, coords=coords, dims=dims, **kwargs)

elif isinstance(arr, np.number | int | float | str | bool | list):
if isinstance(arr, np.number):
arr = float(arr)
# For scalars with coords but no dims, infer dims from coords
# to avoid xarray's CoordinateValidationError
if coords is not None and dims is None and np.ndim(arr) == 0:
inferred = _coords_to_mapping(coords)
if inferred:
arr = DataArray(
arr, coords=coords, dims=list(inferred.keys()), **kwargs
)
else:
arr = DataArray(arr, coords=coords, dims=dims, **kwargs)
else:
arr = DataArray(arr, coords=coords, dims=dims, **kwargs)
elif not isinstance(arr, DataArray):
supported_types = [
np.number,
Expand All @@ -263,10 +323,122 @@ def as_dataarray(
f"Unsupported type of arr: {type(arr)}. Supported types are: {supported_types_str}"
)

arr = fill_missing_coords(arr)
return fill_missing_coords(arr)


def _expand_missing_dims(
arr: DataArray,
coords: CoordsLike,
dims: DimsLike | None = None,
) -> DataArray:
"""Broadcast missing dims via expand_dims."""
expected = _coords_to_mapping(coords, dims)
if not expected:
return arr

# Filter to dimension coordinates only — skip non-dimension coords
# like MultiIndex levels (level1, level2) that share a dimension
# with their parent index.
if hasattr(coords, "dims"):
dim_coords = {k: v for k, v in expected.items() if k in coords.dims}
else:
dim_coords = dict(expected)

expand = {k: v for k, v in dim_coords.items() if k not in arr.dims}
if expand:
arr = arr.expand_dims(expand)
return arr


def _coerce_to_dataarray(
arr: Any,
coords: CoordsLike,
dims: DimsLike | None = None,
**kwargs: Any,
) -> DataArray:
"""
Internal: convert any type to DataArray using coords for construction.

Only does type conversion — no coord validation, no expand_dims.
Callers handle alignment and broadcasting themselves.

Parameters
----------
arr:
The input object.
coords:
Coordinates used as construction hints for types without
their own coords (numpy, scalar, list).
dims:
Dimension names.
**kwargs:
Additional keyword arguments passed to the DataArray constructor.

Returns
-------
DataArray
"""
return _type_dispatch(arr, coords=coords, dims=dims, **kwargs)


def as_dataarray(
arr: Any,
coords: CoordsLike | None = None,
dims: DimsLike | None = None,
**kwargs: Any,
) -> DataArray:
"""
Convert an object to a DataArray with strict coord validation.

Behavior depends on whether ``coords`` is provided:

- **No coords**: just type conversion (scalar->DA, numpy->DA,
pandas->DA, DataArray->passthrough).
- **With coords** (strict): shared dims must have matching
coordinates (ValueError if not), extra dims in input are
rejected (ValueError), missing dims are broadcast via
``expand_dims``.

Parameters
----------
arr:
The input object.
coords:
The coordinates for the DataArray. If None, only type
conversion is performed.
dims:
The dimensions for the DataArray. If None, the dimensions
will be automatically generated.
**kwargs:
Additional keyword arguments passed to the DataArray constructor.

Returns
-------
DataArray:
The converted DataArray.
"""
if coords is not None:
# Inputs that already have their own coordinates (DataArray,
# pandas) or scalars need validation against coords. Raw arrays
# (numpy, list) get coords applied during construction, so
# validation and expand_dims are not needed.
needs_validation = isinstance(arr, DataArray | pd.Series | pd.DataFrame) or (
not isinstance(arr, np.ndarray | pl.Series | list) and np.ndim(arr) == 0
)

if needs_validation:
arr = _type_dispatch(arr, coords=coords, dims=dims, **kwargs)
arr = _expand_missing_dims(arr, coords, dims)
_validate_dataarray_coords(arr, coords, dims)
else:
arr = _type_dispatch(arr, coords=coords, dims=dims, **kwargs)

return arr

# No coords — just type conversion
return _type_dispatch(arr, coords=None, dims=dims, **kwargs)


def broadcast_mask(mask: DataArray, labels: DataArray) -> DataArray:
"""
Broadcast a boolean mask to match the shape of labels.
Expand Down
13 changes: 8 additions & 5 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from linopy.common import (
EmptyDeprecationWrapper,
LocIndexer,
_coerce_to_dataarray,
as_dataarray,
assign_multiindex_safe,
check_common_keys_values,
Expand Down Expand Up @@ -597,7 +598,7 @@ def _add_constant(
# so that missing data does not silently propagate through arithmetic.
if np.isscalar(other) and join is None:
return self.assign(const=self.const.fillna(0) + other)
da = as_dataarray(other, coords=self.coords, dims=self.coord_dims)
da = _coerce_to_dataarray(other, coords=self.coords, dims=self.coord_dims)
self_const, da, needs_data_reindex = self._align_constant(
da, fill_value=0, join=join
)
Expand Down Expand Up @@ -626,7 +627,7 @@ def _apply_constant_op(
- factor (other) is filled with fill_value (0 for mul, 1 for div)
- coeffs and const are filled with 0 (additive identity)
"""
factor = as_dataarray(other, coords=self.coords, dims=self.coord_dims)
factor = _coerce_to_dataarray(other, coords=self.coords, dims=self.coord_dims)
self_const, factor, needs_data_reindex = self._align_constant(
factor, fill_value=fill_value, join=join
)
Expand Down Expand Up @@ -1142,7 +1143,7 @@ def to_constraint(
)

if isinstance(rhs, SUPPORTED_CONSTANT_TYPES):
rhs = as_dataarray(rhs, coords=self.coords, dims=self.coord_dims)
rhs = _coerce_to_dataarray(rhs, coords=self.coords, dims=self.coord_dims)

extra_dims = set(rhs.dims) - set(self.coord_dims)
if extra_dims:
Expand Down Expand Up @@ -1705,7 +1706,9 @@ def __matmul__(
Matrix multiplication with other, similar to xarray dot.
"""
if not isinstance(other, LinearExpression | variables.Variable):
other = as_dataarray(other, coords=self.coords, dims=self.coord_dims)
other = _coerce_to_dataarray(
other, coords=self.coords, dims=self.coord_dims
)

common_dims = list(set(self.coord_dims).intersection(other.dims))
return (self * other).sum(dim=common_dims)
Expand Down Expand Up @@ -2191,7 +2194,7 @@ def __matmul__(
"Higher order non-linear expressions are not yet supported."
)

other = as_dataarray(other, coords=self.coords, dims=self.coord_dims)
other = _coerce_to_dataarray(other, coords=self.coords, dims=self.coord_dims)
common_dims = list(set(self.coord_dims).intersection(other.dims))
return (self * other).sum(dim=common_dims)

Expand Down
Loading
Loading