You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

linopy

Package Overview
Dependencies
Maintainers
2
Versions
64
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

linopy - pypi Package Compare versions

Comparing version
0.5.5
to
0.5.6
+86
.github/workflows/claude-code-review.yml
name: Claude Code Review
on:
pull_request:
types: [opened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Skip review for draft PRs, WIP, or explicitly skipped PRs
if: |
!contains(github.event.pull_request.title, '[skip-review]') &&
!contains(github.event.pull_request.title, '[WIP]') &&
!github.event.pull_request.draft
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Validate required secrets
run: |
if [ -z "${{ secrets.ANTHROPIC_API_KEY }}" ]; then
echo "Error: Missing required secret ANTHROPIC_API_KEY"
echo "Please add the ANTHROPIC_API_KEY secret to your repository settings"
exit 1
fi
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Direct prompt for automated review (no @claude mention needed)
direct_prompt: |
Please review this pull request for the linopy optimization library. Focus on:
- Python best practices and type safety (we use mypy for type checking)
- Proper xarray integration patterns and dimension handling
- Performance implications for large-scale optimization problems
- Mathematical correctness in solver interfaces and constraint formulations
- Memory efficiency considerations for handling large arrays
- Test coverage and edge cases
- Consistency with the existing codebase patterns, avoiding redundant code
Linopy is built on xarray and provides N-dimensional labeled arrays for variables and constraints.
Be constructive and specific in your feedback.
# Optional: Customize review based on file types
# direct_prompt: |
# Review this PR focusing on:
# - For TypeScript files: Type safety and proper interface usage
# - For API endpoints: Security, input validation, and error handling
# - For React components: Performance, accessibility, and best practices
# - For tests: Coverage, edge cases, and test quality
# Optional: Different prompts for different authors
# direct_prompt: |
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
# Project-specific tools for Python development
allowed_tools: "Bash(pytest),Bash(ruff check .),Bash(ruff format .),Bash(mypy .),Bash(uv pip install -e .[dev,solvers])"
# Optional: Skip review for certain conditions
# if: |
# !contains(github.event.pull_request.title, '[skip-review]') &&
# !contains(github.event.pull_request.title, '[WIP]')
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
# This workflow triggers when @claude is mentioned in:
# - Issue comments (on issues or PRs)
# - Pull request review comments (inline code comments)
# - Pull request reviews (general review comments)
# - New issues (in title or body)
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Validate required secrets
run: |
if [ -z "${{ secrets.ANTHROPIC_API_KEY }}" ]; then
echo "Error: Missing required secret ANTHROPIC_API_KEY"
echo "Please add the ANTHROPIC_API_KEY secret to your repository settings"
exit 1
fi
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Project-specific tools for Python development
allowed_tools: "Bash(pytest),Bash(ruff check .),Bash(ruff format .),Bash(mypy .),Bash(uv pip install -e .[dev,solvers])"
# Custom instructions for linopy project
custom_instructions: |
You are working on linopy, a optimization library built on xarray.
Follow these guidelines:
- Use type hints and ensure mypy compliance
- Follow xarray patterns for dimension handling
- Write tests for new features or bug fixes
- Use ruff for linting and formatting (run ruff check --fix .)
- Place tests in the test directory with test_*.py naming
- Maintain consistency with existing codebase patterns, avoiding redundant code
- Consider memory efficiency for large-scale optimization problems
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Running Tests
```bash
# Run all tests
pytest
# Run tests with coverage
pytest --cov=./ --cov-report=xml linopy --doctest-modules test
# Run a specific test file
pytest test/test_model.py
# Run a specific test function
pytest test/test_model.py::test_model_creation
```
### Linting and Type Checking
```bash
# Run linter (ruff)
ruff check .
ruff check --fix . # Auto-fix issues
# Run formatter
ruff format .
# Run type checker
mypy .
# Run all pre-commit hooks
pre-commit run --all-files
```
### Development Setup
```bash
# Create virtual environment and install development dependencies
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install uv
uv pip install -e .[dev,solvers]
```
## High-Level Architecture
linopy is a linear optimization library built on top of xarray, providing N-dimensional labeled arrays for variables and constraints. The architecture follows these key principles:
### Core Components
1. **Model** (`model.py`): Central container for optimization problems
- Manages variables, constraints, and objective
- Handles solver integration through abstract interfaces
- Supports chunked operations for memory efficiency
- Provides matrix representations for solver APIs
2. **Variables** (`variables.py`): Multi-dimensional decision variables
- Built on xarray.Dataset with labels, lower, and upper bounds
- Arithmetic operations automatically create LinearExpressions
- Support for continuous and binary variables
- Container class (Variables) manages collections with dict-like access
3. **Constraints** (`constraints.py`): Linear inequality/equality constraints
- Store coefficients, variable references, signs, and RHS values
- Support ≤, ≥, and = constraints
- Container class (Constraints) provides organized access
4. **Expressions** (`expressions.py`): Linear combinations of variables
- LinearExpression: coeffs × vars + const
- QuadraticExpression: for non-linear optimization
- Support full arithmetic operations with automatic broadcasting
- Special `_term` dimension for handling multiple terms
5. **Solvers** (`solvers.py`): Abstract interface with multiple implementations
- File-based solvers: Write LP/MPS files, call solver, parse results
- Direct API solvers: Use Python bindings (e.g., gurobipy)
- Automatic solver detection based on installed packages
### Data Flow Pattern
1. User creates Model and adds Variables with coordinates (dimensions)
2. Variables combined into LinearExpressions through arithmetic
3. Expressions used to create Constraints and Objective
4. Model.solve() converts to solver format and retrieves solution
5. Solution stored back in xarray format with original dimensions
### Key Design Patterns
- **xarray Integration**: All data structures use xarray for dimension handling
- **Lazy Evaluation**: Expressions built symbolically before solving
- **Broadcasting**: Operations automatically align dimensions
- **Solver Abstraction**: Clean separation between model and solver specifics
- **Memory Efficiency**: Support for dask arrays and chunked operations
When modifying the codebase, maintain consistency with these patterns and ensure new features integrate naturally with the xarray-based architecture.
## Working with the Github Repository
* The main branch is `master`.
* Always create a feature branch for new features or bug fixes.
* Use the github cli (gh) to interact with the Github repository.
### GitHub Claude Code Integration
This repository includes Claude Code GitHub Actions for automated assistance:
1. **Automated PR Reviews** (`claude-code-review.yml`):
- Automatically reviews PRs only when first created (opened)
- Subsequent reviews require manual `@claude` mention
- Focuses on Python best practices, xarray patterns, and optimization correctness
- Can run tests and linting as part of the review
- **Skip initial review by**: Adding `[skip-review]` or `[WIP]` to PR title, or using draft PRs
2. **Manual Claude Assistance** (`claude.yml`):
- Trigger by mentioning `@claude` in any:
- Issue comments
- Pull request comments
- Pull request reviews
- New issue body or title
- Claude can help with bug fixes, feature implementation, code explanations, etc.
**Note**: Both workflows require the `ANTHROPIC_API_KEY` secret to be configured in the repository settings.
## Development Guidelines
1. Always write tests for new features or bug fixes.
2. Always run the tests after making changes and ensure they pass.
3. Always use ruff for linting and formatting, run `ruff check --fix .` to auto-fix issues.
4. Use type hints and mypy for type checking.
5. Always write tests into the `test` directory, following the naming convention `test_*.py`.
6. Always write temporary and non git-tracked code in the `dev-scripts` directory.
#!/usr/bin/env python3
"""
Test infeasibility detection for different solvers.
"""
import pandas as pd
import pytest
from linopy import Model, available_solvers
class TestInfeasibility:
"""Test class for infeasibility detection functionality."""
@pytest.fixture
def simple_infeasible_model(self) -> Model:
"""Create a simple infeasible model."""
m = Model()
time = pd.RangeIndex(10, name="time")
x = m.add_variables(lower=0, coords=[time], name="x")
y = m.add_variables(lower=0, coords=[time], name="y")
# Create infeasible constraints
m.add_constraints(x <= 5, name="con_x_upper")
m.add_constraints(y <= 5, name="con_y_upper")
m.add_constraints(x + y >= 12, name="con_sum_lower")
# Add objective to avoid multi-objective issue with xpress
m.add_objective(x.sum() + y.sum())
return m
@pytest.fixture
def complex_infeasible_model(self) -> Model:
"""Create a more complex infeasible model."""
m = Model()
# Create variables
x = m.add_variables(lower=0, upper=10, name="x")
y = m.add_variables(lower=0, upper=10, name="y")
z = m.add_variables(lower=0, upper=10, name="z")
# Add conflicting constraints
m.add_constraints(x + y >= 15, name="con1")
m.add_constraints(x <= 5, name="con2")
m.add_constraints(y <= 5, name="con3")
m.add_constraints(z >= x + y, name="con4")
m.add_constraints(z <= 8, name="con5")
# Add objective
m.add_objective(x + y + z)
return m
@pytest.fixture
def multi_dimensional_infeasible_model(self) -> Model:
"""Create a multi-dimensional infeasible model."""
m = Model()
# Create multi-dimensional variables
i = pd.RangeIndex(5, name="i")
j = pd.RangeIndex(3, name="j")
x = m.add_variables(lower=0, upper=1, coords=[i, j], name="x")
# Add constraints that make it infeasible
m.add_constraints(x.sum("j") >= 2.5, name="row_sum") # Each row sum >= 2.5
m.add_constraints(x.sum("i") <= 1, name="col_sum") # Each column sum <= 1
# Add objective
m.add_objective(x.sum())
return m
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
def test_simple_infeasibility_detection(
self, simple_infeasible_model: Model, solver: str
) -> None:
"""Test basic infeasibility detection."""
if solver not in available_solvers:
pytest.skip(f"{solver} not available")
m = simple_infeasible_model
status, condition = m.solve(solver_name=solver)
assert status == "warning"
assert "infeasible" in condition
# Test compute_infeasibilities
labels = m.compute_infeasibilities()
assert isinstance(labels, list)
assert len(labels) > 0 # Should find at least one infeasible constraint
# Test print_infeasibilities (just check it doesn't raise an error)
m.print_infeasibilities()
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
def test_complex_infeasibility_detection(
self, complex_infeasible_model: Model, solver: str
) -> None:
"""Test infeasibility detection on more complex model."""
if solver not in available_solvers:
pytest.skip(f"{solver} not available")
m = complex_infeasible_model
status, condition = m.solve(solver_name=solver)
assert status == "warning"
assert "infeasible" in condition
labels = m.compute_infeasibilities()
assert isinstance(labels, list)
assert len(labels) > 0
# The infeasible set should include constraints that conflict
# Different solvers might find different minimal IIS
# We expect at least 2 constraints to be involved
assert len(labels) >= 2
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
def test_multi_dimensional_infeasibility(
self, multi_dimensional_infeasible_model: Model, solver: str
) -> None:
"""Test infeasibility detection on multi-dimensional model."""
if solver not in available_solvers:
pytest.skip(f"{solver} not available")
m = multi_dimensional_infeasible_model
status, condition = m.solve(solver_name=solver)
assert status == "warning"
assert "infeasible" in condition
labels = m.compute_infeasibilities()
assert isinstance(labels, list)
assert len(labels) > 0
def test_unsolved_model_error(self) -> None:
"""Test error when model hasn't been solved yet."""
m = Model()
x = m.add_variables(name="x")
m.add_constraints(x >= 0)
m.add_objective(1 * x) # Convert to LinearExpression
# Don't solve the model - should raise NotImplementedError for unsolved models
with pytest.raises(
NotImplementedError, match="Computing infeasibilities is not supported"
):
m.compute_infeasibilities()
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
def test_no_solver_model_error(self, solver: str) -> None:
"""Test error when solver model is not available after solving."""
if solver not in available_solvers:
pytest.skip(f"{solver} not available")
m = Model()
x = m.add_variables(name="x")
m.add_constraints(x >= 0)
m.add_objective(1 * x) # Convert to LinearExpression
# Solve the model first
m.solve(solver_name=solver)
# Manually remove the solver_model to simulate cleanup
m.solver_model = None
m.solver_name = solver # But keep the solver name
# Should raise ValueError since we know it was solved with supported solver
with pytest.raises(ValueError, match="No solver model available"):
m.compute_infeasibilities()
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
def test_feasible_model_iis(self, solver: str) -> None:
"""Test IIS computation on a feasible model."""
if solver not in available_solvers:
pytest.skip(f"{solver} not available")
m = Model()
x = m.add_variables(lower=0, name="x")
y = m.add_variables(lower=0, name="y")
m.add_constraints(x + y >= 1)
m.add_constraints(x <= 10)
m.add_constraints(y <= 10)
m.add_objective(x + y)
status, condition = m.solve(solver_name=solver)
assert status == "ok"
assert condition == "optimal"
# Calling compute_infeasibilities on a feasible model
# Different solvers might handle this differently
# Gurobi might raise an error, Xpress might return empty list
try:
labels = m.compute_infeasibilities()
# If it doesn't raise an error, it should return empty list
assert labels == []
except Exception:
# Some solvers might raise an error when computing IIS on feasible model
pass
def test_unsupported_solver_error(self) -> None:
"""Test error for unsupported solvers."""
m = Model()
x = m.add_variables(name="x")
m.add_constraints(x >= 0)
m.add_constraints(x <= -1) # Make it infeasible
# Use a solver that doesn't support IIS
if "cbc" in available_solvers:
status, condition = m.solve(solver_name="cbc")
assert "infeasible" in condition
with pytest.raises(NotImplementedError):
m.compute_infeasibilities()
@pytest.mark.parametrize("solver", ["gurobi", "xpress"])
def test_deprecated_method(
self, simple_infeasible_model: Model, solver: str
) -> None:
"""Test that deprecated method still works."""
if solver not in available_solvers:
pytest.skip(f"{solver} not available")
m = simple_infeasible_model
status, condition = m.solve(solver_name=solver)
assert status == "warning"
assert "infeasible" in condition
# Test deprecated method
with pytest.warns(DeprecationWarning):
subset = m.compute_set_of_infeasible_constraints()
# Check that it returns a Dataset
from xarray import Dataset
assert isinstance(subset, Dataset)
# Check that it contains constraint labels
assert len(subset) > 0
import xarray as xr
import linopy
def test_operations_with_data_arrays_are_typed_correctly() -> None:
m = linopy.Model()
a: xr.DataArray = xr.DataArray([1, 2, 3])
v: linopy.Variable = m.add_variables(lower=0.0, name="v")
e: linopy.LinearExpression = v * 1.0
q = v * v
_ = a * v
_ = v * a
_ = v + a
_ = a * e
_ = e * a
_ = e + a
_ = a * q
_ = q * a
_ = q + a
+7
-0
.coverage
.eggs
.DS_Store
.mypy_cache
linopy/__pycache__

@@ -39,1 +40,7 @@ test/__pycache__

benchmark/scripts/leftovers/
# IDE
.idea/
# Claude
.claude/settings.local.json
+2
-2

@@ -12,3 +12,3 @@ ci:

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.8
rev: v0.12.2
hooks:

@@ -25,3 +25,3 @@ - id: ruff

- repo: https://github.com/keewis/blackdoc
rev: v0.3.9
rev: v0.4.1
hooks:

@@ -28,0 +28,0 @@ - id: blackdoc

@@ -7,2 +7,12 @@ Release Notes

Version 0.5.6
--------------
* Improved variable/expression arithmetic methods so that they correctly handle types
* Gurobi: Pass dictionary as env argument `env={...}` through to gurobi env creation
**Breaking Changes**
* With this release, the package support for Python 3.9 was dropped and support for Python 3.10 was officially added.
Version 0.5.5

@@ -13,3 +23,2 @@ --------------

Version 0.5.4

@@ -16,0 +25,0 @@ --------------

Metadata-Version: 2.4
Name: linopy
Version: 0.5.5
Version: 0.5.6
Summary: Linear optimization with N-D labeled arrays in Python

@@ -8,6 +8,6 @@ Author-email: Fabian Hofmann <fabianmarikhofmann@gmail.com>

Project-URL: Source, https://github.com/PyPSA/linopy
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Development Status :: 3 - Alpha

@@ -20,3 +20,3 @@ Classifier: Environment :: Console

Classifier: Operating System :: OS Independent
Requires-Python: >=3.9
Requires-Python: >=3.10
Description-Content-Type: text/markdown

@@ -135,5 +135,5 @@ License-File: LICENSE.txt

>>> days = pd.Index(['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], name='day')
>>> apples = m.add_variables(lower=0, name='apples', coords=[days])
>>> bananas = m.add_variables(lower=0, name='bananas', coords=[days])
>>> days = pd.Index(["Mon", "Tue", "Wed", "Thu", "Fri"], name="day")
>>> apples = m.add_variables(lower=0, name="apples", coords=[days])
>>> bananas = m.add_variables(lower=0, name="bananas", coords=[days])
>>> apples

@@ -154,3 +154,3 @@ ```

```python
>>> m.add_constraints(3 * apples + 2 * bananas >= 8, name='daily_vitamins')
>>> m.add_constraints(3 * apples + 2 * bananas >= 8, name="daily_vitamins")
```

@@ -170,3 +170,3 @@ ```

```python
>>> m.add_constraints((3 * apples + 2 * bananas).sum() >= 50, name='weekly_vitamins')
>>> m.add_constraints((3 * apples + 2 * bananas).sum() >= 50, name="weekly_vitamins")
```

@@ -173,0 +173,0 @@ ```

@@ -5,2 +5,3 @@ .git-blame-ignore-revs

.readthedocs.yaml
CLAUDE.md
CONTRIBUTING.md

@@ -16,2 +17,4 @@ LICENSE.txt

.github/ISSUE_TEMPLATE/feature_request.md
.github/workflows/claude-code-review.yml
.github/workflows/claude.yml
.github/workflows/codeql.yml

@@ -146,2 +149,3 @@ .github/workflows/release.yml

test/test_inconsistency_checks.py
test/test_infeasibility.py
test/test_io.py

@@ -159,4 +163,5 @@ test/test_linear_expression.py

test/test_solvers.py
test/test_typing.py
test/test_variable.py
test/test_variable_assignment.py
test/test_variables.py

@@ -12,6 +12,6 @@ #!/usr/bin/env python3

import os
from collections.abc import Generator, Hashable, Iterable, Sequence
from collections.abc import Callable, Generator, Hashable, Iterable, Sequence
from functools import partial, reduce, wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, overload
from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
from warnings import warn

@@ -41,3 +41,3 @@

from linopy.constraints import Constraint
from linopy.expressions import LinearExpression
from linopy.expressions import LinearExpression, QuadraticExpression
from linopy.variables import Variable

@@ -122,3 +122,3 @@

return None
if isinstance(lst, (Sequence, Iterable)):
if isinstance(lst, Sequence | Iterable):
lst = list(lst)

@@ -216,3 +216,3 @@ else:

ndim = max(arr.ndim, 0 if coords is None else len(coords))
if isinstance(dims, (Iterable, Sequence)):
if isinstance(dims, Iterable | Sequence):
dims = list(dims)

@@ -257,7 +257,7 @@ elif dims is not None:

"""
if isinstance(arr, (pd.Series, pd.DataFrame)):
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, (np.number, int, float, str, bool, list)):
elif isinstance(arr, np.number | int | float | str | bool | list):
arr = DataArray(arr, coords=coords, dims=dims, **kwargs)

@@ -501,3 +501,3 @@

ds = ds.copy()
if not isinstance(ds, (Dataset, DataArray)):
if not isinstance(ds, Dataset | DataArray):
raise TypeError(f"Expected xarray.DataArray or xarray.Dataset, got {type(ds)}.")

@@ -816,3 +816,3 @@

for value in values:
if isinstance(value, (list, tuple)):
if isinstance(value, list | tuple):
formatted.append(f"({', '.join(str(x) for x in value)})")

@@ -956,7 +956,5 @@ else:

arg,
(
variables.Variable,
variables.ScalarVariable,
expressions.LinearExpression,
),
variables.Variable
| variables.ScalarVariable
| expressions.LinearExpression,
):

@@ -1005,3 +1003,3 @@ raise TypeError(f"Assigned rhs must be a constant, got {type(arg)}).")

def align(
*objects: LinearExpression | Variable | T_Alignable,
*objects: LinearExpression | QuadraticExpression | Variable | T_Alignable,
join: JoinOptions = "inner",

@@ -1012,3 +1010,3 @@ copy: bool = True,

fill_value: Any = dtypes.NA,
) -> tuple[LinearExpression | Variable | T_Alignable, ...]:
) -> tuple[LinearExpression | QuadraticExpression | Variable | T_Alignable, ...]:
"""

@@ -1068,3 +1066,3 @@ Given any number of Variables, Expressions, Dataset and/or DataArray objects,

"""
from linopy.expressions import LinearExpression
from linopy.expressions import LinearExpression, QuadraticExpression
from linopy.variables import Variable

@@ -1075,3 +1073,3 @@

for obj in objects:
if isinstance(obj, LinearExpression):
if isinstance(obj, LinearExpression | QuadraticExpression):
finisher.append(partial(obj.__class__, model=obj.model))

@@ -1105,3 +1103,10 @@ das.append(obj.data)

LocT = TypeVar("LocT", "Dataset", "Variable", "LinearExpression", "Constraint")
LocT = TypeVar(
"LocT",
"Dataset",
"Variable",
"LinearExpression",
"QuadraticExpression",
"Constraint",
)

@@ -1108,0 +1113,0 @@

@@ -109,2 +109,3 @@ #!/usr/bin/env python3

suboptimal = "suboptimal"
imprecise = "imprecise"

@@ -145,2 +146,3 @@ # WARNING

TerminationCondition.suboptimal,
TerminationCondition.imprecise,
],

@@ -174,3 +176,3 @@ SolverStatus.warning: [

termination_condition: TerminationCondition
legacy_status: Union[tuple[str, str], str] = ""
legacy_status: tuple[str, str] | str = ""

@@ -220,3 +222,3 @@ @classmethod

status: Status
solution: Union[Solution, None] = None
solution: Solution | None = None
solver_model: Any = None

@@ -223,0 +225,0 @@

@@ -943,3 +943,3 @@ """

"""
if not isinstance(label, (float, int)) or label < 0:
if not isinstance(label, float | int) or label < 0:
raise ValueError("Label must be a positive number.")

@@ -1088,3 +1088,3 @@ for name, ds in self.items():

"""
if not isinstance(rhs, (int, float, np.floating, np.integer)):
if not isinstance(rhs, int | float | np.floating | np.integer):
raise TypeError(f"Assigned rhs must be a constant, got {type(rhs)}).")

@@ -1091,0 +1091,0 @@ self._lhs = lhs

@@ -11,7 +11,7 @@ #!/usr/bin/env python3

import time
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from io import BufferedWriter, TextIOWrapper
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Any, Callable
from typing import TYPE_CHECKING, Any

@@ -18,0 +18,0 @@ import numpy as np

@@ -35,3 +35,3 @@ #!/usr/bin/env python3

max_value = indices.max()
if not isinstance(max_value, (np.integer, int)):
if not isinstance(max_value, np.integer | int):
raise ValueError("Indices must be integers.")

@@ -38,0 +38,0 @@ shape = max_value + 1

@@ -15,3 +15,3 @@ """

from tempfile import NamedTemporaryFile, gettempdir
from typing import Any
from typing import Any, overload

@@ -321,3 +321,3 @@ import numpy as np

def solver_dir(self, value: str | Path) -> None:
if not isinstance(value, (str, Path)):
if not isinstance(value, str | Path):
raise TypeError("'solver_dir' must path-like.")

@@ -619,3 +619,3 @@ self._solver_dir = Path(value)

data = lhs.to_constraint(sign, rhs).data
elif isinstance(lhs, (list, tuple)):
elif isinstance(lhs, list | tuple):
if sign is None or rhs is None:

@@ -639,3 +639,3 @@ raise ValueError(msg_sign_rhs_none)

data = lhs.data
elif isinstance(lhs, (Variable, ScalarVariable, ScalarLinearExpression)):
elif isinstance(lhs, Variable | ScalarVariable | ScalarLinearExpression):
if sign is None or rhs is None:

@@ -717,3 +717,3 @@ raise ValueError(msg_sign_rhs_not_none)

)
self.objective.expression = expr # type: ignore[assignment]
self.objective.expression = expr
self.objective.sense = sense

@@ -889,4 +889,19 @@

@overload
def linexpr(
self, *args: tuple[ConstantLike, str | Variable | ScalarVariable] | Callable
self, *args: Sequence[Sequence | pd.Index | DataArray] | Mapping
) -> LinearExpression: ...
@overload
def linexpr(
self, *args: tuple[ConstantLike, str | Variable | ScalarVariable] | ConstantLike
) -> LinearExpression: ...
def linexpr(
self,
*args: tuple[ConstantLike, str | Variable | ScalarVariable]
| ConstantLike
| Callable
| Sequence[Sequence | pd.Index | DataArray]
| Mapping,
) -> LinearExpression:

@@ -898,5 +913,6 @@ """

----------
args : tuples of (coefficients, variables) or tuples of
coordinates and a function
If args is a collection of coefficients-variables-tuples, the resulting
args : A mixture of tuples of (coefficients, variables) and constants
or a function and tuples of coordinates
If args is a collection of coefficients-variables-tuples and constants, the resulting
linear expression is built with the function LinearExpression.from_tuples.

@@ -910,2 +926,4 @@ * coefficients : int/float/array_like

by name.
* constant: int/float/array_like
The constant value to add to the expression

@@ -921,3 +939,3 @@ If args is a collection of coordinates with an appended function at the

the variables. The function has to return a
`ScalarLinearExpression`. Therefore use the direct getter when
`ScalarLinearExpression`. Therefore, use the direct getter when
indexing variables.

@@ -969,5 +987,10 @@ * coords : coordinate-like

raise TypeError(f"Not supported type {args}.")
tuples = [ # type: ignore
(c, self.variables[v]) if isinstance(v, str) else (c, v) for (c, v) in args
]
tuples: list[tuple[ConstantLike, VariableLike] | ConstantLike] = []
for arg in args:
if isinstance(arg, tuple):
c, v = arg
tuples.append((c, self.variables[v]) if isinstance(v, str) else (c, v))
else:
tuples.append(arg)
return LinearExpression.from_tuples(*tuples, model=self)

@@ -1273,4 +1296,5 @@

This function requires that the model was solved with `gurobi` and the
termination condition was infeasible.
This function requires that the model was solved with `gurobi` or `xpress`
and the termination condition was infeasible. The solver must have detected
the infeasibility during the solve process.

@@ -1282,12 +1306,52 @@ Returns

"""
if "gurobi" not in available_solvers:
raise ImportError("Gurobi is required for this method.")
solver_model = getattr(self, "solver_model", None)
import gurobipy
# Check for Gurobi
if "gurobi" in available_solvers:
try:
import gurobipy
solver_model = getattr(self, "solver_model")
if solver_model is not None and isinstance(
solver_model, gurobipy.Model
):
return self._compute_infeasibilities_gurobi(solver_model)
except ImportError:
pass
if not isinstance(solver_model, gurobipy.Model):
raise NotImplementedError("Solver model must be a Gurobi Model.")
# Check for Xpress
if "xpress" in available_solvers:
try:
import xpress
if solver_model is not None and isinstance(
solver_model, xpress.problem
):
return self._compute_infeasibilities_xpress(solver_model)
except ImportError:
pass
# If we get here, either the solver doesn't support IIS or no solver model is available
if solver_model is None:
# Check if this is a supported solver without a stored model
solver_name = getattr(self, "solver_name", "unknown")
if solver_name in ["gurobi", "xpress"]:
raise ValueError(
"No solver model available. The model must be solved first with "
"'gurobi' or 'xpress' solver and the result must be infeasible."
)
else:
# This is an unsupported solver
raise NotImplementedError(
f"Computing infeasibilities is not supported for '{solver_name}' solver. "
"Only Gurobi and Xpress solvers support IIS computation."
)
else:
# We have a solver model but it's not a supported type
raise NotImplementedError(
"Computing infeasibilities is only supported for Gurobi and Xpress solvers. "
f"Current solver model type: {type(solver_model).__name__}"
)
def _compute_infeasibilities_gurobi(self, solver_model: Any) -> list[int]:
"""Compute infeasibilities for Gurobi solver."""
solver_model.computeIIS()

@@ -1307,4 +1371,77 @@ f = NamedTemporaryFile(suffix=".ilp", prefix="linopy-iis-", delete=False)

labels.append(int(match.group(1)))
f.close()
return labels
def _compute_infeasibilities_xpress(self, solver_model: Any) -> list[int]:
"""Compute infeasibilities for Xpress solver."""
# Compute all IIS
solver_model.iisall()
# Get the number of IIS found
num_iis = solver_model.attributes.numiis
if num_iis == 0:
return []
labels = set()
# Create constraint mapping for efficient lookups
constraint_to_index = {
constraint: idx
for idx, constraint in enumerate(solver_model.getConstraint())
}
# Retrieve each IIS
for iis_num in range(1, num_iis + 1):
iis_constraints = self._extract_iis_constraints(solver_model, iis_num)
# Convert constraint objects to indices
for constraint_obj in iis_constraints:
if constraint_obj in constraint_to_index:
labels.add(constraint_to_index[constraint_obj])
# Note: Silently skip constraints not found in mapping
# This can happen if the model structure changed after solving
return sorted(list(labels))
def _extract_iis_constraints(self, solver_model: Any, iis_num: int) -> list[Any]:
"""
Extract constraint objects from a specific IIS.
Parameters
----------
solver_model : xpress.problem
The Xpress solver model
iis_num : int
IIS number (1-indexed)
Returns
-------
list[Any]
List of xpress.constraint objects in the IIS
"""
# Prepare lists to receive IIS data
miisrow: list[Any] = [] # xpress.constraint objects in the IIS
miiscol: list[Any] = [] # xpress.variable objects in the IIS
constrainttype: list[str] = [] # Constraint types ('L', 'G', 'E')
colbndtype: list[str] = [] # Column bound types
duals: list[float] = [] # Dual values
rdcs: list[float] = [] # Reduced costs
isolationrows: list[str] = [] # Row isolation info
isolationcols: list[str] = [] # Column isolation info
# Get IIS data from Xpress
solver_model.getiisdata(
iis_num,
miisrow,
miiscol,
constrainttype,
colbndtype,
duals,
rdcs,
isolationrows,
isolationcols,
)
return miisrow
def print_infeasibilities(self, display_max_terms: int | None = None) -> None:

@@ -1314,3 +1451,3 @@ """

This function requires that the model was solved using `gurobi`
This function requires that the model was solved using `gurobi` or `xpress`
and the termination condition was infeasible.

@@ -1340,3 +1477,3 @@

This function requires that the model was solved with `gurobi` and the
This function requires that the model was solved with `gurobi` or `xpress` and the
termination condition was infeasible.

@@ -1343,0 +1480,0 @@

from __future__ import annotations
from collections.abc import Callable
from functools import partialmethod, update_wrapper
from typing import Any, Callable
from types import NotImplementedType
from typing import Any

@@ -9,3 +11,2 @@ from xarray import DataArray

from linopy import expressions, variables
from linopy.types import NotImplementedType

@@ -30,4 +31,9 @@

) -> DataArray | NotImplementedType:
if isinstance(other, (variables.Variable, expressions.LinearExpression)):
if isinstance(
other,
variables.Variable
| expressions.LinearExpression
| expressions.QuadraticExpression,
):
return NotImplemented
return unpatched_method(da, other)

@@ -11,4 +11,4 @@ #!/usr/bin/env python3

import functools
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Callable
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any

@@ -179,7 +179,7 @@ import numpy as np

"""
if isinstance(expr, (list, tuple)):
if isinstance(expr, list | tuple):
expr = self.model.linexpr(*expr)
if not isinstance(
expr, (expressions.LinearExpression, expressions.QuadraticExpression)
expr, expressions.LinearExpression | expressions.QuadraticExpression
):

@@ -268,3 +268,3 @@ raise ValueError(

# only allow scalar multiplication
if not isinstance(expr, (int, float, np.floating, np.integer)):
if not isinstance(expr, int | float | np.floating | np.integer):
raise ValueError("Invalid type for multiplication.")

@@ -278,4 +278,4 @@ return Objective(self.expression * expr, self.model, self.sense)

# only allow scalar division
if not isinstance(expr, (int, float, np.floating, np.integer)):
if not isinstance(expr, int | float | np.floating | np.integer):
raise ValueError("Invalid type for division.")
return Objective(self.expression / expr, self.model, self.sense)

@@ -10,4 +10,5 @@ #!/usr/bin/env python3

import tempfile
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Union
from typing import TYPE_CHECKING, Any, Union

@@ -120,4 +121,4 @@ from linopy.io import read_netcdf

port: int = 22
username: Union[str, None] = None
password: Union[str, None] = None
username: str | None = None
password: str | None = None
client: Union["paramiko.SSHClient", None] = None

@@ -124,0 +125,0 @@

@@ -16,9 +16,15 @@ from __future__ import annotations

def assert_linequal(a: LinearExpression, b: LinearExpression) -> None:
def assert_linequal(
a: LinearExpression | QuadraticExpression, b: LinearExpression | QuadraticExpression
) -> None:
"""Assert that two linear expressions are equal."""
assert isinstance(a, LinearExpression)
assert isinstance(b, LinearExpression)
return assert_equal(_expr_unwrap(a), _expr_unwrap(b))
def assert_quadequal(a: QuadraticExpression, b: QuadraticExpression) -> None:
"""Assert that two linear expressions are equal."""
def assert_quadequal(
a: LinearExpression | QuadraticExpression, b: LinearExpression | QuadraticExpression
) -> None:
"""Assert that two quadratic or linear expressions are equal."""
return assert_equal(_expr_unwrap(a), _expr_unwrap(b))

@@ -25,0 +31,0 @@

from __future__ import annotations
import sys
from collections.abc import Hashable, Iterable, Mapping, Sequence

@@ -14,8 +13,2 @@ from pathlib import Path

if sys.version_info >= (3, 10):
from types import EllipsisType, NotImplementedType
else:
EllipsisType = type(Ellipsis)
NotImplementedType = type(NotImplemented)
if TYPE_CHECKING:

@@ -31,4 +24,4 @@ from linopy.constraints import AnonymousScalarConstraint, Constraint

# Type aliases using Union for Python 3.9 compatibility
CoordsLike = Union[
Sequence[Union[Sequence, Index, DataArray]],
CoordsLike = Union[ # noqa: UP007
Sequence[Sequence | Index | DataArray],
Mapping,

@@ -38,5 +31,5 @@ DataArrayCoordinates,

]
DimsLike = Union[str, Iterable[Hashable]]
DimsLike = Union[str, Iterable[Hashable]] # noqa: UP007
ConstantLike = Union[
ConstantLike = Union[ # noqa: UP007
int,

@@ -51,3 +44,3 @@ float,

]
SignLike = Union[str, numpy.ndarray, DataArray, Series, DataFrame]
SignLike = Union[str, numpy.ndarray, DataArray, Series, DataFrame] # noqa: UP007
VariableLike = Union["ScalarVariable", "Variable"]

@@ -60,4 +53,4 @@ ExpressionLike = Union[

ConstraintLike = Union["Constraint", "AnonymousScalarConstraint"]
MaskLike = Union[numpy.ndarray, DataArray, Series, DataFrame]
SideLike = Union[ConstantLike, VariableLike, ExpressionLike]
PathLike = Union[str, Path]
MaskLike = Union[numpy.ndarray, DataArray, Series, DataFrame] # noqa: UP007
SideLike = Union[ConstantLike, VariableLike, ExpressionLike] # noqa: UP007
PathLike = Union[str, Path] # noqa: UP007

@@ -13,2 +13,3 @@ """

from dataclasses import dataclass
from types import NotImplementedType
from typing import (

@@ -55,3 +56,9 @@ TYPE_CHECKING,

from linopy.constants import HELPER_DIMS, TERM_DIM
from linopy.types import ConstantLike, DimsLike, NotImplementedType, SideLike
from linopy.types import (
ConstantLike,
DimsLike,
ExpressionLike,
SideLike,
VariableLike,
)

@@ -61,2 +68,3 @@ if TYPE_CHECKING:

from linopy.expressions import (
GenericExpression,
LinearExpression,

@@ -387,12 +395,14 @@ LinearExpressionGroupby,

def __mul__(
self, other: float | int | ndarray | Variable
) -> LinearExpression | QuadraticExpression:
@overload
def __mul__(self, other: ConstantLike) -> LinearExpression: ...
@overload
def __mul__(self, other: ExpressionLike | VariableLike) -> QuadraticExpression: ...
def __mul__(self, other: SideLike) -> ExpressionLike:
"""
Multiply variables with a coefficient.
Multiply variables with a coefficient, variable, or expression.
"""
try:
if isinstance(
other, (expressions.LinearExpression, Variable, ScalarVariable)
):
if isinstance(other, Variable | ScalarVariable):
return self.to_linexpr() * other

@@ -404,2 +414,11 @@

def __rmul__(self, other: ConstantLike) -> LinearExpression:
"""
Right-multiply variables by a constant
"""
try:
return self * other
except TypeError:
return NotImplemented
def __pow__(self, other: int) -> QuadraticExpression:

@@ -409,19 +428,20 @@ """

"""
if isinstance(other, int) and other == 2:
if not isinstance(other, int):
return NotImplemented
if other == 2:
expr = self.to_linexpr()
return expr._multiply_by_linear_expression(expr)
return NotImplemented
raise ValueError("Can only raise to the power of 2")
def __rmul__(self, other: float | DataArray | int | ndarray) -> LinearExpression:
"""
Right-multiply variables with a coefficient.
"""
try:
return self.to_linexpr(other)
except TypeError:
return NotImplemented
@overload
def __matmul__(self, other: ConstantLike) -> LinearExpression: ...
@overload
def __matmul__(
self, other: LinearExpression | ndarray | Variable
) -> QuadraticExpression | LinearExpression:
self, other: VariableLike | ExpressionLike
) -> QuadraticExpression: ...
def __matmul__(
self, other: ConstantLike | VariableLike | ExpressionLike
) -> LinearExpression | QuadraticExpression:
"""

@@ -438,3 +458,3 @@ Matrix multiplication of variables with a coefficient.

"""
if isinstance(other, (expressions.LinearExpression, Variable)):
if isinstance(other, expressions.LinearExpression | Variable):
raise TypeError(

@@ -458,5 +478,14 @@ "unsupported operand type(s) for /: "

@overload
def __add__(
self, other: int | QuadraticExpression | LinearExpression | Variable
) -> QuadraticExpression | LinearExpression:
self, other: ConstantLike | Variable | ScalarLinearExpression
) -> LinearExpression: ...
@overload
def __add__(self, other: GenericExpression) -> GenericExpression: ...
def __add__(
self,
other: ConstantLike | Variable | ScalarLinearExpression | GenericExpression,
) -> LinearExpression | GenericExpression:
"""

@@ -470,9 +499,20 @@ Add variables to linear expressions or other variables.

def __radd__(self, other: int) -> Variable | NotImplementedType:
# This is needed for using python's sum function
return self if other == 0 else NotImplemented
def __radd__(self, other: ConstantLike) -> LinearExpression:
try:
return self + other
except TypeError:
return NotImplemented
@overload
def __sub__(
self, other: QuadraticExpression | LinearExpression | Variable
) -> QuadraticExpression | LinearExpression:
self, other: ConstantLike | Variable | ScalarLinearExpression
) -> LinearExpression: ...
@overload
def __sub__(self, other: GenericExpression) -> GenericExpression: ...
def __sub__(
self,
other: ConstantLike | Variable | ScalarLinearExpression | GenericExpression,
) -> LinearExpression | GenericExpression:
"""

@@ -486,2 +526,11 @@ Subtract linear expressions or other variables from the variables.

def __rsub__(self, other: ConstantLike) -> LinearExpression:
"""
Subtract linear expressions or other variables from the variables.
"""
try:
return self.to_linexpr(-1) + other
except TypeError:
return NotImplemented
def __le__(self, other: SideLike) -> Constraint:

@@ -719,15 +768,3 @@ return self.to_linexpr().__le__(other)

@classmethod # type: ignore
@property
def fill_value(cls) -> dict[str, Any]:
"""
Return the fill value of the variable.
"""
warn(
"The `.fill_value` attribute is deprecated, use linopy.variables.FILL_VALUE instead.",
DeprecationWarning,
)
return cls._fill_value
@property
def mask(self) -> DataArray:

@@ -993,3 +1030,3 @@ """

_other = {"labels": other.label, "lower": other.lower, "upper": other.upper}
elif isinstance(other, (dict, Dataset)):
elif isinstance(other, dict | Dataset):
_other = other

@@ -1398,3 +1435,3 @@ else:

"""
if not isinstance(label, (float, int, np.integer)) or label < 0:
if not isinstance(label, float | int | np.integer) or label < 0:
raise ValueError("Label must be a positive number.")

@@ -1483,4 +1520,3 @@ for name, labels in self.labels.items():

In contrast to the Variable class, a ScalarVariable only contains
only one label. Use this class to create a expression or constraint
In contrast to the Variable class, a ScalarVariable only contains one label. Use this class to create a expression or constraint
in a rule.

@@ -1533,7 +1569,7 @@ """

def to_scalar_linexpr(self, coeff: int | float = 1) -> ScalarLinearExpression:
if not isinstance(coeff, (int, np.integer, float)):
if not isinstance(coeff, int | np.integer | float):
raise TypeError(f"Coefficient must be a numeric value, got {type(coeff)}.")
return expressions.ScalarLinearExpression((coeff,), (self.label,), self.model)
def to_linexpr(self, coeff: int = 1) -> LinearExpression:
def to_linexpr(self, coeff: int | float = 1) -> LinearExpression:
return self.to_scalar_linexpr(coeff).to_linexpr()

@@ -1558,2 +1594,4 @@

def __rmul__(self, coeff: int | float) -> ScalarLinearExpression:
if isinstance(coeff, Variable | ScalarVariable):
return NotImplemented
return self.to_scalar_linexpr(coeff)

@@ -1560,0 +1598,0 @@

# file generated by setuptools-scm
# don't change, don't track in version control
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
__all__ = [
"__version__",
"__version_tuple__",
"version",
"version_tuple",
"__commit_id__",
"commit_id",
]

@@ -12,4 +19,6 @@ TYPE_CHECKING = False

VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object

@@ -20,4 +29,8 @@ version: str

version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '0.5.5'
__version_tuple__ = version_tuple = (0, 5, 5)
__version__ = version = '0.5.6'
__version_tuple__ = version_tuple = (0, 5, 6)
__commit_id__ = commit_id = 'ge01cfed78'
Metadata-Version: 2.4
Name: linopy
Version: 0.5.5
Version: 0.5.6
Summary: Linear optimization with N-D labeled arrays in Python

@@ -8,6 +8,6 @@ Author-email: Fabian Hofmann <fabianmarikhofmann@gmail.com>

Project-URL: Source, https://github.com/PyPSA/linopy
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Development Status :: 3 - Alpha

@@ -20,3 +20,3 @@ Classifier: Environment :: Console

Classifier: Operating System :: OS Independent
Requires-Python: >=3.9
Requires-Python: >=3.10
Description-Content-Type: text/markdown

@@ -135,5 +135,5 @@ License-File: LICENSE.txt

>>> days = pd.Index(['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], name='day')
>>> apples = m.add_variables(lower=0, name='apples', coords=[days])
>>> bananas = m.add_variables(lower=0, name='bananas', coords=[days])
>>> days = pd.Index(["Mon", "Tue", "Wed", "Thu", "Fri"], name="day")
>>> apples = m.add_variables(lower=0, name="apples", coords=[days])
>>> bananas = m.add_variables(lower=0, name="bananas", coords=[days])
>>> apples

@@ -154,3 +154,3 @@ ```

```python
>>> m.add_constraints(3 * apples + 2 * bananas >= 8, name='daily_vitamins')
>>> m.add_constraints(3 * apples + 2 * bananas >= 8, name="daily_vitamins")
```

@@ -170,3 +170,3 @@ ```

```python
>>> m.add_constraints((3 * apples + 2 * bananas).sum() >= 50, name='weekly_vitamins')
>>> m.add_constraints((3 * apples + 2 * bananas).sum() >= 50, name="weekly_vitamins")
```

@@ -173,0 +173,0 @@ ```

@@ -13,6 +13,6 @@ [build-system]

classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Development Status :: 3 - Alpha",

@@ -27,3 +27,3 @@ "Environment :: Console",

requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [

@@ -30,0 +30,0 @@ "numpy; python_version > '3.10'",

@@ -68,5 +68,5 @@ # linopy: Optimization with array-like variables and constraints

>>> days = pd.Index(['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], name='day')
>>> apples = m.add_variables(lower=0, name='apples', coords=[days])
>>> bananas = m.add_variables(lower=0, name='bananas', coords=[days])
>>> days = pd.Index(["Mon", "Tue", "Wed", "Thu", "Fri"], name="day")
>>> apples = m.add_variables(lower=0, name="apples", coords=[days])
>>> bananas = m.add_variables(lower=0, name="bananas", coords=[days])
>>> apples

@@ -87,3 +87,3 @@ ```

```python
>>> m.add_constraints(3 * apples + 2 * bananas >= 8, name='daily_vitamins')
>>> m.add_constraints(3 * apples + 2 * bananas >= 8, name="daily_vitamins")
```

@@ -103,3 +103,3 @@ ```

```python
>>> m.add_constraints((3 * apples + 2 * bananas).sum() >= 50, name='weekly_vitamins')
>>> m.add_constraints((3 * apples + 2 * bananas).sum() >= 50, name="weekly_vitamins")
```

@@ -106,0 +106,0 @@ ```

@@ -7,5 +7,6 @@ from typing import Any

import xarray as xr
from xarray.testing import assert_equal
from linopy import LESS_EQUAL, Model, Variable
from linopy.testing import assert_linequal
from linopy.testing import assert_linequal, assert_quadequal

@@ -98,13 +99,14 @@

other_datatype = SomeOtherDatatype(data.copy())
assert_linequal(x + data, x + other_datatype) # type: ignore
assert_linequal(x - data, x - other_datatype) # type: ignore
assert_linequal(x * data, x * other_datatype) # type: ignore
assert_linequal(x + data, x + other_datatype)
assert_linequal(x - data, x - other_datatype)
assert_linequal(x * data, x * other_datatype)
assert_linequal(x / data, x / other_datatype) # type: ignore
assert_linequal(data * x, other_datatype * x) # type: ignore
assert x.__add__(object()) is NotImplemented # type: ignore
assert x.__sub__(object()) is NotImplemented # type: ignore
assert x.__mul__(object()) is NotImplemented # type: ignore
assert x.__add__(object()) is NotImplemented
assert x.__sub__(object()) is NotImplemented
assert x.__mul__(object()) is NotImplemented
assert x.__truediv__(object()) is NotImplemented # type: ignore
assert x.__pow__(object()) is NotImplemented # type: ignore
assert x.__pow__(3) is NotImplemented
with pytest.raises(ValueError):
x.__pow__(3)

@@ -128,2 +130,13 @@

def test_arithmetric_operations_vars_and_expr(m: Model) -> None:
x = m.variables["x"]
x_expr = x * 1.0
assert_quadequal(x**2, x_expr**2)
assert_quadequal(x**2 + x, x + x**2)
assert_quadequal(x**2 * 2, x**2 * 2)
with pytest.raises(TypeError):
_ = x**2 * x
def test_arithmetric_operations_con(m: Model) -> None:

@@ -139,5 +152,6 @@ c = m.constraints["c"]

assert_linequal(c.lhs / data, c.lhs / other_datatype)
assert_linequal(c.rhs + data, c.rhs + other_datatype) # type: ignore
assert_linequal(c.rhs - data, c.rhs - other_datatype) # type: ignore
assert_linequal(c.rhs * data, c.rhs * other_datatype) # type: ignore
assert_linequal(c.rhs / data, c.rhs / other_datatype) # type: ignore
assert_equal(c.rhs + data, c.rhs + other_datatype)
assert_equal(c.rhs - data, c.rhs - other_datatype)
assert_equal(c.rhs * data, c.rhs * other_datatype)
assert_equal(c.rhs / data, c.rhs / other_datatype)

@@ -313,3 +313,3 @@ #!/usr/bin/env python3

) -> None:
c.vars = x # type: ignore
c.vars = x
assert_equal(c.vars, x.labels)

@@ -333,3 +333,3 @@

def test_constraint_coeffs_setter(c: linopy.constraints.Constraint) -> None:
c.coeffs = 3 # type: ignore
c.coeffs = 3
assert (c.coeffs == 3).all()

@@ -350,3 +350,3 @@

) -> None:
c.lhs = x # type: ignore
c.lhs = x
assert c.lhs.nterm == 1

@@ -357,3 +357,3 @@

sizes = c.sizes
c.lhs = 10 # type: ignore
c.lhs = 10
assert (c.rhs == -10).all()

@@ -365,3 +365,3 @@ assert c.lhs.nterm == 0

def test_constraint_sign_setter(c: linopy.constraints.Constraint) -> None:
c.sign = EQUAL # type: ignore
c.sign = EQUAL
assert (c.sign == EQUAL).all()

@@ -371,3 +371,3 @@

def test_constraint_sign_setter_alternative(c: linopy.constraints.Constraint) -> None:
c.sign = long_EQUAL # type: ignore
c.sign = long_EQUAL
assert (c.sign == EQUAL).all()

@@ -379,3 +379,3 @@

with pytest.raises(ValueError):
c.sign = "asd" # type: ignore
c.sign = "asd"

@@ -402,3 +402,3 @@

) -> None:
c.rhs = x + y # type: ignore
c.rhs = x + y
assert (c.rhs == 0).all()

@@ -412,3 +412,3 @@ assert (c.coeffs.isel({c.term_dim: -1}) == -1).all()

) -> None:
c.rhs = x + 1 # type: ignore
c.rhs = x + 1
assert (c.rhs == 1).all()

@@ -415,0 +415,0 @@ assert (c.coeffs.sum(c.term_dim) == 0).all()

@@ -10,3 +10,2 @@ #!/usr/bin/env python3

from pathlib import Path
from typing import Union

@@ -169,3 +168,3 @@ import pandas as pd

fn: Union[str, None] = None
fn: str | None = None
model.to_file(fn)

@@ -172,0 +171,0 @@

@@ -17,6 +17,7 @@ #!/usr/bin/env python3

from linopy import LinearExpression, Model, Variable, merge
from linopy import LinearExpression, Model, QuadraticExpression, Variable, merge
from linopy.constants import HELPER_DIMS, TERM_DIM
from linopy.expressions import ScalarLinearExpression
from linopy.testing import assert_linequal
from linopy.testing import assert_linequal, assert_quadequal
from linopy.variables import ScalarVariable

@@ -150,6 +151,3 @@

with pytest.warns(DeprecationWarning):
LinearExpression.fill_value
def test_linexpr_with_scalars(m: Model) -> None:

@@ -163,2 +161,9 @@ expr = m.linexpr((10, "x"), (1, "y"))

def test_linexpr_with_variables_and_constants(
m: Model, x: Variable, y: Variable
) -> None:
expr = m.linexpr((10, x), (1, y), 2)
assert (expr.const == 2).all()
def test_linexpr_with_series(m: Model, v: Variable) -> None:

@@ -193,2 +198,5 @@ lhs = pd.Series(np.arange(20)), v

expr3 = expr.mul(1)
assert_linequal(expr, expr3)
expr = x / 1

@@ -203,2 +211,5 @@ assert isinstance(expr, LinearExpression)

expr3 = expr.div(1)
assert_linequal(expr, expr3)
expr = np.array([1, 2]) * x

@@ -216,3 +227,14 @@ assert isinstance(expr, LinearExpression)

quad = x * x
assert isinstance(quad, QuadraticExpression)
with pytest.raises(TypeError):
quad * quad
expr = x * 1
assert isinstance(expr, LinearExpression)
assert expr.__mul__(object()) is NotImplemented
assert expr.__rmul__(object()) is NotImplemented
def test_linear_expression_with_addition(m: Model, x: Variable, y: Variable) -> None:

@@ -234,3 +256,17 @@ expr = 10 * x + y

expr3 = (x * 1).add(y)
assert_linequal(expr, expr3)
expr3 = x + (x * x)
assert isinstance(expr3, QuadraticExpression)
def test_linear_expression_with_raddition(m: Model, x: Variable) -> None:
expr = x * 1.0
expr_2: LinearExpression = 10.0 + expr
assert isinstance(expr, LinearExpression)
expr_3: LinearExpression = expr + 10.0
assert_linequal(expr_2, expr_3)
def test_linear_expression_with_subtraction(m: Model, x: Variable, y: Variable) -> None:

@@ -244,2 +280,6 @@ expr = x - y

expr3: LinearExpression = x * 1
expr4 = expr3.sub(y)
assert_linequal(expr, expr4)
expr = -x - 8 * y

@@ -250,2 +290,11 @@ assert isinstance(expr, LinearExpression)

def test_linear_expression_rsubtraction(x: Variable, y: Variable) -> None:
expr = x * 1.0
expr_2: LinearExpression = 10.0 - expr
assert isinstance(expr_2, LinearExpression)
expr_3: LinearExpression = (expr - 10.0) * -1
assert_linequal(expr_2, expr_3)
assert expr.__rsub__(object()) is NotImplemented
def test_linear_expression_with_constant(m: Model, x: Variable, y: Variable) -> None:

@@ -289,5 +338,8 @@ expr = x + 1

with pytest.raises(TypeError):
m.linexpr((10, x.labels), (1, "y")) # type: ignore
m.linexpr((10, x.labels), (1, "y"))
with pytest.raises(TypeError):
m.linexpr(a=2) # type: ignore
def test_linear_expression_from_rule(m: Model, x: Variable, y: Variable) -> None:

@@ -335,2 +387,5 @@ def bound(m: Model, i: int) -> ScalarLinearExpression:

res2 = expr.add(other)
assert_linequal(res, res2)
assert isinstance(x - expr, LinearExpression)

@@ -456,2 +511,14 @@ assert isinstance(x + expr, LinearExpression)

def test_linear_expression_power(x: Variable) -> None:
expr: LinearExpression = x * 1.0
qd_expr = expr**2
assert isinstance(qd_expr, QuadraticExpression)
qd_expr2 = expr.pow(2)
assert_quadequal(qd_expr, qd_expr2)
with pytest.raises(ValueError):
expr**3
def test_linear_expression_multiplication(

@@ -900,3 +967,3 @@ x: Variable, y: Variable, z: Variable

if use_fallback:
with pytest.raises(KeyError):
with pytest.raises((KeyError, IndexError)):
expr.groupby(groups).sum(use_fallback=use_fallback)

@@ -1024,2 +1091,43 @@ return

def test_linear_expression_from_tuples(x: Variable, y: Variable) -> None:
expr = LinearExpression.from_tuples((10, x), (1, y))
assert isinstance(expr, LinearExpression)
with pytest.warns(DeprecationWarning):
expr2 = LinearExpression.from_tuples((10, x), (1,))
assert isinstance(expr2, LinearExpression)
assert (expr2.const == 1).all()
expr3 = LinearExpression.from_tuples((10, x), 1)
assert isinstance(expr3, LinearExpression)
assert_linequal(expr2, expr3)
expr4 = LinearExpression.from_tuples((10, x), (1, y), 1)
assert isinstance(expr4, LinearExpression)
assert (expr4.const == 1).all()
expr5 = LinearExpression.from_tuples(1, model=x.model)
assert isinstance(expr5, LinearExpression)
def test_linear_expression_from_tuples_bad_calls(
m: Model, x: Variable, y: Variable
) -> None:
with pytest.raises(ValueError):
LinearExpression.from_tuples((10, x), (1, y), x)
with pytest.raises(ValueError):
LinearExpression.from_tuples((10, x, 3), (1, y), 1)
sv = ScalarVariable(label=0, model=m)
with pytest.raises(TypeError):
LinearExpression.from_tuples((np.array([1, 1]), sv))
with pytest.raises(TypeError):
LinearExpression.from_tuples((x, x))
with pytest.raises(ValueError):
LinearExpression.from_tuples(10)
def test_linear_expression_sanitize(x: Variable, y: Variable, z: Variable) -> None:

@@ -1034,7 +1142,8 @@ expr = 10 * x + y + z

res = merge([expr1, expr2])
res = merge([expr1, expr2], cls=LinearExpression)
assert res.nterm == 6
res = merge([expr1, expr2])
assert res.nterm == 6
with pytest.warns(DeprecationWarning):
res: LinearExpression = merge([expr1, expr2]) # type: ignore
assert res.nterm == 6

@@ -1045,3 +1154,3 @@ # now concat with same length of terms

res = merge([expr1, expr2], dim="dim_1")
res = merge([expr1, expr2], dim="dim_1", cls=LinearExpression)
assert res.nterm == 3

@@ -1053,3 +1162,3 @@

res = merge([expr1, expr2], dim="dim_1")
res = merge([expr1, expr2], dim="dim_1", cls=LinearExpression)
assert res.nterm == 3

@@ -1059,3 +1168,3 @@ assert res.sel(dim_1=0).vars[2].item() == -1

with pytest.warns(DeprecationWarning):
merge(expr1, expr2) # type: ignore
merge(expr1, expr2)

@@ -1062,0 +1171,0 @@

@@ -132,3 +132,3 @@ #!/usr/bin/env python3

m.objective = 2 * x + y # type: ignore
m.objective = 2 * x + y

@@ -146,3 +146,3 @@ return m

m.add_constraints(x + x, GREATER_EQUAL, 10)
m.objective = 1 * x # type: ignore
m.objective = 1 * x

@@ -162,3 +162,3 @@ return m

m.add_constraints(x + y, GREATER_EQUAL, 10.5)
m.objective = 1 * x + 0.5 * y # type: ignore
m.objective = 1 * x + 0.5 * y

@@ -276,5 +276,5 @@ return m

y.lower = 9 # type: ignore
y.lower = 9
c.lhs = 2 * x + y
m.objective = 2 * x + y # type: ignore
m.objective = 2 * x + y

@@ -393,3 +393,3 @@ return m

qexpr = 4 * x * y
qexpr = 4 * (x * y) # type: ignore
assert_equal(qexpr.solution, 4 * x.solution * y.solution)

@@ -597,3 +597,3 @@

if solver == "gurobi":
if solver in ["gurobi", "xpress"]:
# ignore deprecated warning

@@ -600,0 +600,0 @@ with pytest.warns(DeprecationWarning):

@@ -12,3 +12,3 @@ #!/usr/bin/env python3

from linopy.constants import FACTOR_DIM, TERM_DIM
from linopy.expressions import QuadraticExpression
from linopy.expressions import LinearExpression, QuadraticExpression
from linopy.testing import assert_quadequal

@@ -45,2 +45,9 @@

def test_adding_quadratic_expressions(x: Variable) -> None:
quad_expr = x * x
double_quad = quad_expr + quad_expr
assert isinstance(double_quad, QuadraticExpression)
assert double_quad.__add__(object()) is NotImplemented
def test_quadratic_expression_from_variables_power(x: Variable) -> None:

@@ -121,3 +128,15 @@ power_expr = x**2

with pytest.raises(TypeError):
(x**2) @ (y**2)
def test_matmul_with_const(x: Variable) -> None:
expr = x * x
const = DataArray([2.0, 1.0], dims=["dim_0"])
expr2 = expr @ const
assert isinstance(expr2, QuadraticExpression)
assert expr2.nterm == 2
assert expr2.data.sizes[FACTOR_DIM] == 2
def test_quadratic_expression_dot_and_matmul(x: Variable, y: Variable) -> None:

@@ -150,6 +169,10 @@ matmul_expr: QuadraticExpression = 10 * x @ y # type: ignore

with pytest.raises(TypeError):
5 + x * y + x
expr_2 = 5 + x * y + x
assert isinstance(expr_2, QuadraticExpression)
assert (expr_2.const == 5).all()
assert expr_2.nterm == 2
assert_quadequal(expr, expr_2)
def test_quadratic_expression_subtraction(x: Variable, y: Variable) -> None:

@@ -160,2 +183,3 @@ expr = x * y - x - 5

assert expr.nterm == 2
assert expr.__sub__(object()) is NotImplemented

@@ -169,3 +193,8 @@

expr2 = 5 - x * y
assert isinstance(expr2, QuadraticExpression)
assert (expr2.const == 5).all()
assert expr2.nterm == 1
def test_quadratic_expression_sum(x: Variable, y: Variable) -> None:

@@ -197,3 +226,7 @@ base_expr = x * y + x + 5

quad = x * x
with pytest.raises(TypeError):
quad * quad
def merge_raise_deprecation_warning(x: Variable, y: Variable) -> None:

@@ -208,12 +241,14 @@ expr: QuadraticExpression = x * y # type: ignore

) -> None:
linexpr = 10 * x + y + 5
quadexpr = x * y
linexpr: LinearExpression = 10 * x + y + 5
quadexpr: QuadraticExpression = x * y # type: ignore
merge([linexpr.to_quadexpr(), quadexpr], cls=QuadraticExpression)
with pytest.raises(ValueError):
merge([linexpr, quadexpr], cls=QuadraticExpression)
with pytest.warns(DeprecationWarning):
merge(linexpr, quadexpr, cls=QuadraticExpression) # type: ignore
linexpr = linexpr.to_quadexpr()
merged_expr = merge([linexpr, quadexpr], cls=QuadraticExpression)
with pytest.warns(DeprecationWarning):
merge(quadexpr, quadexpr, cls=QuadraticExpression) # type: ignore
quadexpr_2 = linexpr.to_quadexpr()
merged_expr = merge([quadexpr_2, quadexpr], cls=QuadraticExpression)
assert isinstance(merged_expr, QuadraticExpression)

@@ -228,3 +263,9 @@ assert merged_expr.nterm == 3

qdexpr = merge([x**2, y**2], cls=QuadraticExpression)
assert isinstance(qdexpr, QuadraticExpression)
with pytest.raises(ValueError):
merge([x**2, y**2], cls=LinearExpression)
def test_quadratic_expression_loc(x: Variable) -> None:

@@ -274,3 +315,3 @@ expr = x * x

expr = 10 * x * y
model.objective = expr # type: ignore
model.objective = expr

@@ -286,3 +327,3 @@ Q = model.matrices.Q

quad_expr = x * y + x
model.objective = quad_expr + x # type: ignore
model.objective = quad_expr + x

@@ -301,1 +342,14 @@ Q = model.matrices.Q

x * y <= 10
def test_power_of_three(x: Variable) -> None:
with pytest.raises(TypeError):
x * x * x
with pytest.raises(TypeError):
(x * 1) * (x * x)
with pytest.raises(TypeError):
(x * x) * (x * 1)
with pytest.raises(ValueError):
x**3
with pytest.raises(TypeError):
(x * x) * (x * x)

@@ -11,4 +11,5 @@ #!/usr/bin/env python3

import pytest
from test_io import model # noqa: F401
from linopy import solvers
from linopy import Model, solvers

@@ -68,1 +69,56 @@ free_mps_problem = """NAME sample_mip

assert result.solution.objective == 30.0
@pytest.mark.skipif(
"gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed"
)
def test_gurobi_environment_with_dict(model: Model, tmp_path: Path) -> None: # noqa: F811
gurobi = solvers.Gurobi()
mps_file = tmp_path / "problem.mps"
mps_file.write_text(free_mps_problem)
sol_file = tmp_path / "solution.sol"
log1_file = tmp_path / "gurobi1.log"
result = gurobi.solve_problem(
problem_fn=mps_file, solution_fn=sol_file, env={"LogFile": str(log1_file)}
)
assert result.status.is_ok
assert log1_file.exists()
log2_file = tmp_path / "gurobi2.log"
gurobi.solve_problem(
model=model, solution_fn=sol_file, env={"LogFile": str(log2_file)}
)
assert result.status.is_ok
assert log2_file.exists()
@pytest.mark.skipif(
"gurobi" not in set(solvers.available_solvers), reason="Gurobi is not installed"
)
def test_gurobi_environment_with_gurobi_env(model: Model, tmp_path: Path) -> None: # noqa: F811
import gurobipy as gp
gurobi = solvers.Gurobi()
mps_file = tmp_path / "problem.mps"
mps_file.write_text(free_mps_problem)
sol_file = tmp_path / "solution.sol"
log1_file = tmp_path / "gurobi1.log"
with gp.Env(params={"LogFile": str(log1_file)}) as env:
result = gurobi.solve_problem(
problem_fn=mps_file, solution_fn=sol_file, env=env
)
assert result.status.is_ok
assert log1_file.exists()
log2_file = tmp_path / "gurobi2.log"
with gp.Env(params={"LogFile": str(log2_file)}) as env:
gurobi.solve_problem(model=model, solution_fn=sol_file, env=env)
assert result.status.is_ok
assert log2_file.exists()

@@ -20,2 +20,3 @@ #!/usr/bin/env python3

from linopy import Model
from linopy.testing import assert_linequal

@@ -131,3 +132,3 @@

def test_variable_upper_setter(z: linopy.Variable) -> None:
z.upper = 20 # type: ignore
z.upper = 20
assert z.upper.item() == 20

@@ -137,3 +138,3 @@

def test_variable_lower_setter(z: linopy.Variable) -> None:
z.lower = 8 # type: ignore
z.lower = 8
assert z.lower == 8

@@ -188,6 +189,3 @@

with pytest.warns(DeprecationWarning):
linopy.variables.Variable.fill_value
def test_variable_where(x: linopy.Variable) -> None:

@@ -312,1 +310,37 @@ x = x.where([True] * 4 + [False] * 6)

assert s.size <= 2
def test_variable_addition(x: linopy.Variable) -> None:
expr1 = x + 1
assert isinstance(expr1, linopy.expressions.LinearExpression)
expr2 = 1 + x
assert isinstance(expr2, linopy.expressions.LinearExpression)
assert_linequal(expr1, expr2)
assert x.__radd__(object()) is NotImplemented
assert x.__add__(object()) is NotImplemented
def test_variable_subtraction(x: linopy.Variable) -> None:
expr1 = -x + 1
assert isinstance(expr1, linopy.expressions.LinearExpression)
expr2 = 1 - x
assert isinstance(expr2, linopy.expressions.LinearExpression)
assert_linequal(expr1, expr2)
assert x.__rsub__(object()) is NotImplemented
assert x.__sub__(object()) is NotImplemented
def test_variable_multiplication(x: linopy.Variable) -> None:
expr1 = x * 2
assert isinstance(expr1, linopy.expressions.LinearExpression)
expr2 = 2 * x
assert isinstance(expr2, linopy.expressions.LinearExpression)
assert_linequal(expr1, expr2)
expr3 = x * x
assert isinstance(expr3, linopy.expressions.QuadraticExpression)
assert x.__rmul__(object()) is NotImplemented
assert x.__mul__(object()) is NotImplemented

@@ -16,2 +16,3 @@ #!/usr/bin/env python3

from linopy.testing import assert_varequal
from linopy.variables import ScalarVariable

@@ -119,1 +120,7 @@

m.variables.get_name_by_label("anystring") # type: ignore
def test_scalar_variable(m: Model) -> None:
x = ScalarVariable(label=0, model=m)
assert isinstance(x, ScalarVariable)
assert x.__rmul__(x) is NotImplemented # type: ignore

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display