Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 83a5aad

Browse files
committed
Improve handling of degenerate jacobians in non-rectiliear grids.
grid_helper_curvelinear and floating_axes have code to specifically handle the case where the transform from rectlinear to non-rectilinear axes has null derivatives in one of the directions, inferring the angle of the jacobian from the derivative in the other direction. (This angle defines the rotation applied to axis labels, ticks, and tick labels.) This approach, however, is insufficient if the derivatives in both directions are zero. A classical example is e.g. the ``exp(-1/x**2)`` transform, for which all derivatives are zero. To handle this case more robustly (and also to better encapsulate the angle calculation, which is currently repeated at a few places), instead, one can increase the step size of the numerical differentiation until the gradient becomes nonzero. This amounts to moving along the corresponding gridline until one actually leaves the position of the tick, and thus is indeed a justifiable approach to compute the tick rotation. Full example: import matplotlib.pyplot as plt import mpl_toolkits.axisartist.floating_axes as floating_axes import numpy as np # def tr(x, y): return x - y, x + y # def inv_tr(u, v): return (u + v) / 2, (v - u) / 2 @np.errstate(divide="ignore") # at x=0, exp(-1/x**2)=0; div-by-zero can be ignored. def tr(x, y): return np.exp(-x**-2) - np.exp(-y**-2), np.exp(-x**-2) + np.exp(-y**-2) def inv_tr(u, v): return (-np.log((u+v)/2))**(1/2), (-np.log((v-u)/2))**(1/2) ax1 = plt.figure().add_subplot( axes_class=floating_axes.FloatingAxes, grid_helper=floating_axes.GridHelperCurveLinear( (tr, inv_tr), extremes=(0, 10, 0, 10))) plt.show() np.broadcast_shapes requires numpy 1.20, which is allowed per NEP29.
1 parent 3067dad commit 83a5aad

File tree

9 files changed

+94
-39
lines changed

9 files changed

+94
-39
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
numpy>=1.20 is now required
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~

doc/devel/dependencies.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ reference.
2121
* `dateutil <https://pypi.org/project/python-dateutil/>`_ (>= 2.7)
2222
* `fontTools <https://fonttools.readthedocs.io/en/latest/>`_ (>= 4.22.0)
2323
* `kiwisolver <https://github.com/nucleic/kiwi>`_ (>= 1.0.1)
24-
* `NumPy <https://numpy.org>`_ (>= 1.19)
24+
* `NumPy <https://numpy.org>`_ (>= 1.20)
2525
* `packaging <https://pypi.org/project/packaging/>`_ (>= 20.0)
2626
* `Pillow <https://pillow.readthedocs.io/en/latest/>`_ (>= 6.2)
2727
* `pyparsing <https://pypi.org/project/pyparsing/>`_ (>= 2.3.1)
@@ -203,7 +203,7 @@ Setup dependencies
203203
- `setuptools_scm <https://pypi.org/project/setuptools-scm/>`_ (>= 7). Used to
204204
update the reported ``mpl.__version__`` based on the current git commit.
205205
Also a runtime dependency for editable installs.
206-
- `NumPy <https://numpy.org>`_ (>= 1.19). Also a runtime dependency.
206+
- `NumPy <https://numpy.org>`_ (>= 1.20). Also a runtime dependency.
207207

208208

209209
.. _compile-dependencies:

environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dependencies:
1414
- fonttools>=4.22.0
1515
- importlib-resources>=3.2.0
1616
- kiwisolver>=1.0.1
17-
- numpy>=1.19
17+
- numpy>=1.20
1818
- pillow>=6.2
1919
- pygobject
2020
- pyparsing

lib/matplotlib/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ def _check_versions():
217217
("cycler", "0.10"),
218218
("dateutil", "2.7"),
219219
("kiwisolver", "1.0.1"),
220-
("numpy", "1.19"),
220+
("numpy", "1.20"),
221221
("pyparsing", "2.3.1"),
222222
]:
223223
module = importlib.import_module(modname)

lib/mpl_toolkits/axisartist/floating_axes.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,25 +68,19 @@ def trf_xy(x, y):
6868

6969
if self.nth_coord == 0:
7070
mask = (ymin <= yy0) & (yy0 <= ymax)
71-
(xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \
72-
grid_helper_curvelinear._value_and_jacobian(
71+
(xx1, yy1), angle_normal, angle_tangent = \
72+
grid_helper_curvelinear._value_and_jac_angle(
7373
trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax))
7474
labels = self._grid_info["lat_labels"]
7575

7676
elif self.nth_coord == 1:
7777
mask = (xmin <= xx0) & (xx0 <= xmax)
78-
(xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \
79-
grid_helper_curvelinear._value_and_jacobian(
78+
(xx1, yy1), angle_tangent, angle_normal = \
79+
grid_helper_curvelinear._value_and_jac_angle(
8080
trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax))
8181
labels = self._grid_info["lon_labels"]
8282

8383
labels = [l for l, m in zip(labels, mask) if m]
84-
85-
angle_normal = np.arctan2(dyy1, dxx1)
86-
angle_tangent = np.arctan2(dyy2, dxx2)
87-
mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
88-
angle_normal[mm] = angle_tangent[mm] + np.pi / 2
89-
9084
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
9185
in_01 = functools.partial(
9286
mpl.transforms._interval_contains_close, (0, 1))

lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,67 @@
1515
from .grid_finder import GridFinder
1616

1717

18-
def _value_and_jacobian(func, xs, ys, xlims, ylims):
18+
def _value_and_jac_angle(func, xs, ys, xlims, ylims):
1919
"""
20-
Compute *func* and its derivatives along x and y at positions *xs*, *ys*,
21-
while ensuring that finite difference calculations don't try to evaluate
22-
values outside of *xlims*, *ylims*.
20+
Parameters
21+
----------
22+
func : callable
23+
A function that transforms the coordinates of a point (x, y) to a new
24+
coordinate system (u, v), and which can also take x and y as arrays of
25+
shape *shape* and returns (u, v) as a ``(2, shape)`` array.
26+
xs, ys : array-likes
27+
Points where *func* and its derivatives will be evaluated.
28+
xlims, ylims : pairs of floats
29+
(min, max) beyond which *func* should not be evaluated.
30+
31+
Returns
32+
-------
33+
val
34+
Value of *func* at each point of ``(xs, ys)``.
35+
thetas_dx
36+
Angles (in radians) defined by the (u, v) components of the numerically
37+
differentiated ``df/dx`` vector, at each point of ``(xs, ys)``. If
38+
needed, the differentiation step size is increased until at least one
39+
component of ``df/dx`` is nonzero, under the constraint of not going
40+
out of the *xlims*, *ylims* bounds.
41+
thetas_dy
42+
Like *thetas_dx*, but for ``df/dy``.
2343
"""
24-
eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime
44+
shape = np.broadcast_shapes(np.shape(xs), np.shape(ys))
2545
val = func(xs, ys)
2646
# Take the finite difference step in the direction where the bound is the
2747
# furthest; the step size is min of epsilon and distance to that bound.
2848
xlo, xhi = sorted(xlims)
2949
dxlo = xs - xlo
3050
dxhi = xhi - xs
31-
xeps = (np.take([-1, 1], dxhi >= dxlo)
32-
* np.minimum(eps, np.maximum(dxlo, dxhi)))
33-
val_dx = func(xs + xeps, ys)
51+
xsign = np.take([-1, 1], dxhi >= dxlo)
52+
xeps_max = np.maximum(dxlo, dxhi)
53+
thetas_dx = np.empty(shape)
54+
missing = np.full(shape, True)
55+
eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime
56+
while missing.any() and (eps < xeps_max).any():
57+
xeps = xsign * np.minimum(eps, xeps_max)
58+
dfdx_x, dfdx_y = (func(xs + xeps, ys) - val) / xeps
59+
good = missing & ((dfdx_x != 0) | (dfdx_y != 0))
60+
thetas_dx[good] = np.arctan2(dfdx_y, dfdx_x)[good]
61+
missing &= ~good
62+
eps *= 2
3463
ylo, yhi = sorted(ylims)
3564
dylo = ys - ylo
3665
dyhi = yhi - ys
37-
yeps = (np.take([-1, 1], dyhi >= dylo)
38-
* np.minimum(eps, np.maximum(dylo, dyhi)))
39-
val_dy = func(xs, ys + yeps)
40-
return (val, (val_dx - val) / xeps, (val_dy - val) / yeps)
66+
ysign = np.take([-1, 1], dyhi >= dylo)
67+
yeps_max = np.maximum(dylo, dyhi)
68+
thetas_dy = np.empty(shape)
69+
missing = np.full(shape, True)
70+
eps = np.finfo(float).eps ** (1/2)
71+
while missing.any() and (eps < yeps_max).any():
72+
yeps = ysign * np.minimum(eps, yeps_max)
73+
dfdy_x, dfdy_y = (func(xs, ys + yeps) - val) / yeps
74+
good = missing & ((dfdy_y != 0) | (dfdy_y != 0))
75+
thetas_dy[good] = np.arctan2(dfdy_y, dfdy_x)[good]
76+
missing &= ~good
77+
eps *= 2
78+
return (val, thetas_dx, thetas_dy)
4179

4280

4381
class FixedAxisArtistHelper(AxisArtistHelper.Fixed):
@@ -157,12 +195,11 @@ def trf_xy(x, y):
157195
elif self.nth_coord == 1:
158196
xx0 = (xmin + xmax) / 2
159197
yy0 = self.value
160-
xy1, dxy1_dx, dxy1_dy = _value_and_jacobian(
198+
xy1, angle_dx, angle_dy = _value_and_jac_angle(
161199
trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax))
162200
p = axes.transAxes.inverted().transform(xy1)
163201
if 0 <= p[0] <= 1 and 0 <= p[1] <= 1:
164-
d = [dxy1_dy, dxy1_dx][self.nth_coord]
165-
return xy1, np.rad2deg(np.arctan2(*d[::-1]))
202+
return xy1, np.rad2deg([angle_dy, angle_dx][self.nth_coord])
166203
else:
167204
return None, None
168205

@@ -187,23 +224,17 @@ def trf_xy(x, y):
187224
# find angles
188225
if self.nth_coord == 0:
189226
mask = (e0 <= yy0) & (yy0 <= e1)
190-
(xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian(
227+
(xx1, yy1), angle_normal, angle_tangent = _value_and_jac_angle(
191228
trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1))
192229
labels = self._grid_info["lat_labels"]
193230

194231
elif self.nth_coord == 1:
195232
mask = (e0 <= xx0) & (xx0 <= e1)
196-
(xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian(
233+
(xx1, yy1), angle_tangent, angle_normal = _value_and_jac_angle(
197234
trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1))
198235
labels = self._grid_info["lon_labels"]
199236

200237
labels = [l for l, m in zip(labels, mask) if m]
201-
202-
angle_normal = np.arctan2(dyy1, dxx1)
203-
angle_tangent = np.arctan2(dyy2, dxx2)
204-
mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
205-
angle_normal[mm] = angle_tangent[mm] + np.pi / 2
206-
207238
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
208239
in_01 = functools.partial(
209240
mpl.transforms._interval_contains_close, (0, 1))

lib/mpl_toolkits/axisartist/tests/test_floating_axes.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import numpy as np
22

3+
import pytest
4+
35
import matplotlib.pyplot as plt
46
import matplotlib.projections as mprojections
57
import matplotlib.transforms as mtransforms
@@ -112,3 +114,29 @@ def test_axis_direction():
112114
ax.axis['y'] = ax.new_floating_axis(nth_coord=1, value=0,
113115
axis_direction='left')
114116
assert ax.axis['y']._axis_direction == 'left'
117+
118+
119+
def test_transform_with_zero_derivatives():
120+
# The transform is really a 45° rotation
121+
# tr(x, y) = x-y, x+y; inv_tr(u, v) = (u+v)/2, (u-v)/2
122+
# with an additional x->exp(-x**-2) on each coordinate.
123+
# Therefore all ticks should be at +/-45°, even the one at zero where the
124+
# transform derivatives are zero.
125+
126+
# at x=0, exp(-x**-2)=0; div-by-zero can be ignored.
127+
@np.errstate(divide="ignore")
128+
def tr(x, y):
129+
return np.exp(-x**-2) - np.exp(-y**-2), np.exp(-x**-2) + np.exp(-y**-2)
130+
131+
def inv_tr(u, v):
132+
return (-np.log((u+v)/2))**(1/2), (-np.log((v-u)/2))**(1/2)
133+
134+
fig = plt.figure()
135+
ax = fig.add_subplot(
136+
axes_class=FloatingAxes, grid_helper=GridHelperCurveLinear(
137+
(tr, inv_tr), extremes=(0, 10, 0, 10)))
138+
fig.canvas.draw()
139+
140+
for k in ax.axis:
141+
for l, a in ax.axis[k].major_ticks.locs_angles:
142+
assert a % 90 == pytest.approx(45, abs=1e-3)

requirements/testing/minver.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ contourpy==1.0.1
44
cycler==0.10
55
kiwisolver==1.0.1
66
importlib-resources==3.2.0
7-
numpy==1.19.0
7+
numpy==1.20.0
88
packaging==20.0
99
pillow==6.2.1
1010
pyparsing==2.3.1

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ def make_release_tree(self, base_dir, files):
322322
"cycler>=0.10",
323323
"fonttools>=4.22.0",
324324
"kiwisolver>=1.0.1",
325-
"numpy>=1.19",
325+
"numpy>=1.20",
326326
"packaging>=20.0",
327327
"pillow>=6.2.0",
328328
"pyparsing>=2.3.1",

0 commit comments

Comments
 (0)