Skip to content

Commit 1eddef9

Browse files
author
Robbie Muir
committed
merged
2 parents 7626b59 + b2e4bfe commit 1eddef9

File tree

11 files changed

+650
-27
lines changed

11 files changed

+650
-27
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ Release Notes
33

44
.. Upcoming Version
55
* Add convenience function to create LinearExpression from constant
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
69

710
Version 0.5.8
811
--------------

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)