Skip to content

Commit f266dd8

Browse files
committed
fix(transform): support arrays and NumPy < 2 in QwtPowerTransform
QwtPowerTransform could not be used to draw curves: curve data are mapped by QwtScaleMap.transform as NumPy arrays, and the scalar test `if value < 0.0` raised "ValueError: The truth value of an array with more than one element is ambiguous". It also relied on numpy.pow, which was added in NumPy 2.0, whereas PythonQwt requires NumPy >= 1.21. - Apply the Qwt C++ formula (value < 0.0 ? -pow(-value, e) : pow(value, e)) element-wise with numpy.less, numpy.where and numpy.power. For scalars, results are bit-for-bit identical to the previous implementation (value, type, sign of zero, infinities, NaN and warnings), as checked on 364 scalar cases. - Regression tests: scalars against the C++ formula, arrays and round trip, numpy.pow unavailable (simulating NumPy < 2.0), scale map on arrays, curve rendered on a power-scaled axis. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5
1 parent 86b10a9 commit f266dd8

3 files changed

Lines changed: 126 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- 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))
1313
- 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))
1414
- 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))
15+
- 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))
1516

1617
## Version 0.16.3
1718

‎qwt/tests/test_issue115_porting_bugs.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,29 @@
1010
which made the API raise an exception or ignore some of its arguments.
1111
"""
1212

13+
import math
14+
15+
import numpy as np
1316
import pytest
1417
from qtpy import QtCore as QC
1518
from qtpy import QtGui as QG
1619
from qtpy import QtWidgets as QW
1720

1821
from qwt import (
1922
QwtLegend,
23+
QwtLinearScaleEngine,
2024
QwtPlot,
25+
QwtPlotCurve,
2126
QwtPlotGrid,
2227
QwtPlotItem,
2328
QwtPlotMarker,
29+
QwtPlotRenderer,
30+
QwtScaleMap,
2431
QwtSymbol,
2532
)
2633
from qwt.graphic import QwtGraphic
34+
from qwt.plot_curve import series_to_polyline
35+
from qwt.transform import QwtPowerTransform
2736

2837
RED = QG.QColor(QC.Qt.red)
2938

@@ -196,6 +205,105 @@ def test_grid_make_without_minor_pen():
196205
del app
197206

198207

208+
# ---------------------------------------------------------------------------
209+
# QwtPowerTransform
210+
# ---------------------------------------------------------------------------
211+
212+
POWER_VALUES = (-9.0, -2.5, -1.0, 0.0, 0.25, 1.0, 4.0, 100.0)
213+
214+
215+
def _cpp_transform(value, exponent):
216+
"""Reference: QwtPowerTransform::transform of Qwt C++"""
217+
if value < 0.0:
218+
return -math.pow(-value, 1.0 / exponent)
219+
return math.pow(value, 1.0 / exponent)
220+
221+
222+
def _cpp_inv_transform(value, exponent):
223+
"""Reference: QwtPowerTransform::invTransform of Qwt C++"""
224+
if value < 0.0:
225+
return -math.pow(-value, exponent)
226+
return math.pow(value, exponent)
227+
228+
229+
@pytest.mark.parametrize("exponent", (2, 3.0, 0.5))
230+
def test_power_transform_scalars(exponent):
231+
"""Scalars are transformed as in Qwt C++, keeping their sign"""
232+
transform = QwtPowerTransform(exponent)
233+
for value in POWER_VALUES:
234+
result = transform.transform(value)
235+
assert np.ndim(result) == 0
236+
assert result == pytest.approx(_cpp_transform(value, exponent))
237+
result = transform.invTransform(value)
238+
assert np.ndim(result) == 0
239+
assert result == pytest.approx(_cpp_inv_transform(value, exponent))
240+
241+
242+
@pytest.mark.parametrize("exponent", (2, 3.0, 0.5))
243+
def test_power_transform_arrays(exponent):
244+
"""Arrays are transformed element-wise, and back"""
245+
transform = QwtPowerTransform(exponent)
246+
values = np.array(POWER_VALUES)
247+
expected = [_cpp_transform(value, exponent) for value in POWER_VALUES]
248+
np.testing.assert_allclose(transform.transform(values), expected)
249+
expected = [_cpp_inv_transform(value, exponent) for value in POWER_VALUES]
250+
np.testing.assert_allclose(transform.invTransform(values), expected)
251+
roundtrip = transform.invTransform(transform.transform(values))
252+
np.testing.assert_allclose(roundtrip, values, atol=1e-12)
253+
254+
255+
def test_power_transform_without_numpy_pow(monkeypatch):
256+
"""QwtPowerTransform does not need `numpy.pow` (added in NumPy 2.0)"""
257+
monkeypatch.delattr(np, "pow", raising=False)
258+
transform = QwtPowerTransform(2)
259+
assert transform.transform(-9.0) == pytest.approx(-3.0)
260+
assert transform.invTransform(-3.0) == pytest.approx(-9.0)
261+
262+
263+
def test_power_transform_scale_map():
264+
"""A scale map with a power transformation maps arrays like scalars"""
265+
smap = QwtScaleMap()
266+
smap.setTransformation(QwtPowerTransform(2))
267+
smap.setScaleInterval(-100.0, 100.0)
268+
smap.setPaintInterval(0.0, 400.0)
269+
values = np.array(POWER_VALUES)
270+
expected = [smap.transform(value) for value in POWER_VALUES]
271+
np.testing.assert_allclose(smap.transform(values), expected)
272+
273+
274+
def test_power_transform_curve_rendering():
275+
"""A curve can be drawn on an axis with a power transformation"""
276+
app = _ensure_app() # keep a reference alive for the duration of the test
277+
plot = QwtPlot()
278+
plot.resize(400, 300)
279+
engine = QwtLinearScaleEngine()
280+
engine.setTransformation(QwtPowerTransform(2))
281+
plot.setAxisScaleEngine(QwtPlot.yLeft, engine)
282+
plot.setAxisScale(QwtPlot.yLeft, -100.0, 100.0)
283+
x = np.linspace(-10.0, 10.0, 21)
284+
curve = QwtPlotCurve.make(x, x * np.abs(x), plot=plot)
285+
plot.replot()
286+
287+
xmap = plot.canvasMap(QwtPlot.xBottom)
288+
ymap = plot.canvasMap(QwtPlot.yLeft)
289+
polyline = series_to_polyline(xmap, ymap, curve.data(), 0, x.size - 1)
290+
for index in range(x.size):
291+
point = polyline[index]
292+
sample = curve.data().sample(index)
293+
assert point.x() == pytest.approx(xmap.transform(sample.x()))
294+
assert point.y() == pytest.approx(ymap.transform(sample.y()))
295+
296+
image = QG.QImage(400, 300, QG.QImage.Format_ARGB32)
297+
image.fill(QC.Qt.white)
298+
painter = QG.QPainter(image)
299+
try:
300+
QwtPlotRenderer().render(plot, painter, QC.QRectF(0.0, 0.0, 400.0, 300.0))
301+
finally:
302+
painter.end()
303+
plot.close()
304+
del app
305+
306+
199307
if __name__ == "__main__":
200308
for name in ("toPixmap", "toImage"):
201309
test_graphic_conversion_default_size(name)
@@ -205,4 +313,6 @@ def test_grid_make_without_minor_pen():
205313
test_marker_legend_icon_with_symbol(size)
206314
test_marker_with_symbol_in_legend()
207315
test_grid_make_without_minor_pen()
316+
test_power_transform_scale_map()
317+
test_power_transform_curve_rendering()
208318
print("OK")

‎qwt/transform.py‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,14 @@ def copy(self):
207207

208208
class QwtPowerTransform(QwtTransform):
209209
"""
210-
A transformation using `numpy.pow()`
210+
A transformation using `numpy.power()`
211211
212212
`QwtPowerTransform` preserves the sign of a value.
213213
F.e. a transformation with a factor of 2
214214
transforms a value of -3 to -9 and v.v. Thus `QwtPowerTransform`
215215
can be used for scales including negative values.
216+
217+
Values may be scalars or NumPy arrays (transformed element-wise).
216218
"""
217219

218220
def __init__(self, exponent):
@@ -223,33 +225,35 @@ def transform(self, value):
223225
"""
224226
Transformation function
225227
226-
:param float value: Value
228+
:param value: Value (scalar or NumPy array)
227229
:return: Modified value
228230
229231
.. seealso::
230232
231233
:py:meth:`invTransform()`
232234
"""
233-
if value < 0.0:
234-
return -np.pow(-value, 1.0 / self.__exponent)
235-
else:
236-
return np.pow(value, 1.0 / self.__exponent)
235+
return self.__signed_power(value, 1.0 / self.__exponent)
237236

238237
def invTransform(self, value):
239238
"""
240239
Inverse transformation function
241240
242-
:param float value: Value
241+
:param value: Value (scalar or NumPy array)
243242
:return: Modified value
244243
245244
.. seealso::
246245
247246
:py:meth:`transform()`
248247
"""
249-
if value < 0.0:
250-
return -np.pow(-value, self.__exponent)
251-
else:
252-
return np.pow(value, self.__exponent)
248+
return self.__signed_power(value, self.__exponent)
249+
250+
@staticmethod
251+
def __signed_power(value, exponent):
252+
# Same as Qwt C++ (`value < 0.0 ? -pow(-value, e) : pow(value, e)`),
253+
# element-wise for arrays: scalars give the same results as before
254+
negative = np.less(value, 0.0)
255+
result = np.power(np.where(negative, np.abs(value), value), exponent)
256+
return np.where(negative, np.negative(result), result)[()]
253257

254258
def copy(self):
255259
"""

0 commit comments

Comments
 (0)