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

Skip to content

Commit cd3e502

Browse files
committed
Improve handling of degenerate jacobians in non-rectilinear 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 examples: import numpy as np from matplotlib import pyplot as plt from matplotlib.projections.polar import PolarTransform from matplotlib.transforms import Affine2D from mpl_toolkits.axisartist import ( angle_helper, GridHelperCurveLinear, HostAxes) import mpl_toolkits.axisartist.floating_axes as floating_axes # 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) plt.subplot( 121, axes_class=floating_axes.FloatingAxes, grid_helper=floating_axes.GridHelperCurveLinear( (tr, inv_tr), extremes=(0, 10, 0, 10))) ax = plt.subplot( 122, axes_class=HostAxes, grid_helper=GridHelperCurveLinear( Affine2D().scale(np.pi / 180, 1) + PolarTransform() + Affine2D().scale(2, 1), extreme_finder=angle_helper.ExtremeFinderCycle( 20, 20, lon_cycle=360, lat_cycle=None, lon_minmax=None, lat_minmax=(0, np.inf), ), grid_locator1=angle_helper.LocatorDMS(12), tick_formatter1=angle_helper.FormatterDMS(), ), aspect=1, xlim=(-5, 12), ylim=(-5, 10)) ax.axis["lat"] = axis = ax.new_floating_axis(0, 40) axis.label.set_text(r"$\theta = 40^{\circ}$") axis.label.set_visible(True) ax.grid(True) plt.show()
1 parent ddb3163 commit cd3e502

File tree

3 files changed

+103
-41
lines changed

3 files changed

+103
-41
lines changed

lib/mpl_toolkits/axisartist/floating_axes.py

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

7272
if self.nth_coord == 0:
7373
mask = (ymin <= yy0) & (yy0 <= ymax)
74-
(xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \
75-
grid_helper_curvelinear._value_and_jacobian(
74+
(xx1, yy1), angle_normal, angle_tangent = \
75+
grid_helper_curvelinear._value_and_jac_angle(
7676
trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax))
7777
labels = self._grid_info["lat_labels"]
7878

7979
elif self.nth_coord == 1:
8080
mask = (xmin <= xx0) & (xx0 <= xmax)
81-
(xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \
82-
grid_helper_curvelinear._value_and_jacobian(
81+
(xx1, yy1), angle_tangent, angle_normal = \
82+
grid_helper_curvelinear._value_and_jac_angle(
8383
trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax))
8484
labels = self._grid_info["lon_labels"]
8585

8686
labels = [l for l, m in zip(labels, mask) if m]
87-
88-
angle_normal = np.arctan2(dyy1, dxx1)
89-
angle_tangent = np.arctan2(dyy2, dxx2)
90-
mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal
91-
angle_normal[mm] = angle_tangent[mm] + np.pi / 2
92-
9387
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
9488
in_01 = functools.partial(
9589
mpl.transforms._interval_contains_close, (0, 1))

lib/mpl_toolkits/axisartist/grid_helper_curvelinear.py

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,76 @@
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, xlim, ylim):
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 coordinate
24+
system (u, v), and which can also take x and y as arrays of shape *shape* and
25+
returns (u, v) as a ``(2, shape)`` array.
26+
xs, ys : array-likes
27+
Points where *func* and its derivatives will be evaluated.
28+
xlim, ylim : 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 needed, the
38+
differentiation step size is increased until at least one component of df/dx
39+
is nonzero, under the constraint of not going out of the *xlims*, *ylims*
40+
bounds. If the gridline at a point is actually null (and the angle is thus not
41+
well defined), the derivatives are evaluated after taking a small step along y;
42+
this ensures e.g. that the tick at r=0 on a radial axis of a polar plot is
43+
parallel with the ticks at r!=0.
44+
thetas_dy
45+
Like *thetas_dx*, but for df/dy.
2346
"""
24-
eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime
47+
48+
shape = np.broadcast_shapes(np.shape(xs), np.shape(ys))
2549
val = func(xs, ys)
26-
# Take the finite difference step in the direction where the bound is the
27-
# furthest; the step size is min of epsilon and distance to that bound.
28-
xlo, xhi = sorted(xlims)
29-
dxlo = xs - xlo
30-
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)
34-
ylo, yhi = sorted(ylims)
35-
dylo = ys - ylo
36-
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)
50+
51+
thetas = []
52+
eps0 = np.finfo(float).eps ** (1/2) # cf. scipy.optimize.approx_fprime
53+
54+
# Take finite difference steps towards the furthest bound; the step size will be the
55+
# min of epsilon and the distance to that bound.
56+
xlo, xhi = sorted(xlim)
57+
xdlo = xs - xlo
58+
xdhi = xhi - xs
59+
xeps_max = np.maximum(xdlo, xdhi)
60+
xeps0 = np.where(xdhi >= xdlo, 1, -1) * np.minimum(eps0, xeps_max)
61+
ylo, yhi = sorted(ylim)
62+
ydlo = ys - ylo
63+
ydhi = yhi - ys
64+
yeps_max = np.maximum(ydlo, ydhi)
65+
yeps0 = np.where(ydhi >= ydlo, 1, -1) * np.minimum(eps0, yeps_max)
66+
67+
for dfunc, ps, eps0, eps_max, eps_q in [
68+
(lambda eps_p, eps_q: func(xs + eps_p, ys + eps_q),
69+
xs, xeps0, xeps_max, yeps0),
70+
(lambda eps_p, eps_q: func(xs + eps_q, ys + eps_p),
71+
ys, yeps0, yeps_max, xeps0),
72+
]:
73+
thetas_dp = np.full(shape, np.nan)
74+
missing = np.full(shape, True)
75+
eps_p = eps0
76+
for it, eps_q in enumerate([0, eps_q]):
77+
while missing.any() and (abs(eps_p) < eps_max).any():
78+
if it == 0 and (eps_p > 1).any():
79+
break # Degenerate derivative, move a bit along the other coord.
80+
eps_p = np.minimum(eps_p, eps_max)
81+
df_x, df_y = (dfunc(eps_p, eps_q) - dfunc(0, eps_q)) / eps_p
82+
good = missing & ((df_x != 0) | (df_y != 0))
83+
thetas_dp[good] = np.arctan2(df_y, df_x)[good]
84+
missing &= ~good
85+
eps_p *= 2
86+
thetas.append(thetas_dp)
87+
return (val, *thetas)
4188

4289

4390
class FixedAxisArtistHelper(AxisArtistHelper.Fixed):
@@ -157,12 +204,11 @@ def trf_xy(x, y):
157204
elif self.nth_coord == 1:
158205
xx0 = (xmin + xmax) / 2
159206
yy0 = self.value
160-
xy1, dxy1_dx, dxy1_dy = _value_and_jacobian(
207+
xy1, angle_dx, angle_dy = _value_and_jac_angle(
161208
trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax))
162209
p = axes.transAxes.inverted().transform(xy1)
163210
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]))
211+
return xy1, np.rad2deg([angle_dy, angle_dx][self.nth_coord])
166212
else:
167213
return None, None
168214

@@ -187,23 +233,17 @@ def trf_xy(x, y):
187233
# find angles
188234
if self.nth_coord == 0:
189235
mask = (e0 <= yy0) & (yy0 <= e1)
190-
(xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian(
236+
(xx1, yy1), angle_normal, angle_tangent = _value_and_jac_angle(
191237
trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1))
192238
labels = self._grid_info["lat_labels"]
193239

194240
elif self.nth_coord == 1:
195241
mask = (e0 <= xx0) & (xx0 <= e1)
196-
(xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian(
242+
(xx1, yy1), angle_tangent, angle_normal = _value_and_jac_angle(
197243
trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1))
198244
labels = self._grid_info["lon_labels"]
199245

200246
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-
207247
tick_to_axes = self.get_tick_transform(axes) - axes.transAxes
208248
in_01 = functools.partial(
209249
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
@@ -113,3 +115,29 @@ def test_axis_direction():
113115
ax.axis['y'] = ax.new_floating_axis(nth_coord=1, value=0,
114116
axis_direction='left')
115117
assert ax.axis['y']._axis_direction == 'left'
118+
119+
120+
def test_transform_with_zero_derivatives():
121+
# The transform is really a 45° rotation
122+
# tr(x, y) = x-y, x+y; inv_tr(u, v) = (u+v)/2, (u-v)/2
123+
# with an additional x->exp(-x**-2) on each coordinate.
124+
# Therefore all ticks should be at +/-45°, even the one at zero where the
125+
# transform derivatives are zero.
126+
127+
# at x=0, exp(-x**-2)=0; div-by-zero can be ignored.
128+
@np.errstate(divide="ignore")
129+
def tr(x, y):
130+
return np.exp(-x**-2) - np.exp(-y**-2), np.exp(-x**-2) + np.exp(-y**-2)
131+
132+
def inv_tr(u, v):
133+
return (-np.log((u+v)/2))**(1/2), (-np.log((v-u)/2))**(1/2)
134+
135+
fig = plt.figure()
136+
ax = fig.add_subplot(
137+
axes_class=FloatingAxes, grid_helper=GridHelperCurveLinear(
138+
(tr, inv_tr), extremes=(0, 10, 0, 10)))
139+
fig.canvas.draw()
140+
141+
for k in ax.axis:
142+
for l, a in ax.axis[k].major_ticks.locs_angles:
143+
assert a % 90 == pytest.approx(45, abs=1e-3)

0 commit comments

Comments
 (0)