Skip to content

Commit 36bc106

Browse files
FBumannclaude
andauthored
fix: add_variables ignoring coords for DataArray bounds (#614)
* Fix add_variables silently ignoring coords for DataArray bounds When DataArray bounds were passed to add_variables with explicit coords, the coords parameter was silently ignored because as_dataarray skips conversion for DataArray inputs. Now validates DataArray bounds against coords: raises ValueError on mismatched or extra dimensions, and broadcasts missing dimensions via expand_dims. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Another test * Add additional test coverage for DataArray bounds validation Test MultiIndex coords (validation skip), xarray Coordinates object, dims-only DataArrays, and upper bound mismatch detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add TODO noting as_dataarray fails for scalars with dict coords Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix as_dataarray for scalars with dict coords Infer dims from dict keys when dims is None and the input is a scalar. Previously this raised xarray's CoordinateValidationError because xarray can't broadcast a 0-dim value to coords without explicit dims. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace individual tests with parameterized test suite Consolidate add_variables tests into TestAddVariablesBoundsWithCoords class with parameterized tests covering all bound types (scalar, np.number, numpy, pandas, list, DataArray, DataArray-no-coords) x both coord formats (sequence, dict). Also fixes as_dataarray for scalars with dict coords by inferring dims from dict keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Assert broadcast test checks actual values, not NaN Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add mixed bound type combination and edge case tests Test DataArray+numpy, DataArray+scalar, DataArray+DataArray combos for lower/upper. Also test both bounds covering different dim subsets with broadcast, and that only the mismatched bound raises ValueError. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add 0-dim bound types and fix numpy_to_dataarray with dict coords Add numpy-0d and dataarray-0d to the parameterized bound type tests. Fix numpy_to_dataarray to infer dims from dict keys for 0-dim arrays, matching the scalar fix in as_dataarray. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for inferred coords, multi-dim, string/datetime coords Cover three gaps: coords inferred from bounds (no coords arg) for DataArray and pandas, multi-dimensional coord specifications with both scalar and DataArray bounds, and real-world coordinate types (string regions, datetime index) including mismatch detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add test for bounds with different dimension order Verify lower(time, space) and upper(space, time) align correctly via xarray broadcast. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Reindex DataArray bounds with reordered coordinates When a DataArray bound has the same coordinate values as coords but in a different order, reindex to match instead of raising ValueError. Still raises when the values actually differ (not just reordered). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix mypy errors: remove dead code branch, add type annotations Remove unreachable hasattr(coords, "dims") branch in _coords_to_dict (xarray Coordinates are Mappings, caught by isinstance check above). Add Any type annotations to parameterized test arguments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move TestAddVariablesBoundsWithCoords to test_variable.py Per review feedback, these tests belong in test_variable.py where they overlap with existing variable tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c6b07e5 commit 36bc106

File tree

3 files changed

+383
-0
lines changed

3 files changed

+383
-0
lines changed

linopy/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ def numpy_to_dataarray(
191191
"""
192192
# fallback case for zero dim arrays
193193
if arr.ndim == 0:
194+
if dims is None and is_dict_like(coords):
195+
dims = list(coords.keys())
194196
return DataArray(arr.item(), coords=coords, dims=dims, **kwargs)
195197

196198
if isinstance(dims, Iterable | Sequence):
@@ -242,8 +244,12 @@ def as_dataarray(
242244
elif isinstance(arr, pl.Series):
243245
arr = numpy_to_dataarray(arr.to_numpy(), coords=coords, dims=dims, **kwargs)
244246
elif isinstance(arr, np.number):
247+
if dims is None and is_dict_like(coords) and np.ndim(arr) == 0:
248+
dims = list(coords.keys())
245249
arr = DataArray(float(arr), coords=coords, dims=dims, **kwargs)
246250
elif isinstance(arr, int | float | str | bool | list):
251+
if dims is None and is_dict_like(coords) and np.ndim(arr) == 0:
252+
dims = list(coords.keys())
247253
arr = DataArray(arr, coords=coords, dims=dims, **kwargs)
248254

249255
elif not isinstance(arr, DataArray):

linopy/model.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,73 @@
100100
logger = logging.getLogger(__name__)
101101

102102

103+
def _coords_to_dict(
104+
coords: Sequence[Sequence | pd.Index | DataArray] | Mapping,
105+
) -> dict[str, Any]:
106+
"""Normalize coords to a dict mapping dim names to coordinate values."""
107+
if isinstance(coords, Mapping):
108+
return dict(coords)
109+
# Sequence of indexes
110+
result: dict[str, Any] = {}
111+
for c in coords:
112+
if isinstance(c, pd.Index) and c.name:
113+
result[c.name] = c
114+
return result
115+
116+
117+
def _validate_dataarray_bounds(arr: Any, coords: Any) -> Any:
118+
"""
119+
Validate and expand DataArray bounds against explicit coords.
120+
121+
If ``arr`` is not a DataArray, return it unchanged (``as_dataarray``
122+
will handle conversion). For DataArray inputs:
123+
124+
- Raises ``ValueError`` if the array has dimensions not in coords.
125+
- Raises ``ValueError`` if shared dimension coordinates don't match.
126+
- Expands missing dimensions via ``expand_dims``.
127+
"""
128+
if not isinstance(arr, DataArray):
129+
return arr
130+
131+
expected = _coords_to_dict(coords)
132+
if not expected:
133+
return arr
134+
135+
extra = set(arr.dims) - set(expected)
136+
if extra:
137+
raise ValueError(f"DataArray has extra dimensions not in coords: {extra}")
138+
139+
for dim, coord_values in expected.items():
140+
if dim not in arr.dims:
141+
continue
142+
if isinstance(arr.indexes.get(dim), pd.MultiIndex):
143+
continue
144+
expected_idx = (
145+
coord_values
146+
if isinstance(coord_values, pd.Index)
147+
else pd.Index(coord_values)
148+
)
149+
actual_idx = arr.coords[dim].to_index()
150+
if not actual_idx.equals(expected_idx):
151+
# Same values, different order → reindex to match expected order
152+
if len(actual_idx) == len(expected_idx) and set(actual_idx) == set(
153+
expected_idx
154+
):
155+
arr = arr.reindex({dim: expected_idx})
156+
else:
157+
raise ValueError(
158+
f"Coordinates for dimension '{dim}' do not match: "
159+
f"expected {expected_idx.tolist()}, got {actual_idx.tolist()}"
160+
)
161+
162+
# Expand missing dimensions
163+
expand = {k: v for k, v in expected.items() if k not in arr.dims}
164+
if expand:
165+
arr = arr.expand_dims(expand)
166+
167+
return arr
168+
169+
103170
class Model:
104171
"""
105172
Linear optimization model.
@@ -611,6 +678,10 @@ def add_variables(
611678
"Semi-continuous variables require a positive scalar lower bound."
612679
)
613680

681+
if coords is not None:
682+
lower = _validate_dataarray_bounds(lower, coords)
683+
upper = _validate_dataarray_bounds(upper, coords)
684+
614685
data = Dataset(
615686
{
616687
"lower": as_dataarray(lower, coords, **kwargs),

0 commit comments

Comments
 (0)