Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d3cadb1
Fix as_dataarray to apply coords parameter for DataArray input
FBumann Jan 20, 2026
4d32c34
1. Reindexes existing dims to match coord order
FBumann Jan 20, 2026
ce7c7e2
Raise on coord mismatch instead of silent reindex in as_dataarray
FBumann Feb 19, 2026
fa8605d
Add allow_extra_dims flag to as_dataarray
FBumann Feb 19, 2026
6be142f
Normalize sequence coords to dict for DataArray validation
FBumann Feb 19, 2026
b6c1758
Extract _coords_to_mapping helper for coords normalization
FBumann Feb 19, 2026
fa16ba2
Update docstrings for as_dataarray and add_variables
FBumann Feb 19, 2026
e46a19e
Extract dataarray path into method
FBumann Feb 19, 2026
2905fd4
Improve performance
FBumann Feb 19, 2026
fea8f9b
ensure mask broadcasting still works as expected, even with misaligne…
FBumann Feb 19, 2026
65bcfb2
fix types
FBumann Feb 19, 2026
be93663
Update Changelog and add some tests to ensure behaviour
FBumann Feb 19, 2026
9508117
Fix DataArray validation for upstream compatibility
FBumann Mar 11, 2026
83ad30b
Simplify: validate DataArray coords only in add_variables
FBumann Mar 11, 2026
9d3cba7
Merge branch 'master' into fix/as-dataarray
FBumann Mar 12, 2026
0786839
Fix typo in warning message and add TODO for mask validation
FBumann Mar 12, 2026
b99dc11
Redesign as_dataarray: clean public API + internal _coerce_to_dataarr…
FBumann Mar 12, 2026
bd955ef
Rename _coerce_to_dataarray to ensure_dataarray, revert as_dataarray …
FBumann Mar 13, 2026
31733c7
Add validation to _coords_to_mapping for edge cases
FBumann Mar 13, 2026
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
242 changes: 217 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,89 @@ 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]
if len(dim_names) != len(seq):
raise ValueError(
f"Length of dims ({len(dim_names)}) does not match "
f"length of coords sequence ({len(seq)})."
)
return dict(zip(dim_names, seq))
result: dict = {}
for c in seq:
if not hasattr(c, "name") or not c.name:
warn(
f"Coordinate {c!r} has no .name attribute and will be ignored. "
"Pass dims explicitly or use named indexes (e.g. pd.RangeIndex).",
UserWarning,
stacklevel=3,
)
continue
if c.name in result:
raise ValueError(
f"Duplicate coordinate name '{c.name}' in coords sequence."
)
result[c.name] = c
return result


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 +286,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.
Convert arr to a DataArray via type dispatch.

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.

Returns
-------
DataArray:
The converted DataArray.
This is the shared conversion logic used by both ``as_dataarray``
and ``ensure_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,7 +343,119 @@ 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 ensure_dataarray(
arr: Any,
coords: CoordsLike | None = None,
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). Optional.
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 optional strict coord validation.

When ``coords`` is provided, performs type conversion plus
validation:

- For inputs with their own coordinates (DataArray, pandas, scalars): shared dims must match exactly
(``ValueError`` if not), extra dims are rejected (``ValueError``), missing dims are broadcast via ``expand_dims``.
- For raw arrays (numpy, list, polars): ``coords`` is applied
during construction (no validation needed).

When ``coords`` is ``None``, performs pure type conversion only
(accepts any input type, including numpy/list/polars).

Parameters
----------
arr :
The input object.
coords : CoordsLike, optional
Expected coordinates. When provided, used for construction
(numpy, scalar, list) and for validation (DataArray, pandas).
When ``None``, only type conversion is performed.
dims : DimsLike, optional
Dimension names.
**kwargs :
Additional keyword arguments passed to the DataArray
constructor.

Returns
-------
DataArray
"""
if coords is None:
return _type_dispatch(arr, coords=None, dims=dims, **kwargs)

# 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
)

arr = _type_dispatch(arr, coords=coords, dims=dims, **kwargs)

if needs_validation:
arr = _expand_missing_dims(arr, coords, dims)
_validate_dataarray_coords(arr, coords, dims)

return arr


Expand Down
20 changes: 10 additions & 10 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@
from linopy.common import (
EmptyDeprecationWrapper,
LocIndexer,
as_dataarray,
assign_multiindex_safe,
check_common_keys_values,
check_has_nulls,
check_has_nulls_polars,
ensure_dataarray,
fill_missing_coords,
filter_nulls_polars,
forward_as_properties,
Expand Down Expand Up @@ -355,7 +355,7 @@ def __init__(self, data: Dataset | Any | None, model: Model) -> None:
da = xr.DataArray([], dims=[TERM_DIM])
data = Dataset({"coeffs": da, "vars": da, "const": 0.0})
elif isinstance(data, SUPPORTED_CONSTANT_TYPES):
const = as_dataarray(data)
const = ensure_dataarray(data)
da = xr.DataArray([], dims=[TERM_DIM])
data = Dataset({"coeffs": da, "vars": da, "const": const})
elif not isinstance(data, Dataset):
Expand Down Expand Up @@ -597,7 +597,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 = ensure_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 +626,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 = ensure_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 +1142,7 @@ def to_constraint(
)

if isinstance(rhs, SUPPORTED_CONSTANT_TYPES):
rhs = as_dataarray(rhs, coords=self.coords, dims=self.coord_dims)
rhs = ensure_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 +1705,7 @@ 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 = ensure_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 @@ -2039,7 +2039,7 @@ def process_one(
DeprecationWarning,
)
# assume that the element is a constant
const = as_dataarray(t[0])
const = ensure_dataarray(t[0])
if model is None:
raise ValueError("Model must be provided when using constants.")
return LinearExpression(const, model)
Expand Down Expand Up @@ -2067,7 +2067,7 @@ def from_constant(cls, model: Model, constant: ConstantLike) -> LinearExpression
linopy.LinearExpression
A linear expression representing the constant value.
"""
const_da = as_dataarray(constant)
const_da = ensure_dataarray(constant)
return LinearExpression(const_da, model)


Expand Down Expand Up @@ -2191,7 +2191,7 @@ def __matmul__(
"Higher order non-linear expressions are not yet supported."
)

other = as_dataarray(other, coords=self.coords, dims=self.coord_dims)
other = ensure_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 @@ -2326,7 +2326,7 @@ def as_expression(
return obj.to_linexpr()
else:
try:
obj = as_dataarray(obj, **kwargs)
obj = ensure_dataarray(obj, **kwargs)
except ValueError as e:
raise ValueError("Cannot convert to LinearExpression") from e
return LinearExpression(obj, model)
Expand Down
Loading
Loading