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
2 changes: 1 addition & 1 deletion doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ 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_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>`__); tuple coord entries follow xarray's ``(dim_name, values)`` convention (e.g. ``coords=[("origin", origins)]``), while a bare value sequence uses a ``list``. 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``.
* Pandas inputs whose index names *levels* of a stacked-``MultiIndex`` ``coords`` dimension are now projected onto that dimension: a level subset broadcasts across the others, the full set aligns element-wise. This fixes PyPSA multi-investment arithmetic (e.g. an expression over a ``(period, timestep)`` ``snapshot`` MultiIndex times a ``period``-indexed weighting). In ``add_variables`` / ``add_constraints`` the input must provide a value for every level combination of the MultiIndex or a ``ValueError`` is raised (the error lists the missing combinations). **Implicit level projections are deprecated**: they emit an ``EvolvingAPIWarning`` everywhere — in arithmetic *and* in ``add_variables`` / ``add_constraints`` — and will raise under the upcoming v1 convention. Project the input onto the dimension explicitly (select with the dimension's level values) to keep current behavior. Aligning the full level set with full coverage stays silent. Strict validation also rejects a ``MultiIndex`` input with *unnamed* levels whose combinations don't match ``coords`` (previously a silent bypass, as such inputs can't be projected by level name).
* ``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.
Expand Down
47 changes: 35 additions & 12 deletions linopy/alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ def _coords_to_dict(
Sequence-entry rules (``i`` is the position in ``coords``, ``dims[i]``
is the matching entry in ``dims`` when one exists). An entry is
*unlabeled* if it's an unnamed ``pd.Index`` or a bare ``list`` /
``tuple`` / ``range`` / ``ndarray``.
``range`` / ``ndarray``. A ``tuple`` is **not** unlabeled: following
xarray, it is read as ``(dim_name, values[, attrs])`` — the first
element names the dimension.

+---------------------------------+-----------------------+-----------+
| Entry | Naming source | Outcome |
Expand All @@ -80,6 +82,12 @@ def _coords_to_dict(
| | | ``dim_0`` |
| | | etc. |
+---------------------------------+-----------------------+-----------+
| ``(name, values)`` tuple | ``name`` (1st elem) | accepted |
| | | (xarray |
| | | form) |
+---------------------------------+-----------------------+-----------+
| tuple of length < 2 | — | TypeError |
+---------------------------------+-----------------------+-----------+
| ``pd.MultiIndex`` with ``.name``| ``.name`` | accepted |
+---------------------------------+-----------------------+-----------+
| ``pd.MultiIndex`` w/o ``.name`` | ``dims[i]`` | accepted |
Expand Down Expand Up @@ -124,16 +132,36 @@ def _coords_to_dict(
else (dim_names[i] if dim_names and i < len(dim_names) else None)
)
if name is not None:
result[name] = c
elif isinstance(c, list | tuple | range | np.ndarray):
result[name] = c if c.name == name else c.rename(name)
elif isinstance(c, tuple):
if (
len(c) < 2
or not isinstance(c[0], Hashable)
or isinstance(c[0], list | tuple | np.ndarray)
):
raise TypeError(
f"tuple coords entries follow xarray's (dim_name, values) "
f"convention; got {c!r}. Pass a list for a bare sequence "
f"of coordinate values."
)
name, values = c[0], c[1]
try:
result[name] = pd.Index(values, name=name)
except TypeError as err:
raise TypeError(
f"tuple coords entries follow xarray's (dim_name, values) "
f"convention with array-like values; got {c!r}. Pass a "
f"list for a bare sequence of coordinate values."
) from err
elif isinstance(c, list | range | np.ndarray):
if dim_names and i < len(dim_names):
result[dim_names[i]] = pd.Index(c, name=dim_names[i])
else:
raise TypeError(
f"coords entries must be pd.Index or an unnamed sequence "
f"(list / tuple / range / numpy.ndarray); got "
f"{type(c).__name__}. For an xarray DataArray coord, pass "
f"`variable.indexes[<dim>]` (a pd.Index) instead."
f"coords entries must be pd.Index, an unlabeled sequence "
f"(list / range / numpy.ndarray), or a (dim_name, values) "
f"tuple; got {type(c).__name__}. For an xarray DataArray "
f"coord, pass `variable.indexes[<dim>]` (a pd.Index) instead."
)
return result

Expand Down Expand Up @@ -527,11 +555,6 @@ def _broadcast_to_coords(
if coords is None:
return as_dataarray(arr, coords, dims, **kwargs), []

if isinstance(coords, list | tuple) and any(isinstance(c, tuple) for c in coords):
# xarray reads bare `(a, b)` as `(dim_name, values)`; normalize so a
# coords entry passed as a tuple behaves identically to a list.
coords = [list(c) if isinstance(c, tuple) else c for c in coords]

expected = _coords_to_dict(coords, dims=dims)
if not expected:
return as_dataarray(arr, coords, dims, **kwargs), []
Expand Down
Loading
Loading