From 7a7c990692003182088c6fd318547384c9807c5f Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:55:41 +0200 Subject: [PATCH 01/12] ci: move GitHub Actions to Node 24 versions --- .github/workflows/build.yml | 4 ++-- .github/workflows/build_deploy.yml | 4 ++-- .github/workflows/test_pyqt5.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8171c691..ba136656 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,12 @@ jobs: os: [ubuntu-latest, windows-latest, macos-13, macos-14] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build wheels run: pipx run cibuildwheel==2.21.2 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} path: ./wheelhouse/*.whl diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml index 54470189..f2a14f49 100644 --- a/.github/workflows/build_deploy.yml +++ b/.github/workflows/build_deploy.yml @@ -17,7 +17,7 @@ jobs: os: [ubuntu-latest, windows-latest, macos-15] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install build dependencies (for prebuild) run: | @@ -38,7 +38,7 @@ jobs: name: Build source distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install build dependencies run: | diff --git a/.github/workflows/test_pyqt5.yml b/.github/workflows/test_pyqt5.yml index 70c99afa..8d30ef13 100644 --- a/.github/workflows/test_pyqt5.yml +++ b/.github/workflows/test_pyqt5.yml @@ -25,9 +25,9 @@ jobs: python-version: ["3.9", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From 9a221b7803263ed8fdd9481d911bcf74da59cc51 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:24:41 +0200 Subject: [PATCH 02/12] fix(csection): only keep cross-section curves for visible images Cross-section panels kept one curve for every image ever shown, so plots with hundreds of hidden images became progressively slower to refresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- plotpy/panels/csection/csplot.py | 10 ++- .../unit/test_cross_section_visible_items.py | 73 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 plotpy/tests/unit/test_cross_section_visible_items.py diff --git a/plotpy/panels/csection/csplot.py b/plotpy/panels/csection/csplot.py index eb567212..5f84fb1b 100644 --- a/plotpy/panels/csection/csplot.py +++ b/plotpy/panels/csection/csplot.py @@ -167,13 +167,19 @@ def get_cross_section_curves(self) -> list[CrossSectionItem]: def items_changed(self, plot: BasePlot) -> None: """Items have changed in the plot + Cross section curves are kept for visible image items only: the curve + of an image item that is hidden or removed is deleted, and created + again when the item is shown. Keeping the curves of hidden items would + make every change cost more as images accumulate in the plot (e.g. an + application showing one image at a time among many hidden ones). + Args: plot: Plot containing the image items to be used for cross section """ - # Del obsolete cross section items new_sources = plot.get_items(item_type=ICSImageItemType) + # Del cross section items of removed or hidden image items for source in self.known_items.copy(): - if source not in new_sources: + if source not in new_sources or not source.isVisible(): curve = self.known_items.pop(source) curve.clear_data() # useful to emit SIG_CS_CURVE_CHANGED # (eventually notify other panels that the diff --git a/plotpy/tests/unit/test_cross_section_visible_items.py b/plotpy/tests/unit/test_cross_section_visible_items.py new file mode 100644 index 00000000..1f6dab98 --- /dev/null +++ b/plotpy/tests/unit/test_cross_section_visible_items.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Cross section panels keep curves for visible images only; unchanged axis +styles do not lay out the plot again.""" + +from __future__ import annotations + +import numpy as np +from guidata.qthelpers import qt_app_context +from qtpy import QtWidgets as QW + +from plotpy.builder import make + + +def _window(): + win = make.dialog(type="image", show_xsection=True, show_ysection=True) + win.show() + return win + + +def test_cross_section_curves_follow_visible_images(): + """Hidden images have no cross section curve, until they are shown again.""" + with qt_app_context(exec_loop=False): + win = _window() + plot = win.manager.get_plot() + xcs = win.manager.get_xcs_panel().cs_plot + ycs = win.manager.get_ycs_panel().cs_plot + data = np.random.default_rng(0).random((16, 16)) + images = [] + for _index in range(20): + if images: + plot.set_item_visible(images[-1], False, notify=False, replot=False) + image = make.image(data.copy()) + plot.add_item(image) + images.append(image) + QW.QApplication.processEvents() + assert list(xcs.known_items) == [images[-1]] + assert list(ycs.known_items) == [images[-1]] + plot.set_item_visible(images[-1], False) + assert not xcs.known_items + plot.set_item_visible(images[0], True) + assert list(xcs.known_items) == [images[0]] + assert xcs.known_items[images[0]].isVisible() + plot.del_item(images[0]) + assert not xcs.known_items and not ycs.known_items + win.close() + + +def test_unchanged_axis_style_does_not_relayout(): + """Setting the same titles again neither lays out the plot nor notifies.""" + with qt_app_context(exec_loop=False): + win = _window() + plot = win.manager.get_plot() + plot.set_titles(title="T", xlabel="x", ylabel="y", xunit="mm", yunit="mm") + layouts, notifications = [], [] + update_layout = plot.updateLayout + plot.updateLayout = lambda: (layouts.append(1), update_layout()) + plot.SIG_PLOT_LABELS_CHANGED.connect(notifications.append) + plot.set_titles(title="T", xlabel="x", ylabel="y", xunit="mm", yunit="mm") + assert not layouts and not notifications + plot.set_titles(xlabel="x2") + assert plot.get_axis_title("bottom") == "x2" + assert plot.axisTitle(plot.get_axis_id("bottom")).text() == "x2 (mm)" + assert layouts and notifications + win.close() + + +if __name__ == "__main__": + test_cross_section_curves_follow_visible_images() + test_unchanged_axis_style_does_not_relayout() From 89fe07accb8be4721eddf547ce6f9f3b211eed5c Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:24:45 +0200 Subject: [PATCH 03/12] perf(plot): skip layout update when title and axis styles are unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- plotpy/plot/base.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/plotpy/plot/base.py b/plotpy/plot/base.py index 7e978fae..df6ed662 100644 --- a/plotpy/plot/base.py +++ b/plotpy/plot/base.py @@ -886,6 +886,10 @@ def set_title(self, title: str) -> None: Args: title (str): the title """ + current = self.title() + if current.text() == title and current.font().key() == self.font_title.key(): + # Nothing changed: setting the title would lay out the plot again + return text = qwt.QwtText(title) text.setFont(self.font_title) self.setTitle(text) @@ -1044,18 +1048,28 @@ def update_axis_style(self, axis_id: int) -> None: title_font = style.title_font.build_font() ticks_font = style.ticks_font.build_font() - self.setAxisFont(axis_id, ticks_font) - if style.title and style.unit: title = f"{style.title} ({style.unit})" elif style.title: title = style.title else: title = style.unit + color = QG.QColor(style.color) axis_text = self.axisTitle(axis_id) + # Fonts are compared by key: the widget font is resolved against its + # parent font, so it never compares equal to the built font + if ( + self.axisFont(axis_id).key() == ticks_font.key() + and axis_text.text() == title + and axis_text.font().key() == title_font.key() + and axis_text.color() == color + ): + # Nothing changed: setting the axis title would lay out the plot again + return + self.setAxisFont(axis_id, ticks_font) axis_text.setFont(title_font) axis_text.setText(title) - axis_text.setColor(QG.QColor(style.color)) + axis_text.setColor(color) self.setAxisTitle(axis_id, axis_text) self.SIG_PLOT_LABELS_CHANGED.emit(self) From 6cbe09820f89322534b108c4c4e4c390cd1701be Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:24:48 +0200 Subject: [PATCH 04/12] perf(tools): avoid quadratic selected-item lookups in item tools and item list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- plotpy/panels/itemlist.py | 4 ++-- plotpy/tools/image.py | 3 ++- plotpy/tools/item.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plotpy/panels/itemlist.py b/plotpy/panels/itemlist.py index 342773a9..bf4d5e7e 100644 --- a/plotpy/panels/itemlist.py +++ b/plotpy/panels/itemlist.py @@ -233,9 +233,9 @@ def active_item_changed(self, plot: BasePlot) -> None: self.plot = plot _block = self.blockSignals(True) active = plot.get_active_item() - for item in self.items: + for row, item in enumerate(self.items): title = item.title().text() - lw_item = self.item(self.items.index(item)) + lw_item = self.item(row) lw_item.setText(title) lw_item.setSelected(item.selected) font = lw_item.font() diff --git a/plotpy/tools/image.py b/plotpy/tools/image.py index bb96a2f2..614764a6 100644 --- a/plotpy/tools/image.py +++ b/plotpy/tools/image.py @@ -416,7 +416,8 @@ def get_supported_items(self, plot: BasePlot) -> list[BaseImageItem]: and hasattr(item, "get_zaxis_log_state") ] if len(items) > 1: - items = [item for item in items if item in plot.get_selected_items()] + selected = plot.get_selected_items() + items = [item for item in items if item in selected] if items: self.action.setChecked(items[0].get_zaxis_log_state()) return items diff --git a/plotpy/tools/item.py b/plotpy/tools/item.py index 3813d46b..bdb6f2fd 100644 --- a/plotpy/tools/item.py +++ b/plotpy/tools/item.py @@ -86,7 +86,8 @@ def get_supported_items(self, plot: BasePlot) -> list: if len(all_items) == 1: return all_items else: - return [item for item in all_items if item in plot.get_selected_items()] + selected = plot.get_selected_items() + return [item for item in all_items if item in selected] def update_status(self, plot: BasePlot) -> None: """ From 7b7add1cc0c5f5c2a5e69e744fb8d20e06e66c6e Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:24:51 +0200 Subject: [PATCH 05/12] docs: add release notes for PlotPy 2.11.2 performance fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/release_notes/release_2.11.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/release_notes/release_2.11.md b/doc/release_notes/release_2.11.md index e54197be..62941e9b 100644 --- a/doc/release_notes/release_2.11.md +++ b/doc/release_notes/release_2.11.md @@ -1,5 +1,16 @@ # Version 2.11 # +## PlotPy Version 2.11.2 ## + +🛠️ Bug fixes: + +* **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again + +⚡ Performance: + +* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change +* **Item tools and item list** — Updating the status of item-related tools and of the item list panel is no longer quadratic in the number of plot items + ## PlotPy Version 2.11.1 ## 🛠️ Bug fixes: From 43e72108250e4f032f56bf0c3c28b45d957f40f9 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:57:52 +0200 Subject: [PATCH 06/12] perf(image): cache colormap lookup tables by colormap content Every new image item recomputed its 1024-entry lookup table (and the 256-color table) in Python, twice, even for the colormap used by all the other images. Tables are now cached by colormap content, inversion and alpha settings, so that an edited colormap still gets a new table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/release_notes/release_2.11.md | 1 + plotpy/items/image/base.py | 143 ++++++++++++++----- plotpy/tests/unit/test_colormap_lut_cache.py | 83 +++++++++++ 3 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 plotpy/tests/unit/test_colormap_lut_cache.py diff --git a/doc/release_notes/release_2.11.md b/doc/release_notes/release_2.11.md index 62941e9b..dacde109 100644 --- a/doc/release_notes/release_2.11.md +++ b/doc/release_notes/release_2.11.md @@ -10,6 +10,7 @@ * **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change * **Item tools and item list** — Updating the status of item-related tools and of the item list panel is no longer quadratic in the number of plot items +* **Image creation** — Creating an image item no longer recomputes the colormap lookup table when the same colormap, inversion and transparency settings were already used: the table (more than 1000 colormap evaluations) is computed once and reused, which saves a few milliseconds per image ## PlotPy Version 2.11.1 ## diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index 0a251d85..e3f80a7a 100644 --- a/plotpy/items/image/base.py +++ b/plotpy/items/image/base.py @@ -7,6 +7,7 @@ import os import os.path as osp +from collections import OrderedDict from typing import TYPE_CHECKING, Any import numpy as np @@ -46,6 +47,7 @@ from plotpy.mathutils.arrayfuncs import get_nan_range from plotpy.mathutils.colormap import FULLRANGE, get_cmap from plotpy.styles.image import RawImageParam +from plotpy.widgets.colormap.widget import EditableColormap if TYPE_CHECKING: import guidata.io @@ -57,7 +59,110 @@ from plotpy.interfaces import IItemType from plotpy.plot import BasePlot from plotpy.styles.base import ItemParameters - from plotpy.widgets.colormap.widget import EditableColormap + +#: Lookup tables already computed by :func:`_get_colormap_lut`, most recent last +_LUT_CACHE: OrderedDict[tuple, tuple[tuple[int, ...], np.ndarray]] = OrderedDict() +_LUT_CACHE_SIZE = 32 + + +def _lut_cache_key( + table: EditableColormap, alpha: float, alpha_function: int +) -> tuple | None: + """Return the key identifying the lookup table of a colormap, if cacheable. + + The key is built from the colormap content (not its identity), so that an + edited colormap gets a new lookup table. + + Args: + table: Colormap + alpha: Global alpha value + alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`) + + Returns: + Cache key, or None if the lookup table must be computed: colormap + subclasses may compute colors differently, and the colors of translucent + stops depend on the order in which the stops were inserted. + """ + if type(table) is not EditableColormap: + return None + stops = tuple((stop.pos, stop.rgb) for stop in table.colorStops()) + if any(stop.a != 255 for stop in table.colorStops()): + return None + return (stops, table.mode(), table.invert, alpha, alpha_function) + + +def _compute_colormap_lut( + table: EditableColormap, alpha: float, alpha_function: int +) -> tuple[tuple[int, ...], np.ndarray]: + """Compute the color table and the lookup table of a colormap. + + Args: + table: Colormap + alpha: Global alpha value + alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`) + + Returns: + Color table (256 colors) and lookup table (``LUT_SIZE`` ARGB values) + """ + colors = tuple(table.colorTable(FULLRANGE)) + lut = np.zeros((LUT_SIZE,), np.uint32) + for i in range(LUT_SIZE): + if alpha_function == LUTAlpha.NONE.value: + pix_alpha = 1.0 + elif alpha_function == LUTAlpha.CONSTANT.value: + pix_alpha = alpha + else: + x = i / float(LUT_SIZE - 1) + if alpha_function == LUTAlpha.LINEAR.value: + # Linear alpha function + pix_alpha = alpha * x + elif alpha_function == LUTAlpha.SIGMOID.value: + # Sigmoid alpha function + pix_alpha = alpha / (1 + np.exp(-10 * x)) + elif alpha_function == LUTAlpha.TANH.value: + # Hyperbolic tangent alpha function + pix_alpha = alpha * np.tanh(5 * x) + elif alpha_function == LUTAlpha.STEP.value: + # Fully transparent lowest value and `alpha` transparent elsewhere + pix_alpha = alpha if x > 0 else 0 + else: + raise ValueError(f"Invalid alpha function {alpha_function}") + # pylint: disable=too-many-function-args + alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24 + lut[i] = ( + np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF) | alpha_channel + ) + return colors, lut + + +def _get_colormap_lut( + table: EditableColormap, alpha: float, alpha_function: int +) -> tuple[tuple[int, ...], np.ndarray]: + """Return the color table and the lookup table of a colormap. + + Computing them takes more than 1000 calls to the colormap: they are cached, + since every new image item usually shows the same colormap. + + Args: + table: Colormap + alpha: Global alpha value + alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`) + + Returns: + Color table (256 colors) and lookup table (``LUT_SIZE`` ARGB values), + the latter being read-only + """ + key = _lut_cache_key(table, alpha, alpha_function) + if key is not None and key in _LUT_CACHE: + _LUT_CACHE.move_to_end(key) + return _LUT_CACHE[key] + colors, lut = _compute_colormap_lut(table, alpha, alpha_function) + lut.flags.writeable = False + if key is not None: + _LUT_CACHE[key] = (colors, lut) + if len(_LUT_CACHE) > _LUT_CACHE_SIZE: + _LUT_CACHE.popitem(last=False) + return colors, lut class BaseImageItem(QwtPlotItem): @@ -466,37 +571,11 @@ def set_color_map( if invert is not None: table.invert = invert self.cmap_table = table - self.cmap = table.colorTable(FULLRANGE) - cmap_a = self.lut[3] - alpha = self.param.alpha - alpha_function = self.param.alpha_function - for i in range(LUT_SIZE): - if alpha_function == LUTAlpha.NONE.value: - pix_alpha = 1.0 - elif alpha_function == LUTAlpha.CONSTANT.value: - pix_alpha = alpha - else: - x = i / float(LUT_SIZE - 1) - if alpha_function == LUTAlpha.LINEAR.value: - # Linear alpha function - pix_alpha = alpha * x - elif alpha_function == LUTAlpha.SIGMOID.value: - # Sigmoid alpha function - pix_alpha = alpha / (1 + np.exp(-10 * x)) - elif alpha_function == LUTAlpha.TANH.value: - # Hyperbolic tangent alpha function - pix_alpha = alpha * np.tanh(5 * x) - elif alpha_function == LUTAlpha.STEP.value: - # Fully transparent lowest value and `alpha` transparent elsewhere - pix_alpha = alpha if x > 0 else 0 - else: - raise ValueError(f"Invalid alpha function {alpha_function}") - # pylint: disable=too-many-function-args - alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24 - cmap_a[i] = ( - np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF) - | alpha_channel - ) + colors, lut = _get_colormap_lut( + table, self.param.alpha, self.param.alpha_function + ) + self.cmap = list(colors) + self.lut[3][:] = lut plot = self.plot() if plot: plot.update_colormap_axis(self) diff --git a/plotpy/tests/unit/test_colormap_lut_cache.py b/plotpy/tests/unit/test_colormap_lut_cache.py new file mode 100644 index 00000000..c16c441d --- /dev/null +++ b/plotpy/tests/unit/test_colormap_lut_cache.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Image lookup tables are cached by colormap content: a cached table always +matches the colormap, its inversion and the alpha settings.""" + +from __future__ import annotations + +import copy + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtGui as QG + +from plotpy.builder import make +from plotpy.constants import LUT_MAX, LUT_SIZE, LUTAlpha +from plotpy.mathutils.colormap import FULLRANGE, get_cmap + + +def _expected_lut(table, alpha: float, alpha_function: int) -> np.ndarray: + """Lookup table computed directly from the colormap.""" + x = np.arange(LUT_SIZE) / float(LUT_SIZE - 1) + pix_alpha = { + LUTAlpha.NONE.value: np.ones(LUT_SIZE), + LUTAlpha.CONSTANT.value: np.full(LUT_SIZE, alpha), + LUTAlpha.LINEAR.value: alpha * x, + LUTAlpha.SIGMOID.value: alpha / (1 + np.exp(-10 * x)), + LUTAlpha.TANH.value: alpha * np.tanh(5 * x), + LUTAlpha.STEP.value: np.where(x > 0, alpha, 0.0), + }[alpha_function] + alpha_channel = np.clip((255 * pix_alpha + 0.5).astype(np.uint32), 0, 255) << 24 + rgb = np.array( + [table.rgb(FULLRANGE, i / LUT_MAX) & 0xFFFFFF for i in range(LUT_SIZE)], + dtype=np.uint32, + ) + return rgb | alpha_channel.astype(np.uint32) + + +def _image(colormap: str = "jet", **kwargs): + return make.image(np.zeros((4, 4)), colormap=colormap, **kwargs) + + +@pytest.mark.parametrize("alpha_function", list(LUTAlpha)) +def test_lut_matches_colormap(alpha_function: LUTAlpha) -> None: + """Each image gets the lookup table of its colormap, alpha and inversion.""" + with qt_app_context(exec_loop=False): + for invert in (False, True, False): + for name in ("jet", "viridis", "gray"): + item = _image(name, alpha_function=alpha_function, alpha=0.6) + item.set_color_map(name, invert=invert) + expected = _expected_lut(get_cmap(name), 0.6, alpha_function.value) + assert np.array_equal(item.lut[3], expected) + assert item.cmap == get_cmap(name).colorTable(FULLRANGE) + + +def test_lut_follows_edited_colormap() -> None: + """Editing a colormap after a first use gives a new lookup table.""" + with qt_app_context(exec_loop=False): + table = copy.deepcopy(get_cmap("jet")) + first = _image() + first.set_color_map(table) + before = first.lut[3].copy() + + table.addColorStop(0.5, QG.QColor(255, 255, 255)) + second = _image() + second.set_color_map(table) + expected = _expected_lut(table, 1.0, LUTAlpha.NONE.value) + assert np.array_equal(second.lut[3], expected) + assert not np.array_equal(second.lut[3], before) + + +def test_items_do_not_share_lookup_tables() -> None: + """Changing the lookup table of an item leaves the other items unchanged.""" + with qt_app_context(exec_loop=False): + first, second = _image(), _image() + first.lut[3][:] = 0 + third = _image() + expected = _expected_lut(get_cmap("jet"), 1.0, LUTAlpha.NONE.value) + assert np.array_equal(second.lut[3], expected) + assert np.array_equal(third.lut[3], expected) From fe7f9d991011098c076c66c2060c2fcdabcf6d55 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:27:10 +0200 Subject: [PATCH 07/12] ci(rtd): install development dependencies for branch and pull request builds Read the Docs installs dependencies from PyPI only, so documentation builds of development branches and pull requests fail as soon as PlotPy requires a guidata or PythonQwt version that is not released yet. Add a pre_install step mirroring the GitHub Actions test workflows: for branch and pull request builds, guidata is installed from its develop branch and PythonQwt from master. Tag builds and the master branch keep using PyPI releases. The script is shared with Sigima and SigimaX; only its project-specific settings differ. Assisted-by: Claude Opus 5.5 --- .readthedocs.yaml | 3 + scripts/rtd_install_dev_deps.py | 135 ++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 scripts/rtd_install_dev_deps.py diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 05246569..49e185d6 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,6 +10,9 @@ build: post_create_environment: - pip install PyQt5 setuptools numpy Cython - python setup.py build_ext --inplace + pre_install: + # Development builds may require dependency versions not yet released on PyPI + - python scripts/rtd_install_dev_deps.py sphinx: configuration: doc/conf.py formats: diff --git a/scripts/rtd_install_dev_deps.py b/scripts/rtd_install_dev_deps.py new file mode 100644 index 00000000..d9e1b879 --- /dev/null +++ b/scripts/rtd_install_dev_deps.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +Install development versions of dependencies for Read the Docs builds. + +This project is developed alongside other PlotPyStack and DataLab Platform +projects: development branches and pull requests may require dependency versions +which are not yet published on PyPI, so installing the documentation requirements +from PyPI alone would fail. + +This script mirrors the GitHub Actions test workflows: + +- tag builds and release branches: nothing is done, dependencies are installed + from PyPI by the regular ``python.install`` step; +- other branches and pull requests: the dependencies listed in + ``DEV_DEPENDENCIES`` are installed from GitHub. The regular ``python.install`` + step then keeps these versions as they already satisfy the project + requirements. + +It is intended to be run as a ``build.jobs.pre_install`` step and only relies on +the standard library and ``git``. + +The same script is used by several projects: only the project-specific settings +below differ between copies. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +# ---- Project-specific settings ---------------------------------------------- +PROJECT_REPO = "https://github.com/PlotPyStack/PlotPy.git" +#: Placeholder for the name of the branch being built +SAME_BRANCH = "{branch}" +#: Dependencies to install from GitHub for development builds: +#: package name -> (repository URL, candidate branches, first existing one is used) +DEV_DEPENDENCIES = { + "PythonQwt": ("https://github.com/PlotPyStack/PythonQwt.git", ("master",)), + "guidata": ("https://github.com/PlotPyStack/guidata.git", ("develop",)), +} +# ----------------------------------------------------------------------------- + +RELEASE_BRANCHES = ("main", "master", "release") + + +def get_remote_branches(url: str) -> dict[str, str]: + """Return the branches of a remote Git repository. + + Args: + url: Remote repository URL + + Returns: + Mapping of branch names to the commit hash of their head + """ + output = subprocess.run( + ["git", "ls-remote", "--heads", url], + capture_output=True, + text=True, + check=True, + ).stdout + branches = {} + for line in output.splitlines(): + commit, ref = line.split("\t", 1) + branches[ref.removeprefix("refs/heads/")] = commit + return branches + + +def get_built_branch() -> str | None: + """Return the branch being built, if it can be determined. + + For pull request builds, Read the Docs only provides the pull request number, + so the branch is the one of the project repository whose head is the commit + being built. + + Returns: + Branch name, or None for tag builds and pull requests from forks + """ + version_type = os.environ.get("READTHEDOCS_VERSION_TYPE") + if version_type == "branch": + return os.environ["READTHEDOCS_GIT_IDENTIFIER"] + if version_type == "external": + commit = os.environ["READTHEDOCS_GIT_COMMIT_HASH"] + for branch, head in get_remote_branches(PROJECT_REPO).items(): + if head == commit: + return branch + return None + + +def select_branch(url: str, candidates: tuple[str, ...], built: str | None) -> str: + """Return the first candidate branch which exists in a remote repository. + + Args: + url: Remote repository URL + candidates: Candidate branch names, ``SAME_BRANCH`` standing for the + branch being built + built: Branch being built, or None if unknown + + Returns: + Selected branch name + """ + names = [built if name == SAME_BRANCH else name for name in candidates] + existing = get_remote_branches(url) + for name in names: + if name is not None and name in existing: + return name + raise SystemExit(f"None of the branches {names} exists in {url}") + + +def main() -> None: + """Install dependencies from GitHub for development builds.""" + version_type = os.environ.get("READTHEDOCS_VERSION_TYPE") + if version_type not in ("branch", "external"): + print(f"Version type {version_type!r}: dependencies installed from PyPI") + return + built = get_built_branch() + if built in RELEASE_BRANCHES: + print(f"Branch {built!r}: dependencies installed from PyPI") + return + print(f"Development build (branch: {built or 'unknown'})") + requirements = [] + for name, (url, candidates) in DEV_DEPENDENCIES.items(): + branch = select_branch(url, candidates, built) + print(f" {name}: {branch!r} branch") + requirements.append(f"{name} @ git+{url}@{branch}") + # Single pip call, so that dependencies between these packages are resolved + # against their development versions rather than against PyPI releases + subprocess.run( + [sys.executable, "-m", "pip", "install", "--no-cache-dir", *requirements], + check=True, + ) + + +if __name__ == "__main__": + main() From 137153c04ac21a6c661b36ee10af35108ce41f4b Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:46:17 +0200 Subject: [PATCH 08/12] chore: require Python 3.10 or newer Refs PlotPyStack/PlotPy#74 Assisted-by: GPT-6.1 Sol --- .github/copilot-instructions.md | 2 +- .github/workflows/test_pyqt5.yml | 2 +- doc/dev/platforms.rst | 2 +- doc/release_notes/release_2.11.md | 4 ++++ doc/requirements.rst | 2 +- pyproject.toml | 7 +++---- scripts/build-wheels.sh | 22 +++++++++++----------- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ed3c5837..0fde6ae9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,7 +12,7 @@ PythonQwt and guidata. ### Technology Stack -- **Python**: 3.9+ (`from __future__ import annotations`) +- **Python**: 3.10+ (`from __future__ import annotations`) - **Core**: NumPy, SciPy, scikit-image - **GUI**: Qt via QtPy, guidata, PythonQwt - **Build**: Some features rely on Cython extensions diff --git a/.github/workflows/test_pyqt5.yml b/.github/workflows/test_pyqt5.yml index 8d30ef13..8edb2bce 100644 --- a/.github/workflows/test_pyqt5.yml +++ b/.github/workflows/test_pyqt5.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.13", "3.14"] + python-version: ["3.10", "3.13", "3.14"] steps: - uses: actions/checkout@v7 diff --git a/doc/dev/platforms.rst b/doc/dev/platforms.rst index 621c19e6..ee11f096 100644 --- a/doc/dev/platforms.rst +++ b/doc/dev/platforms.rst @@ -7,7 +7,7 @@ The following sections describe the reference platforms for the project. .. note:: - The officially supported Python versions are 3.9, 3.10, 3.11, 3.12 and 3.13. + The officially supported Python versions are 3.10, 3.11, 3.12, 3.13 and 3.14. The officially supported Qt binding is PyQt5. However, efforts have been made and will continue to be made to support PyQt6 and PySide6 as well. diff --git a/doc/release_notes/release_2.11.md b/doc/release_notes/release_2.11.md index dacde109..a2aa3633 100644 --- a/doc/release_notes/release_2.11.md +++ b/doc/release_notes/release_2.11.md @@ -1,5 +1,9 @@ # Version 2.11 # +## Unreleased ## + +* Python 3.10 or later is now required, and wheels are no longer built for Python 3.9. Python 3.9 users must keep an earlier compatible release. + ## PlotPy Version 2.11.2 ## 🛠️ Bug fixes: diff --git a/doc/requirements.rst b/doc/requirements.rst index 40263393..bf7b24cf 100644 --- a/doc/requirements.rst +++ b/doc/requirements.rst @@ -8,7 +8,7 @@ The `PlotPy` package requires the following Python modules: - Version - Summary * - Python - - >=3.9, <4 + - >=3.10, <4 - Python programming language * - guidata - >= 3.14.1 diff --git a/pyproject.toml b/pyproject.toml index cc5bb739..f21399f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows :: Windows 10", "Operating System :: Microsoft :: Windows :: Windows 11", "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -36,7 +35,7 @@ classifiers = [ "Topic :: Software Development :: Widget Sets", "Topic :: Utilities", ] -requires-python = ">=3.9, <4" +requires-python = ">=3.10, <4" dependencies = [ "guidata >= 3.14.1", "PythonQwt >= 0.16", @@ -86,7 +85,7 @@ testpaths = ["plotpy"] exclude = [".git", ".vscode", "build", "dist","venv*",".venv*"] line-length = 88 # Same as Black. indent-width = 4 # Same as Black. -target-version = "py39" # Assume Python 3.9. +target-version = "py310" # Assume Python 3.10. [tool.ruff.lint] # all rules can be found here: https://beta.ruff.rs/docs/rules/ @@ -117,5 +116,5 @@ line-ending = "auto" # Like Black, automatically detect the appropr convention = "google" [tool.cibuildwheel] -build = ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"] +build = ["cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"] skip = ["*-manylinux_i686", "*-musllinux*"] diff --git a/scripts/build-wheels.sh b/scripts/build-wheels.sh index 062eca5e..92ea53b2 100644 --- a/scripts/build-wheels.sh +++ b/scripts/build-wheels.sh @@ -4,22 +4,22 @@ set -e -u -x ARCH=$(uname -m) export PLAT=manylinux_2_24_$ARCH -# Accurately check if Python version is 3.9 or greater -function is_python_version_ge_39 { +# Accurately check if Python version is 3.10 or greater +function is_python_version_ge_310 { local pydir="$1" - if [[ $pydir =~ cp([0-9]+)([0-9]+)-cp ]]; then + if [[ $pydir =~ cp([0-9])([0-9]+)-cp ]]; then local version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" - # Use sort with version sort flag to compare version against 3.9 - if [[ $(echo -e "3.9\n$version" | sort -V | head -n1) == "3.9" ]]; then - return 0 # True, version is >= 3.9 + # Use sort with version sort flag to compare version against 3.10 + if [[ $(echo -e "3.10\n$version" | sort -V | head -n1) == "3.10" ]]; then + return 0 # True, version is >= 3.10 fi fi - return 1 # False, version is < 3.9 + return 1 # False, version is < 3.10 } -# Compile wheels, only for CPython 3.9+ +# Compile wheels, only for CPython 3.10+ for PYDIR in /opt/python/*; do - if is_python_version_ge_39 "$PYDIR"; then + if is_python_version_ge_310 "$PYDIR"; then PYBIN="$PYDIR/bin" "${PYBIN}/pip" install -r /io/requirements.txt "${PYBIN}/pip" wheel /io/ --no-deps -w wheelhouse/ @@ -33,9 +33,9 @@ for wheel in wheelhouse/*.whl; do fi done -# Install packages and test, only for CPython 3.9+ +# Install packages and test, only for CPython 3.10+ for PYDIR in /opt/python/*; do - if is_python_version_ge_39 "$PYDIR"; then + if is_python_version_ge_310 "$PYDIR"; then PYBIN="$PYDIR/bin" "${PYBIN}/pip" install plotpy --no-index -f /io/wheelhouse (cd "$HOME"; INSTDIR=$("${PYBIN}/python" -c "import plotpy, os.path as osp; print(osp.dirname(plotpy.__file__))"); export QT_QPA_PLATFORM=offscreen; "${PYBIN}/pytest" "$INSTDIR") From 42d5da4cd081eac2f66328d0fa31439c3b1c4031 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:13:24 +0100 Subject: [PATCH 09/12] fix(fit): keep parameter sliders usable when a value is outside their range The slider position was computed without clipping, so a fitted value far outside the [min, max] range of its parameter (unconstrained optimization methods) exceeded the 32-bit range of QSlider.setValue: OverflowError, slider signals left blocked and following parameters not updated. Non-finite values, empty ranges and log scale values below min - 1 raised similar errors. - Clip the slider position to the slider range, using the inverse mapping of slider_value_changed, and skip undefined positions - Restore the slider signal-blocking state in a finally clause - Show a warning icon with a tooltip when the value is outside the range - Reject non-finite values typed in the parameter field - Require at least 2 slider steps in the parameter properties dialog Fixes #76 (cherry picked from commit b38c0a409db4415df85135a4067ddc882da730e7) Assisted-by: Claude Opus 5.5 --- plotpy/locale/fr/LC_MESSAGES/plotpy.po | 4 + plotpy/tests/unit/test_fit_slider.py | 143 +++++++++++++++++++++++++ plotpy/widgets/fit.py | 77 ++++++++++--- 3 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 plotpy/tests/unit/test_fit_slider.py diff --git a/plotpy/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index 74942d18..a5cc4c1f 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -1628,6 +1628,10 @@ msgstr "Paramètre d'ajustement" msgid "Edit '{name}' fit parameter properties" msgstr "Modifier les propriétés du paramètre d'ajustement '{name}'" +#, python-brace-format +msgid "This value is outside the slider range ({vmin} to {vmax})" +msgstr "Cette valeur est en dehors de la plage du curseur ({vmin} à {vmax})" + msgid "Fit parameters" msgstr "Paramètres d'ajustement" diff --git a/plotpy/tests/unit/test_fit_slider.py b/plotpy/tests/unit/test_fit_slider.py new file mode 100644 index 00000000..04819908 --- /dev/null +++ b/plotpy/tests/unit/test_fit_slider.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Curve fitting parameter sliders: values outside the slider range, non-finite +values and invalid inputs keep the fit dialog usable.""" + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context + +from plotpy.widgets.fit import FitDialog, FitParam, FitParamDataSet + + +def _param( + value: float = 0.5, vmin: float = 0.0, vmax: float = 1.0, logscale: bool = False +) -> FitParam: + """Return a fit parameter with its widgets, updated for the given value""" + param = FitParam("p", 0.5, 0.0, 1.0, logscale=logscale) + param.create_widgets(None, lambda: None) + param.min, param.max, param.value = vmin, vmax, value + param.update() + return param + + +def _has_range_warning(param: FitParam) -> bool: + """Return True if the parameter shows the out-of-range warning""" + return param.range_warning.isVisible() and bool(param.lineedit.toolTip()) + + +@pytest.mark.parametrize("logscale", [False, True]) +@pytest.mark.parametrize("value", [1e15, 2.0, -1.0, -1e15]) +def test_value_outside_range_clamps_slider(value: float, logscale: bool): + """The slider stays at the nearest end and a warning is shown.""" + with qt_app_context(exec_loop=False): + param = _param(value, logscale=logscale) + expected = param.steps - 1 if value > param.max else 0 + assert param.slider.value() == expected + assert not param.slider.signalsBlocked() + assert param.value == value + assert _has_range_warning(param) + param.value = 0.5 + param.update() + assert not _has_range_warning(param) + + +@pytest.mark.parametrize( + "value, vmin, vmax", + [ + (np.nan, 0.0, 1.0), + (np.inf, 0.0, 1.0), + (-np.inf, 0.0, 1.0), + (2.0, 1.0, 1.0), + (np.float64(2.0), 1.0, 1.0), + (1e308, -1e308, 1e308), + ], +) +def test_undefined_slider_position(value: float, vmin: float, vmax: float): + """Non-finite values and empty ranges leave the slider position unchanged.""" + with qt_app_context(exec_loop=False): + param = _param(0.5) + position = param.slider.value() + param.min, param.max, param.value = vmin, vmax, value + param.update() + assert param.slider.value() == position + assert not param.slider.signalsBlocked() + + +@pytest.mark.parametrize("logscale", [False, True]) +def test_slider_mapping_round_trip(logscale: bool): + """Slider position -> value -> slider position gives the same position.""" + with qt_app_context(exec_loop=False): + param = _param(0.5, 0.0, 10.0, logscale) + last = param.steps - 1 + for position in (0, 1, 1234, last - 1, last): + param.slider_value_changed(position) + param.update_slider_value() + assert param.slider.value() == position + param.value = param.min + param.update_slider_value() + assert param.slider.value() == 0 + param.value = param.max + param.update_slider_value() + assert param.slider.value() == last + + +def test_autofit_with_value_far_outside_range(): + """All parameters are updated when the fit leaves a slider range.""" + x = np.linspace(0.0, 1.0, 50) + y = 1e6 * x + 3.0 + + def fitfunc(x: np.ndarray, params: list[float]) -> np.ndarray: + a, b = params + return a * x + b + + with qt_app_context(exec_loop=False): + # The fitted slope is 1e6 times the slider range width: its slider position + # used to exceed the 32-bit integer range accepted by `QSlider.setValue` + params = [FitParam("a", 0.5, 0.0, 1.0), FitParam("b", 0.0, -5.0, 5.0)] + dialog = FitDialog(edit=True, auto_fit=True) + dialog.set_data(x, y, fitfunc, params) + dialog.autofit() + a, b = params + assert a.value == pytest.approx(1e6) + assert b.value == pytest.approx(3.0) + for param in params: + assert param.lineedit.text() == param.format % param.value + assert not param.slider.signalsBlocked() + assert a.slider.value() == a.steps - 1 + assert _has_range_warning(a) + assert not _has_range_warning(b) + dialog.close() + + +@pytest.mark.parametrize("text", ["nan", "inf", "-inf", "abc", ""]) +def test_line_edit_rejects_invalid_values(text: str): + """Typing a non-numeric or non-finite value restores the previous value.""" + with qt_app_context(exec_loop=False): + param = _param(0.5) + param.lineedit.setText(text) + param.line_editing_finished() + assert param.value == 0.5 + assert param.lineedit.text() == param.format % 0.5 + + +def test_line_edit_accepts_large_values(): + """A large finite value is accepted, with the slider at its end.""" + with qt_app_context(exec_loop=False): + param = _param(0.5) + param.lineedit.setText("1e300") + param.line_editing_finished() + assert param.value == 1e300 + assert param.slider.value() == param.steps - 1 + assert _has_range_warning(param) + + +def test_steps_minimum(): + """The parameter properties dialog requires at least two slider steps.""" + assert not FitParamDataSet.steps.check_value(1) + assert FitParamDataSet.steps.check_value(2) diff --git a/plotpy/widgets/fit.py b/plotpy/widgets/fit.py index 3fbf7195..20ec89ce 100644 --- a/plotpy/widgets/fit.py +++ b/plotpy/widgets/fit.py @@ -62,7 +62,7 @@ restore_dataset, update_dataset, ) -from guidata.qthelpers import create_groupbox, exec_dialog +from guidata.qthelpers import create_groupbox, exec_dialog, get_std_icon # Do not remove this import (used by optimization funcs) from numpy import inf # noqa: F401 @@ -121,7 +121,7 @@ class FitParamDataSet(DataSet): value = FloatItem(_("Value"), default=0.0) min = FloatItem(_("Min"), default=-1.0) max = FloatItem(_("Max"), default=1.0).set_pos(col=1) - steps = IntItem(_("Steps"), default=5000) + steps = IntItem(_("Steps"), default=5000, min=2) format = StringItem(_("Format"), default="%.3f").set_pos(col=1) logscale = BoolItem(_("Logarithmic"), _("Scale")) unit = StringItem(_("Unit"), default="").set_pos(col=1) @@ -169,6 +169,7 @@ def __init__( self.locked = locked self.prefix_label = None self.lineedit = None + self.range_warning = None self.unit_label = None self.slider = None self.button = None @@ -216,6 +217,10 @@ def create_widgets(self, parent: QWidget, refresh_callback: Callable) -> None: self.button.clicked.connect(lambda: self.edit_param(parent)) self.lineedit = QW.QLineEdit() self.lineedit.editingFinished.connect(self.line_editing_finished) + self.range_warning = self.lineedit.addAction( + get_std_icon("MessageBoxWarning"), QW.QLineEdit.TrailingPosition + ) + self.range_warning.setVisible(False) self.unit_label = QW.QLabel(self.unit) self.slider = QW.QSlider() self.slider.setOrientation(QC.Qt.Horizontal) @@ -278,14 +283,40 @@ def set_text(self, fmt: str = None) -> None: self.locked or (self.value == self.min and self.max == self.min) ) self.lineedit.setDisabled(is_disabled) + self._update_range_warning() + + def _update_range_warning(self) -> None: + """Show a warning when the value is outside the slider range""" + out_of_range = bool( + self.value is not None + and self.min is not None + and self.max is not None + and np.isfinite(self.value) + and not self.min <= self.value <= self.max + ) + tooltip = "" + if out_of_range: + tooltip = _("This value is outside the slider range ({vmin} to {vmax})") + tooltip = tooltip.format( + vmin=self.format % self.min, vmax=self.format % self.max + ) + self.range_warning.setVisible(out_of_range) + self.range_warning.setToolTip(tooltip) + self.lineedit.setToolTip(tooltip) def line_editing_finished(self): """Line editing finished""" try: - self.value = float(self.lineedit.text()) + value = float(self.lineedit.text()) except ValueError: + value = np.nan + if not np.isfinite(value): + # Not a valid value (including "nan" and "inf"): restore the previous one self.set_text() + return + self.value = value self.update_slider_value() + self._update_range_warning() self._refresh_callback() def slider_value_changed(self, int_value: int) -> None: @@ -305,6 +336,29 @@ def slider_value_changed(self, int_value: int) -> None: self.set_text() self._refresh_callback() + def _get_slider_position(self) -> int | None: + """Return the slider position matching the value + + Values outside the [min, max] range give the position of the nearest end of + the slider. This is the inverse of the mapping of `slider_value_changed`. + + Returns: + Slider position, or None if the value is not finite or the range is empty + """ + vmin, vmax, value = self.min, self.max, self.value + width = vmax - vmin + if not (np.isfinite(value) and np.isfinite(width) and width > 0): + return None + if value <= vmin: + ratio = 0.0 + elif value >= vmax: + ratio = 1.0 + elif self.logscale: + ratio = np.log10(1 + value - vmin) / np.log10(1 + width) + else: + ratio = (value - vmin) / width + return int(round(float(ratio) * (self.steps - 1))) + def update_slider_value(self): """Update slider value""" if self.value is None or self.min is None or self.max is None: @@ -317,16 +371,13 @@ def update_slider_value(self): self.slider.setEnabled(not self.locked) if self.slider.parentWidget() and self.slider.parentWidget().isVisible(): self.slider.show() - if self.logscale: - value_delta = max([np.log10(1 + self.value - self.min), 0.0]) - total_delta = np.log10(1 + self.max - self.min) - else: - value_delta = self.value - self.min - total_delta = self.max - self.min - intval = int(self.steps * value_delta / total_delta) - self.slider.blockSignals(True) - self.slider.setValue(intval) - self.slider.blockSignals(False) + position = self._get_slider_position() + if position is not None: + was_blocked = self.slider.blockSignals(True) + try: + self.slider.setValue(position) + finally: + self.slider.blockSignals(was_blocked) def edit_param(self, parent: QWidget) -> None: """Edit param From 42451a3c87a7bd5ea062364e2c9eb84a6bb68532 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:13:27 +0100 Subject: [PATCH 10/12] docs: add release notes for the curve fitting slider fix (cherry picked from commit fbf22fd829a3106a1e4960ba9aee140d47430c6d) Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_2.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release_notes/release_2.11.md b/doc/release_notes/release_2.11.md index a2aa3633..71942eb3 100644 --- a/doc/release_notes/release_2.11.md +++ b/doc/release_notes/release_2.11.md @@ -8,6 +8,7 @@ 🛠️ Bug fixes: +* **Curve fitting dialog** — Running the automatic fit no longer raises `OverflowError: argument 1 overflowed` errors when a fitted value ends up far outside the range of its slider (e.g. with the Powell method on a fit range restricted to the tail of a peak). These errors left the slider of the parameter unresponsive, and the values of the following parameters were not displayed. Such values are now shown with a warning icon, whose tooltip gives the slider range, while the slider stays at its nearest end. Typing `nan` or `inf` in a parameter field now restores the previous value instead of raising errors, and the number of slider steps must be at least 2 in the parameter properties dialog (closes [Issue #76](https://github.com/PlotPyStack/PlotPy/issues/76)) * **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again ⚡ Performance: From 98bbc52664000b1b3ef87b0ff0c289d15fc997a3 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:03:19 +0100 Subject: [PATCH 11/12] fix(i18n): enable the French translation of "Polygon" The entry was flagged as fuzzy, so it was left out of the compiled catalog and "Polygon" was shown untranslated in the French interface. Assisted-by: Claude Opus 5.5 --- plotpy/locale/fr/LC_MESSAGES/plotpy.po | 1 - 1 file changed, 1 deletion(-) diff --git a/plotpy/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index a5cc4c1f..cbebc66a 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -1489,7 +1489,6 @@ msgstr "Sélection" msgid "Polyline" msgstr "Ligne brisée" -#, fuzzy msgid "Polygon" msgstr "Polygone" From 339b62626559d5352d25d76bed5157453313dcbf Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:03:21 +0100 Subject: [PATCH 12/12] chore(release): bump version to 2.12.0 and update release notes PlotPy 2.11.2 is not released: its changes, together with the Python 3.10 requirement, are now part of PlotPy 2.12.0. Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_2.11.md | 17 ----------------- doc/release_notes/release_2.12.md | 18 ++++++++++++++++++ plotpy/__init__.py | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 doc/release_notes/release_2.12.md diff --git a/doc/release_notes/release_2.11.md b/doc/release_notes/release_2.11.md index 71942eb3..e54197be 100644 --- a/doc/release_notes/release_2.11.md +++ b/doc/release_notes/release_2.11.md @@ -1,22 +1,5 @@ # Version 2.11 # -## Unreleased ## - -* Python 3.10 or later is now required, and wheels are no longer built for Python 3.9. Python 3.9 users must keep an earlier compatible release. - -## PlotPy Version 2.11.2 ## - -🛠️ Bug fixes: - -* **Curve fitting dialog** — Running the automatic fit no longer raises `OverflowError: argument 1 overflowed` errors when a fitted value ends up far outside the range of its slider (e.g. with the Powell method on a fit range restricted to the tail of a peak). These errors left the slider of the parameter unresponsive, and the values of the following parameters were not displayed. Such values are now shown with a warning icon, whose tooltip gives the slider range, while the slider stays at its nearest end. Typing `nan` or `inf` in a parameter field now restores the previous value instead of raising errors, and the number of slider steps must be at least 2 in the parameter properties dialog (closes [Issue #76](https://github.com/PlotPyStack/PlotPy/issues/76)) -* **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again - -⚡ Performance: - -* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change -* **Item tools and item list** — Updating the status of item-related tools and of the item list panel is no longer quadratic in the number of plot items -* **Image creation** — Creating an image item no longer recomputes the colormap lookup table when the same colormap, inversion and transparency settings were already used: the table (more than 1000 colormap evaluations) is computed once and reused, which saves a few milliseconds per image - ## PlotPy Version 2.11.1 ## 🛠️ Bug fixes: diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md new file mode 100644 index 00000000..8c727182 --- /dev/null +++ b/doc/release_notes/release_2.12.md @@ -0,0 +1,18 @@ +# Version 2.12 # + +## PlotPy Version 2.12.0 ## + +🛠️ Bug fixes: + +* **Curve fitting dialog** — Running the automatic fit no longer raises `OverflowError: argument 1 overflowed` errors when a fitted value ends up far outside the range of its slider (e.g. with the Powell method on a fit range restricted to the tail of a peak). These errors left the slider of the parameter unresponsive, and the values of the following parameters were not displayed. Such values are now shown with a warning icon, whose tooltip gives the slider range, while the slider stays at its nearest end. Typing `nan` or `inf` in a parameter field now restores the previous value instead of raising errors, and the number of slider steps must be at least 2 in the parameter properties dialog (closes [Issue #76](https://github.com/PlotPyStack/PlotPy/issues/76)) +* **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again + +⚡ Performance: + +* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change +* **Item tools and item list** — Updating the status of item-related tools and of the item list panel is no longer quadratic in the number of plot items +* **Image creation** — Creating an image item no longer recomputes the colormap lookup table when the same colormap, inversion and transparency settings were already used: the table (more than 1000 colormap evaluations) is computed once and reused, which saves a few milliseconds per image + +⚙️ Dependencies: + +* Python 3.10 or later is now required, and wheels are no longer built for Python 3.9. Python 3.9 users must keep an earlier compatible release (PlotPy 2.11.x) diff --git a/plotpy/__init__.py b/plotpy/__init__.py index 3b73b465..d89c0cf1 100644 --- a/plotpy/__init__.py +++ b/plotpy/__init__.py @@ -20,7 +20,7 @@ .. _GitHub: https://github.com/PierreRaybaut/plotpy """ -__version__ = "2.11.1" +__version__ = "2.12.0" __VERSION__ = tuple([int(number) for number in __version__.split(".")]) # --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools