Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d51cda8
fix(variables): broadcast and order pandas/DataArray bounds in coords
FBumann May 23, 2026
a246006
docs(variables): frame add_variables coords as source of truth
FBumann May 23, 2026
aa0c80d
docs: frame bounds fix as extending 0.7.0's coords-as-truth fix
FBumann May 23, 2026
4ddc3c2
docs: reword as "extend and finalize", emphasize hardening
FBumann May 23, 2026
5557a9f
docs: rephrase as "0.7.0 made ... this release closes the two remaini…
FBumann May 23, 2026
cdc987b
docs: spell out dims/order/values in coords-as-truth bullet
FBumann May 23, 2026
001d071
test(variables): cover pandas MultiIndex bounds and dim reindex
FBumann May 23, 2026
bca89e7
refactor: move as_dataarray_in_coords to common.py
FBumann May 23, 2026
b28f3df
refactor(common): simplify _named_pandas_to_dataarray + cover edge br…
FBumann May 24, 2026
9b4d7cc
fix(common): only accept string axis names in _named_pandas_to_dataarray
FBumann May 24, 2026
7705156
fix(common): align positional inputs to coords, with clear shape errors
FBumann May 24, 2026
26f3e73
fix(sos): use var.indexes[d] for reformulated bounds; widen _coords_t…
FBumann May 24, 2026
095b510
fix(common): tighten _coords_to_dict to raise on non-pd.Index entries
FBumann May 24, 2026
68c4e09
fix(common): proper MultiIndex support in coords helpers (#729)
FabianHofmann May 27, 2026
a3d6f59
fix: apply coords-as-truth rule to mask in add_variables/add_constrai…
FBumann May 27, 2026
48de61b
refactor: unify as_dataarray; split broadcasting from coords validati…
FBumann May 27, 2026
f766318
test(repr): set .name on MultiIndex coord
FBumann May 27, 2026
280f77c
fix(types): widen _coords_to_dict to Hashable; sort with key=str
FBumann May 27, 2026
570fd6f
refactor(common): clarify coords-entry rules and tighten error labels…
FBumann May 27, 2026
e9a354c
docs(release_notes): restore lost bullets, surface coords breaking ch…
FBumann May 27, 2026
cdfff36
docs(release_notes): condense coords-as-truth entries
FBumann May 27, 2026
a23a24c
Merge branch 'master' into fix/bounds-coords-broadcast
FabianHofmann May 28, 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
4 changes: 4 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,23 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y

**Bug Fixes**

* ``add_variables`` / ``add_constraints``: extends 0.7.0's coords-as-truth rule to ``lower``, ``upper`` and ``mask`` for every bound type and dim order. Pandas ``Series`` / ``DataFrame`` bounds or masks missing a dimension are broadcast to ``coords`` instead of being silently dropped (`#709 <https://github.com/PyPSA/linopy/issues/709>`__); the variable's dimension order always follows ``coords`` (`#706 <https://github.com/PyPSA/linopy/issues/706>`__); bare-tuple coord entries (``coords=[(0, 1, 2)]``) now behave like lists. Mismatched values or extra dims raise ``ValueError`` with a labelled message; sparse-coord masks (formerly a v0.6.3 ``FutureWarning``, #580) raise ``ValueError``, and masks with dims not in the data raise ``ValueError`` instead of ``AssertionError``.
* ``add_piecewise_formulation`` now produces a reproducible dimension order in the broadcast breakpoint array. The previous set-based expansion gave a hash-randomized order that varied between processes.
* SOS constraints on masked variables no longer cause solver-specific failures (Gurobi ``IndexError``, Xpress ``?404 Invalid column number``, LP parse errors, silent set corruption). ``Model.solve()`` and ``Model.to_file()`` now raise a clear ``NotImplementedError`` referring users to `#688 <https://github.com/PyPSA/linopy/issues/688>`__; pass ``reformulate_sos=True`` as a workaround.
* ``Model.solve(..., reformulate_sos=True)`` now actually reformulates SOS constraints even when the solver supports them natively. Previously it was silently ignored with a warning.
* Fix Mosek interface to inspect both the basic and IPM solutions and pick the one with the better status, so that an optimal crossover solution is not discarded when IPM terminates with a (near-)Farkas certificate.

**Breaking Changes**

* ``add_variables`` / ``add_constraints``: the v0.6.3 ``mask`` deprecations (#580) are now hard ``ValueError``\ s; an unnamed ``pd.MultiIndex`` in sequence-form ``coords`` raises ``TypeError`` unless paired with ``dims=[i]``. See Bug Fixes above.
* ``available_solvers`` now lists all *installed* solvers, even ones without a working license. If you used it to decide "can I actually solve with X?", switch to ``linopy.licensed_solvers`` or ``SolverClass.license_status()``.
* ``Model.solver_model`` and ``Model.solver_name`` are now read-only properties that delegate to ``model.solver``. You can't reassign them (only ``= None`` is allowed, which closes the solver), and ``solver_name`` is ``None`` before the first solve.
* ``result.solution.primal`` and ``result.solution.dual`` are now ``numpy`` arrays indexed by linopy's integer labels (with ``NaN`` for slots without a value), instead of pandas Series keyed by variable/constraint name. If you accessed them by name, use ``model.variables[name].solution`` (or ``model.constraints[name].dual``) instead.
* Drop Python 3.10 support. Minimum supported version is now Python 3.11.

**Internal**

* ``linopy.common.as_dataarray`` is the single broadcasting primitive; strict subset-dim / coord-value checks live in ``validate_alignment`` (via ``align_to_coords`` in ``add_variables`` / ``add_constraints``). When ``coords`` is a mapping, extra keys beyond the positional ``dims`` are broadcast in rather than dropped.
* Each ``Solver`` subclass now overrides at most three hooks: ``_build_direct`` (build the native model), ``_run_direct`` (run it), and ``_run_file`` (run the solver on an LP/MPS file). File-only solvers (CBC, GLPK, CPLEX, SCIP, Knitro, COPT, MindOpt) only override ``_run_file``.
* New ``ConstraintLabelIndex`` cached on ``Model.constraints`` (mirrors the existing ``Variables.label_index``); ``ConstraintBase`` gains ``active_labels()`` and a ``range`` property; ``CSRConstraint`` exposes ``coords``.
* ``linopy.common`` gains ``values_to_lookup_array``; the legacy pandas-based helpers ``series_to_lookup_array`` and ``lookup_vals`` are removed.
Expand Down
385 changes: 348 additions & 37 deletions linopy/common.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1865,7 +1865,7 @@ def from_rule(
cls,
model: Model,
rule: Callable,
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None = None,
coords: Sequence[Sequence | pd.Index] | Mapping | None = None,
) -> LinearExpression:
"""
Create a linear expression from a rule and a set of coordinates.
Expand Down
194 changes: 102 additions & 92 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@
import pandas as pd
import xarray as xr
from deprecation import deprecated
from numpy import inf, ndarray
from numpy import inf
from pandas.core.frame import DataFrame
from pandas.core.series import Series
from xarray import DataArray, Dataset
from xarray.core.types import T_Chunks

from linopy import solvers
from linopy.common import (
align_to_coords,
as_dataarray,
assign_multiindex_safe,
best_int,
broadcast_mask,
maybe_replace_signs,
replace_by_map,
to_path,
Expand Down Expand Up @@ -112,73 +112,6 @@
logger = logging.getLogger(__name__)


def _coords_to_dict(
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping,
) -> dict[str, Any]:
"""Normalize coords to a dict mapping dim names to coordinate values."""
if isinstance(coords, Mapping):
return dict(coords)
# Sequence of indexes
result: dict[str, Any] = {}
for c in coords:
if isinstance(c, pd.Index) and c.name:
result[c.name] = c
return result


def _validate_dataarray_bounds(arr: Any, coords: Any) -> Any:
"""
Validate and expand DataArray bounds against explicit coords.

If ``arr`` is not a DataArray, return it unchanged (``as_dataarray``
will handle conversion). For DataArray inputs:

- Raises ``ValueError`` if the array has dimensions not in coords.
- Raises ``ValueError`` if shared dimension coordinates don't match.
- Expands missing dimensions via ``expand_dims``.
"""
if not isinstance(arr, DataArray):
return arr

expected = _coords_to_dict(coords)
if not expected:
return arr

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

for dim, coord_values in expected.items():
if dim not in arr.dims:
continue
if isinstance(arr.indexes.get(dim), pd.MultiIndex):
continue
expected_idx = (
coord_values
if isinstance(coord_values, pd.Index)
else pd.Index(coord_values)
)
actual_idx = arr.coords[dim].to_index()
if not actual_idx.equals(expected_idx):
# Same values, different order → reindex to match expected order
if len(actual_idx) == len(expected_idx) and set(actual_idx) == set(
expected_idx
):
arr = arr.reindex({dim: expected_idx})
else:
raise ValueError(
f"Coordinates for dimension '{dim}' do not match: "
f"expected {expected_idx.tolist()}, got {actual_idx.tolist()}"
)

# Expand missing dimensions
expand = {k: v for k, v in expected.items() if k not in arr.dims}
if expand:
arr = arr.expand_dims(expand)

return arr


class Model:
"""
Linear optimization model.
Expand Down Expand Up @@ -657,9 +590,9 @@ def add_variables(
self,
lower: Any = -inf,
upper: Any = inf,
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None = None,
coords: Sequence[Sequence | pd.Index] | Mapping | None = None,
name: str | None = None,
mask: DataArray | ndarray | Series | None = None,
mask: MaskLike | None = None,
binary: bool = False,
integer: bool = False,
semi_continuous: bool = False,
Expand All @@ -682,12 +615,27 @@ def add_variables(
upper : TYPE, optional
Upper bound of the variable(s). Ignored if `binary` is True.
The default is inf.
coords : list/xarray.Coordinates, optional
The coords of the variable array.
These are directly passed to the DataArray creation of
`lower` and `upper`. For every single combination of
coordinates a optimization variable is added to the model.
The default is None.
coords : list/dict/xarray.Coordinates, optional
The coords of the variable array. When provided with **named
dimensions** (a ``Mapping``, ``xarray.Coordinates``, a
sequence of named ``pd.Index`` objects, or an unnamed
sequence paired with ``dims=`` in ``**kwargs``), ``coords``
is the source of truth for the variable's dimensions,
order, and values. ``lower``, ``upper`` and ``mask`` are
aligned to this contract:

- dims of every bound must be a subset of ``coords.dims``;
extra dims raise ``ValueError``;
- dim order in the variable always follows ``coords``;
- shared-dim coordinate values must equal ``coords``; same
values in a different order are auto-reindexed, different
value sets raise ``ValueError``;
- dims listed in ``coords`` but missing from a bound are
broadcast to ``coords`` shape.

One optimization variable is added per combination of
coordinates. The default is ``None``, in which case the
shape is inferred from the bounds.
name : str, optional
Reference name of the added variables. The default None results in
a name like "var1", "var2" etc.
Expand Down Expand Up @@ -740,6 +688,67 @@ def add_variables(
[7]: x[7] ∈ [0, inf]
[8]: x[8] ∈ [0, inf]
[9]: x[9] ∈ [0, inf]

Strict coords-as-truth: a bound with an extra dim raises.

>>> import xarray as xr
>>> m = Model()
>>> bad = xr.DataArray(
... [[1.0, 2.0, 3.0]] * 2,
... dims=["extra", "x"],
... coords={"x": [0, 1, 2]},
... )
>>> m.add_variables(lower=bad, coords=[pd.Index([0, 1, 2], name="x")], name="v")
Traceback (most recent call last):
...
ValueError: lower bound has dimension(s) ['extra'] not declared in coords ...

Strict coords-as-truth: a bound whose shared-dim values don't
match raises.

>>> m = Model()
>>> wrong = xr.DataArray(
... [1.0, 2.0, 3.0], dims=["x"], coords={"x": [10, 20, 30]}
... )
>>> m.add_variables(
... lower=wrong, coords=[pd.Index([0, 1, 2], name="x")], name="v"
... )
Traceback (most recent call last):
...
ValueError: lower bound: coordinate values for dimension 'x' do not match coords ...

Strict coords-as-truth, helpful side: a bound whose coord values
match ``coords`` only in a different order is auto-reindexed.

>>> m = Model()
>>> reordered = xr.DataArray(
... [3.0, 1.0, 2.0], dims=["x"], coords={"x": ["c", "a", "b"]}
... )
>>> v = m.add_variables(
... lower=reordered,
... coords=[pd.Index(["a", "b", "c"], name="x")],
... name="r",
... )
>>> v.lower.values.tolist()
[1.0, 2.0, 3.0]

Unnamed-coords sequence + ``dims=`` opts into the same strict
enforcement as a named index — extra dims still raise.

>>> m = Model()
>>> m.add_variables(lower=bad, coords=[[0, 1, 2]], dims=["x"], name="w")
Traceback (most recent call last):
...
ValueError: lower bound has dimension(s) ['extra'] not declared in coords ...

The same strict contract applies to ``mask`` (including with
``coords=[[...]], dims=[...]``).

>>> m = Model()
>>> m.add_variables(mask=bad, coords=[[0, 1, 2]], dims=["x"], name="wm")
Traceback (most recent call last):
...
ValueError: mask has dimension(s) ['extra'] not declared in coords ...
"""
if name is None:
name = f"var{self._varnameCounter}"
Expand All @@ -765,14 +774,12 @@ def add_variables(
"Semi-continuous variables require a positive scalar lower bound."
)

if coords is not None:
lower = _validate_dataarray_bounds(lower, coords)
upper = _validate_dataarray_bounds(upper, coords)

lower_da = align_to_coords(lower, coords, label="lower bound", **kwargs)
upper_da = align_to_coords(upper, coords, label="upper bound", **kwargs)
data = Dataset(
{
"lower": as_dataarray(lower, coords, **kwargs),
"upper": as_dataarray(upper, coords, **kwargs),
"lower": lower_da,
"upper": upper_da,
"labels": -1,
}
)
Expand All @@ -781,8 +788,12 @@ def add_variables(
self._check_valid_dim_names(data)

if mask is not None:
mask = as_dataarray(mask, coords=data.coords, dims=data.dims).astype(bool)
mask = broadcast_mask(mask, data.labels)
mask = align_to_coords(
mask,
coords if coords is not None else data.coords,
label="mask",
**kwargs,
).astype(bool)

# Auto-mask based on NaN in bounds (use numpy for speed)
if self.auto_mask:
Expand Down Expand Up @@ -891,7 +902,7 @@ def add_constraints(
sign: SignLike | None = ...,
rhs: ConstantLike | VariableLike | ExpressionLike | None = ...,
name: str | None = ...,
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None = ...,
coords: Sequence[Sequence | pd.Index] | Mapping | None = ...,
mask: MaskLike | None = ...,
freeze: Literal[False] = ...,
) -> Constraint: ...
Expand All @@ -907,7 +918,7 @@ def add_constraints(
sign: SignLike | None = ...,
rhs: ConstantLike | VariableLike | ExpressionLike | None = ...,
name: str | None = ...,
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None = ...,
coords: Sequence[Sequence | pd.Index] | Mapping | None = ...,
mask: MaskLike | None = ...,
freeze: Literal[True] = ...,
) -> CSRConstraint: ...
Expand All @@ -922,7 +933,7 @@ def add_constraints(
sign: SignLike | None = None,
rhs: ConstantLike | VariableLike | ExpressionLike | None = None,
name: str | None = None,
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping | None = None,
coords: Sequence[Sequence | pd.Index] | Mapping | None = None,
mask: MaskLike | None = None,
freeze: bool | None = None,
) -> ConstraintBase:
Expand Down Expand Up @@ -1046,8 +1057,7 @@ def add_constraints(
(data,) = xr.broadcast(data, exclude=[TERM_DIM])

if mask is not None:
mask = as_dataarray(mask, coords=data.coords, dims=data.dims).astype(bool)
mask = broadcast_mask(mask, data.labels)
mask = align_to_coords(mask, data.coords, label="mask").astype(bool)

# Auto-mask based on null expressions or NaN RHS (use numpy for speed)
if self.auto_mask:
Expand Down Expand Up @@ -1428,7 +1438,7 @@ def calculate_block_maps(self) -> None:

@overload
def linexpr(
self, *args: Sequence[Sequence | pd.Index | DataArray] | Mapping
self, *args: Sequence[Sequence | pd.Index] | Mapping
) -> LinearExpression: ...

@overload
Expand All @@ -1441,7 +1451,7 @@ def linexpr(
*args: tuple[ConstantLike, str | Variable | ScalarVariable]
| ConstantLike
| Callable
| Sequence[Sequence | pd.Index | DataArray]
| Sequence[Sequence | pd.Index]
| Mapping,
) -> LinearExpression:
"""
Expand Down
20 changes: 9 additions & 11 deletions linopy/piecewise.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,20 +1006,18 @@ def _broadcast_points(

lin_exprs = [_to_linexpr(e) for e in exprs]

target_dims: set[str] = set()
for le in lin_exprs:
target_dims.update(str(d) for d in le.coord_dims)

missing = target_dims - skip - {str(d) for d in points.dims}
if not missing:
return points
point_dims = {str(d) for d in points.dims}

# Iterate exprs/dims in order; a set would give a hash-dependent,
# run-varying expanded dimension order.
expand_map: dict[str, list] = {}
for d in missing:
for le in lin_exprs:
for le in lin_exprs:
for dim in le.coord_dims:
d = str(dim)
if d in skip or d in point_dims or d in expand_map:
continue
if d in le.coords:
expand_map[str(d)] = list(le.coords[d].values)
break
expand_map[d] = list(le.coords[d].values)

if expand_map:
points = points.expand_dims(expand_map)
Expand Down
6 changes: 3 additions & 3 deletions linopy/sos_reformulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def reformulate_sos1(
upper_name = f"{prefix}{name}_upper"
card_name = f"{prefix}{name}_card"

coords = [var.coords[d] for d in var.dims]
coords = [var.indexes[d] for d in var.dims]
y = model.add_variables(coords=coords, name=y_name, binary=True)

model.add_constraints(var <= M * y, name=upper_name)
Expand Down Expand Up @@ -173,9 +173,9 @@ def reformulate_sos2(
card_name = f"{prefix}{name}_card"

z_coords = [
pd.Index(var.coords[sos_dim].values[:-1], name=sos_dim)
pd.Index(var.indexes[sos_dim][:-1], name=sos_dim)
if d == sos_dim
else var.coords[d]
else var.indexes[d]
for d in var.dims
]
z = model.add_variables(coords=z_coords, name=z_name, binary=True)
Expand Down
Loading
Loading