Skip to content

Commit 14883b5

Browse files
Merge branch 'master' into master
2 parents 81b202b + b2e4bfe commit 14883b5

File tree

11 files changed

+679
-33
lines changed

11 files changed

+679
-33
lines changed

.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

doc/release_notes.rst

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,48 @@
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+
* Add support for GPU-accelerated solver [cuPDLPx](https://github.com/MIT-Lu-Lab/cuPDLPx)
10+
11+
Version 0.5.8
12+
--------------
513

614
* Replace pandas-based LP file writing with polars implementation for significantly improved performance on large models
715
* Consolidate "lp" and "lp-polars" io_api options - both now use the optimized polars backend
816
* 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
1017
* Minor bugfix for multiplying variables with numpy type constants
1118
* 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.
1219
* Add option to use polars series as constant input
1320
* Fix expression merge to explicitly use outer join when combining expressions with disjoint coordinates for consistent behavior across xarray versions
14-
* Add support for GPU-accelerated solver [cuPDLPx](https://github.com/MIT-Lu-Lab/cuPDLPx)
21+
* Adding xpress postsolve if necessary
22+
* Handle ImportError in xpress import
23+
* Fetch and display OETC worker error logs
24+
* Fix windows permission error when dumping model file
25+
* Performance improvements for xpress solver using C interface
26+
27+
Version 0.5.7
28+
--------------
29+
30+
* Removed deprecated future warning for scalar get item operations
31+
* Silenced version output from the HiGHS solver
32+
* Mosek: Remove explicit use of Env, use global env instead
33+
* Objectives can now be created from variables via `linopy.Model.add_objective`
34+
* Added integration with OETC platform (refactored implementation)
35+
* Add error message if highspy is not installed
36+
* Fix MindOpt floating release issue
37+
* Made merge expressions function infer class without triggering warnings
38+
* Improved testing coverage
39+
* Fix pypsa-eur environment path in CI
1540

1641
Version 0.5.6
1742
--------------
1843

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

2547
**Breaking Changes**
2648

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)