diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6f75a..6932f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PythonQwt Releases -## Unreleased +## Version 0.17.0 ### Requirements @@ -9,6 +9,22 @@ ### Bug fixes - Fixed [Issue #113](https://github.com/PlotPyStack/PythonQwt/issues/113): after replacing the scale draw of an axis (`QwtPlot.setAxisScaleDraw`), the axis ticks could be misaligned with the grid lines and plot items, until the next scale division change (e.g. after panning or zooming): a replot did not fix it. The new scale draw was laid out before getting the position and length of the previous one, so that the axis reserved space for its end labels that the canvas did not take into account. The new scale draw now takes the geometry of the previous one, and the axis stays aligned with the canvas +- Fixed `QwtGraphic.toPixmap(size)`, `QwtGraphic.toImage()` and `QwtGraphic.toImage(size)`, which raised a `TypeError` (possibly aborting the process, as the painter was still active on the destroyed pixmap or image) instead of rendering the graphic: they now render it like `QwtGraphic.toPixmap()` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPlotMarker.legendIcon` for markers with a symbol, which raised an `AssertionError` or a `TypeError`: such markers can now be shown in a legend ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPlotGrid.make`, which raised a `TypeError` when only some of the minor pen arguments (`mincolor`, `minwidth`, `minstyle`) were given: each of them is now honored, the missing ones taking their documented default value ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPowerTransform`, which raised a `ValueError` when drawing a curve on an axis with a power scale (curve data are mapped as NumPy arrays), and an `AttributeError` with NumPy < 2.0 (`numpy.pow` does not exist there): it now transforms scalars (with unchanged results) as well as NumPy arrays, with any supported NumPy version ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPlotDirectPainter.setAttribute`, which did not set or clear attributes as requested: enabling `AtomicPainter` twice disabled it, and `FullRepaint` or `CopyBackingStore` could not be disabled anymore once enabled ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed painting into a `QwtGraphic` with clipping enabled, which raised a `TypeError` on each paint operation (aborting the process with PyQt's default exception hook): clipped paint operations are now recorded, and the bounding rectangle of the graphic is clipped as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed the `QwtPainterCommand` copy constructor (`QwtPainterCommand(other)`), which raised an `AttributeError` for state commands and a `TypeError` (Qt objects cannot be pickled) for pixmap and image commands: commands of every type can now be copied, the copy being independent of the original ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtDynGridLayout` (used by `QwtLegend`), whose `numRows()` returned a float (e.g. `2.5` for 3 items on 2 columns) and which raised a `TypeError` when laying out its items with vertical expansion (`setExpandingDirections(Qt.Vertical)`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed canvas style sheets with rounded borders (e.g. `plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;")`), which raised a `TypeError` on each style change, resize and paint event (aborting the process with PyQt's default exception hook): the rounded border of the style sheet is now taken into account to clip the plot items and to fill the corners with the background of the parent widget, as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed the image formats listed by the file dialog of `QwtPlotRenderer.exportTo`, which read `*.b'bmp' *.b'png' ...` instead of `*.bmp *.png ...` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtInterval.intersect` (and the `&` operator), which returned an invalid interval for partially overlapping intervals (e.g. `QwtInterval(0, 2) & QwtInterval(1, 3)` instead of `[1, 2]`), and `QwtInterval.intersects`, which gave the opposite result for intervals sharing a bound (e.g. `False` for `[0, 1]` and `[1, 2]`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) + +### Other changes + +- The source distribution now includes the Qt Designer plugin (`qtdesigner/qwtplugin.py`, see the "Qt Designer" section of the documentation) and this changelog ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Documentation: the reference of the `qwt.qtdesigner` module, whose docstrings follow the Google style, is now rendered properly (it produced formatting errors), and `QwtDateTimeScaleDraw` and `QwtDateTimeScaleEngine` are now documented ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/MANIFEST.in b/MANIFEST.in index f814f5f..608fba5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ graft doc -include *.desktop \ No newline at end of file +include *.desktop +include CHANGELOG.md +include qtdesigner/*.py \ No newline at end of file diff --git a/doc/conf.py b/doc/conf.py index 5969162..2f92ca9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -19,7 +19,7 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc"] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] try: import sphinx.ext.viewcode # noqa: F401 diff --git a/qwt/__init__.py b/qwt/__init__.py index 5519dd2..6bfa467 100644 --- a/qwt/__init__.py +++ b/qwt/__init__.py @@ -63,7 +63,7 @@ from qwt.text import QwtText # noqa: F401 from qwt.toqimage import array_to_qimage as toQImage # noqa: F401 -__version__ = "0.16.3" +__version__ = "0.17.0" QWT_VERSION_STR = "6.1.5" diff --git a/qwt/dyngrid_layout.py b/qwt/dyngrid_layout.py index 45850da..56d2a28 100644 --- a/qwt/dyngrid_layout.py +++ b/qwt/dyngrid_layout.py @@ -159,7 +159,7 @@ def setGeometry(self, rect): if self.isEmpty(): return self.__data.numColumns = self.columnsForWidth(rect.width()) - self.__data.numRows = self.itemCount() / self.__data.numColumns + self.__data.numRows = self.itemCount() // self.__data.numColumns if self.itemCount() % self.__data.numColumns: self.__data.numRows += 1 itemGeometries = self.layoutItems(rect, self.__data.numColumns) @@ -314,7 +314,7 @@ def stretchGrid(self, rect, numColumns, rowHeight, colWidth): colWidth[col] += space xDelta -= space if expandV: - numRows = self.itemCount() / numColumns + numRows = self.itemCount() // numColumns if self.itemCount() % numColumns: numRows += 1 yDelta = rect.height() - hmargins - (numRows - 1) * self.spacing() diff --git a/qwt/graphic.py b/qwt/graphic.py index 9e18a4b..5370265 100644 --- a/qwt/graphic.py +++ b/qwt/graphic.py @@ -15,7 +15,7 @@ import math -from qtpy.QtCore import QObject, QPointF, QRect, QRectF, QSize, QSizeF, Qt +from qtpy.QtCore import QObject, QPointF, QRectF, QSize, QSizeF, Qt from qtpy.QtGui import ( QImage, QPaintEngine, @@ -603,7 +603,7 @@ def toPixmap(self, *args): aspectRatioMode = args[-1] pixmap = QPixmap(size) pixmap.fill(Qt.transparent) - r = QRect(0, 0, size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) painter = QPainter(pixmap) self.render(painter, r, aspectRatioMode) painter.end() @@ -652,7 +652,7 @@ def toImage(self, *args): h = math.ceil(sz.height()) image = QImage(w, h, QImage.Format_ARGB32) image.fill(0) - r = QRect(0, 0, sz.width(), sz.height()) + r = QRectF(0.0, 0.0, sz.width(), sz.height()) painter = QPainter(image) self.render(painter, r, Qt.KeepAspectRatio) painter.end() @@ -664,9 +664,10 @@ def toImage(self, *args): aspectRatioMode = args[-1] image = QImage(size, QImage.Format_ARGB32_Premultiplied) image.fill(0) - r = QRect(0, 0, size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) painter = QPainter(image) self.render(painter, r, aspectRatioMode) + painter.end() return image def drawPath(self, path): @@ -756,7 +757,7 @@ def updateBoundingRect(self, rect): br = QRectF(rect) painter = self.paintEngine().painter() if painter and painter.hasClipping(): - cr = painter.clipRegion().boundingRect() + cr = QRectF(painter.clipRegion().boundingRect()) cr = painter.transform().mapRect(cr) br &= cr if self.__data.boundingRect.width() < 0: diff --git a/qwt/interval.py b/qwt/interval.py index bd2a5df..4b51753 100644 --- a/qwt/interval.py +++ b/qwt/interval.py @@ -296,7 +296,7 @@ def intersect(self, other): if i1.borderFlags() & self.ExcludeMinimum: i1, i2 = i2, i1 - if i1.maxValue() < i2.maxValue(): + if i1.maxValue() < i2.minValue(): return QwtInterval() if i1.maxValue() == i2.minValue(): @@ -346,9 +346,9 @@ def intersects(self, other): if i1.maxValue() > i2.minValue(): return True elif i1.maxValue() == i2.minValue(): - return ( + return not ( i1.borderFlags() & self.ExcludeMaximum - and i2.borderFlags() & self.ExcludeMinimum + or i2.borderFlags() & self.ExcludeMinimum ) return False diff --git a/qwt/painter_command.py b/qwt/painter_command.py index d923be9..3fdf225 100644 --- a/qwt/painter_command.py +++ b/qwt/painter_command.py @@ -13,9 +13,44 @@ :members: """ -import copy - -from qtpy.QtGui import QPaintEngine, QPainterPath +from qtpy.QtCore import QPointF, QRectF +from qtpy.QtGui import ( + QBrush, + QFont, + QImage, + QPaintEngine, + QPainterPath, + QPen, + QPixmap, + QRegion, + QTransform, +) + +# Qt value types stored in painter command data, copied with their copy +# constructor (other stored values are immutable: enums, flags, numbers...) +_QT_VALUE_TYPES = ( + QBrush, + QFont, + QImage, + QPainterPath, + QPen, + QPixmap, + QPointF, + QRectF, + QRegion, + QTransform, +) + + +def _copy_data(data): + """Return a copy of painter command data, like the Qwt C++ copy + constructors of `PixmapData`, `ImageData` and `StateData`""" + result = data.__class__() + for name, value in vars(data).items(): + if isinstance(value, _QT_VALUE_TYPES): + value = type(value)(value) + setattr(result, name, value) + return result def _flag_int(flag): @@ -215,11 +250,11 @@ def copy(self, other): if other.__type == self.Path: self.__path = QPainterPath(other.__path) elif other.__type == self.Pixmap: - self.__pixmapData = copy.deepcopy(other.__pixmapData) + self.__pixmapData = _copy_data(other.__pixmapData) elif other.__type == self.Image: - self.__imageData = copy.deepcopy(other.__imageData) + self.__imageData = _copy_data(other.__imageData) elif other.__type == self.State: - self.__stateData == copy.deepcopy(other.__stateData) + self.__stateData = _copy_data(other.__stateData) def reset(self): self.__type = self.Invalid diff --git a/qwt/plot_canvas.py b/qwt/plot_canvas.py index 9205e44..720bb8d 100644 --- a/qwt/plot_canvas.py +++ b/qwt/plot_canvas.py @@ -16,7 +16,17 @@ import weakref from collections.abc import Sequence -from qtpy.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt +from qtpy.QtCore import ( + QEvent, + QObject, + QPoint, + QPointF, + QRect, + QRectF, + QSize, + QSizeF, + Qt, +) from qtpy.QtGui import ( QBrush, QGradient, @@ -81,7 +91,7 @@ def drawRects(self, rects, count): ) def drawPath(self, path): - rect = QRectF(QPointF(0.0, 0.0), self.__size) + rect = QRectF(QPointF(0.0, 0.0), QSizeF(self.__size)) if path.controlPointRect().contains(rect.center()): self.setCornerRects(path) self.alignCornerRects(rect) @@ -188,9 +198,11 @@ def qwtCombinePathList(rect, pathList): if not pathList: return QPainterPath() - ordered = [None] * 8 + rect = QRectF(rect) + ordered = [QPainterPath() for _i in range(8)] for subPath in pathList: index = -1 + subPath = QPainterPath(subPath) br = subPath.controlPointRect() if br.center().x() < rect.center().x(): if br.center().y() < rect.center().y(): @@ -199,7 +211,7 @@ def qwtCombinePathList(rect, pathList): else: index = 0 else: - if abs(br.bottom() - rect.bottom) < abs(br.left() - rect.left()): + if abs(br.bottom() - rect.bottom()) < abs(br.left() - rect.left()): index = 6 else: index = 7 @@ -297,7 +309,8 @@ def qwtFillBackground(*args): else: clipRegion = widget.contentsRect() bgWidget = qwtBackgroundWidget(widget.parentWidget()) - for rect in fillRects: + for fillRect in fillRects: + rect = QRectF(fillRect).toAlignedRect() if clipRegion.intersects(rect): pm = QPixmap(rect.size()) QwtPainter.fillPixmap( diff --git a/qwt/plot_directpainter.py b/qwt/plot_directpainter.py index 06697d3..c566277 100644 --- a/qwt/plot_directpainter.py +++ b/qwt/plot_directpainter.py @@ -119,12 +119,13 @@ def setAttribute(self, attribute, on=True): :py:meth:`testAttribute()` """ - if self.testAttribute(attribute) != on: - self.__data.attributes |= attribute - else: - self.__data.attributes &= ~attribute - if attribute == self.AtomicPainter and on: - self.reset() + if bool(self.testAttribute(attribute)) != on: + if on: + self.__data.attributes |= attribute + else: + self.__data.attributes &= ~attribute + if attribute == self.AtomicPainter and on: + self.reset() def testAttribute(self, attribute): """ diff --git a/qwt/plot_grid.py b/qwt/plot_grid.py index a75a07c..e72bd32 100644 --- a/qwt/plot_grid.py +++ b/qwt/plot_grid.py @@ -106,8 +106,8 @@ def make( item.setPen(QPen(color, width, style)) if mincolor is not None or minwidth is not None or minstyle is not None: mincolor = qcolor_from_str(mincolor, Qt.gray) - minwidth = 1.0 if width is None else minwidth - minstyle = Qt.DotLine if style is None else minstyle + minwidth = 1.0 if minwidth is None else float(minwidth) + minstyle = Qt.DotLine if minstyle is None else minstyle item.setMinorPen(QPen(mincolor, minwidth, minstyle)) if enablemajor is not None: if isinstance(enablemajor, tuple) and len(enablemajor) == 2: diff --git a/qwt/plot_layout.py b/qwt/plot_layout.py index 0919914..70424ef 100644 --- a/qwt/plot_layout.py +++ b/qwt/plot_layout.py @@ -746,9 +746,7 @@ def expandLineBreaks(self, options, rect): :param options: Options how to layout the legend :param QRectF rect: Bounding rectangle for title, footer, axes and canvas. - :return: tuple `(dimTitle, dimFooter, dimAxes)` - - Returns: + :return: tuple `(dimTitle, dimFooter, dimAxes)`, where: * `dimTitle`: Expanded height of the title widget * `dimFooter`: Expanded height of the footer widget diff --git a/qwt/plot_marker.py b/qwt/plot_marker.py index 1db25cd..8de25d5 100644 --- a/qwt/plot_marker.py +++ b/qwt/plot_marker.py @@ -13,7 +13,7 @@ :members: """ -from qtpy.QtCore import QLineF, QObject, QPointF, QRect, QRectF, QSizeF, Qt +from qtpy.QtCore import QLineF, QObject, QPointF, QRectF, QSizeF, Qt from qtpy.QtGui import QPainter, QPen from qwt.graphic import QwtGraphic @@ -628,6 +628,6 @@ def legendIcon(self, index, size): x = 0.5 * size.width() painter.drawLine(QLineF(x, 0.0, x, size.height())) if self.__data.symbol: - r = QRect(0, 0, size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) self.__data.symbol.drawSymbol(painter, r) return icon diff --git a/qwt/plot_renderer.py b/qwt/plot_renderer.py index d8b2640..44e72e5 100644 --- a/qwt/plot_renderer.py +++ b/qwt/plot_renderer.py @@ -722,7 +722,7 @@ def exportTo(self, plot, documentname, sizeMM=None, resolution=85): for idx, fmt in enumerate(imageFormats): if idx > 0: imageFilter += " " - imageFilter += "*." + str(fmt) + imageFilter += "*." + bytes(fmt).decode() imageFilter += ")" filter_ += [imageFilter] filename, _s = getsavefilename( diff --git a/qwt/scale_draw.py b/qwt/scale_draw.py index 15c4d5b..348f26a 100644 --- a/qwt/scale_draw.py +++ b/qwt/scale_draw.py @@ -17,6 +17,12 @@ .. autoclass:: QwtScaleDraw :members: + +QwtDateTimeScaleDraw +-------------------- + +.. autoclass:: QwtDateTimeScaleDraw + :members: """ import math @@ -1294,7 +1300,7 @@ def get_format(self) -> str: """Get the current datetime format string Returns: - str: Format string + Format string """ return self._format @@ -1313,7 +1319,7 @@ def label(self, value: float) -> QwtText: value: Unix timestamp (seconds since epoch) Returns: - QwtText: Formatted label + Formatted label """ try: dt = datetime.fromtimestamp(value) diff --git a/qwt/scale_engine.py b/qwt/scale_engine.py index 8b6545f..ea63761 100644 --- a/qwt/scale_engine.py +++ b/qwt/scale_engine.py @@ -23,6 +23,12 @@ .. autoclass:: QwtLogScaleEngine :members: + +QwtDateTimeScaleEngine +---------------------- + +.. autoclass:: QwtDateTimeScaleEngine + :members: """ import math diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py new file mode 100644 index 0000000..ecc2f95 --- /dev/null +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -0,0 +1,766 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# (see LICENSE file for more details) + +""" +Tests for issue 115: porting bugs making several rarely used APIs fail. + +Each test covers a difference between the Python port and the Qwt C++ code, +which made the API raise an exception or ignore some of its arguments. +""" + +import contextlib +import math +import sys +import traceback + +import numpy as np +import pytest +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +import qwt.plot_renderer +from qwt import ( + QwtInterval, + QwtLegend, + QwtLinearScaleEngine, + QwtPlot, + QwtPlotCurve, + QwtPlotDirectPainter, + QwtPlotGrid, + QwtPlotItem, + QwtPlotMarker, + QwtPlotRenderer, + QwtScaleMap, + QwtSymbol, +) +from qwt.dyngrid_layout import QwtDynGridLayout +from qwt.graphic import QwtGraphic +from qwt.painter_command import QwtPainterCommand +from qwt.plot_canvas import QwtPlotCanvas, qwtCombinePathList +from qwt.plot_curve import series_to_polyline +from qwt.transform import QwtPowerTransform + +RED = QG.QColor(QC.Qt.red) + + +def _ensure_app(): + # A live QApplication must exist before constructing any QWidget or + # QPixmap, otherwise Qt aborts the process. Tests run in a shared + # interpreter, but no test keeps a persistent Python reference to the + # application, so the singleton may be garbage-collected between tests. + return QW.QApplication.instance() or QW.QApplication([]) + + +@contextlib.contextmanager +def _qt_exceptions(): + """Collect the exceptions raised in Qt virtual method overrides. + + PyQt aborts the process on such exceptions when the default exception + hook is installed: collecting them makes the test fail instead. + """ + errors = [] + old_hook = sys.excepthook + sys.excepthook = lambda *exc_info: errors.append(exc_info) + try: + yield errors + finally: + sys.excepthook = old_hook + + +def _format_exceptions(errors): + return "".join("".join(traceback.format_exception(*exc)) for exc in errors) + + +# --------------------------------------------------------------------------- +# QwtGraphic.toPixmap / QwtGraphic.toImage +# --------------------------------------------------------------------------- + + +def _make_graphic(): + """Return a graphic made of a red rectangle of 20 x 10 points""" + graphic = QwtGraphic() + path = QG.QPainterPath() + path.addRect(QC.QRectF(0.0, 0.0, 20.0, 10.0)) + painter = QG.QPainter(graphic) + painter.setPen(QC.Qt.NoPen) + painter.setBrush(RED) + painter.drawPath(path) + painter.end() + return graphic + + +def _to_qimage(result): + """Return result as a QImage (result: QPixmap or QImage)""" + if isinstance(result, QG.QPixmap): + return result.toImage() + return result + + +def _is_red(image, x, y): + color = image.pixelColor(x, y) + return color.alpha() == 255 and color.rgb() == RED.rgb() + + +@pytest.mark.parametrize("method", ("toPixmap", "toImage")) +def test_graphic_conversion_default_size(method): + """toPixmap() and toImage() render the graphic in its default size""" + app = _ensure_app() # keep a reference alive for the duration of the test + result = getattr(_make_graphic(), method)() + assert not result.isNull() + assert result.size() == QC.QSize(20, 10) + assert _is_red(_to_qimage(result), 10, 5) + del app + + +@pytest.mark.parametrize("method", ("toPixmap", "toImage")) +def test_graphic_conversion_given_size(method): + """toPixmap(size) and toImage(size) scale the graphic to the given size""" + app = _ensure_app() # keep a reference alive for the duration of the test + result = getattr(_make_graphic(), method)(QC.QSize(40, 20)) + assert not result.isNull() + assert result.size() == QC.QSize(40, 20) + image = _to_qimage(result) + assert _is_red(image, 20, 10) + assert _is_red(image, 2, 2) + assert _is_red(image, 37, 17) + del app + + +@pytest.mark.parametrize("method", ("toPixmap", "toImage")) +def test_graphic_conversion_keep_aspect_ratio(method): + """toPixmap(size, mode) and toImage(size, mode) honor the aspect ratio""" + app = _ensure_app() # keep a reference alive for the duration of the test + result = getattr(_make_graphic(), method)(QC.QSize(40, 40), QC.Qt.KeepAspectRatio) + assert result.size() == QC.QSize(40, 40) + image = _to_qimage(result) + # The 2:1 graphic is scaled to 40 x 20 and centered vertically + assert _is_red(image, 20, 20) + assert image.pixelColor(20, 2).alpha() == 0 + assert image.pixelColor(20, 37).alpha() == 0 + del app + + +def test_graphic_clipped_recording(): + """Clipped paint operations are recorded, with a clipped bounding rect""" + app = _ensure_app() # keep a reference alive for the duration of the test + pixmap = QG.QPixmap(4, 4) + pixmap.fill(QC.Qt.blue) + image = QG.QImage(4, 4, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.green) + path = QG.QPainterPath() + path.addRect(QC.QRectF(0.0, 0.0, 20.0, 20.0)) + subrect = QC.QRectF(0.0, 0.0, 4.0, 4.0) + graphic = QwtGraphic() + with _qt_exceptions() as errors: + painter = QG.QPainter(graphic) + painter.translate(5.0, 5.0) + painter.setClipRect(QC.QRectF(0.0, 0.0, 10.0, 10.0)) + painter.setPen(QC.Qt.NoPen) + painter.setBrush(RED) + painter.drawPath(path) + painter.drawPixmap(QC.QRectF(0.0, 0.0, 4.0, 4.0), pixmap, subrect) + painter.drawImage(QC.QRectF(8.0, 8.0, 4.0, 4.0), image, subrect) + painter.end() + assert not errors, _format_exceptions(errors) + assert len(graphic.commands()) > 3 + # Control points are not clipped, the bounding rectangle is + assert graphic.controlPointRect() == QC.QRectF(5.0, 5.0, 20.0, 20.0) + assert graphic.boundingRect() == QC.QRectF(5.0, 5.0, 10.0, 10.0) + del app + + +# --------------------------------------------------------------------------- +# QwtPlotMarker.legendIcon +# --------------------------------------------------------------------------- + + +def _make_symbol(): + return QwtSymbol( + QwtSymbol.Rect, QG.QBrush(RED), QG.QPen(QC.Qt.NoPen), QC.QSize(8, 8) + ) + + +@pytest.mark.parametrize( + "size", (QC.QSize(8, 8), QC.QSizeF(8.0, 8.0)), ids=("QSize", "QSizeF") +) +def test_marker_legend_icon_with_symbol(size): + """The legend icon of a marker with a symbol shows the symbol""" + app = _ensure_app() # keep a reference alive for the duration of the test + marker = QwtPlotMarker.make(xvalue=0.0, yvalue=0.0, symbol=_make_symbol()) + icon = marker.legendIcon(0, size) + assert not icon.isNull() + assert _is_red(icon.toImage(), 4, 4) + del app + + +def test_marker_with_symbol_in_legend(): + """A marker with a symbol can be shown in a legend""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = QwtPlot() + legend = QwtLegend() + plot.insertLegend(legend) + marker = QwtPlotMarker.make( + xvalue=1.0, yvalue=1.0, title="Marker", symbol=_make_symbol(), plot=plot + ) + marker.setItemAttribute(QwtPlotItem.Legend, True) + plot.updateLegend(marker) + label = legend.legendWidget(marker) + assert label is not None + assert not label.icon().isNull() + plot.close() + del app + + +# --------------------------------------------------------------------------- +# QwtPlotGrid.make +# --------------------------------------------------------------------------- + + +def _pen_attrs(pen): + return pen.color().name(), pen.widthF(), pen.style() + + +@pytest.mark.parametrize( + "kwargs, expected", + ( + ({"mincolor": "red"}, ("#ff0000", 1.0, QC.Qt.DotLine)), + ({"minwidth": 2}, ("#a0a0a4", 2.0, QC.Qt.DotLine)), + ({"minstyle": QC.Qt.DashLine}, ("#a0a0a4", 1.0, QC.Qt.DashLine)), + ({"mincolor": "red", "minwidth": 2}, ("#ff0000", 2.0, QC.Qt.DotLine)), + ( + {"mincolor": "red", "minstyle": QC.Qt.DashLine}, + ("#ff0000", 1.0, QC.Qt.DashLine), + ), + ( + {"minwidth": 3.0, "minstyle": QC.Qt.SolidLine}, + ("#a0a0a4", 3.0, QC.Qt.SolidLine), + ), + ( + {"mincolor": "blue", "minwidth": 0, "minstyle": QC.Qt.DashDotLine}, + ("#0000ff", 0.0, QC.Qt.DashDotLine), + ), + ), +) +def test_grid_make_minor_pen(kwargs, expected): + """QwtPlotGrid.make honors each minor pen argument, with documented defaults""" + app = _ensure_app() # keep a reference alive for the duration of the test + grid = QwtPlotGrid.make(color="green", width=4, style=QC.Qt.DashLine, **kwargs) + assert _pen_attrs(grid.minorPen()) == expected + assert _pen_attrs(grid.majorPen()) == ("#00ff00", 4.0, QC.Qt.DashLine) + del app + + +def test_grid_make_without_minor_pen(): + """Without minor pen arguments, both pens are built from the major ones""" + app = _ensure_app() # keep a reference alive for the duration of the test + grid = QwtPlotGrid.make(color="green", width=0, style=QC.Qt.SolidLine) + expected = ("#00ff00", 0.0, QC.Qt.SolidLine) + assert _pen_attrs(grid.majorPen()) == expected + assert _pen_attrs(grid.minorPen()) == expected + del app + + +# --------------------------------------------------------------------------- +# QwtPowerTransform +# --------------------------------------------------------------------------- + +POWER_VALUES = (-9.0, -2.5, -1.0, 0.0, 0.25, 1.0, 4.0, 100.0) + + +def _cpp_transform(value, exponent): + """Reference: QwtPowerTransform::transform of Qwt C++""" + if value < 0.0: + return -math.pow(-value, 1.0 / exponent) + return math.pow(value, 1.0 / exponent) + + +def _cpp_inv_transform(value, exponent): + """Reference: QwtPowerTransform::invTransform of Qwt C++""" + if value < 0.0: + return -math.pow(-value, exponent) + return math.pow(value, exponent) + + +@pytest.mark.parametrize("exponent", (2, 3.0, 0.5)) +def test_power_transform_scalars(exponent): + """Scalars are transformed as in Qwt C++, keeping their sign""" + transform = QwtPowerTransform(exponent) + for value in POWER_VALUES: + result = transform.transform(value) + assert np.ndim(result) == 0 + assert result == pytest.approx(_cpp_transform(value, exponent)) + result = transform.invTransform(value) + assert np.ndim(result) == 0 + assert result == pytest.approx(_cpp_inv_transform(value, exponent)) + + +@pytest.mark.parametrize("exponent", (2, 3.0, 0.5)) +def test_power_transform_arrays(exponent): + """Arrays are transformed element-wise, and back""" + transform = QwtPowerTransform(exponent) + values = np.array(POWER_VALUES) + expected = [_cpp_transform(value, exponent) for value in POWER_VALUES] + np.testing.assert_allclose(transform.transform(values), expected) + expected = [_cpp_inv_transform(value, exponent) for value in POWER_VALUES] + np.testing.assert_allclose(transform.invTransform(values), expected) + roundtrip = transform.invTransform(transform.transform(values)) + np.testing.assert_allclose(roundtrip, values, atol=1e-12) + + +def test_power_transform_without_numpy_pow(monkeypatch): + """QwtPowerTransform does not need `numpy.pow` (added in NumPy 2.0)""" + monkeypatch.delattr(np, "pow", raising=False) + transform = QwtPowerTransform(2) + assert transform.transform(-9.0) == pytest.approx(-3.0) + assert transform.invTransform(-3.0) == pytest.approx(-9.0) + + +def test_power_transform_scale_map(): + """A scale map with a power transformation maps arrays like scalars""" + smap = QwtScaleMap() + smap.setTransformation(QwtPowerTransform(2)) + smap.setScaleInterval(-100.0, 100.0) + smap.setPaintInterval(0.0, 400.0) + values = np.array(POWER_VALUES) + expected = [smap.transform(value) for value in POWER_VALUES] + np.testing.assert_allclose(smap.transform(values), expected) + + +def test_power_transform_curve_rendering(): + """A curve can be drawn on an axis with a power transformation""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = QwtPlot() + plot.resize(400, 300) + engine = QwtLinearScaleEngine() + engine.setTransformation(QwtPowerTransform(2)) + plot.setAxisScaleEngine(QwtPlot.yLeft, engine) + plot.setAxisScale(QwtPlot.yLeft, -100.0, 100.0) + x = np.linspace(-10.0, 10.0, 21) + curve = QwtPlotCurve.make(x, x * np.abs(x), plot=plot) + plot.replot() + + xmap = plot.canvasMap(QwtPlot.xBottom) + ymap = plot.canvasMap(QwtPlot.yLeft) + polyline = series_to_polyline(xmap, ymap, curve.data(), 0, x.size - 1) + for index in range(x.size): + point = polyline[index] + sample = curve.data().sample(index) + assert point.x() == pytest.approx(xmap.transform(sample.x())) + assert point.y() == pytest.approx(ymap.transform(sample.y())) + + image = QG.QImage(400, 300, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.white) + painter = QG.QPainter(image) + try: + QwtPlotRenderer().render(plot, painter, QC.QRectF(0.0, 0.0, 400.0, 300.0)) + finally: + painter.end() + plot.close() + del app + + +# --------------------------------------------------------------------------- +# QwtPlotDirectPainter.setAttribute +# --------------------------------------------------------------------------- + +DIRECT_PAINTER_ATTRIBUTES = ( + QwtPlotDirectPainter.AtomicPainter, + QwtPlotDirectPainter.FullRepaint, + QwtPlotDirectPainter.CopyBackingStore, +) + + +class _CountingDirectPainter(QwtPlotDirectPainter): + """Direct painter counting the calls to `reset`""" + + def __init__(self): + super().__init__() + self.reset_count = 0 + + def reset(self): + self.reset_count += 1 + super().reset() + + +@pytest.mark.parametrize( + "attribute", + DIRECT_PAINTER_ATTRIBUTES, + ids=("AtomicPainter", "FullRepaint", "CopyBackingStore"), +) +def test_direct_painter_set_attribute(attribute): + """setAttribute sets or clears an attribute, as requested""" + app = _ensure_app() # keep a reference alive for the duration of the test + painter = QwtPlotDirectPainter() + others = [attr for attr in DIRECT_PAINTER_ATTRIBUTES if attr != attribute] + for other in others: + painter.setAttribute(other, True) + assert not painter.testAttribute(attribute) + for on in (True, True, False, False, True): + painter.setAttribute(attribute, on) + assert bool(painter.testAttribute(attribute)) is on + assert all(painter.testAttribute(other) for other in others) + painter.setAttribute(attribute, False) + painter.setAttribute(attribute) # `on` defaults to True + assert painter.testAttribute(attribute) + del app + + +def test_direct_painter_atomic_painter_reset(): + """Enabling AtomicPainter resets the painter, as in Qwt C++""" + app = _ensure_app() # keep a reference alive for the duration of the test + painter = _CountingDirectPainter() + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, True) + assert painter.reset_count == 1 + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, True) # unchanged + assert painter.reset_count == 1 + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, False) + assert painter.reset_count == 1 + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, True) + assert painter.reset_count == 2 + painter.setAttribute(QwtPlotDirectPainter.FullRepaint, True) + assert painter.reset_count == 2 + del app + + +# --------------------------------------------------------------------------- +# QwtPainterCommand copy +# --------------------------------------------------------------------------- + +COMMAND_TYPES = { + QwtPainterCommand.Path: "path", + QwtPainterCommand.Pixmap: "pixmapData", + QwtPainterCommand.Image: "imageData", + QwtPainterCommand.State: "stateData", +} + + +def _make_recorded_graphic(): + """Return a graphic recording path, pixmap, image and state commands""" + pixmap = QG.QPixmap(4, 4) + pixmap.fill(QC.Qt.blue) + image = QG.QImage(4, 4, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.green) + path = QG.QPainterPath() + path.addEllipse(QC.QRectF(0.0, 0.0, 20.0, 10.0)) + graphic = QwtGraphic() + with _qt_exceptions() as errors: + painter = QG.QPainter(graphic) + painter.setRenderHint(QG.QPainter.Antialiasing) + painter.setPen(QG.QPen(RED, 2.0)) + painter.setBrush(QG.QBrush(QC.Qt.yellow)) + painter.setFont(QG.QFont("Arial", 12)) + painter.setOpacity(0.8) + painter.setClipRect(QC.QRectF(0.0, 0.0, 30.0, 30.0)) + painter.translate(1.0, 2.0) + painter.drawPath(path) + rect, subrect = QC.QRectF(5.0, 5.0, 4.0, 4.0), QC.QRectF(0.0, 0.0, 4.0, 4.0) + painter.drawPixmap(rect, pixmap, subrect) + painter.drawImage(rect.translated(7.0, 0.0), image, subrect) + painter.end() + assert not errors, _format_exceptions(errors) + return graphic + + +def _comparable(value): + """Return a value which can be compared with `==`""" + if isinstance(value, QG.QPixmap): + return value.toImage() + return value + + +def _command_data(command): + return getattr(command, COMMAND_TYPES[command.type()])() + + +def test_painter_command_copy(): + """Copying a painter command gives an equal command, for every type""" + app = _ensure_app() # keep a reference alive for the duration of the test + commands = _make_recorded_graphic().commands() + assert {command.type() for command in commands} == set(COMMAND_TYPES) + for command in commands: + copied = QwtPainterCommand(command) + assert copied.type() == command.type() + data, copied_data = _command_data(command), _command_data(copied) + assert copied_data is not data + if command.type() == QwtPainterCommand.Path: + assert copied_data == data + continue + for name, value in vars(data).items(): + copied_value = getattr(copied_data, name) + assert _comparable(copied_value) == _comparable(value), name + del app + + +def test_painter_command_copy_is_independent(): + """Modifying a copied painter command leaves the original unchanged""" + app = _ensure_app() # keep a reference alive for the duration of the test + for command in _make_recorded_graphic().commands(): + copied = QwtPainterCommand(command) + data, copied_data = _command_data(command), _command_data(copied) + if command.type() == QwtPainterCommand.Path: + reference = QG.QPainterPath(data) + copied_data.lineTo(100.0, 100.0) + assert data == reference + elif command.type() in (QwtPainterCommand.Pixmap, QwtPainterCommand.Image): + reference = QC.QRectF(data.rect) + copied_data.rect.setWidth(99.0) + assert data.rect == reference + elif copied_data.pen is not None: + reference = QG.QPen(data.pen) + copied_data.pen.setWidthF(9.0) + assert data.pen == reference + del app + + +def test_painter_command_copy_replay(): + """A graphic replaying copied commands renders like the original one""" + app = _ensure_app() # keep a reference alive for the duration of the test + graphic = _make_recorded_graphic() + replayed = QwtGraphic() + replayed.setCommands([QwtPainterCommand(cmd) for cmd in graphic.commands()]) + assert replayed.toImage() == graphic.toImage() + del app + + +# --------------------------------------------------------------------------- +# QwtDynGridLayout +# --------------------------------------------------------------------------- + + +def _make_grid_layout(count, max_columns): + widget = QW.QWidget() + layout = QwtDynGridLayout(widget) + layout.setMaxColumns(max_columns) + for index in range(count): + layout.addWidget(QW.QLabel(f"Item {index}")) + return widget, layout + + +@pytest.mark.parametrize( + "count, max_columns, rows", + ((1, 1, 1), (3, 2, 2), (4, 2, 2), (5, 3, 2), (6, 1, 6), (7, 3, 3)), +) +def test_dyngrid_layout_rows(count, max_columns, rows): + """The number of rows of the layout is an integer""" + app = _ensure_app() # keep a reference alive for the duration of the test + widget, layout = _make_grid_layout(count, max_columns) + layout.setGeometry(QC.QRect(0, 0, 2000, 2000)) + assert layout.numColumns() == max_columns + assert layout.numRows() == rows + assert isinstance(layout.numRows(), int) + widget.close() + del app + + +def test_dyngrid_layout_vertical_expanding(): + """Rows are stretched to fill the layout height with vertical expansion""" + app = _ensure_app() # keep a reference alive for the duration of the test + widget, layout = _make_grid_layout(3, 2) + layout.setExpandingDirections(QC.Qt.Vertical) + layout.setGeometry(QC.QRect(0, 0, 400, 300)) + geometries = [layout.itemAt(index).geometry() for index in range(3)] + assert geometries[0].top() == geometries[1].top() + assert geometries[2].top() > geometries[0].bottom() + margins = layout.contentsMargins() + assert geometries[2].bottom() + 1 + margins.bottom() == 300 + widget.close() + del app + + +# --------------------------------------------------------------------------- +# Canvas style sheets with rounded borders +# --------------------------------------------------------------------------- + + +def _curve_path(start, ctrl1, ctrl2, end): + path = QG.QPainterPath(QC.QPointF(*start)) + path.cubicTo(QC.QPointF(*ctrl1), QC.QPointF(*ctrl2), QC.QPointF(*end)) + return path + + +@pytest.mark.parametrize("rect_type", (QC.QRect, QC.QRectF)) +def test_combine_path_list_incomplete_border(rect_type): + """Incomplete rounded borders are not combined (as in Qwt C++)""" + rect = rect_type(0, 0, 100, 100) + assert qwtCombinePathList(rect, []).isEmpty() + corner = _curve_path((0.0, 10.0), (0.0, 7.0), (0.0, 6.0), (0.0, 5.0)) + assert qwtCombinePathList(rect, [corner]).isEmpty() + + +def test_combine_path_list_rounded_corner(): + """The sub-paths of a rounded corner are combined with the other corners""" + rect = QC.QRectF(0.0, 0.0, 100.0, 100.0) + left = _curve_path((0.0, 5.0), (0.0, 6.0), (0.0, 7.0), (0.0, 10.0)) + top = _curve_path((5.0, 0.0), (6.0, 0.0), (7.0, 0.0), (10.0, 0.0)) + references = [QG.QPainterPath(left), QG.QPainterPath(top)] + path = qwtCombinePathList(rect, [left, top]) + assert not path.isEmpty() + assert path.boundingRect() == rect + # The sub-paths are reverted when needed, but the input ones are unchanged + assert [left, top] == references + + +ROUNDED_STYLE_SHEETS = ( + "border-radius: 10px; background-color: white;", + "border: 2px solid black; border-radius: 8px;", + "border: 1px solid gray; border-top-left-radius: 12px; background-color: white;", +) + + +@pytest.mark.parametrize("backing_store", (False, True), ids=("direct", "backing")) +@pytest.mark.parametrize("style_sheet", ROUNDED_STYLE_SHEETS) +def test_canvas_rounded_style_sheet(style_sheet, backing_store): + """A canvas style sheet with rounded borders is supported""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = QwtPlot() + canvas = plot.canvas() + canvas.setPaintAttribute(QwtPlotCanvas.BackingStore, backing_store) + with _qt_exceptions() as errors: + canvas.setStyleSheet(style_sheet) + plot.resize(400, 300) + plot.show() + app.processEvents() + plot.replot() + image = canvas.grab().toImage() + border_path = canvas.borderPath(canvas.rect()) + assert not errors, _format_exceptions(errors) + assert not border_path.isEmpty() + if "background-color: white" in style_sheet: + # Rounded top left corner: filled with the background of the plot + white = QG.QColor(QC.Qt.white).rgb() + assert image.pixelColor(0, 0).rgb() != white + assert image.pixelColor(image.width() // 2, image.height() // 2).rgb() == white + plot.close() + del app + + +# --------------------------------------------------------------------------- +# QwtPlotRenderer.exportTo +# --------------------------------------------------------------------------- + + +def test_renderer_export_to_image_filter(monkeypatch): + """The file dialog of exportTo lists the image formats properly""" + app = _ensure_app() # keep a reference alive for the duration of the test + captured = [] + + def fake_getsavefilename(*args, **kwargs): + captured.append(args[3]) # filters + return "", "" # dialog canceled + + monkeypatch.setattr(qwt.plot_renderer, "getsavefilename", fake_getsavefilename) + plot = QwtPlot() + assert QwtPlotRenderer().exportTo(plot, "plot") is False + filters = captured[0].split(";;") + formats = QG.QImageWriter.supportedImageFormats() + expected = " ".join("*." + bytes(fmt).decode() for fmt in formats) + assert filters[-1] == f"Images ({expected})" + assert "*.png" in filters[-1].split() + assert "b'" not in captured[0] + plot.close() + del app + + +# --------------------------------------------------------------------------- +# QwtInterval.intersect / QwtInterval.intersects +# --------------------------------------------------------------------------- + +INC = QwtInterval.IncludeBorders +EXC_MIN = QwtInterval.ExcludeMinimum +EXC_MAX = QwtInterval.ExcludeMaximum + +# (interval 1, interval 2, expected intersection or None if invalid), +# intervals being (min, max, border flags); expected values follow Qwt C++ +INTERSECT_CASES = ( + ((0, 2, INC), (1, 3, INC), (1, 2, INC)), + ((1, 3, INC), (0, 2, INC), (1, 2, INC)), + ((0, 3, INC), (1, 2, INC), (1, 2, INC)), + ((0, 2, INC), (0, 2, INC), (0, 2, INC)), + ((0, 1, INC), (2, 3, INC), None), + ((0, 1, INC), (1, 2, INC), (1, 1, INC)), + ((0, 1, EXC_MAX), (1, 2, INC), None), + ((0, 1, INC), (1, 2, EXC_MIN), None), + ((0, 2, EXC_MIN), (1, 3, EXC_MAX), (1, 2, INC)), + ((0, 2, EXC_MAX), (1, 3, INC), (1, 2, EXC_MAX)), + ((0, 2, INC), (0, 1, EXC_MIN), (0, 1, EXC_MIN)), +) + + +def _interval(values): + return QwtInterval(*values) + + +@pytest.mark.parametrize("values1, values2, expected", INTERSECT_CASES) +def test_interval_intersect(values1, values2, expected): + """QwtInterval.intersect and `&` return the intersection of two intervals""" + interval1, interval2 = _interval(values1), _interval(values2) + for result in (interval1.intersect(interval2), interval1 & interval2): + if expected is None: + assert not result.isValid() + else: + assert result == _interval(expected) + + +def test_interval_intersect_invalid(): + """The intersection with an invalid interval is invalid""" + assert not QwtInterval().intersect(QwtInterval(0, 1)).isValid() + assert not QwtInterval(0, 1).intersect(QwtInterval()).isValid() + + +@pytest.mark.parametrize( + "values1, values2, expected", + ( + ((0, 2, INC), (1, 3, INC), True), + ((1, 3, INC), (0, 2, INC), True), + ((0, 3, INC), (1, 2, INC), True), + ((0, 1, INC), (2, 3, INC), False), + ((0, 1, INC), (1, 2, INC), True), + ((1, 2, INC), (0, 1, INC), True), + ((0, 1, EXC_MAX), (1, 2, INC), False), + ((0, 1, INC), (1, 2, EXC_MIN), False), + ((0, 1, EXC_MAX), (1, 2, EXC_MIN), False), + ), +) +def test_interval_intersects(values1, values2, expected): + """QwtInterval.intersects tells whether two intervals overlap""" + result = _interval(values1).intersects(_interval(values2)) + assert result is expected + + +def test_interval_intersects_invalid(): + """An invalid interval does not intersect any interval""" + assert QwtInterval().intersects(QwtInterval(0, 1)) is False + assert QwtInterval(0, 1).intersects(QwtInterval()) is False + + +if __name__ == "__main__": + for name in ("toPixmap", "toImage"): + test_graphic_conversion_default_size(name) + test_graphic_conversion_given_size(name) + test_graphic_conversion_keep_aspect_ratio(name) + test_graphic_clipped_recording() + for size in (QC.QSize(8, 8), QC.QSizeF(8.0, 8.0)): + test_marker_legend_icon_with_symbol(size) + test_marker_with_symbol_in_legend() + test_grid_make_without_minor_pen() + test_power_transform_scale_map() + test_power_transform_curve_rendering() + for attr in DIRECT_PAINTER_ATTRIBUTES: + test_direct_painter_set_attribute(attr) + test_direct_painter_atomic_painter_reset() + test_painter_command_copy() + test_painter_command_copy_is_independent() + test_painter_command_copy_replay() + test_dyngrid_layout_vertical_expanding() + test_combine_path_list_rounded_corner() + for sheet in ROUNDED_STYLE_SHEETS: + for backing in (False, True): + test_canvas_rounded_style_sheet(sheet, backing) + for values1, values2, expected in INTERSECT_CASES: + test_interval_intersect(values1, values2, expected) + test_interval_intersect_invalid() + test_interval_intersects_invalid() + print("OK") diff --git a/qwt/transform.py b/qwt/transform.py index 00b72a7..8a47df8 100644 --- a/qwt/transform.py +++ b/qwt/transform.py @@ -207,12 +207,14 @@ def copy(self): class QwtPowerTransform(QwtTransform): """ - A transformation using `numpy.pow()` + A transformation using `numpy.power()` `QwtPowerTransform` preserves the sign of a value. F.e. a transformation with a factor of 2 transforms a value of -3 to -9 and v.v. Thus `QwtPowerTransform` can be used for scales including negative values. + + Values may be scalars or NumPy arrays (transformed element-wise). """ def __init__(self, exponent): @@ -223,33 +225,35 @@ def transform(self, value): """ Transformation function - :param float value: Value + :param value: Value (scalar or NumPy array) :return: Modified value .. seealso:: :py:meth:`invTransform()` """ - if value < 0.0: - return -np.pow(-value, 1.0 / self.__exponent) - else: - return np.pow(value, 1.0 / self.__exponent) + return self.__signed_power(value, 1.0 / self.__exponent) def invTransform(self, value): """ Inverse transformation function - :param float value: Value + :param value: Value (scalar or NumPy array) :return: Modified value .. seealso:: :py:meth:`transform()` """ - if value < 0.0: - return -np.pow(-value, self.__exponent) - else: - return np.pow(value, self.__exponent) + return self.__signed_power(value, self.__exponent) + + @staticmethod + def __signed_power(value, exponent): + # Same as Qwt C++ (`value < 0.0 ? -pow(-value, e) : pow(value, e)`), + # element-wise for arrays: scalars give the same results as before + negative = np.less(value, 0.0) + result = np.power(np.where(negative, np.abs(value), value), exponent) + return np.where(negative, np.negative(result), result)[()] def copy(self): """