diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ed3c583..0fde6ae 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/build.yml b/.github/workflows/build.yml index 8171c69..ba13665 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 5447018..f2a14f4 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 70c99af..8edb2bc 100644 --- a/.github/workflows/test_pyqt5.yml +++ b/.github/workflows/test_pyqt5.yml @@ -22,12 +22,12 @@ 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@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 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 0524656..49e185d 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/doc/dev/platforms.rst b/doc/dev/platforms.rst index 621c19e..ee11f09 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.12.md b/doc/release_notes/release_2.12.md new file mode 100644 index 0000000..8c72718 --- /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/doc/requirements.rst b/doc/requirements.rst index 4026339..bf7b24c 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/plotpy/__init__.py b/plotpy/__init__.py index 3b73b46..d89c0cf 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 diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index 0a251d8..e3f80a7 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/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index 74942d1..cbebc66 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" @@ -1628,6 +1627,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/panels/csection/csplot.py b/plotpy/panels/csection/csplot.py index eb56721..5f84fb1 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/panels/itemlist.py b/plotpy/panels/itemlist.py index 342773a..bf4d5e7 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/plot/base.py b/plotpy/plot/base.py index 7e978fa..df6ed66 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) 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 0000000..c16c441 --- /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) 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 0000000..1f6dab9 --- /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() diff --git a/plotpy/tests/unit/test_fit_slider.py b/plotpy/tests/unit/test_fit_slider.py new file mode 100644 index 0000000..0481990 --- /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/tools/image.py b/plotpy/tools/image.py index bb96a2f..614764a 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 3813d46..bdb6f2f 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: """ diff --git a/plotpy/widgets/fit.py b/plotpy/widgets/fit.py index 3fbf719..20ec89c 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 diff --git a/pyproject.toml b/pyproject.toml index cc5bb73..f21399f 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 062eca5..92ea53b 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") diff --git a/scripts/rtd_install_dev_deps.py b/scripts/rtd_install_dev_deps.py new file mode 100644 index 0000000..d9e1b87 --- /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()