Skip to content

Commit 24b0c58

Browse files
Merge branch 'master' into pre-commit-ci-update-config
2 parents cc95d73 + 3807adb commit 24b0c58

15 files changed

Lines changed: 941 additions & 71 deletions

.github/workflows/claude.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
id-token: write
3232
steps:
3333
- name: Checkout repository
34-
uses: actions/checkout@v5
34+
uses: actions/checkout@v6
3535
with:
3636
fetch-depth: 1
3737

.github/workflows/codeql.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ jobs:
5555
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
5656
steps:
5757
- name: Checkout repository
58-
uses: actions/checkout@v5
58+
uses: actions/checkout@v6
5959

6060
# Initializes the CodeQL tools for scanning.
6161
- name: Initialize CodeQL
62-
uses: github/codeql-action/init@v3
62+
uses: github/codeql-action/init@v4
6363
with:
6464
languages: ${{ matrix.language }}
6565
build-mode: ${{ matrix.build-mode }}
@@ -87,6 +87,6 @@ jobs:
8787
exit 1
8888
8989
- name: Perform CodeQL Analysis
90-
uses: github/codeql-action/analyze@v3
90+
uses: github/codeql-action/analyze@v4
9191
with:
9292
category: "/language:${{matrix.language}}"

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
name: Build and verify package
1212
runs-on: ubuntu-latest
1313
steps:
14-
- uses: actions/checkout@v5
14+
- uses: actions/checkout@v6
1515
- uses: hynek/build-and-inspect-python-package@v2
1616

1717
release:
@@ -20,7 +20,7 @@ jobs:
2020
needs: [build]
2121
runs-on: ubuntu-latest
2222
steps:
23-
- uses: actions/checkout@v5
23+
- uses: actions/checkout@v6
2424
- uses: softprops/action-gh-release@v2
2525
with:
2626
generate_release_notes: true
@@ -36,7 +36,7 @@ jobs:
3636
permissions:
3737
id-token: write
3838
steps:
39-
- uses: actions/download-artifact@v5
39+
- uses: actions/download-artifact@v6
4040
with:
4141
name: Packages
4242
path: dist

.github/workflows/test-models.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
shell: bash -l {0}
3232

3333
steps:
34-
- uses: actions/checkout@v5
34+
- uses: actions/checkout@v6
3535
with:
3636
repository: PyPSA/pypsa-eur
3737
ref: master
@@ -101,7 +101,7 @@ jobs:
101101
102102
- name: Upload artifacts
103103
if: env.pinned == 'false'
104-
uses: actions/upload-artifact@v4
104+
uses: actions/upload-artifact@v5
105105
with:
106106
name: results-pypsa-eur-${{ matrix.version }}
107107
path: |

.github/workflows/test.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
name: Build and verify package
1919
runs-on: ubuntu-latest
2020
steps:
21-
- uses: actions/checkout@v5
21+
- uses: actions/checkout@v6
2222
with:
2323
fetch-depth: 0 # Needed for setuptools_scm
2424
- uses: hynek/build-and-inspect-python-package@v2
@@ -42,7 +42,7 @@ jobs:
4242
- windows-latest
4343

4444
steps:
45-
- uses: actions/checkout@v5
45+
- uses: actions/checkout@v6
4646
with:
4747
fetch-depth: 0 # Needed for setuptools_scm
4848

@@ -74,7 +74,7 @@ jobs:
7474
choco install glpk
7575
7676
- name: Download package
77-
uses: actions/download-artifact@v5
77+
uses: actions/download-artifact@v6
7878
with:
7979
name: Packages
8080
path: dist
@@ -102,7 +102,7 @@ jobs:
102102
runs-on: ubuntu-latest
103103

104104
steps:
105-
- uses: actions/checkout@v5
105+
- uses: actions/checkout@v6
106106
with:
107107
fetch-depth: 0 # Needed for setuptools_scm
108108

@@ -112,7 +112,7 @@ jobs:
112112
python-version: 3.12
113113

114114
- name: Download package
115-
uses: actions/download-artifact@v5
115+
uses: actions/download-artifact@v6
116116
with:
117117
name: Packages
118118
path: dist

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ repos:
2424
rev: v0.4.3
2525
hooks:
2626
- id: blackdoc
27+
exclude: ^dev-scripts/
2728
additional_dependencies: ['black==24.8.0']
2829
- repo: https://github.com/codespell-project/codespell
2930
rev: v2.4.1

doc/release_notes.rst

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,47 @@
11
Release Notes
22
=============
33

4-
Upcoming Version
4+
.. Upcoming Version
5+
6+
* Fix compatibility for xpress versions below 9.6 (regression)
7+
* Performance: Up to 50x faster ``repr()`` for variables/constraints via O(log n) label lookup and direct numpy indexing
8+
* Performance: Up to 46x faster ``ncons`` property by replacing ``.flat.labels.unique()`` with direct counting
9+
10+
Version 0.5.8
11+
--------------
512

613
* Replace pandas-based LP file writing with polars implementation for significantly improved performance on large models
714
* Consolidate "lp" and "lp-polars" io_api options - both now use the optimized polars backend
815
* Reduced memory usage and faster file I/O operations when exporting models to LP format
9-
* Improved constraint equality check in `linopy.testing.assert_conequal` to less strict optionally
1016
* Minor bugfix for multiplying variables with numpy type constants
1117
* Harmonize dtypes before concatenation in lp file writing to avoid dtype mismatch errors. This error occurred when creating and storing models in netcdf format using windows machines and loading and solving them on linux machines.
1218
* Add option to use polars series as constant input
1319
* Fix expression merge to explicitly use outer join when combining expressions with disjoint coordinates for consistent behavior across xarray versions
20+
* Adding xpress postsolve if necessary
21+
* Handle ImportError in xpress import
22+
* Fetch and display OETC worker error logs
23+
* Fix windows permission error when dumping model file
24+
* Performance improvements for xpress solver using C interface
25+
26+
Version 0.5.7
27+
--------------
28+
29+
* Removed deprecated future warning for scalar get item operations
30+
* Silenced version output from the HiGHS solver
31+
* Mosek: Remove explicit use of Env, use global env instead
32+
* Objectives can now be created from variables via `linopy.Model.add_objective`
33+
* Added integration with OETC platform (refactored implementation)
34+
* Add error message if highspy is not installed
35+
* Fix MindOpt floating release issue
36+
* Made merge expressions function infer class without triggering warnings
37+
* Improved testing coverage
38+
* Fix pypsa-eur environment path in CI
1439

1540
Version 0.5.6
1641
--------------
1742

1843
* Improved variable/expression arithmetic methods so that they correctly handle types
1944
* Gurobi: Pass dictionary as env argument `env={...}` through to gurobi env creation
20-
* Added integration with OETC platform
21-
* Mosek: Remove explicit use of Env, use global env instead
22-
* Objectives can now be created from variables via `linopy.Model.add_objective`.
2345

2446
**Breaking Changes**
2547

linopy/common.py

Lines changed: 169 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,118 @@ def get_dims_with_index_levels(
750750
return dims_with_levels
751751

752752

753-
def get_label_position(
753+
class LabelPositionIndex:
754+
"""
755+
Index for fast O(log n) lookup of label positions using binary search.
756+
757+
This class builds a sorted index of label ranges and uses binary search
758+
to find which container (variable/constraint) a label belongs to.
759+
760+
Parameters
761+
----------
762+
obj : Any
763+
Container object with items() method returning (name, val) pairs,
764+
where val has .labels and .range attributes.
765+
"""
766+
767+
__slots__ = ("_starts", "_names", "_obj", "_built")
768+
769+
def __init__(self, obj: Any) -> None:
770+
self._obj = obj
771+
self._starts: np.ndarray | None = None
772+
self._names: list[str] | None = None
773+
self._built = False
774+
775+
def _build_index(self) -> None:
776+
"""Build the sorted index of label ranges."""
777+
if self._built:
778+
return
779+
780+
ranges = []
781+
for name, val in self._obj.items():
782+
start, stop = val.range
783+
ranges.append((start, name))
784+
785+
# Sort by start value
786+
ranges.sort(key=lambda x: x[0])
787+
self._starts = np.array([r[0] for r in ranges])
788+
self._names = [r[1] for r in ranges]
789+
self._built = True
790+
791+
def invalidate(self) -> None:
792+
"""Invalidate the index (call when items are added/removed)."""
793+
self._built = False
794+
self._starts = None
795+
self._names = None
796+
797+
def find_single(self, value: int) -> tuple[str, dict] | tuple[None, None]:
798+
"""Find the name and coordinates for a single label value."""
799+
if value == -1:
800+
return None, None
801+
802+
self._build_index()
803+
starts = self._starts
804+
names = self._names
805+
assert starts is not None and names is not None
806+
807+
# Binary search to find the right range
808+
idx = int(np.searchsorted(starts, value, side="right")) - 1
809+
810+
if idx < 0 or idx >= len(starts):
811+
raise ValueError(f"Label {value} is not existent in the model.")
812+
813+
name = names[idx]
814+
val = self._obj[name]
815+
start, stop = val.range
816+
817+
# Verify the value is in range
818+
if value < start or value >= stop:
819+
raise ValueError(f"Label {value} is not existent in the model.")
820+
821+
labels = val.labels
822+
index = np.unravel_index(value - start, labels.shape)
823+
coord = {dim: labels.indexes[dim][i] for dim, i in zip(labels.dims, index)}
824+
return name, coord
825+
826+
def find_single_with_index(
827+
self, value: int
828+
) -> tuple[str, dict, tuple[int, ...]] | tuple[None, None, None]:
829+
"""
830+
Find name, coordinates, and raw numpy index for a single label value.
831+
832+
Returns (name, coord, index) where index is a tuple of integers that
833+
can be used for direct numpy indexing (e.g., arr.values[index]).
834+
This avoids the overhead of xarray's .sel() method.
835+
"""
836+
if value == -1:
837+
return None, None, None
838+
839+
self._build_index()
840+
starts = self._starts
841+
names = self._names
842+
assert starts is not None and names is not None
843+
844+
# Binary search to find the right range
845+
idx = int(np.searchsorted(starts, value, side="right")) - 1
846+
847+
if idx < 0 or idx >= len(starts):
848+
raise ValueError(f"Label {value} is not existent in the model.")
849+
850+
name = names[idx]
851+
val = self._obj[name]
852+
start, stop = val.range
853+
854+
# Verify the value is in range
855+
if value < start or value >= stop:
856+
raise ValueError(f"Label {value} is not existent in the model.")
857+
858+
labels = val.labels
859+
index = np.unravel_index(value - start, labels.shape)
860+
coord = {dim: labels.indexes[dim][i] for dim, i in zip(labels.dims, index)}
861+
return name, coord, index
862+
863+
864+
def _get_label_position_linear(
754865
obj: Any, values: int | np.ndarray
755866
) -> (
756867
tuple[str, dict]
@@ -760,6 +871,9 @@ def get_label_position(
760871
):
761872
"""
762873
Get tuple of name and coordinate for variable labels.
874+
875+
This is the original O(n) implementation that scans through all items.
876+
Used only for testing/benchmarking comparisons.
763877
"""
764878

765879
def find_single(value: int) -> tuple[str, dict] | tuple[None, None]:
@@ -795,6 +909,53 @@ def find_single(value: int) -> tuple[str, dict] | tuple[None, None]:
795909
raise ValueError("Array's with more than two dimensions is not supported")
796910

797911

912+
def get_label_position(
913+
obj: Any,
914+
values: int | np.ndarray,
915+
index: LabelPositionIndex | None = None,
916+
) -> (
917+
tuple[str, dict]
918+
| tuple[None, None]
919+
| list[tuple[str, dict] | tuple[None, None]]
920+
| list[list[tuple[str, dict] | tuple[None, None]]]
921+
):
922+
"""
923+
Get tuple of name and coordinate for variable labels.
924+
925+
Uses O(log n) binary search with a cached index for fast lookups.
926+
927+
Parameters
928+
----------
929+
obj : Any
930+
Container object with items() method (Variables or Constraints).
931+
values : int or np.ndarray
932+
Label value(s) to look up.
933+
index : LabelPositionIndex, optional
934+
Pre-built index for fast lookups. If None, one will be created.
935+
936+
Returns
937+
-------
938+
tuple or list
939+
(name, coord) tuple for single values, or list of tuples for arrays.
940+
"""
941+
if index is None:
942+
index = LabelPositionIndex(obj)
943+
944+
if isinstance(values, int):
945+
return index.find_single(values)
946+
947+
values = np.array(values)
948+
ndim = values.ndim
949+
if ndim == 0:
950+
return index.find_single(values.item())
951+
elif ndim == 1:
952+
return [index.find_single(int(v)) for v in values]
953+
elif ndim == 2:
954+
return [[index.find_single(int(v)) for v in col] for col in values.T]
955+
else:
956+
raise ValueError("Array's with more than two dimensions is not supported")
957+
958+
798959
def print_coord(coord: dict[str, Any] | Iterable[Any]) -> str:
799960
"""
800961
Format coordinates into a string representation.
@@ -838,14 +999,16 @@ def print_single_variable(model: Any, label: int) -> str:
838999
return "None"
8391000

8401001
variables = model.variables
841-
name, coord = variables.get_label_position(label)
1002+
name, coord, index = variables.get_label_position_with_index(label)
8421003

843-
lower = variables[name].lower.sel(coord).item()
844-
upper = variables[name].upper.sel(coord).item()
1004+
var = variables[name]
1005+
# Use direct numpy indexing instead of .sel() for performance
1006+
lower = var.lower.values[index]
1007+
upper = var.upper.values[index]
8451008

846-
if variables[name].attrs["binary"]:
1009+
if var.attrs["binary"]:
8471010
bounds = " ∈ {0, 1}"
848-
elif variables[name].attrs["integer"]:
1011+
elif var.attrs["integer"]:
8491012
bounds = f" ∈ Z ⋂ [{lower:.4g},...,{upper:.4g}]"
8501013
else:
8511014
bounds = f" ∈ [{lower:.4g}, {upper:.4g}]"

0 commit comments

Comments
 (0)